这个文档会介绍protocol buffer的二进制有线格式(binary wire format)。你并不是需要理解这些后才能在应用里使用protocol buffer,但是当你想知道不同的protocol buffer格式是如何影响编码后的消息体的体积时,这些知识会非常有用。
一个简单的消息
假设有一个非常简单的消息定义:
message Test1 {
optional int32 a = 1;
}
在应用中,你创建了一个Test1
消息并把a
设置为150。然后你把消息序列化到输出流中,如果你能查看编码后的消息,你会看到三个字节:
08 96 01
到目前为止,如此小而且都是数字-但是这是什么意思呢?继续往下看
Varint编码
要理解上面protocol buffer编码的数据,你需要先理解vaints
,Varints
是一种使用一个或多个字节编码整数的方法。较小的数字使用较少的字节。
除了最后一个字节外,varint编码中的每个字节都设置了最高有效位(most significant bit – msb)–msb为1则表明后面的字节还是属于当前数据的,如果是0那么这是当前数据的最后一个字节数据。每个字节的低7位用于以7位为一组存储数字的二进制补码表示,最低有效组在前,或者叫最低有效字节在前。这表明varint编码后数据的字节是按照小端序排列的。
举例来说,对于数字1-它占用单个字节,所以字节的最高位上是0
0000 0001
对于数字300会有一点复杂,它占用俩个字节
1010 1100 0000 0010
那么是怎么计算出来是300的呢?首先你需要把每个字节的msb去掉,因为它只用来告诉我们是否已经到达数字的最后一个字节(本例的varint占用俩个字节所以第一个字节的msb为1)
1010 1100 0000 0010
→ 010 1100 000 0010
将两组7位反转,因为你记得,varint存储的数字最低有效组在前。然后,将它们连接起来以获得最终值
000 0010 010 1100 (去掉最高有效位,并反转7位组)
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
注:varint编码理解起来有点难,可以看之前写的varint编码原理解析。
消息的组成
如你所知,一个protocol buffer是一系列键值对。消息的二进制格式只使用消息字段的字段编号作为键–字段名和声明的类型只能在解析端通过引用参考消息类型定义(即.proto
文件)才能确定。
当一个消息被编码时,键和值会被连接放入字节流中。当消息被解码时,分析器需要能够跳过未识别的字段。这样,新加入消息的字段就不会破坏不知道他们存在的那些老程序。为此,有线格式消息中每个对的“键”实际上是两个值-.proto文件中的字段编号,加上一种有线类型,该类型仅提供足够的信息来查找随后的值的长度。在大多数语言实现中,这个键称为标签。
可用的有线类型如下:
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 |
在消息流中的每个键都是varint,使用(filed_number << 3) | wire_type
获得–也就是说字节的后三位存储的是有线类型。
现在让我们再回到上面的消息示例。你现在知道字节流中的首个字节永远都是一个varint键,在我们的例子中它是08或者下面的二进制(去掉了msb)。
000 1000
通过后三位得出有线类型(0),然后右移三位得到字段编号(1)。现在你知道字段的编号是1对应的值是一个varint。使用前面学到的解码varint的知识,你可以看到下面的两个字节存储着值150。
96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (去掉最高有效位,并反转7位组)
→ 10010110
→ 128 + 16 + 4 + 2 = 150
更多值类型
有符号整数
就像你在上一部分看到的那样,protocol buffer中所有与有线类型0关联的类型都会被编码为varint。但是,在编码负数时,带符号的int类型(sint32和sint64)与“标准” int类型(int32和int64)之间存在着巨大区别。如果将int32或int64用作负数的类型,则结果varint总是十个字节长––实际上,它被视为一个非常大的无符号整数。如果使用带符号类型(sint32和sint64)之一,则生成的varint使用ZigZag编码,效率更高。
ZigZag编码将有符号数映射到无符号数以便具有较小绝对值的数字(比如-1)也具有较小的varint编码值。这样做的方式是通过正整数和负整数来回“曲折”,将-1编码为1,将1编码为2,将-2编码为3,依此类推,可以在下表中看到:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
非varint数字
对与非可varint编码的数字来说比较简单–double
和fixed64
使用有线类型1,这会告诉解析器期望固定的64-bit的数据块。相似地float
和fixed32
使用有线类型5,这会告诉解析器期望固定的32-bit数据块。这两种情况都是使用小端序排列字节存储数据的。
字符串
有线类型2(长度分隔)表示该值是varint编码的长度值,后跟长度值指定数量的数据字节。
message Test2 {
optional string b = 2;
}
设置b的值为”testing”后消息对应的二进制有线格式为
12 07 <font color=”red”>74 65 73 74 69 6e 67</font>
红色的字节是UTF-8编码后的”testing”
这里的键是0x12→0001 0010
→字段号= 2,类型=2(第一个字节的后三位表示有线类型的编号,然后右移三位变成000 0010
得到字段号)。值中的varint表示的数据字节长度是7,如你所见我们在它后面找到的七个字节–就是解析器要找的字符串。
内嵌消息
下面是一个拥有内嵌消息的消息定义Test3
,内嵌的消息类型是我们上面示例中定义的Test1
message Test3 {
optional Test1 c = 3;
}
下面则是内嵌的Test1中的
a设置为150,
Test3`被编码后的版本
1a 03 <font color=”red”>08 96 01</font>
如你所见,最后三个字节和我们第一个例子编码后的结果一样(08 96 01
),在他们之前是数字3,–内嵌消息会像字符串一样被对对待(有线格式=2)。
可选和可重复元素
如果proto2消息定义具有重复的元素(不带[packed = true]选项),则编码消息具有零个或多个具有相同字段编号的键值对。这些重复的值不必连续出现。它们可能与其他字段交错。解析时,元素之间的顺序会保留下来,尽管其他字段的顺序会丢失。在proto3中,重复字段使用packed编码,可以在下面看到相关编码。
通常,编码消息永远不会有一个以上非重复字段的实例。但是,解析器能处理这种实际情况,对于数字类型和字符串,如果同一字段多次出现,则解析器将接受它看到的最后一个值。对于嵌入式消息字段,解析器将合并同一字段的多个实例,就像使用Message :: MergeFrom
方法一样-也就是说,后一个实例中的所有单个标量字段将替换前一个实例中的单个标量字段,可重复字段会被串联到一块。这些规则的作用是,解析两个编码的消息的连接所产生的结果与您分别解析两个消息并合并结果对象的结果完全相同。也就是说:
MyMessage message;
message.ParseFromString(str1 + str2);
等同于
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这个特性有时很有用,因为即使您不知道它们的类型,也允许你合并两个消息。
压缩重复字段
proto版本2.1.0引入了压缩重复字段,在proto2中声明为重复字段,并使用特殊的[packed = true]选项。在proto3中,默认情况下压缩标量数字类型的重复字段。这些功能类似于重复的字段,但编码方式不同。包含零元素的压缩重复字段不会出现在编码的消息中。否则,该字段的所有元素都将打包为有线类型为2(定界)的单个键值对。每个元素的编码方式与通常相同,不同之处在于元素之前没有键。
举例来说,你有以下消息类型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
现在假设您构造一个Test4,为重复的字段d提供值3、270和86942。然后,消息编码后的形式为:
22 // key (field number 4, wire type 2)
06 // payload size (6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)
只能将原始数字类型(使用varint,32位或64位线型的类型)的重复字段声明为“packed”。
字段顺序
字段编号可以在.proto文件中以任何顺序使用。选择使用的顺序对消息的序列化方式没有影响。
序列化消息时,对于如何写入其已知字段或未知字段没有保证的顺序。序列化顺序是一个实现细节,将来任何特定实现的细节都可能更改。因此,protocol buffer解析器必须能够以任何顺序解析字段。