本文介绍protocol buffer消息二进制传输格式。在应用程序中使用protocol buffer时,你并不需要了解这些,但它对你了解protocol buffer格式如何影响你的编码消息的大小很有用。
简单消息
我们从一个非常简单的消息定义开始:
message Test1 {
int32 a = 1;
}
在程序中,你可以创建一个Test1
,然后设置a
为150。之后你讲消息序列化到一个输出流。如果你想检查编码的消息,你会看到三个字节:
08 96 01
那么,这些数字代表什么呢?接着往下看。
Base 128 Varints
要了解简单的protocol buffer编码。首先你要了解varints。Varints是使用一个或多个字节序列化整数的一种方法。数字越小,占用的字节越少。
Varint中除最后一个字节外的的每个字节,都设置了最高有效位(most significant bit, msb) -- 这表明还有更多的字节要处理。每个字节的低7位用来存储以7位为一组的数字的补码表示形式,最不重要的一组优先。
例如,数字1 -- 单个字节,所以msb未设置:
0000 00001
数字300,更复杂的一个bit:
1010 1100 0000 0010
你如何知道它是300呢?首先,从每个字节中删除msb,因为它只是用来告诉我们是否已经到达数字的末尾(如您所见,它是在第一个字节中设置的,因为varint中有多个字节):
1010 1100 0000 0010
→ 010 1100 000 0010
取反这两组7bit是,因为Varints先存储最不重要的有效组。然后把它们串联起来就得到你的最终值:
000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
消息结构
如你所见,protocol buffer消息是一系列的键值对。消息的二进制版本只是用字段序号作为键 -- 字段的名称和类型只能通过引用消息类型的定义(.proto
文件)来解码。
消息编码时,键和值被串联到字节流中。消息解码时,解析者需要能忽略掉不能识别的字段。这样,新的字段可以在不破坏不能识别它们的旧程序的情况下添加到消息中。为此,在传输格式中的消息的“键”有两部分:.proto
文件中的字段序号,和一个wire类型,它仅提供足够找到下列值的长度的信息。在大多数语言的实现中,这个键作为tag使用。
可用的wire类型如下:
Type | 意义 | 用途 |
---|---|---|
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 |
在流式消息中,每个键都是一个包含(field_number << 3) | wire_type
值得varint -- 换句话说,数字的最后三位存储wire类型。
现在我们再来看看我们的例子。现在你知道流中的第一个数总是varint键,这里它是08,或(丢弃msb):
000 1000
最后3个bit是wire类型(0),然后再右移3位得到字段序号(1)。现在你知道字段序号是1,之后的值是varint。使用从上面得到的varint解码知识,我们可以得出下2位存着值150。
96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (drop the msb and reverse the groups of 7 bits)
→ 10010110
→ 128 + 16 + 4 + 2 = 150
更多变量类型
有符号整型
如你所见,在上节中所有wire类型为0的protocol buffer类型都被编码为variants。然而,在编码负数时,有符号整型(sint32
和sint64
)和“标准的”整型(int32
和int64
)有很大的不同。如果你用int32
或int64
类型来表示负数,编码后的varint总是10字节长 — 负数作为非常大的无符号整数是非常有效的。如果你使用的是有符号类型之一,编码后的varint使用ZigZag编码(一种更有效的编码)。
ZigZag编码将有符号整数映射为无符号整数,这样绝对值 小的数字(例如,-1)varint编码的值也很小。如下表所示,采用“zig-zags”在正负数间交替的方式,这样,-1被编码为1,1编码成2,-2编码成3,以此类推:
有符号原始数字 | 编码后 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-214748364 | 4294967295 |
换句话说,n
采用如下方式进行编码:
对sint32
,
(n << 1) ^ (n >> 31)
对64位版本,
(n << 1) ^ (n >> 63)
注意,第二个移位 --- (n >> 31)
部分 --- 是一个算术移位。所以,换言之,移位的结果要么是一个所有位全是0的数字(如果n
是正数),要么是一个所有位全是1的数字(如果n
是负数)。
当sint32
或sint64
被解析时,其值将会解码为原始的、有符号版本。
非varint数字
非varint数字类型很简单 --- double
和fixed64
wire类型为1,意味着告诉解析器需要固定的64位存储数据;类似的,float
和fixed32
wire类型为5,意味着需要32字节存储数据。在这两种情况下,值都是以低位字节顺序存储的。
字符串
wire类型2(以长度分隔)意味着该值是一个varint编码的长度,后跟指定的数据字节数。
message Test2 {
string b = 2;
}
当b被设置为“testing”时:
12 07 74 65 73 74 69 6e 67
红色字体部分是UTF-8编码的“testing”。此处的键0x12表示字段序号为2,类型为2。该变量的varint长度为7,因为我们在它后面找到了7个字节 --- 我们赋值的字符串。
内嵌类型
下面的消息定义内嵌了我们之前的示例类型,Test1:
message Test3 {
Test1 c = 3;
}
下面是编码后的版本,依然是将Test1的a
字段设置为150:
1a 03 08 96 01
如你所见,最后3个字节实际上与我们的第一个示例(08 96 01
)一样,它们位于数字3之后 --- 实际上嵌入类型处理方式与字符串(wire类型为2)一样。
重复元素
在proto3中,重复字段使用(packed encoding)[###打包重复字段],之后会有介绍。
在proto3中的任意非重复字段,编码的消息可能有与字段序号对应的键值对,也可能没有。
通常,编码的消息不会有超过一个非重复字段的实例。但是,解析器将按照它们的方式来处理这种情况。对数字和字符串类型,如果相同的字段出现多次,解析器只接受它遇到的最后一个值。对于内嵌类型,解析器会合并同一字段的多个实例,就像使用Message::MergeFrom
方法 --- 即,后一个实例中的所有单个标量字段替换前一个实例中的所有标量字段,合并单个内嵌消息,并连接重复的字段。这些规则的效果就是,在解析两个编码消息的连接时产生相同的结果,就好像你在解析两个独立的消息并合并解析后的对象。即:
MyMessage message;
message.ParseFromString(str1 + str2);
等同于:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这个特性有时候很有用,因为它允许你合并两个消息,即使你并不知道它们的类型。
打包重复字段
2.1.0版本有介绍打包重复字段。在proto3中,标量数字类型的重复字段默认是打包的。这些函数与重复字段的类似,但编码方式不同。编码的消息中不会出现包含零个元素的压缩重复字段。否则,字段的所有元素都被打包成一个键-值对,wire类型为2(以长度分隔)。每个元素的编码方式与常用的相同,只是前面没有键。
如下的消息类型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
现在来构建Test4
,为重复字段d
提供值3270和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-bit或64-bit的wire类型)的重复类型才能直接“打包”。
注意,尽管通常不会编码超过一个键值对的被打包的重复字段,但是,编码者必须准备好接受多个键值对。在这种情况下,应该连接有效载荷。每对都必须包含一个完整的元素。
Protocol buffer解析器必须能像解析packed
的重复字段一样解析未打包的字段,反之亦然。为已有字段添加[packed=true]
以保证向前和向后兼容。
字段排序
在.proto
文件中,字段序号可以以任何顺序使用。所选的顺序对如何序列化消息没有任何影响。
当消息序列化时,其已知或未知字段的写入顺序是没有保证。序列化顺序是实现细节,将来实现的任何部分细节都是可能改变的。因此,解析器必须能够解析任何顺序的字段。
说明
- 不要假设序列化的消息输出是固定的。这对表示其他序列化的protocol buffer消息的传递字节字段的消息更是如此。
- 默认情况下,重复调用同一protocol buffer消息实例的序列化方法并不会返回相同的输出;即序列化结果是不确定的。
- 确定性序列化仅保证特定二进制文件的相同字节输出。字节输出可能在不同版本的二进制文件之间改变。
- 对于protocol buffer消息实例
foo
,下列的检查可能会失败:foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
- 即使
foo
和bar
可能序列化出不同的字节输出,在下列场景中它们逻辑上是等价的:bar
是由一个将某些字段视为未知的旧服务序列化的。bar
是由不同语言实现的服务序列化的,且以不同的顺序序列化字段。bar
有一个以不确定方式序列化的字段。bar
有一个字段,该字段存储着被不同方式序列化的protocol buffer消息的序列化字节输出。bar
由一个新服务器序列化,该服务器根据实现更改以不同的顺序序列化字段。foo
和bar
都是独立消息的连接,但顺序不同。
更多信息,参见这里。