编码

解释 Protocol Buffers 如何将数据编码到文件或通过网络传输。

本文档描述了 protocol buffer 的线格式,它定义了消息如何通过网络发送以及在磁盘上占用多少空间的详细信息。您可能不需要理解这些信息即可在应用程序中使用 protocol buffers,但了解这些信息对于进行优化很有用。

如果您已经了解这些概念但想要一份参考,请跳到精简参考卡部分。

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

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

Protoscope 工具还可以将编码的 protocol buffers 转储为文本。请参阅https://github.com/protocolbuffers/protoscope/tree/main/testdata 查看示例。

本主题中的所有示例都假定您使用的是 2023 或更高版本。

一个简单的消息

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

message Test1 {
  int32 a = 1;
}

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

08 96 01

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

Base 128 Varints

可变宽度整数,或称 varints,是线格式的核心。它们允许使用一到十个字节编码无符号 64 位整数,其中小值使用较少的字节。

varint 中的每个字节都有一个延续位,指示其后面的字节是否是 varint 的一部分。这是字节的最高有效位 (MSB)(有时也称为符号位)。较低的 7 位是有效载荷;通过将其组成字节的 7 位有效载荷拼接在一起构建出结果整数。

因此,例如,数字 1 编码为 `01` – 这是一个单字节,因此 MSB 未设置

0000 0001
^ msb

这里是数字 150,编码为 `9601` – 这有点复杂

10010110 00000001
^ msb    ^ msb

