Protobuf编码指南

5次阅读

共计 4113 个字符,预计需要花费 11 分钟才能阅读完成。

这个文档会介绍 protocol buffer 的二进制有线格式(binary wire format)。你并不是需要理解这些后才能在应用里使用 protocol buffer,但是当你想知道不同的 protocol buffer 格式是如何影响编码后的消息体的体积时,这些知识会非常有用。

一个简单的消息

假设有一个非常简单的消息定义:

message Test1 {optional int32 a = 1;}

在应用中,你创建了一个 Test1 消息并把 a 设置为 150。然后你把消息序列化到输出流中,如果你能查看编码后的消息,你会看到三个字节:

08 96 01

到目前为止,如此小而且都是数字 - 但是这是什么意思呢?继续往下看

Varint 编码

要理解上面 protocol buffer 编码的数据,你需要先理解 vaintsVarints 是一种使用一个或多个字节编码整数的方法。较小的数字使用较少的字节。

除了最后一个字节外,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 编码的数字来说比较简单 –doublefixed64 使用有线类型 1,这会告诉解析器期望固定的 64-bit 的数据块。相似地 floatfixed32使用有线类型 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 解析器必须能够以任何顺序解析字段。

正文完
 0