编码

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

本文档描述了 Protocol Buffer 的*线路格式*,它定义了您的消息如何在网络上传输以及它在磁盘上占用多少空间的细节。您可能不需要了解这些就能在应用程序中使用 Protocol Buffer,但这些信息对于进行优化很有用。

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

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

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

Protoscope 工具还可以将编码后的 Protocol Buffer 转储为文本。请参阅 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 不止一个字节)。这些 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,该 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 位整数,它们的最高位被设置。因此,这意味着必须使用*全部十个字节*。例如,-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 数字

非 varint 数值类型很简单。doublefixed64 的线路类型为 I64,这告诉解析器期望一个固定的八字节数据块。double 值以 IEEE 754 双精度格式编码。我们可以通过写入 5: 25.4 来指定一个 double 记录,或者用 6: 200i64 来指定一个 fixed64 记录。

类似地,floatfixed32 的线路类型为 I32,这告诉它期望四个字节。float 值以 IEEE 754 单精度格式编码。这些类型的语法包括添加一个 i32 后缀。25.4i32200i32 都会产生四个字节。标签类型被推断为 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 字段被设置为 expanded(覆盖默认的 packed 状态)或不可打包(字符串和消息),那么会为每个单独的值编码一个条目。此外,e 的记录不需要连续出现,可以与其他字段交错;只有相同字段的记录之间的相对顺序是保留的。因此,这可能看起来像下面这样:

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

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

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

6: {3 270}
6: {86942}

Protocol buffer 解析器必须能够将编译为 packed 的 repeated 字段当作未打包来解析,反之亦然。这允许以向前和向后兼容的方式向现有字段添加 [packed=true]

Oneof

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);

这个特性偶尔会很有用,因为它允许您合并两个消息(通过串联),即使您不知道它们的类型。

映射

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 类型的记录,每个记录有两个字段。唯一的例外是,在序列化期间,不保证保留 map 的顺序。

Groups

组(Groups)是一个已弃用的功能,不应使用,但它们仍然存在于线路格式中,值得一提。

一个组有点像一个子消息,但它是由特殊的标签而不是由 LEN 前缀来界定的。消息中的每个组都有一个字段号,这个字段号用在这些特殊标签上。

一个字段号为 8 的组以一个 8:SGROUP 标签开始。SGROUP 记录的有效载荷为空,所以它只表示组的开始。一旦组内的所有字段都列出完毕,一个对应的 8:EGROUP 标签表示其结束。EGROUP 记录也没有有效载荷,所以 8:EGROUP 就是整个记录。组的字段号需要匹配。如果我们在期望 8:EGROUP 的地方遇到了 7:EGROUP,那么消息就是格式错误的。

Protoscope 提供了一种方便的语法来编写组。Protoscope 允许这样写,而不是写成:

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

Protoscope 允许:

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

这将生成相应的开始和结束组标记。!{} 语法只能紧跟在像 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 (float is IEEE 754
                single-precision); memcpy of the equivalent C types (u?int32_t,
                float)
i64        := sfixed64 | fixed64 | double;
                encoded as 8-byte little-endian (double is IEEE 754
                double-precision); 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 位字节的序列
如上所述,bytes 可以存储自定义数据类型,大小最大为 2GB。
packed := varint* | i32* | i64*
当您存储协议定义中描述类型的连续值时,请使用 packed 数据类型。第一个值之后的标签会被省略,这将标签的成本分摊到每个字段一次,而不是每个元素一次。