您如何确定这是 150?首先,去掉每个字节的 MSB,因为它们只是用来告诉我们是否已到达数字的末尾(正如您所见,由于 varint 中有多个字节,第一个字节的 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.

由于 varints 对 protocol buffers 至关重要,因此在 Protoscope 语法中,我们将它们称为普通整数。150 等同于 `9601`

消息结构

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

消息编码时,每个键值对都会转换为一个记录,包含字段编号、线类型和有效载荷。线类型告诉解析器其后面的有效载荷有多大。这使得旧的解析器可以跳过它们不理解的新字段。这种方案有时被称为 Tag-Length-Value,或 TLV。

有六种线类型:VARINTI64LENSGROUPEGROUPI32

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

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

现在我们再看看我们的简单示例。您现在知道流中的第一个数字总是 varint 键,这里它是 `08`,或者(去掉 MSB)

000 1000

您取后三位得到线类型 (0),然后右移三位得到字段编号 (1)。Protoscope 将标签表示为一个整数,后跟冒号和线类型,因此我们可以将上面的字节写为 1:VARINT

由于线类型是 0,即 VARINT,我们知道需要解码一个 varint 来获取有效载荷。如上所示,字节 `9601` varint 解码为 150,从而得到我们的记录。我们可以在 Protoscope 中将其写为 1:VARINT 150

如果 : 后面有空格,Protoscope 可以推断标签的类型。它通过查看下一个标记并猜测您的意图来实现(规则详细记录在Protoscope 的 language.txt 中)。例如,在 1: 150 中,无类型标签后面紧跟着一个 varint,因此 Protoscope 推断其类型为 VARINT。如果您写 2: {},它会看到 { 并猜测为 LEN;如果您写 3: 5i32,它会猜测为 I32,依此类推。

更多整数类型

布尔型和枚举

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

有符号整数

如前所述,所有与线类型 0 关联的 protocol buffer 类型都编码为 varints。然而,varints 是无符号的,因此不同的有符号类型,sint32sint64int32int64 编码负整数的方式不同。

intN 类型将负数编码为补码,这意味着作为无符号 64 位整数,它们的最高位被设置。因此,这意味着必须使用所有十个字节。例如,Protoscope 将 -2 转换为

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 数字

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

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

长度分隔记录

长度前缀是线格式中的另一个主要概念。LEN 线类型具有动态长度,由标签后的 varint 指定,其后是通常的有效载荷。

考虑这个消息模式

message Test2 {
  string b = 2;
}

字段 b 的记录是一个字符串,字符串是 LEN 编码的。如果我们将 b 设置为 "testing",我们编码为一个字段编号为 2 的 LEN 记录,其中包含 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 {
  Test1 c = 3;
}

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

 1a 03 [08 96 01]

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

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

缺失的元素

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

重复元素

从 2023 版本开始,原始类型的 repeated 字段(任何非 stringbytes标量类型)默认是“紧凑的”

紧凑的 repeated 字段不像每个条目编码为一个记录,而是编码为一个包含每个元素连接起来的单个 LEN 记录。解码时,从 LEN 记录中一个接一个地解码元素,直到有效载荷耗尽。下一个元素的开始由前一个元素的长度决定,而前一个元素的长度本身取决于字段的类型。因此,如果我们有

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

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

4: {"hello"}
6: {3 270 86942}

然而,如果 repeated 字段设置为展开(覆盖默认的紧凑状态)或不可紧凑(字符串和消息),则会为每个单独的值编码一个条目。此外,e 的记录不需要连续出现,并且可以与其他字段交错;只保留同一字段记录之间的相对顺序。因此,它可能看起来像这样

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

只有原始数字类型的 repeated 字段可以声明为“紧凑”。这些是通常使用 VARINTI32I64 线类型的类型。

请注意,虽然对于紧凑的 repeated 字段通常没有理由编码多个键值对,但解析器必须准备好接受多个键值对。在这种情况下,有效载荷应被连接起来。每对必须包含整数个元素。以下是上述相同消息的一种有效编码,解析器必须接受

6: {3 270}
6: {86942}

protocol buffer 解析器必须能够解析编译为 packed 的 repeated 字段,就像它们未紧凑一样,反之亦然。这允许以向前和向后兼容的方式向现有字段添加 [packed=true]

Oneof

Oneof 字段的编码方式与字段不在 oneof 中的情况相同。适用于 oneofs 的规则与它们在线上如何表示无关。

后一个获胜

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

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

等效于此

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

此属性偶尔很有用,因为它允许您即使不知道两个消息的类型,也可以通过连接来合并它们。

Map

Map 字段只是特殊 repeated 字段的简写。如果我们有

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

这实际上与此相同

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

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

Group

Group 是一个已弃用的功能,不应使用,但它们保留在线格式中,值得一提。

Group 有点像子消息,但它是通过特殊标签而不是 LEN 前缀来分隔的。消息中的每个 Group 都有一个字段编号,用于这些特殊标签。

字段编号为 8 的 Group 以 8:SGROUP 标签开头。SGROUP 记录的有效载荷为空,因此它仅表示 Group 的开始。Group 中列出所有字段后,相应的 8:EGROUP 标签表示其结束。EGROUP 记录也没有有效载荷,因此 8:EGROUP 是整个记录。Group 的字段编号需要匹配。如果我们遇到 7:EGROUP 而预期是 8:EGROUP,则消息格式错误。

Protoscope 提供了一种方便的 Group 编写语法。您可以写成

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

Protoscope 允许

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

这将生成相应的 Group 开始和结束标记。!{} 语法只能紧跟在无类型标签表达式(例如 8:)后面。

字段顺序

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

消息序列化时,已知或未知字段的写入顺序没有保证。序列化顺序是一个实现细节,任何特定实现的细节将来都可能改变。因此,protocol buffer 解析器必须能够按任何顺序解析字段。

影响

  • 不要假设序列化消息的字节输出是稳定的。对于包含表示其他序列化 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())
  • 以下是一些示例场景,逻辑上等效的 protocol buffer 消息 foobar 可能会序列化为不同的字节输出
    • bar 由一个将某些字段视为未知字段的旧服务器序列化。
    • bar 由一个使用不同编程语言实现并以不同顺序序列化字段的服务器序列化。
    • bar 包含一个以非确定性方式序列化的字段。
    • bar 包含一个字段,该字段存储一个序列化 protocol buffer 消息的字节输出,而该消息以不同方式序列化。
    • bar 由一个因实现更改而以不同顺序序列化字段的新服务器序列化。
    • foobar 是相同个体消息以不同顺序连接的结果。

编码 Proto 大小限制

Protos 序列化后必须小于 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 := (字段 << 3) 按位或 线类型
标签是 wire_type(存储在最低有效三位中)和 .proto 文件中定义的字段编号的组合。
值 := varint (当 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 存储列出的任何数据类型。
长度前缀 := 大小 (消息 | 字符串 | 字节 | 紧凑)
长度前缀值存储为一个长度(编码为 varint),然后是列出的数据类型之一。
string := 有效 UTF-8 字符串 (例如 ASCII)
如前所述,字符串必须使用 UTF-8 字符编码。字符串不能超过 2GB。
bytes := 任意 8 位字节序列
如前所述,bytes 可以存储自定义数据类型,大小最大为 2GB。
packed := varint* | i32* | i64*
当您存储协议定义中描述的类型的连续值时,使用 packed 数据类型。第一个值之后的标签被丢弃,这会将标签的成本摊销到每个字段一个,而不是每个元素一个。