本文档描述了protocol buffers
消息的二进制传输格式。不需要了解这一点就可以在应用程序中使用protocol buffers
,但了解不同的protocol buffers
格式如何影响编码消息的大小是非常有用。
假设有以下非常简单的消息定义:
message Test1 {
optional int32 a = 1;
}
在应用程序中,创建一个Test1
消息并将a
设置为150,然后将消息序列化为输出流。如果能够检查编码的消息,会看到三个字节:
08 96 01
到目前为止,这么小的数字,它是什么意思?继续阅读......
要了解简单的protocol buffers
编码,首先需要了解varints
,它是一种使用一个或多个字节序列化整数的方法。较小的数字占用较少的字节数。
varint
中的每个字节(最后一个字节除外)都设置了最高有效位(msb,most significant bit):这表示还有很多字节。每个字节的低7位用于存储7位组中的二进制补码表示,最低有效组优先。
因此,如下所示:
0000 0001 // 这里是数字1,它是单个字节,因此`msb`未设置
1010 1100 0000 0010 // 这是300,有点复杂:
怎么知道这是300?首先从每个字节中删除msb
,因为这只是告诉我们是否已到达数字的末尾(如所见,它在第一个字节中设置,因为varint
中有多个字节) :
1010 1100 0000 0010
→ 010 1100 000 0010
可以反转这两组7位,因为varints
会将具有最低有效组的数字存储起来。然后连接它们以获得最终的值:
000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
protocol buffers
消息是一系列键值对。消息的二进制版本只使用字段的数字编号作为键值。每个字段的名称和声明的类型只能通过引用消息类型的定义(即.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 |
流式消息中的每个键都是带有值(field_number<<3)| wire_type
的varint
。换句话说,数字的最后三位存储传输类型。
现在让再看一下上面的例子。现在知道流中的第一个数字总是一个varint
键,这里是08,或者(删除msb):
000 1000
取最后三位得到传输类型(0),然后右移三次得到字段编号(1)。所以现在知道字段编号是1,以及下一个值是varint
。使用上一节中的varint-decoding
知识,可以看到接下来的两个字节存储值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
正如在上一节中看到的,与传输类型0关联的所有protocol buffers
类型都被编码为varints
。但是,在编码负数时,signed int
类型(sint32
和sint64
)与“standard”int
类型(int32
和int64
)之间存在重要差异。如果使用int32
或int64
作为负数的类型,则生成的varint
总是十个字节长,实际上,它被视为一个非常大的无符号整数。如果使用其中一种有符号类型,则生成的varint
使用ZigZag
编码,这样效率更高。
ZigZag
编码将有符号整数映射到无符号整数,因此具有较小绝对值(例如,-1)的数字也具有较小的varint
编码值。它通过正负整数来回“zig-zags”
的方式做到这一点,因此-1被编码为1,1被编码为2,-2被编码为3,依此类推,可以在下表中看到:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
换句话说,每个值n
被编码:
(n << 1) ^ (n >> 31)
成为sint32
s,或者:
(n << 1) ^ (n >> 63)
成为64为的版本。
注意,第二个移位(n >> 31)
部分是算术移位。因此,移位的结果是一个全为零的数字(如果n
为正)或全部为一位(如果n
为负)。
varint
数字非varint
数字类型很简单:double
和fixed64
有传输类型1,它告诉解析器期望一个固定的64位数据块; 类似地,float
和fixed32
具有传输类型5,这告诉它期望32位。在这两种情况下,值都以little-endian
字节顺序存储。
传输类型为2(长度分隔)表示该值是varint
编码长度,后跟指定的数据字节数。
message Test2 {
optional string b = 2;
}
将b
的值设置为“testing”
可以得到:
12 07 74 65 73 74 69 6e 67
第三个开始的字节是“testing”
的UTF8编码。这里的关键是0x12
→字段编号=2,类型=2.值中的长varint
是7,它后面的七个字节是所需要的字符串。
这是一个消息定义,带有示例类型Test1
的嵌入消息:
message Test3 {
optional Test1 c = 3;
}
这是编码版本,再次将Test1
的a
字段设置为150:
1a 03 08 96 01
如上所示,最后三个字节与第一个示例(08 96 01
)完全相同,并且它们前面是数字3,嵌入式消息的处理方式与字符串完全相同(传输类型= 2) 。
如果proto2
消息定义具有repeated
元素(没有[packed = true
]选项),则编码消息具有零个或多个具有相同字段编号的键值对。这些重复值不必连续出现; 它们可能与其他字段交错。解析时保留元素相对于彼此的顺序,尽管丢失了关于其他字段的顺序。
在proto3
中,repeated
字段使用压缩编码,可以在下面阅读。
对于proto3
中的任何非repeated
字段或proto2
中的optional
字段,编码消息可能具有或不具有该字段编号的键值对。
通常,编码消息永远不会有多个非repeated
字段的实例。但是,解析器应该处理这些情况。
Message::MergeFrom
方法一样。也就是说,后一个实例中的所有单个标量字段都替换前者,单个嵌入消息被合并,并且连接重复字段。这些规则的作用是解析两个连接的编码消息产生的结果与分别解析两个消息后并合解析结果所得到的结果完全相同。就是这样:MyMessage message;
message.ParseFromString(str1 + str2);
相当于:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
此属性偶尔会有用,因为它允许合并两条消息,即使不知道它们的类型。
版本2.1.0
引入压缩重复字段,在proto2
中声明为重复字段,但具有特殊的[packed = true
]选项。在proto3
中,默认情况下会压缩标量数字类型的重复字段。这些功能类似于重复的字段,但编码方式不同。包含零元素的压缩重复字段不会出现在编码消息中。否则,该字段的所有元素都被压缩到一个键值对中,其中传输类型为2(长度分隔)。每个元素的编码方式与正常情况相同,只是前面没有键。
例如,假设有如下消息类型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
现在假设构造一个Test4
,为重复字段d
提供值3,270和86942。然后,编码的形式将是:
22 // key (字段编号4, 传输类型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”
。
请注意,虽然通常没有理由为压缩重复字段编码多个键值对,但编码器必须准备好接受多个键值对。在这种情况下,应该连接有效负载(payloads)。每个键值对必须包含大量元素。
protocol buffers
解析器必须能够解析压缩的重复字段,就好像它们没有压缩一样,反之亦然。这允许以向前和后向兼容的方式将[packed = true
]添加到现有字段。
字段编号可以在.proto
文件中以任何顺序使用。选择的顺序对消息的序列化方式没有影响。
当序列化消息时,不能保证其已知或未知字段的写入顺序。序列化顺序是一个实现细节,任何特定实现的细节可能在将来发生变化。因此,protocol buffers
解析器必须能够以任何顺序解析字段。
protocol buffers
消息的传递字节字段的消息尤其如此。protocol buffers
消息实例上重复调用序列化方法可能不会返回相同的字节输出;即默认序列化不是确定性的。protocol buffers
消息实例foo
,以下检查可能会失败。foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
protocol buffers
消息foo
和bar
可以序列化为不同的字节输出。bar
由旧服务器序列化,将某些字段视为未知。bar
由服务器序列化,该服务器以不同的编程语言实现,并按不同顺序序列化字段。bar
有一个以非确定性方式序列化的字段。bar
有一个字段,用于存储protocol buffers
消息的序列化字节输出,该消息以不同方式序列化。bar
由新服务器序列化,该服务器由于实现更改因此以不同顺序序列化字段。foo
和bar
都是单个消息的串联,但顺序不同。