编码

解释 Protocol Buffers 如何将数据编码到文件或网络中。

本文档描述了协议缓冲区的 *线格式*,它定义了消息如何在网络上传输以及在磁盘上占用多少空间的详细信息。您可能不需要了解这一点才能在应用程序中使用协议缓冲区,但对于进行优化来说,这是一个有用的信息。

如果您已经了解这些概念但想要参考,请跳到 浓缩参考卡片 部分。

Protoscope 是一种非常简单的语言,用于描述低级线格式的片段,我们将使用它来提供各种消息编码的视觉参考。Protoscope 的语法由一系列 *标记* 组成,每个标记都编码到特定的字节序列中。

例如,反引号表示原始十六进制文字,如 `70726f746f6275660a`。这编码为文字中表示的十六进制的精确字节。引号表示 UTF-8 字符串,如 "Hello, Protobuf!"。此文字与 `48656c6c6f2c2050726f746f62756621` 同义(如果您仔细观察,它由 ASCII 字节组成)。在讨论线格式的各个方面时,我们将介绍更多 Protoscope 语言。

Protoscope 工具还可以将编码的协议缓冲区转储为文本。有关示例,请参阅 https://github.com/protocolbuffers/protoscope/tree/main/testdata

一个简单的消息

假设您有以下非常简单的消息定义

message Test1 {
  optional int32 a = 1;
}

在应用程序中,您创建一个 Test1 消息并将 a 设置为 150。然后,您将消息序列化到输出流。如果您能够检查编码的消息,您将看到三个字节

08 96 01

到目前为止,它很小且是数字的——但它意味着什么?如果您使用 Protoscope 工具转储这些字节,您将获得类似 1: 150 的内容。它如何知道这是消息的内容?

Base 128 变长整数

可变宽度整数,或 *变长整数*,是线格式的核心。它们允许使用 1 到 10 个字节对无符号 64 位整数进行编码,其中较小的值使用较少的字节。

变长整数中的每个字节都有一个 *延续位*,指示其后面的字节是否属于该变长整数。这是字节的 *最高有效位* (MSB)(有时也称为 *符号位*)。较低的 7 位是有效负载;生成的整数是通过将组成字节的 7 位有效负载拼接在一起构建的。

因此,例如,以下是数字 1,编码为 `01`——它是一个字节,因此 MSB 未设置

0000 0001
^ msb

以下是 150,编码为 `9601`——这有点复杂

10010110 00000001
^ msb    ^ msb

您如何确定这是 150?首先,您从每个字节中删除 MSB,因为这只是为了告诉我们是否已到达数字的末尾(如您所见,它在第一个字节中已设置,因为变长整数中有多个字节)。这些 7 位有效负载采用小端序。转换为大端序,连接,并解释为无符号 64 位整数

10010110 00000001        // Original inputs.
 0010110  0000001        // Drop continuation bits.
 0000001  0010110        // Convert to big-endian.
   00000010010110        // Concatenate.
 128 + 16 + 4 + 2 = 150  // Interpret as an unsigned 64-bit integer.

因为变长整数对协议缓冲区至关重要,所以在 protoscope 语法中,我们将它们称为普通整数。150`9601` 相同。

消息结构

协议缓冲区消息是一系列键值对。消息的二进制版本仅使用字段的编号作为键——每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。Protoscope 无法访问此信息,因此它只能提供字段编号。

当消息被编码时,每个键值对都会转换为一个 *记录*,该记录由字段编号、线类型和有效负载组成。线类型告诉解析器后面的有效负载有多大。这允许旧解析器跳过它们不理解的新字段。这种类型的方案有时称为 标记-长度-值 或 TLV。

有六种线类型:VARINTI64LENSGROUPEGROUPI32

ID名称用于
0VARINTint32、int64、uint32、uint64、sint32、sint64、bool、enum
1I64fixed64、sfixed64、double
2LENstring、bytes、嵌入式消息、打包重复字段
3SGROUP组开始(已弃用)
4EGROUP组结束(已弃用)
5I32fixed32、sfixed32、float

记录的“标记”编码为一个变长整数,该整数由字段编号和线类型通过公式 (field_number << 3) | wire_type 形成。换句话说,在解码表示字段的变长整数后,低 3 位告诉我们线类型,其余整数告诉我们字段编号。

现在让我们再次查看我们的简单示例。您现在知道流中的第一个数字始终是变长整数键,这里它是 `08`,或者(删除 MSB)

000 1000

