转载自https://segmentfault.com/a/1190000022356844
二进制编码传输协议
思考
- 何为二进制协议传输,何为文本协议数据传输?
- 网络编程中数据协议的制定方式有哪些?
-
Protobuf
等二进制数据序列化传输协议的机制是什么?
在网络编程中,经常看到要求数据要以二进制的方式进行传输,起初我很不理解,为什么要刻意的说明二进制方式呢?数据在底层的传输不都是二进制流吗?而且还引出了 pack/unpack
方法簇。
我们经常用到的 rpc
,比如 json-rpc
是以 文本方式
传输序列化的数据的。grpc(protobuf)
, thrift
都是以 二进制方式
传输数据的。所以到底何为二进制传输呢?
大家可以先想一下日常中发送请求时经常用到的方式: xml
, json
, formData
,他们虽然格式不同,但都有一个特征,自带描述信息(直白说就是携带 参数名
),像 文本
一样,能很直观的看到数据表征的内容。
如果我们事先定义了数据中的n~m
个字节位固定作为某参数的数据段,就可以免去 参数名
所带来的额外开销。比如 0 ~ 10
字节为 account
,11 ~ 24
字节为 passowrd
。又因为用户名或密码是非定长的,而解析数据时又要根据字节位精准的截取,所以我们需要对数据项进行打包填充,使其固定字节长度,而后在服务端进行解包,pack/unpack
便可以实现此功能。
白话
tcp
协议是日常中最为常见的二进制协议,协议体的字节位都有约定好的表征。
http
在广义上来说也是二进制模式,使用 \r\n
对协议进行解包,解析,但 http
携带的数据通常都是文本模式的,比如 "sqrtcat" 占了 7 个字节,在文本 or 二进制模式下没什么区别,但"29",以文本模式发送需要 2bytes,以二进制模式打包至字符类型,只需要 1bytes。
二进制为何能提高数据传输效率:
- 根据协议约定,省去参数名所占用的字节,缩减了数据。
- 将数值类型的数据打包至相应范围内的二进制,节省了空间,4bytes能表示 32 位的文本数值,但文本数据值要 32bytes。
- 在一定程度上可以起到加密数据的作用,如果第三方不知道数据协议,就没有办法截取相应的字节为获取数据,或得到数据的表征。
文本方式传输
日常开发,比如发送一个用户注册 http协议
请求,发送的数据格式分别如下:
$registerData = [
"account" => "sqrtcat",
"password" => "123456"
];
formData 31bytes
account=sqrtcat&password=123456
json 41bytes
{"account":"sqrtcat","password":"123456"}
xml 94bytes
sqrtcat
123456
以上三种皆为,我们可以很直观的在数据体重得到各项参数。
二进制传输方式
二进制传输,离不开协议的制定。文本方式传输的数据可以自我描述,而二进制方式传输的数据,需要通过协议进行解析和读取。
最简单的,参数定长的方式,account
固定为 11 位,password
固定为 14 位,使用 pack
将数据填充至相应的协议长度,发送,服务端按协议进行字节长度的截取获得对应的参数值。
// binary protocal:
// |-- 11 bytes account --|-- 14 bytes password --|
password = "123456";// pack
// A 以空白符对数据进行填充 php 解包时会自动 trim 掉
// a 以 0x00 字符对数据进行填充 php 解包时会保留 0x00
account, $password);// send
echo "data pack to bin len: " . strlen(dataBin . PHP_EOL;// unpack
dataBin);
var_dump($dataArr);// result
data pack to bin len: 25
data pack to bin: sqrtcat 123456
array(2) {
["account"]=>
string(7) "sqrtcat"
["password"]=>
string(6) "123456"
}
对比文本方式发送,我们在协议和二进制传输的方式下,只用了 25bytes。这就满足了?并不能够~,这种简单协议的二进制传输方式只是在一定场景下发挥了传输效率,在某些场景下可能还不如文本方式。因为严格的数据定长填充,可能会造成数据的冗余,比如 account
只有一个字符 s
,password
也只有一个字符 1
,在此协议下还是固定 25bytes,文本传输反而效率会高一些。
二进制传输败北了?No,是我们协议太简单,不够灵活,没有最大程度上发挥协议+二进制的高效性,可以说,协议下的二进制传输方式,能做到绝对的高效于文本传输,这里我们可以简单的分析和模拟以二进制方式传输的 protobuf
的协议模式。
Protobuf 的二进制传输
我们可以简单分析下 protobuf
传输数据的方式:
- 定义
IDL
,其实就相当于制定了协议体 - 生成
proto
文件,得到具体的消息字段的参数项位
和参数长度位
映射的消息协议包。 - 发送端根据消息协议定义的参数数据类型(主要是变长 or 定长),将数据打包至相应的二进制格式。
- 发送数据。
- 接收端按消息协议格式对二进制数据进行解析,获得文本数据。
这里原谅我自己造了两个词,参数项位
和 参数长度位
,如何理解呢?通过下面模仿 protobuf
的协议示例来理解。
定义消息体的IDL
message RegisterRequest {
string account = 1; // 数据位1 type string name account
string password = 2; // 数据位2 type string name password
tinyint age = 3; // 数据位3 type tinyint name age
}
注意下面是我自己仿 protobuf 写的一套 php 二进制序列化组件,完整版已放置 github 支持的数据类型还是很全面的
:protoBin。
协议数据类型的二进制格式约定
主要是定义哪些类型是定长,哪些类型是变长,变长类型还需给定长度位的字节数。
/**
协议数据类型
//| 参数位1(变长数据) | 参数位2(定长类型) | 参数位3(变长数据) |
-
//| param1Len | param1Data | param3Data | param3Len | param3Data |
*/
class ProtocolType {
const TYPE_TINYINT = 'tinyint';
const TYPE_INT16 = 'int16';
const TYPE_INT32 = 'int32';
const TYPE_INT64 = 'int64';
const TYPE_STRING = 'string';
const TYPE_TEXT = 'text';/**
- 数据类型是否为定长
*/
const TYPE_FIXED_LEN = [
self::TYPE_TINYINT => true,
self::TYPE_INT16 => true,
self::TYPE_INT32 => true,
self::TYPE_INT64 => true,
self::TYPE_STRING => false,
self::TYPE_TEXT => false,
];
// 定长数据类型的字节数 paramBytes = dataBytes
const TYPE_FIXED_LEN_BYTES = [
self::TYPE_TINYINT => 1, // tinyint 固定1字节 不需要长度表征 追求极致
self::TYPE_INT16 => 2, // int16 固定2字节 不需要长度表征 追求极致
self::TYPE_INT32 => 4, // int32 固定4字节 不需要长度表征 追求极致
self::TYPE_INT64 => 8, // int64 固定8字节 不需要长度表征 追求极致
];/**
- 变长数据类型长度位字节数 paramBytes = dataLenBytes . dataBytes
*/
const TYPE_VARIABLE_LEN_BYTES = [
self::TYPE_STRING => 1, // string 用 1bytes 表征数据长度 0 ~ 255 个字符长度
self::TYPE_TEXT => 4, // text 用 4bytes 表征数据长度 能表征 2 ^ 32 - 1个字符长度 1PB的数据 噗
];
/**
- 数据类型对应的打包方式
*/
const TYPE_PACK_SYMBOL = [
self::TYPE_TINYINT => 'C', // tinyint 固定1字节 不需要长度表征 追求极致 无符号字节
self::TYPE_INT16 => 'n', // int16 固定2字节 不需要长度表征 追求极致 大端无符号短整形
self::TYPE_INT32 => 'N', // int32 固定4字节 不需要长度表征 追求极致 大端无符号整形
self::TYPE_INT64 => 'J', // int64 固定8字节 不需要长度表征 追求极致 大端无符号长整形
self::TYPE_STRING => 'C', // string 用 1bytes 表征数据长度 0 ~ 255 个字符长度
self::TYPE_TEXT => 'N', // text 用 4bytes 表征数据长度 能表征 2 ^ 32 - 1个字符长度 1PB的数据 噗
];
/**
- 是否为定长类型
- @param [type] $type [description]
- @return boolean [description]
*/
public static function isFixedLenType(type];
}
/**
- 定长获得字节数
- 变长获得数据长度为字节数
- @param [type] $type [description]
- @return [type] [description]
*/
public static function getTypeOrTypeLenBytes(type)) {
return self::TYPE_FIXED_LEN_BYTES[type];
}
}
/**
打包二进制数据
@param [type] $data [description]
@param [type] $paramType [description]
-
@return [type] [description]
*/
public static function pack(paramType) {
paramType];
if (self::isFixedLenType(paramProtocDataBin = pack(data);
} else {
// 变长类型 数据长度位 + 数据位
packSymbol, strlen(data;
}return $paramProtocDataBin;
}
/**
解包二进制数据
@param [type] &$dataBin [description]
@param [type] $paramType [description]
-
@return [type] [description]
*/
public static function unPack(¶mType) {
paramType];// 定长数据直接读取对应的字节数解包
if (self::isFixedLenType(paramBytes = self::TYPE_FIXED_LEN_BYTES[paramBin = substr(paramBytes);
// 定长类型 直接打包数据至相应的二进制
packSymbol, typeLenBytes = self::TYPE_VARIABLE_LEN_BYTES[paramLenBytes = substr(typeLenBytes);
// 解析二进制的数据长度
packSymbol, paramData = substr(typeLenBytes, paramBytes = paramDataLen;
}// 剩余待处理的数据
dataBin, $paramBytes);return $paramData;
}
}
- 数据类型是否为定长
/**
-
协议消息体
/
class ProtocolMessage {
/*- 二进制协议流
- @var [type]
*/
public $dataBin;
/**
- [paramName1, paramName2, paramName3]
- @var array
*/
public static $paramNameMapping = [];
/**
- paramName => ProtocolType
- @var array
*/
public static $paramProtocolTypeMapping = [];
/**
- 获取参数的协议数据类型
- @param [type] $param [description]
- @return [type] [description]
*/
public static function getParamType(paramProtocolTypeMapping[$param];
}
/**
按参数位序依次打包
-
@return [type] [description]
*/
public function packToBinStream() {
// 按参数位序
foreach (static::key => this->dataBin .= paramName . 'Bin'};
}return $this->dataBin;
}
/**
- 按参数位序一次解包
- @param [type] $dataBin [description]
- @return [type] [description]
*/
public function unpackFromBinStream(paramNameMapping as paramName) {
paramName);
paramName} = ProtocolType::unPack(paramType);
}
}
}
得到消息协议包
class RegisterRequest extends ProtocolMessage {
public password;
public $age;// 参数项位序 accoutBin PaaswordBin ageBin public static $paramNameMapping = [ 0 => 'account', 1 => 'password', 2 => 'age', ]; // 参数类型 public static $paramProtocolTypeMapping = [ 'account' => ProtocolType::TYPE_STRING, 'password' => ProtocolType::TYPE_STRING, 'age' => ProtocolType::TYPE_TINYINT, ]; public function setAccount($account) { $paramType = static::getParamType('account'); $this->accountBin = ProtocolType::pack($account, $paramType); } public function getAccount() { return $this->account; } public function setPassword($password) { $paramType = static::getParamType('password'); $this->passwordBin = ProtocolType::pack($password, $paramType); } public function getPassword() { return $this->password; } public function setAge($age) { $paramType = static::getParamType('age'); $this->ageBin = ProtocolType::pack($age, $paramType); } public function getAge() { return $this->age; }
}
打包至二进制
$data = [
'account' => 'sqrtcat',
'password' => '123456',
'age' => 29,
];// 文本表单
var_dump(http_build_query(data));// 二进制协议
registerRequest->setAccount('sqrtcat');
registerRequest->setAge(29);
registerRequest->packToBinStream();
var_dump($dataBin);// 解析二进制协议
dataBin);echo registerRequest->getPassword() . PHP_EOL;
echo $registerRequest->getAge() . PHP_EOL;
数据解析
开始解析数据:
- 按协议约定,第一个参数项位是
account
, 类型是string
,用 1byte 表示数据长度,读取 1byte 获取account
的长度,再读取相应的长度,获得account
的数据内容,参数项1解析完成。 - 按协议约定,第二个参数项位是
password
,类型是string
,用 1byte 表示数据长度,读取 1byte 获取password
的长度,再读取相应的长度,获得password
的数据内容,参数项2解析完成。 - 按协议约定,第三个参数项位是
age
,类型是tinyint
,固定1byte,读取 1byte 获得age
的数据内容,参数项3解析完成。
大概的机制就是这样,所以我们发送端和接收端都需要载入 protobuf
生成的数据协议包,用来解析和映射。
protobuf
类的数据打包成二进制的方式,要更多的考虑到大量变长数据的场景,如果死板的固定每个数据项的字节数,可能会带来一定的数据冗余
1、解决死板固定字段长度造成的数据填充过多的问题
为每个字段加一个长度位,表征后面多少字节为数据位
|1byteLen | account | 1byteLen| password || 7 | account | 6 | password |
|0000 0111|s|q|r|t|c|a|t|0000 0110|1|2|3|4|5|6|
但还是不够完美:
- 长度位不够灵活,例子中固定用1bytes去表示,那万一数据长度超过 255 了呢,最好有一个约定,定义好某参数的长度位的bytes数。
- '123456'占了 6bytes, 如果我打包至定长的短整型,2bytes就可以表示出来,而且短整型就是定长的,我只需要知道我第二个参数是短整型就好,不需要使用长度标识位来记录。
所以,消息协议就应邀而出了。
2、解决长度位固定导致场景受限的问题
我们需要一个协议,突出两点:
1、某个参数的协议结构是怎样的,根据字段类型,分配不同的字段协议,比如变长的字符串,结构要以 paramBytes = lenBytes + dataBytes
的方式,定长的数值型,则以 paramBytes = dataBytes
。
2、参数项的位序与数据类型的映射关系,要能确定第N个参数的字段协议结构是怎样的,字符串则读取相应的长度字节位,再向后读取长度个字节,获得数据,定长的数值型则直接读取相应的固定的字节数,即可获得数据。
pack/unpack
a 以NUL字节填充字符串空白
A 以SPACE(空格)填充字符串
h 十六进制字符串,低位在前
H 十六进制字符串,高位在前
c 有符号字符 -128 ~ 127
C 无符号字符 0 ~ 255
s 有符号短整型(16位,主机字节序)
S 无符号短整型(16位,主机字节序)
n 无符号短整型(16位,大端字节序)
v 无符号短整型(16位,小端字节序)
i 有符号整型(机器相关大小字节序)
I 无符号整型(机器相关大小字节序)
l 有符号整型(32位,主机字节序) -2147483648 ~ 2147483647
L 无符号整型(32位,主机字节序) 0 ~ 4294967296
N 无符号整型(32位,大端字节序)
V 无符号整型(32位,小端字节序)
q 有符号长整型(64位,主机字节序)
Q 无符号长整型(64位,主机字节序) 0 ~ 18446744073709551616
J 无符号长整型(64位,大端字节序)
P 无符号长整型(64位,小端字节序)
f 单精度浮点型(机器相关大小)
d 双精度浮点型(机器相关大小)
x NUL字节
X 回退一字节
Z 以NUL字节填充字符串空白(new in PHP 5.5)
@ NUL填充到绝对位置
二进制数据压缩
$raw = "69984567982132123122231";
echo "raw data: " . raw) . PHP_EOL;
$segmentRaw = [];
while (true) {
$offset = 3;if (strlen($raw) < 3) { $segmentRaw[] = $raw; break; } $rawEle = substr($raw, 0, $offset); if (intval($rawEle) > 255) { $offset = 2; $rawEle = substr($raw, 0, $offset); } $segmentRaw[] = $rawEle;