二进制编码传输协议
思考
- 何为二进制协议传输,何为文本协议数据传输?
- 网络编程中数据协议的制定方式有哪些?
- Protobuf 等二进制数据序列化传输协议的机制是什么?
在网络编程中,经常看到要求数据要以二进制的方式进行传输,起初我很不理解,为什么要刻意的说明二进制方式呢?数据在底层的传输不都是二进制流吗?而且还引出了pack/unpack
方法簇。
我们经常用到的 rpc
,比如json-rpc
是以文本方式
传输序列化的数据的。grpc(protobuf)
, thrift
都是以二进制方式
传输数据的。那到底何为二进制传输呢?
大家可以先想一下日常中发送请求时经常用到的方式: xml
,json
,formData
,他们虽然格式不同,但都有一个特征,自带描述信息(直白说就是携带参数名
),像文本
一样,能很直观的看到数据表征的内容。
如果我们事先定义了数据中的n~m
个字节固定作为某参数的数据段,就可以免去参数名
所带来的额外开销。比如 0 ~ 10 字节为account
,11 ~ 24 字节为passowrd
。又因为用户名或密码是非定长的,而解析数据时又要根据字节位精准的截取,所以我们需要对数据项进行打包填充,使其固定字节长度,而后在服务端进行解包,pack/unpack
便可以实现此功能。
白话
tcp 协议是日常中最为常见的二进制协议,协议体的字节位都有约定好的表征。
http 在广义上来说也是二进制模式,使用 rn 对协议进行解包,解析,但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
将数据填充至相应的协议长度,发送,服务端按协议进行字节长度的截取获得对应的参数值。
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
}
协议数据类型的二进制格式约定
主要是定义哪些类型是定长,哪些类型是变长,变长类型还需给定长度位的字节数。
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)
{
return self::TYPE_FIXED_LEN[$type];
}
/**
* 定长获得字节数
* 变长获得数据长度为字节数
* @param [type] $type [description]
* @return [type] [description]
*/
public static function getTypeOrTypeLenBytes($type)
{
if (self::isFixedLenType($type)) {
return self::TYPE_FIXED_LEN_BYTES[$type];
} else {
return self::TYPE_VARIABLE_LEN_BYTES[$type];
}
}
/**
* 打包二进制数据
* @param [type] $data [description]
* @param [type] $paramType [description]
* @return [type] [description]
*/
public static function pack($data, $paramType)
{
$packSymbol = self::TYPE_PACK_SYMBOL[$paramType];
if (self::isFixedLenType($paramType)) {
// 定长类型 直接打包数据至相应的二进制
$paramProtocDataBin = pack($packSymbol, $data);
} else {
// 变长类型 数据长度位 + 数据位
$paramProtocDataBin = pack($packSymbol, strlen($data)) . $data;
}
return $paramProtocDataBin;
}
/**
* 解包二进制数据
* @param [type] &$dataBin [description]
* @param [type] $paramType [description]
* @return [type] [description]
*/
public static function unPack(&$dataBin, $paramType)
{
$packSymbol = self::TYPE_PACK_SYMBOL[$paramType];
// 定长数据直接读取对应的字节数解包
if (self::isFixedLenType($paramType)) {
// 参数的字节数
$paramBytes = self::TYPE_FIXED_LEN_BYTES[$paramType];
$paramBin = substr($dataBin, 0, $paramBytes);
// 定长类型 直接打包数据至相应的二进制
$paramData = unpack($packSymbol, $paramBin)[1];
} else {
// 类型的长度位字节数
$typeLenBytes = self::TYPE_VARIABLE_LEN_BYTES[$paramType];
// 数据长度位
$paramLenBytes = substr($dataBin, 0, $typeLenBytes);
// 解析二进制的数据长度
$paramDataLen = unpack($packSymbol, $paramLenBytes)[1];
// 读取变长的数据内容
$paramData = substr($dataBin, $typeLenBytes, $paramDataLen);
// 参数项的总字节数
$paramBytes = $typeLenBytes + $paramDataLen;
}
// 剩余待处理的数据
$dataBin = substr($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($param)
{
return static::$paramProtocolTypeMapping[$param];
}
/**
* 按参数位序依次打包
* @return [type] [description]
*/
public function packToBinStream()
{
// 按参数位序
foreach (static::$paramNameMapping as $key => $paramName) {
$this->dataBin .= $this->{$paramName . 'Bin'};
}
return $this->dataBin;
}
/**
* 按参数位序一次解包
* @param [type] $dataBin [description]
* @return [type] [description]
*/
public function unpackFromBinStream($dataBin)
{
foreach (static::$paramNameMapping as $key => $paramName) {
$paramType = static::getParamType($paramName);
$this->{$paramName} = ProtocolType::unPack($dataBin, $paramType);
}
}
}
得到消息协议包
'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;
}
}
打包至二进制
'sqrtcat',
'password' => '123456',
'age' => 29,
];
// 文本表单
var_dump(http_build_query($data));
// 文本json
var_dump(json_encode($data));
// 二进制协议
$registerRequest = new RegisterRequest();
$registerRequest->setAccount('sqrtcat');
$registerRequest->setPassword('123456');
$registerRequest->setAge(29);
$dataBin = $registerRequest->packToBinStream();
var_dump($dataBin);
// 解析二进制协议
$registerRequest->unpackFromBinStream($dataBin);
echo $registerRequest->getAccount() . PHP_EOL;
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填充到绝对位置
二进制数据压缩
255) {
$offset = 2;
$rawEle = substr($raw, 0, $offset);
}
$segmentRaw[] = $rawEle;
$raw = substr($raw, $offset);
}
// c 有符号字符打包 -128 ~ 127
// C 无符号字符打包 0 ~ 255
$rawBin = pack("C*", ...$segmentRaw);
echo "transfer data: " . $rawBin . PHP_EOL;
echo "transfer len: " . strlen($rawBin) . PHP_EOL;
echo "unpack: " . implode("", unpack("C*", $rawBin));