编码机制
- 消息示例
- Base 128 Varints
- 消息结构
- 其他数据类型
- 嵌入式的消息
- Optional And Repeated Elements
- 字段顺序
这篇文档介绍了 protocol buffer 消息的二进制传输格式。理解与否不影响对 protocol buffer 的使用,
但是对于了解消息编码后的不同类型数据的大小区别会很有帮助。
消息示例
假设我们已经定义好了下面的消息:
message Test1 {
optional int32 a = 1;
}
在应用中我们给消息 Test1 的 a 赋值 150 ,然后在发送消息的过程中抓包会看到下面三个字节的数据:
08 96 01
这代表些什么意思?接下来会慢慢告诉你。
Base 128 Varints
要理解 protocol buffer 的编码机制,首先需要了解 varints。varints 用一个或多个字节序列化整型数据,越小的数需要的字节越少。
在 varint 中每一个字节(除了最后一个字节)的最高位(msb)都会被用来表示该字节后面还有没有其它字节,剩下的低七位用来表示
被拆分的这两部分数值,优先表示低位的数值。举例来说,单字节数值 1 的 msb 为 0,表示后面没有其他字节来共同表示 1:
0000 0001
稍复杂的例子如 300:
1010 1100 0000 0010
- 先去掉每个字节的最高位(代表该字节后面还有没有字节)
1010 1100 0000 0010
→ 010 1100 000 0010
- 然后把这两组七位二进制反转(因为 varint 先表示低位数值),拼接反转后的两组二进制得到原先的 300
000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
消息结构
Protocol buffer 消息是由一系列的键值对定义的。上面用于传输的二进制消息只用到了定义中字段的编号 —— 字段的名称和类型是在
二进制消息解码时通过参考消息类型的定义(即 .proto 定义文件)来最终确定的。
消息在编码时,键和值被连接成了字节流。消息在解码时,解释器会过滤掉它无法识别的字段。如此说来,在消息中添加新的字段并不会影响旧版本应用的正常运行。
为了不影响旧版本应用的正常运行,传输消息中代表键值对的 key 实际上包含两部分 —— 在 .proto 文件中的字段编号和一个可以表示数据长度的传输类型。
在很多程序语言的设计和实现中,这个 key 被称为 tag。下面是可用消息传输数据类型和消息定义数据类型的对照表:
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 |
在传输流中的每一个 key 都是一个 varint,这个 varint 是由字段编号左移三位和传输类型做或运算得到的,换言之,这个 varint 的后三个比特位
代表的就是传输类型。让我们再来看一下上面的例子,在传输流中的第一个数值代表的是 varint 类型的 key,在例子中是 08,去掉 msb 位为:
000 1000
从后三位可以得出传输类型(0),右移三位得到该字段的编号为 1 ,用上一节的 varint-decoding 方法解码剩下的两个字节得到该字段的值为 150:
96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (去掉 msb 位并反转两组7位二进制)
→ 10010110
→ 128 + 16 + 4 + 2 = 150
其他数据类型
- Signed Integers
上一小节已经介绍过了,所有关联传输类型 0 的 protocol buffer 数据类型都被用 varints 来编码。
然而,在对负数进行编码时,有符号的整型(sint32 和 sint64)和 “标准” 整型(int32 和 int64)是有很大区别的。
如果定义一个负数的类型为 int32 或 int64,编码后的 varint 长度总是 10 个字节 —— 实际上,这个负数会被当作一个非常大的无符号整数来处理。
如果这个负数的类型被定义成一种有符号的整型,在编码时会用更高效的 ZigZag 方式。ZigZag 编码方式对有符号的整型和无符号的整型做了映射,
所以说,绝对值较小的数值(比如说 -1)也会对应较小的 varint 编码值。
ZigZag 是通过来回地对正负值做 “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 的编码方式如下:
对于 sint32 系来说
(n << 1) ^ (n >> 31)
对于 64 位来说
(n << 1) ^ (n >> 63)
注意第二个右移 —— n >> 31
部分 —— 是算数右移(算数右移左边添加的数字和符号位有关,逻辑右移左边统一添加 0;
算数左移和逻辑左移一样,在右边统一添加 0)。所以说,右移的结果要么是全 0 位(如果 n 是一个正数),要么是全 1 位(如果 n 是一个负数)。
sint32 或 sint64 类型的数据在解析时,他们的值会被解码成对应的原始有符号数值。
- Non-varint Numbers
Non-varint 数值类型是易于理解的 —— double 和 fixed64 对应传输类型 1 ,这表明解释器期望解析的是一个固定的 64 位数据块;
类似地,float 和 fixed32 对应传输类型 5 ,它表明期望解析 32 位的数据块。
在这所有的情况下,数据都是采用小端存储(低尾端存储,即尾端存储在低地址)。
- Strings
传输类型 2 (length-delimited)表示该值是一个 varint 编码的长度后面紧跟着指定数量字节的数据。
message Test2 {
optional string b = 2;
}
给 b 赋值 “testing”:
12 07 74 65 73 74 69 6e 67
后七个字节代表 UTF8 编码的 “testing”。在这里 key 是 0x12,代表字段编号为 2,传输类型为 2 。
数据长度是 7 个字节,在其后面正好是 7 个字节的字符串 “testing”。
嵌入式的消息
定义一个嵌入 Test1 的嵌入式消息如下:
message Test3 {
optional Test1 c = 3;
}
下面是该消息在传输中的编码格式(还是给 Test1 的 a 字段赋值 150):
1a 03 08 96 01
后三个字节(08 96 01)和我们上面 Test1 的例子一样,并且在它们前面是长度 3 。这表示嵌入式消息的编码方式和 strings(传输类型为 2)是一样的。
Optional And Repeated Elements
如果在 proto2 消息中定义了不带[packed=true]
选项的 repeated 元素,编码后的消息数据会含有 0 或多个相同字段编号的键值对。
这些重复的键值对没有必要连续出项,它们有可能被其它字段错开。在解析时,元素相对于彼此的顺序会被保留,尽管这些顺序会丢失。
在 proto3 中,repeated 字段会采用 packed 编码方式,我们会在下面讲解。
对任何 proto3 中的 non-repeated 字段或 proto2 中的 optional 字段来讲,编码后的键值对表示不一定会包含它们。
通常来说,non-repeated 字段在编码后的消息中不会存在多个实例。然而,解释器已经可以处理这种重复实例的情况。
对于 numeric types 和 strings 来讲,如果一个字段出现了多次,解释器只会保留它的最后一个值。对于嵌入式的消息字段来讲,
解释器会合并同一字段的值,就好像使用了 Message::MergeFrom 方法一样 —— 用所有 singular 标准字段后来的值替换了
former 中相同字段的值,合并了 singular 嵌入式的消息,连接了 repeated 字段。
这些规则将会使以下两种方式产生一样的效果:
- 解析两个嵌入式消息的连接体
MyMessage message;
message.ParseFromString(str1 + str2);
- 分别解析两个单独的消息之后再合并结果
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这种特性在合并两种未知类型的消息时会有用武之地。
Packed Repeated Fields
2.1.0 版本引入了 packed repeated 字段,在 proto2 中被关键字 repeated 修饰并带有[packed=true]
选项,
在 proto3 中 repeated 字段默认就是 packed。这些功能和 repeated 字段类似,但是在编码上有区别。
包含 0 个元素的 packed repeated 字段不会出现在编码消息中,否则,这个字段的所有元素都会被 packed 成
一个 single 的键值对并且它的 wire type 为 2 (length-delimited),每个元素的编码方式与以往相同,只是在前面没有 key。
举个例子:
message Test4 {
repeated int32 d = 4 [packed=true];
}
为 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-bit、64-bit)的 repeated 字段才能被声明为 “packed”。
注意:通常不会用多个键值对来编码一个 packed repeated 字段,但是编码器必须能接收多个键值对。在这种情况下,
payloads 应该被连接,每一个键值对应该包含全部数量的元素。
Protocol buffer 解释器能够解析以 packed 方式编译的 repeated 字段,就像它们没被以 packed 方式编译过一样,
反之亦然。这使得在已有的字段上添加[packed=true]
选项有了向前和向后的兼容性。
字段顺序
虽然可以在 .proto 文件中以任意的顺序使用字段的编号,但是就如同我们给出的 C++、Java 和 Python 序列化示例代码中一样:消息在序列化时,已知字段会按它们的编号顺序地被写入。这样做允许解析代码能够依赖顺序的字段编号做优化。然而,protocol buffer 解释器可以解析按任意顺序排列的字段,因为不是所有的消息都会被简单地序列化 —— 比如说,有时通过简单地连接两个消息来合并它们会大有裨益。如果一个消息包含未知字段,目前 Java 和 C++ 的实现是在按编号顺序排列的已知字段后面以任意顺序写入它们,而在 Python 的实现中没有跟踪这些未知字段。