Google Protocol Buffer的编解码原理
Google Protocol Buffer(简称protbuf)是一种紧凑的可扩展的二进制消息格式,并且如上一篇文章《解析Google Protocol Buffer消息类型的自动反射原理》中介绍的那样,它还自带消息类型反射的功能。从Google公布的benchmark来看,protobuf的效率还是很高的,这主要依赖于它的高效编解码方式。
先来看一个简单的例子:message Test1
{
required int32 id = 1;
}
在一个应用中,我们将一个Test1的Message中的id设为150,将这个Message进行编码序列化以后以16进制输出到stdout中,会看到这样的3个字节:08 96 01
也就是说只用了3个字节就表示了数据类型和数据的值。
Base 128 Varints
Varints是protobuf的序列化整型数据的一种编码方式,它的优点是整型数据的值越小,编码后所用的字节数越小。
经过Varints编码后的数据,它的每一个字节8bit的高位代表一个标记位。如果标记位是1,则代表下一个字节仍然是当前整型数据的组成;如果标记位是0,则代表下一个字节就是下一个整型数据了。
先看最简单的数值1,一个字节足以表示它,所以它的标记位置0:0000 0001
再看数值300,经过Varints编码后的序列:1010 1100 0000 0010
从左到右,依次将每个字节的高位(标志位)去掉,若标志位为1,则下一个字节仍是当前数据的组成,若标志位为0,则下一个字节是下一个数据的组成。1010 1100 0000 0010
→ 010 1100 000 0010
因为protobuf是以little endian来编码字节序的,所以将两个字节交换、拼接,即可得到原始数据的二进制表示:000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
消息结构
我们知道,protobuf的消息是经过编码序列化的一系列key-value对,一个类型的数据对应一个key-value。value就是原始数据经过编码后的数据,而key由field number和wire type组成。其中field number是指.proto文件中定义的数据变量的field值。message Test
{
required int32 id = 1; // (1为field number)
}
Test类型中的数据id的field number就为1。
wire type是指的是.proto文件中定义的数据变量的类型的序号,protobuf中可以定义的所有数据类型的序号如下表:
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64 | 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 |
所以,数据id的wire type就为0,代表它是用varint进行编码的。
对于key-value中的key的值,是通过移位再相或的方法将field number和wire type用一个字节来存储的:(field_number << 3) | wire_type
即低三位表示wire type,其他的位表示field number。
再回到本文开头的例子,先看第一个字节080000 1000
wire type为0,field name为1。符合id的类型是int32,编码方式是Varints,field是1的这一情况。
而后两个字节应该是150经过Varints编码后的序列:96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (drop the msb and reverse the groups of 7 bits)
→ 10010110
→ 2 + 4 + 16 + 128 = 150
有符号整型
上文中提到例子都是wire type为0,也就是Varints编码。然而,对于protobuf来说,编码「标准」的整型(int32,int64)和编码「有符号」的整型(sint32,sint64)的时候,对于负数的处理方式是不同的。如果用int32或者int64来编码一个负数的话,通常需要耗费10个字节来表示,因为负数在计算机中是以补码表示的,相当于一个数值很大的无符号数。
所以这个时候就需要用「有符号」的整型(sint32,sint64)来表示负数了,它们先使用ZigZag编码方式将负数转换为绝对值较小正数,再进行Varints编码。
ZigZag将会这样编码,-1编码为1,1编码为2,-2编码为3……
Signed Original | Encoded As |
---|---|
0 | 0 |
1 | 1 |
1 | 2 |
2 | 3 |
2147483647 | 4294967294 |
2147483648 | 4294967295 |
sint32的ZigZag编码公式为(n << 1) ^ (n >> 31)
相应的,sint64的ZigZag编码公式为(n << 1) ^ (n >> 63)
有一点需要注意,(n >> 31)和(n >> 64)使用的是算术移位(也就是用符号位扩展移位)。
Non-varint Numbers
wire type为1和5的整型数据都是非Varints编码的。fixed64, sfixed64, double固定用64bit也就是8个字节来表示,fixed32, sfixed32, float固定的用32bit也就是4个字节来表示。
需要注意的是他们都使用little endian来编码字节序的。
字符串
wire type为2代表着该类型的数据长度是指定的。也就是用在key-value的中间加上一个经过Varints编码的表示value长度字段,即key-len-value。message Test2
{
required string str = 2;
}
将str设置为「testing」,然后把经过protobuf编码序列化后的数据以16进制的方式输出到stdout12 07 74 65 73 74 69 6e 67
第一个字节0x12表示field name为2,wire type为2。第二个字节0x07表示数据长度为7,所以后面7个字节就是使用UTF8编码的字符数据,即「testing」。
嵌套消息
下面这个例子中,嵌套使用了上文的Test1类型message Test3
{
required Test1 c = 3;
}
同样的,Test1中c的值设为150,编码后得到的16进制序列如下1a 03 08 96 01
第一个字节0x1a表示field number为3,wire type为2。我们发现后三个字节(08 96 01)和第一个例子中的序列一样,所以第二个字节0x03的意义就和string中的一样,代表数据的长度。
Optional And Repeated Elements
如果数据类型定义为repeated(没有选项packed=true)的话,将存在一个或多个具有相同field number值的key-value对。由于历史遗留的原因,如果repeated数据没有加上[packed=true]选项的话,repeated的系列数据并不一定是连续的。由于可能有多个key-value对的存在,导致效率会略有降低。
如果数据类型定义为optional的话,将存在0个或1个包含field number值的key-value对。
正常情况下,required和optional类型的最多只能出现一次,但是protobuf在做parse的时候,得考虑到所有的情况。所以对数值类型和字符串,如果出现多次,那么将只接受最后出现那个数据。对于嵌套类型,将有相同field值数据像Message::MergeFrom一样进行合并,即
all singular scalar fields in the latter instance replace those in the former, singular embedded messages are merged, and repeated fields are concatenated
这个规则带来的好处就是,parse两个连续的已编码好的Message的结果与分别单独parse两个Message后再合并得到的结果是一致的。所以,下面两种是情况是相等的。// 情况一
MyMessage message;
message.ParseFromString(str1 + str2);
// 情况二
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
Packed Repeated Fields
protobuf从2.1版本开始,对于repeated类型数据可以加上[packed=true]选项。加上这个选项后,每个repeated类型的数据连续排列,并且所有的repeated类型数据和field number、wrie type、len组成一个大的key-value(相当于key-value, value, value…)。
例如,下面一个例子:message Test4
{
repeated int32 d = 4 [packed=true];
}
建立一个Test4类型的Message对象,添加3个d类型变量,值分别为3, 270, 86492。得到编码后的16进制数据为:22 // tag (field number 4, wire type 2)
06 // len (6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)
需要注意的是只有repeated类型的数据才能设置[packed=true]选项。
Field Order
尽管我们在编写.proto文件的Message时,可以按任意顺序设置变量类型的field number,但是protobuf在进行编码序列化的时候,会按field number的大小顺序存储数据类型,这样有利于parse时候的一些优化。但由于不是所有的消息都是由一个经过编码序列化的Message对象组成的,所以protobuf也可以parse按任意field number顺序存放的数据。在某些场合下,单纯的将2个Message对象合并(连接)起来也是有用的。
在C++实现中,如果一个Message对象中的数据含有无效的field number值,那么protobuf会把这个对象的数据按任意顺序存放。
参考文献
- Protocol Buffers. Encoding
- Wikipedia. Zigzag
- Google Project Hosting. Benchmarking