您取最后三位以获取线类型 (0),然后右移三位以获取字段编号 (1)。Protoscope 将标记表示为整数后跟冒号和线类型,因此我们可以将上述字节写为 1:VARINT

因为线类型是 0 或 VARINT,所以我们知道我们需要解码一个变长整数以获取有效负载。如上所述,字节 `9601` 变长整数解码为 150,从而给出我们的记录。我们可以用 Protoscope 将其写成 1:VARINT 150

如果 : 后有空格,Protoscope 可以推断标记的类型。它通过查看下一个标记并猜测您的意思来做到这一点(规则在 Protoscope 的 language.txt 中详细记录)。例如,在 1: 150 中,在未类型化标记后立即有一个变长整数,因此 Protoscope 推断其类型为 VARINT。如果您写了 2: {},它会看到 { 并猜测 LEN;如果您写了 3: 5i32,它会猜测 I32,依此类推。

更多整数类型

布尔值和枚举

布尔值和枚举都像 int32 一样编码。特别是,布尔值始终编码为 `00``01`。在 Protoscope 中,falsetrue 是这些字节字符串的别名。

有符号整数

正如您在上一节中看到的,与线类型 0 关联的所有协议缓冲区类型都编码为变长整数。但是,变长整数是无符号的,因此不同的有符号类型,sint32sint64int32int64 相比,对负整数的编码方式不同。

intN 类型将负数编码为二进制补码,这意味着作为无符号 64 位整数,它们设置了最高位。结果,这意味着必须使用 *所有十个字节*。例如,-2 由 protoscope 转换为

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

这是 2 的二进制补码,在无符号算术中定义为~0 - 2 + 1,其中~0 是全为 1 的 64 位整数。理解为什么这会产生这么多 1 是一项有益的练习。

sintN 使用“ZigZag”编码而不是二进制补码来编码负整数。正整数p 编码为2 * p(偶数),而负整数n 编码为2 * |n| - 1(奇数)。因此,编码在正数和负数之间“锯齿形”变化。例如

带符号原始值编码为
00
-11
12
-23
0x7fffffff0xfffffffe
-0x800000000xffffffff

换句话说,每个值n 使用以下方式编码:

(n << 1) ^ (n >> 31)

对于sint32,或者

(n << 1) ^ (n >> 63)

对于 64 位版本。

当解析sint32sint64 时,其值会被解码回原始的带符号版本。

在 Protoscope 中,在整数后面添加后缀z 将使其编码为 ZigZag。例如,-500z 与 varint 999 相同。

非变长整数

非 varint 数值类型很简单 - doublefixed64 的线类型为I64,它告诉解析器预期一个固定的八字节数据块。我们可以通过编写5: 25.4 来指定double 记录,或通过6: 200i64 来指定fixed64 记录。在这两种情况下,省略显式线类型都意味着I64 线类型。

类似地,floatfixed32 的线类型为I32,它告诉解析器预期四个字节。这些类型的语法包括添加i32 后缀。25.4i32 将发出四个字节,200i32 也是如此。标签类型被推断为I32

长度限定记录

长度前缀是线格式中的另一个主要概念。LEN 线类型具有动态长度,由标签后面紧跟的一个 varint 指定,然后像往常一样后跟有效负载。

考虑以下消息模式

message Test2 {
  optional string b = 2;
}

字段b 的记录是一个字符串,字符串是LEN 编码的。如果我们将b 设置为"testing",则将其编码为一个LEN 记录,字段号为 2,包含 ASCII 字符串"testing"。结果为`120774657374696e67`。分解字节,

12 07 [74 65 73 74 69 6e 67]

我们可以看到标签`12`00010 010,或2:LEN。后面的字节是 int32 varint 7,接下来的七个字节是"testing" 的 UTF-8 编码。int32 varint 表示字符串的最大长度为 2GB。

在 Protoscope 中,这写成2:LEN 7 "testing"。但是,重复字符串的长度(在 Protoscope 文本中,它已经被引号分隔)可能不方便。将 Protoscope 内容括在花括号中将为其生成长度前缀:{"testing"}7 "testing" 的简写。字段始终推断{}LEN 记录,因此我们可以简单地将此记录写成2: {"testing"}

bytes 字段以相同的方式编码。

子消息

子消息字段也使用LEN 线类型。这是一个消息定义,其中嵌入了我们原始示例消息Test1 的消息

message Test3 {
  optional Test1 c = 3;
}

如果Test1a 字段(即Test3c.a 字段)设置为 150,则得到``1a03089601``。分解它

 1a 03 [08 96 01]

最后三个字节(在[] 中)与我们第一个示例中的完全相同。这些字节前面有一个LEN 类型的标签,以及长度为 3,与字符串编码的方式完全相同。

在 Protoscope 中,子消息非常简洁。``1a03089601`` 可以写成3: {1: 150}

可选和重复元素

缺少的optional 字段很容易编码:如果不存在,我们只需省略该记录。这意味着只有少数几个字段设置的“巨大”proto 非常稀疏。

repeated 字段稍微复杂一些。普通(未打包)的重复字段为字段的每个元素发出一个记录。因此,如果我们有

message Test4 {
  optional string d = 4;
  repeated int32 e = 5;
}

并且我们构造一个Test4 消息,将d 设置为"hello",将e 设置为123,这可以编码为`220568656c6c6f280128022803`,或写成 Protoscope,

4: {"hello"}
5: 1
5: 2
5: 3

但是,e 的记录不需要连续出现,并且可以与其他字段交错;只有同一字段的记录彼此之间的顺序会被保留。因此,这也可以编码为

5: 1
5: 2
4: {"hello"}
5: 3

Oneofs

Oneof 字段 的编码方式与字段不在oneof 中时相同。适用于oneof 的规则与其在线路上如何表示无关。

最后写入获胜

通常,编码的消息永远不会具有非repeated 字段的一个以上实例。但是,解析器应能够处理它们确实存在的情况。对于数值类型和字符串,如果同一字段多次出现,则解析器接受它看到的最后一个值。对于嵌入式消息字段,解析器会合并同一字段的多个实例,就像使用Message::MergeFrom 方法一样 - 也就是说,后一个实例中的所有单数标量字段都会替换前一个实例中的字段,单数嵌入式消息会被合并,并且repeated 字段会被连接。这些规则的效果是,解析两个编码消息的连接产生的结果与分别解析这两个消息并合并结果对象完全相同。也就是说,这个

MyMessage message;
message.ParseFromString(str1 + str2);

等价于这个

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

此属性有时很有用,因为它允许您合并两个消息(通过连接),即使您不知道它们的类型。

打包重复字段

从 v2.1.0 开始,原始类型(任何标量类型,而不是stringbytes)的repeated 字段可以声明为“打包”。在 proto2 中,这是使用字段选项[packed=true] 完成的。在 proto3 中,这是默认设置。

它们不是作为每个条目一个记录进行编码,而是作为包含每个连接元素的单个LEN 记录进行编码。要解码,元素会从LEN 记录中一个接一个地解码,直到有效负载耗尽。下一个元素的开始由上一个元素的长度确定,而上一个元素的长度本身取决于字段的类型。

例如,假设您具有消息类型

message Test5 {
  repeated int32 f = 6 [packed=true];
}

现在假设您构造了一个Test5,为重复字段f 提供值 3、270 和 86942。编码后,这会给我们`3206038e029ea705`,或作为 Protoscope 文本,

6: {3 270 86942}

只有原始数值类型的重复字段可以声明为“打包”。这些是通常使用VARINTI32I64 线类型的类型。

请注意,尽管通常没有理由为打包的重复字段编码多个键值对,但解析器必须准备好接受多个键值对。在这种情况下,有效负载应连接起来。每个对必须包含整数个元素。以下是上面相同消息的有效编码,解析器必须接受

6: {3 270}
6: {86942}

协议缓冲区解析器必须能够解析编译为packed 的重复字段,就好像它们没有打包一样,反之亦然。这允许以向前和向后兼容的方式向现有字段添加[packed=true]

映射

映射字段只是特殊类型的重复字段的简写。如果我们有

message Test6 {
  map<string, int32> g = 7;
}

这实际上与以下内容相同

message Test6 {
  message g_Entry {
    optional string key = 1;
    optional int32 value = 2;
  }
  repeated g_Entry g = 7;
}

因此,映射的编码方式与repeated 消息字段完全相同:作为一系列LEN 类型的记录,每个记录有两个字段。

组是一个已弃用的特性,不应使用,但它保留在线格式中,值得一提。

组有点像子消息,但它由特殊标签而不是LEN 前缀分隔。

消息中的每个组都有一个字段号,该字段号用于这些特殊标签上。字段号 8 的组以8:SGROUP 标签开头。SGROUP 记录的有效负载为空,因此这仅仅表示组的开始。列出组中的所有字段后,相应的8:EGROUP 标签表示其结束。EGROUP 记录也没有有效负载,因此8:EGROUP 是整个记录。组字段号需要匹配。如果我们在预期8:EGROUP 的位置遇到7:EGROUP,则消息格式错误。

Protoscope 提供了一种方便的语法来编写组。而不是写

8:SGROUP
  1: 2
  3: {"foo"}
8:EGROUP

Protoscope 允许

8: !{
  1: 2
  3: {"foo"}
}

这将生成相应的开始和结束组标记。!{} 语法只能出现在未类型化标签表达式(如8:)之后。

字段顺序

字段号可以在.proto 文件中以任何顺序声明。选择的顺序对消息的序列化方式没有影响。

当消息被序列化时,其已知或未知字段 将如何写入没有保证的顺序。序列化顺序是实现细节,任何特定实现的细节将来都可能发生变化。因此,协议缓冲区解析器必须能够以任何顺序解析字段。

影响

  • 不要假设序列化消息的字节输出是稳定的。对于表示其他序列化协议缓冲区消息的传递字节字段的消息尤其如此。
  • 默认情况下,在同一协议缓冲区消息实例上重复调用序列化方法可能不会产生相同的字节输出。也就是说,默认序列化不是确定性的。
    • 确定性序列化仅保证特定二进制文件具有相同的字节输出。字节输出可能会在不同版本的二进制文件之间发生变化。
  • 以下检查可能会针对协议缓冲区消息实例foo 失败
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • 以下是一些示例场景,在这些场景中,逻辑上等效的协议缓冲区消息foobar 可能会序列化为不同的字节输出
    • bar 由将某些字段视为未知的旧服务器序列化。
    • bar 由以不同编程语言实现并以不同顺序序列化字段的服务器序列化。
    • bar 具有以非确定性方式序列化的字段。
    • bar 具有存储协议缓冲区消息的序列化字节输出的字段,该字段的序列化方式不同。
    • bar 由由于实现更改而以不同顺序序列化字段的新服务器序列化。
    • foobar 是以不同顺序连接的相同单个消息的连接。

编码的 Proto 大小限制

序列化后的 Proto 消息大小必须小于 2 GiB。许多 Proto 实现会拒绝序列化或解析超过此限制的消息。

浓缩参考卡片

以下内容以易于参考的格式提供了线格式中最突出的部分。

message    := (tag value)*

tag        := (field << 3) bit-or wire_type;
                encoded as uint32 varint
value      := varint      for wire_type == VARINT,
              i32         for wire_type == I32,
              i64         for wire_type == I64,
              len-prefix  for wire_type == LEN,
              <empty>     for wire_type == SGROUP or EGROUP

varint     := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
                encoded as varints (sintN are ZigZag-encoded first)
i32        := sfixed32 | fixed32 | float;
                encoded as 4-byte little-endian;
                memcpy of the equivalent C types (u?int32_t, float)
i64        := sfixed64 | fixed64 | double;
                encoded as 8-byte little-endian;
                memcpy of the equivalent C types (u?int64_t, double)

len-prefix := size (message | string | bytes | packed);
                size encoded as int32 varint
string     := valid UTF-8 string (e.g. ASCII);
                max 2GB of bytes
bytes      := any sequence of 8-bit bytes;
                max 2GB of bytes
packed     := varint* | i32* | i64*,
                consecutive values of the type specified in `.proto`

另请参阅 Protoscope 语言参考

关键

message := (tag value)*
消息被编码为零个或多个标签和值对的序列。
tag := (field << 3) bit-or wire_type
标签是wire_type(存储在最低三位)和在.proto文件中定义的字段编号的组合。
value := varint for wire_type == VARINT, ...
值根据标签中指定的wire_type以不同的方式存储。
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
您可以使用 varint 存储任何列出的数据类型。
i32 := sfixed32 | fixed32 | float
您可以使用 fixed32 存储任何列出的数据类型。
i64 := sfixed64 | fixed64 | double
您可以使用 fixed64 存储任何列出的数据类型。
len-prefix := size (message | string | bytes | packed)
长度前缀的值存储为一个长度(编码为 varint),然后是列出的一种数据类型。
string := 有效的 UTF-8 字符串(例如 ASCII)
如所述,字符串必须使用 UTF-8 字符编码。字符串不能超过 2GB。
bytes := 任何 8 位字节序列
如所述,字节可以存储自定义数据类型,大小最多为 2GB。
packed := varint* | i32* | i64*
当您存储协议定义中描述的类型的连续值时,使用packed数据类型。除了第一个值外,其他值的标签都会被丢弃,这将标签的成本摊销到每个字段,而不是每个元素。