12

二进制编码传输协议

思考

  1. 何为二进制协议传输,何为文本协议数据传输?
  2. 网络编程中数据协议的制定方式有哪些?
  3. Protobuf 等二进制数据序列化传输协议的机制是什么?

在网络编程中,经常看到要求数据要以二进制的方式进行传输,起初我很不理解,为什么要刻意的说明二进制方式呢?数据在底层的传输不都是二进制流吗?而且还引出了 pack/unpack 方法簇。

我们经常用到的 rpc,比如 json-rpc 是以 文本方式 传输序列化的数据的。grpc(protobuf), thrift 都是以 二进制方式 传输数据的。所以到底何为二进制传输呢?

大家可以先想一下日常中发送请求时经常用到的方式: xml, json, formData,他们虽然格式不同,但都有一个特征,自带描述信息(直白说就是携带 参数名),像 文本 一样,能很直观的看到数据表征的内容。

如果我们事先定义了数据中的n~m个字节位固定作为某参数的数据段,就可以免去 参数名 所带来的额外开销。比如 0 ~ 10 字节为 account11 ~ 24 字节为 passowrd。又因为用户名或密码是非定长的,而解析数据时又要根据字节位精准的截取,所以我们需要对数据项进行打包填充,使其固定字节长度,而后在服务端进行解包,pack/unpack便可以实现此功能。

白话

tcp 协议是日常中最为常见的二进制协议,协议体的字节位都有约定好的表征。

http 在广义上来说也是二进制模式,使用 \r\n 对协议进行解包,解析,但 http 携带的数据通常都是文本模式的,比如 "sqrtcat" 占了 7 个字节,在文本 or 二进制模式下没什么区别,但"29",以文本模式发送需要 2bytes,以二进制模式打包至字符类型,只需要 1bytes。

二进制为何能提高数据传输效率:

  1. 根据协议约定,省去参数名所占用的字节,缩减了数据。
  2. 将数值类型的数据打包至相应范围内的二进制,节省了空间,4bytes能表示 32 位的文本数值,但文本数据值要 32bytes。
  3. 在一定程度上可以起到加密数据的作用,如果第三方不知道数据协议,就没有办法截取相应的字节为获取数据,或得到数据的表征。

文本方式传输

日常开发,比如发送一个用户注册 http协议 请求,发送的数据格式分别如下:

$registerData = [
    "account"  => "sqrtcat",
    "password" => "123456"
];

formData 31bytes

account=sqrtcat&password=123456

json 41bytes

{"account":"sqrtcat","password":"123456"}

xml 94bytes

<?xml version="1.0" encoding="UTF-8" ?>
<account>sqrtcat</account>
<password>123456</password>

以上三种皆为,我们可以很直观的在数据体重得到各项参数。

二进制传输方式

二进制传输,离不开协议的制定。文本方式传输的数据可以自我描述,而二进制方式传输的数据,需要通过协议进行解析和读取。

最简单的,参数定长的方式,account 固定为 11 位,password 固定为 14 位,使用 pack将数据填充至相应的协议长度,发送,服务端按协议进行字节长度的截取获得对应的参数值。

<?php
// binary protocal:
// |-- 11 bytes account --|-- 14 bytes password --|
$account = "sqrtcat";
$password = "123456";

// pack
// A 以空白符对数据进行填充 php 解包时会自动 trim 掉
// a 以 0x00 字符对数据进行填充 php 解包时会保留 0x00
$dataBin = pack("A11A14", $account, $password);

// send
echo "data pack to bin len: " . strlen($dataBin) . PHP_EOL;
echo "data pack to bin: " . $dataBin . PHP_EOL;

// unpack
$dataArr = unpack("A11account/A14password", $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只有一个字符 spassword 也只有一个字符 1,在此协议下还是固定 25bytes,文本传输反而效率会高一些。

二进制传输败北了?No,是我们协议太简单,不够灵活,没有最大程度上发挥协议+二进制的高效性,可以说,协议下的二进制传输方式,能做到绝对的高效于文本传输,这里我们可以简单的分析和模拟以二进制方式传输的 protobuf 的协议模式。

Protobuf 的二进制传输

我们可以简单分析下 protobuf传输数据的方式:

  1. 定义 IDL,其实就相当于制定了协议体
  2. 生成 proto 文件,得到具体的消息字段的 参数项位参数长度位 映射的消息协议包。
  3. 发送端根据消息协议定义的参数数据类型(主要是变长 or 定长),将数据打包至相应的二进制格式。
  4. 发送数据。
  5. 接收端按消息协议格式对二进制数据进行解析,获得文本数据。

这里原谅我自己造了两个词,参数项位参数长度位,如何理解呢?通过下面模仿 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

协议数据类型的二进制格式约定

主要是定义哪些类型是定长,哪些类型是变长,变长类型还需给定长度位的字节数。

<?php
/**
 * 协议数据类型
 * //|    参数位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)
    {
        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);
        }
    }
}
得到消息协议包
<?php
class RegisterRequest extends ProtocolMessage
{
    public $account;
    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;
    }
}
打包至二进制
<?php
$data = [
    'account'  => '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;
数据解析

开始解析数据:

  1. 按协议约定,第一个参数项位是 account, 类型是 string,用 1byte 表示数据长度,读取 1byte 获取 account 的长度,再读取相应的长度,获得 account 的数据内容,参数项1解析完成。
  2. 按协议约定,第二个参数项位是 password,类型是 string,用 1byte 表示数据长度,读取 1byte 获取 password 的长度,再读取相应的长度,获得 password 的数据内容,参数项2解析完成。
  3. 按协议约定,第三个参数项位是 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|

但还是不够完美:

  1. 长度位不够灵活,例子中固定用1bytes去表示,那万一数据长度超过 255 了呢,最好有一个约定,定义好某参数的长度位的bytes数。
  2. '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填充到绝对位置
二进制数据压缩
<?php

$raw = "69984567982132123122231";

echo "raw data: " . $raw . PHP_EOL;
echo "raw len:" . strlen($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;

    $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));

big_cat
1.7k 声望130 粉丝

规范至上


引用和评论

0 条评论