Protobuf 编码原理

Posted by Masutangu on September 2, 2016

最近项目组有在用 protobuf,于是抽空读了些 protobuf 的相关资料。本文总结 protobuf 的编码原理,重点在于其如何实现版本兼容。文中样例及说明都参考了 protobuf 的官方文档

编码方法的介绍

要了解 protobuf 的编码方式,首先介绍下 Varint 和 ZigZag 这两种编码。

Varint

Varint 编码的优势在于值越小的数字,占用的字节更少。一般 int32 的数字都需要占用 4 个字节。使用 Varint 进行编码则有可能缩减到 1 个字节。反过来,如果是比较大的数字,则可能需要占用 5 个字节。 理解了 Varint 的目的(节省空间),来看看其原理:

Each byte in a varint, except the last byte, has the most significant bit (msb) set – this indicates that there are further bytes to come. The lower 7 bits of each byte are used to store the two’s complement representation of the number in groups of 7 bits, least significant group first.

即是说,Varint 中的每个字节的最高位 bit 有特殊的含义,该位为 1 表示后续的字节也是该数字的一部分,如果该位为 0 则是最后一个字节。其他的 7 个bit都用来表示数字,从最低有效字节开始。

举个例子,1 的二进制表示为 0000 0001。通过 Varint 编码后的二进制表示为 0000 0001,过程如下:

再举个例子,300 的二进制表示为 100101100,通过 Varint 编码后的二进制表示为 10101100 00000010,详细过程如下:

ZigZag

上节介绍了 Varints,我们知道 Varint 在处理小数值的数字很有效,而在处理值较大的数字则占用了多一个子节。对于负数来说,二进制最高有效位为 1,如果用 varint 来编码,无疑要占用比较多的子节。因此我们可以搭配 zigzag 来编码。

ZigZag 编码将有符整型转化成无符的整型,其原理是将最高位的符号位放到最低位(-1 除外),这样大大减少了字节占用。

举个例子,-2 的二进制表示为 1111 1110,用zigzag编码,-2 的绝对值为 2,二进制为 0000 0010,将符号位放到最低位,则变成 0000 0011。

公式如下:

  • 32 位整型:(n « 1) ^ (n » 31)
  • 64 位整型:(n « 1) ^ (n » 63)

Note that the second shift – the (n » 31) part – is an arithmetic shift. So, in other words, the result of the shift is either a number that is all zero bits (if n is positive) or all one bits (if n is negative).

注意这里的位移操作符。如果在位移运算符左边的变量是有符号数,编译产生的汇编指令是算术位移指令,如果该变量是无符号数,编译产生的汇编指令则是逻辑位移指令。对于左移,它们都一样,整个二进制右移,低位补 0;右移则有所区分,算数右移左边补最高符号位,逻辑位移左边补 0

举个例子,-2 经过 ZigZag 编码后为 3,过程如下:

Protobuf 的编码原理

版本兼容

Protobuf 支持向前向后兼容。向后兼容即升级的解码程序能够正确解析旧版本协议的数据,向前兼容则指旧版本的解码程序能够正确解析新版本协议的数据。如果新的协议新增了字段,旧版本的解析程序是如何自动跳过新字段的呢?

协议,即通信双方约定好的规则。收到数据时,可以根据约定好的规则进行解包。假设我们使用简单粗暴的编解码方法,将结构体定义的成员按类型依此打解包。如下图,我们约定好的协议包括三个字段,类型都为 int32,那解码函数将从接收到的字节流中依次取出 4 个字节并按整型解析。

如果发送方升级了协议,如下图:

很明显,如果旧的解码程序还是按照依次取出 3 个 int32 去解析的话,毫无疑问是错误的(会把结构体 old_message 的成员 d 的值解析成 0x1a )。如果有办法能跳过新增字段,就可以做到兼容,即旧的解码程序能正确解析出旧协议定义的字段,新增字段一律忽略。

Protobuf 采用的方法很简单也很实用,把 tag 和其类型一起打进去字节流,解码程序只要解析出不认识的 tag,就能知道该字段是新协议定义的,再通过其类型可以推断出该字段内容的长度,就能正确的跳过这部分 buffer,继续解析下一个字段。如下图:

当旧的解码程序解析到 tag 为 3 时,发现在旧协议里找不到该 tag,又从其类型 int 64 知道该 tag 的值占了 8 个字节,于是他跳过这 8 个字节,继续解析剩下的字节流。

实现

Protobuf 的实现中,将每个字段的 key 设为 varint 编码后的 (tag number << 3) | wire_type。即 key 的最低三位表示字段类型,将 key 右移三位后的值表示 tag number。wire_type 如下表格所示:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

如果是 Length-delimited Type,意味着长度不定,这时还需要在 key 后面多写入长度信息(用 varint 编码)。

举个例子:

1
2
3
message Test2 {
  required string b = 2;
}

将 b 的值设为 “testing”,protobuf 编码后的字节流为12 07 74 65 73 74 69 6e 67 key 为 0x12,可以算出 tag 值为 2(0x12 » 3), type 为 2 ( 0x12 取最低三位)。下个字节为 0x07 ,该字节表示长度,即长度为 7,因此后续的 7 个字节都为该 tag 的值。

另外 protobuf 还定义了 sint32/sint64 类型。sint32/sin64 类型专门用于编码负数。如果使用 int32/int64 来编码负数, 通过 varint 编码后的 buffer 长达 5/10 个字节。(int32 时占用5个字节,int64时占用10个字节,因为负数的最高位为1,会被当作非常大的整数处理)。

而 sint32/sint64 类型的值会先经过 zigzag 编码,转换成无符号整数,再采用 varint 编码,可以大大减少编码后占用的字节数。

If you use int32 or int64 as the type for a negative number, the resulting varint is always ten bytes long – it is, effectively, treated like a very large unsigned integer. If you use one of the signed types, the resulting varint uses ZigZag encoding, which is much more efficient.

使用建议

  • 常用消息字段(尤其是 repeated 字段)的 tag number 尽量分配在 1 ~ 15 之间。tag number 超过 16,key 的编码将占用多一个字节
  • 尽可能多的(全部)使用 optional 字段
  • 不能修改字段的 tag number
  • 不能增删任何 required 字段