注意:此网站已被弃用。该网站将在 2023 年 1 月 31 日后关闭,而流量将重定向到位于 https://protobuf.dev 的新网站。在此期间,我们仅会针对 protobuf.dev 进行更新。

编码

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

本文档介绍了协议缓冲区传输格式,此协议定义了有关您的消息如何通过网络发送以及它占用磁盘空间的详细信息。您可能不需要了解这一点,即可在应用中使用协议缓冲区,但这对于执行优化很有用。

如果您已了解相关概念,但想要参考,请跳至精简参考卡片部分。

Protoscope 是一种非常简单的语言,用于描述低级别有线格式的代码段,我们将使用它为各种消息的编码提供可视化参考。Protoscope 的语法由一系列令牌组成,每个令牌向下编码到特定的字节序列。

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

Protoscope 工具也可以将编码的协议缓冲区转储为文本。有关示例,请参阅测试数据

简单广告内容

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

message Test1 {
  optional int32 a = 1;
}

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

08 96 01

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

基数 128 变量

可变宽度整数(也称为“变量”)位于传输格式的核心。它们允许使用 1 至 10 字节之间的任意位置对无符号的 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        // Put into little-endian order.
 10010110                // Concatenate.
 128 + 16 + 4 + 2 = 150  // Interpret as integer.

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

消息结构

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

对消息进行编码后,每个键值对都会变成包含字段编号、传输类型和载荷的记录。传输类型会告诉解析器其载荷有多大。这样一来,旧解析器可以跳过一些它们不了解的新字段。此类方案有时称为“标记长度值”或 TLV。

有六种传输类型:VARINTI64LENSGROUPEGROUPI32

ID 名称 用途
0 变量 int32、int64、uint32、uint64、sint32、sint64、bool、枚举
1 I64 Fix64、SFix64、Double
2 罗马尼亚列伊 字符串、字节、嵌入的消息、封装的重复字段
3 群组开始(已弃用)
4 分组 group end(已弃用)
5 I32 Fix32、SFix32、浮点数

记录的“标记”编码为根据字段编号和传输类型通过公式 (field_number << 3) | wire_type 形成的 varint。换句话说,在对表示字段的 varint 进行解码后,低 3 位表示传输类型,其余整数表示字段编号。

现在,我们再来看看一个简单的示例。现在,您已经知道信息流中的第一个数字始终是 varint 键,此处是 `08`,或(丢弃 MSB):

000 1000

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

由于传输类型为 0 或 VARINT,我们知道需要解码 varint 来获取载荷。如上所示,字节 `9601` varint-decode 变成了 150,为我们提供了记录。我们可以在 Protoscope 中将其写为 1:VARINT 150

如果 : 后面有空格,Protoscope 就可以推断标记的类型。为此,您需要查看下一个令牌并猜测您的意图(Protoscope 的 language.txt 中详细记录了这些规则)。例如,在 1: 150 中,由于未输入类型的标记后面紧跟着一个 varint,因此 Protoscope 会将其类型推断为 VARINT。如果您编写了 2: {},它会看到 { 并猜出 LEN;如果您编写 3: 5i32,它就会猜出 I32,依此类推。

更多整数类型

布尔值和枚举

布尔值和枚举的编码方式就好像它们是 int32 一样。尤其是,Bool 始终编码为 `00``01`。在 Protoscope 中,falsetrue 是这些字节字符串的别名。

有符号整数

如上一部分所示,与电汇类型 0 关联的所有协议缓冲区类型都会被编码为 varint。不过,varint 无符号,因此不同的有符号类型(sint32sint64int32int64)对负整数的编码方式不同。

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

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

这是 2 的二进制补码,以无符号算术定义为 ~0 - 2 + 1,其中 ~0 是全 64 位整数。了解此操作产生这么多原因非常有用。

另一方面,sintN 使用“ZigZag”编码(而不是两个的补码)对负整数进行编码。正整数 n 编码为 2 * n(偶数),而负整数 -n 编码为 2 * n + 1(奇数)。因此编码为正数和负数之间的“Z 字形”。例如:

签名原文 编码为
0 0
-1 1
1 2
-2 3
…… ……
0x7fffffff 0xfffffffe
-0x80000000 0xffffffff

使用一些小技巧,将 n 转换为其 ZigZag 表示法的开销很低:

n + n + (n < 0)

这里我们假设布尔值 n < 0 如果为 true 则转换为整数 1,如果为 false 则转换为整数 0。

解析 sint32sint64 后,其值会被解码回原始的签名版本。

在 protoscope 中,为整数添加 z 后缀会使其编码为 ZigZag。例如,-500z 与 varint 1001 相同。

非变量数字

非变量数值类型很简单,即 doublefixed64 的传输类型是 I64,它会告知解析器需要固定的八字节数据块。我们可以通过写入 5: 25.4 指定 double 记录,或使用 6: 200i64 指定 fixed64 记录。在这两种情况下,省略显式传输类型都会推断出 I64 传输类型。

同样,floatfixed32 的传输类型为 I32,这表明它预计需要 4 个字节。这些语句的语法包括添加 i32 前缀。25.4i32200i32 都会发出四个字节。标记类型推断为 I32

长度分隔记录

长度前缀是传输格式的另一个主要概念。LEN 传输类型具有动态长度,由变量在标记正后指定,后跟像往常一样的载荷。

请参考以下消息架构:

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 0102:LEN。后面的字节是 varint 7,后面的七个字节是 "testing" 的 UTF-8 编码。

在 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]

最后 3 个字节(在 [] 中)与第一个示例中的字节完全相同。这些字节前面带有 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

传输格式对 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 开始,可以将标量类型的 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}

只能将基元数值类型的重复字段声明为“packed”。这些是通常使用 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:EGROUP7: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 是同一条消息的串联,顺序不同。

精简参考卡

下面以易于参考的格式提供了有线格式的最突出部分。

message    := (tag value)*

tag        := (field << 3) bit-or wire_type;
                encoded as 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?int32_t, float)

len-prefix := size (message | string | bytes | packed);
                size encoded as 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
您可以使用固定 32 来存储列出的任何数据类型。
i64 := sfixed64 | fixed64 | double
您可以使用 Fix64 来存储任意列出的数据类型。
len-prefix := size (message | string | bytes | packed)
带长度前缀的值存储为长度(编码为 varint),然后存储列出的数据类型之一。
string := valid UTF-8 string (e.g. ASCII)
如上所述,字符串必须使用 UTF-8 字符编码。字符串不能超过 2GB。
bytes := any sequence of 8-bit bytes
如上所述,字节可以存储自定义数据类型,最大为 2GB。
packed := varint* | i32* | i64*
存储协议定义中描述的连续类型的值时,请使用 packed 数据类型。对于第一个值之后的标记,该标记会将值分摊到每个字段而不是每个元素,