编码
本文档描述了 Protocol Buffer 的*线路格式*(wire format),它定义了您的消息如何在线路上传输以及在磁盘上占用多少空间的细节。您在应用程序中使用 Protocol Buffers 可能不需要理解这些,但这些信息对于进行优化很有用。
如果您已经了解这些概念但想要一份参考资料,请跳到精简参考卡部分。
Protoscope 是一种非常简单的语言,用于描述底层线路格式的片段,我们将用它来为各种消息的编码提供可视化参考。Protoscope 的语法由一系列*令牌*(tokens)组成,每个令牌都会编码成特定的字节序列。
例如,反引号表示原始的十六进制字面量,如 `70726f746f6275660a`
。它会编码成字面量中十六进制表示的精确字节。引号表示 UTF-8 字符串,如 "Hello, Protobuf!"
。这个字面量等同于 `48656c6c6f2c2050726f746f62756621`
(如果您仔细观察,会发现它是由 ASCII 字节组成的)。在讨论线路格式的各个方面时,我们会介绍更多 Protoscope 语言的知识。
Protoscope 工具还可以将编码后的 Protocol Buffers 转储为文本。示例请参见 https://github.com/protocolbuffers/protoscope/tree/main/testdata。
本主题中的所有示例都假设您使用的是 Edition 2023 或更高版本。
一个简单的消息
假设您有以下非常简单的消息定义:
message Test1 {
int32 a = 1;
}
在应用程序中,您创建一个 Test1
消息并将 a
设置为 150。然后,您将该消息序列化到一个输出流。如果您能够检查编码后的消息,您会看到三个字节:
08 96 01
到目前为止,它既小又是数字——但这是什么意思呢?如果您使用 Protoscope 工具来转储这些字节,您会得到类似 1: 150
的结果。它是如何知道这是消息内容的呢?
Base 128 Varints
可变宽度整数(Variable-width integers),或称 *varints*,是线路格式的核心。它们允许使用一到十个字节来编码无符号 64 位整数,其中较小的值使用较少的字节。
varint 中的每个字节都有一个*连续位*(continuation bit),用于指示其后的字节是否也是该 varint 的一部分。这是字节的*最高有效位*(most significant bit,MSB)(有时也称为*符号位*)。低 7 位是有效负载(payload);最终的整数是通过将构成它的字节的 7 位有效负载连接起来构建的。
因此,举个例子,这是数字 1,编码为 `01`
——它是一个单字节,所以 MSB 没有被设置:
0000 0001
^ msb
这是 150,编码为 `9601`
——这要复杂一些:
10010110 00000001
^ msb ^ msb
您如何计算出这是 150 呢?首先,您从每个字节中去掉 MSB,因为它的作用只是告诉我们是否已到达数字的末尾(如您所见,它在第一个字节中被设置,因为这个 varint 不止一个字节)。这些 7 位的有效负载是小端序(little-endian order)的。将其转换成大端序(big-endian order),连接起来,并解释为一个无符号 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 无法访问这些信息,因此它只能提供字段编号。
当消息被编码时,每个键值对都被转换成一个*记录*(record),由字段编号、线路类型和有效负载组成。线路类型告诉解析器其后的有效负载有多大。这使得旧的解析器可以跳过它们不认识的新字段。这种方案有时被称为标签-长度-值(Tag-Length-Value),或 TLV。
有六种线路类型:VARINT
、I64
、LEN
、SGROUP
、EGROUP
和 I32
。
ID | 名称 | 用于 |
---|---|---|
0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | fixed64, sfixed64, double |
2 | LEN | string, bytes, 嵌入式消息, 打包的重复字段 |
3 | SGROUP | group 开始(已弃用) |
4 | EGROUP | group 结束(已弃用) |
5 | I32 | fixed32, sfixed32, float |
记录的“标签”(tag)被编码为一个 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 中,false
和 true
是这些字节串的别名。
有符号整数
正如您在上一节中看到的,所有与线路类型 0 相关的 Protocol Buffer 类型都被编码为 varints。然而,varints 是无符号的,所以不同的有符号类型,sint32
和 sint64
与 int32
或 int64
,对负整数的编码方式不同。
intN
类型使用二进制补码(two's complement)来编码负数,这意味着作为无符号 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
(奇数)。因此,编码在正数和负数之间“之字形”摆动。例如:
有符号原值 | 编码为 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
… | … |
0x7fffffff | 0xfffffffe |
-0x80000000 | 0xffffffff |
换句话说,每个值 n
都使用以下方式编码:
(n << 1) ^ (n >> 31)
对于 sint32
类型,或者
(n << 1) ^ (n >> 63)
对于 64 位版本。
当解析 sint32
或 sint64
时,其值会被解码回原始的有符号版本。
在 Protoscope 中,给整数加上后缀 z
会使其按 ZigZag 编码。例如,-500z
与 varint 999
相同。
非 Varint 数字
非 varint 的数字类型很简单。double
和 fixed64
的线路类型为 I64
,这告诉解析器期望一个固定的八字节数据块。double
值以 IEEE 754 双精度格式编码。我们可以通过写 5: 25.4
来指定一个 double
记录,或者用 6: 200i64
来指定一个 fixed64
记录。
类似地,float
和 fixed32
的线路类型为 I32
,这告诉它期望四个字节。float
值以 IEEE 754 单精度格式编码。这些的语法包括添加一个 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;
}
如果 Test1
的 a
字段(即 Test3
的 c.a
字段)被设置为 150,我们得到 ``1a03089601``
。分解它:
1a 03 [08 96 01]
最后三个字节(在 []
中)与我们第一个例子中的完全相同。这些字节前面是一个 LEN
类型的标签和一个长度 3,与字符串的编码方式完全一样。
在 Protoscope 中,子消息非常简洁。``1a03089601``
可以写成 3: {1: 150}
。
缺失元素
缺失字段的编码很简单:如果它不存在,我们 просто省去该记录。这意味着只有少数几个字段被设置的“巨大”proto 是相当稀疏的。
重复元素
从 Edition 2023 开始,原始类型的 repeated
字段(任何非 string
或 bytes
的标量类型)默认是“打包的”(packed)。
打包的 repeated
字段,不是为每个条目编码一个记录,而是编码为单个 LEN
记录,其中包含每个连接起来的元素。为了解码,元素从 LEN
记录中逐个解码,直到有效负载耗尽。下一个元素的开始由上一个元素的长度决定,而该长度又取决于字段的类型。因此,如果我们有:
message Test4 {
string d = 4;
repeated int32 e = 6;
}
我们构造一个 Test4
消息,将 d
设置为 "hello"
,并将 e
设置为 1
、2
和 3
,这*可以*被编码为 `3206038e029ea705`
,或者写成 Protoscope 形式:
4: {"hello"}
6: {3 270 86942}
然而,如果重复字段被设置为展开的(覆盖默认的打包状态)或者不可打包(字符串和消息),那么会为每个单独的值编码一个条目。此外,e
的记录不需要连续出现,可以与其他字段交错;只有相同字段的记录相对于彼此的顺序被保留。因此,这可能看起来像下面这样:
6: 1
6: 2
4: {"hello"}
6: 3
只有原始数字类型的重复字段可以被声明为“packed”。这些是通常会使用 VARINT
、I32
或 I64
线路类型的类型。
请注意,尽管通常没有理由为一个打包的重复字段编码多个键值对,但解析器必须准备好接受多个键值对。在这种情况下,有效负载应该被连接起来。每对必须包含整数个元素。以下是解析器必须接受的上述相同消息的有效编码:
6: {3 270}
6: {86942}
Protocol Buffer 解析器必须能够将编译为 packed
的重复字段当作未打包来解析,反之亦然。这允许以向前和向后兼容的方式向现有字段添加 [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 字段只是一种特殊类型的重复字段的简写。如果我们有:
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 是一个已弃用的功能,不应再使用,但它们仍然存在于线路格式中,值得一提。
group 有点像子消息,但它由特殊的标签分隔,而不是由 LEN
前缀分隔。消息中的每个 group 都有一个字段编号,该编号用于这些特殊标签。
一个字段编号为 8
的 group 以一个 8:SGROUP
标签开始。SGROUP
记录的有效负载为空,所以它只是表示 group 的开始。一旦 group 中的所有字段都列出完毕,一个相应的 8:EGROUP
标签表示其结束。EGROUP
记录也没有有效负载,所以 8:EGROUP
就是整个记录。Group 的字段编号需要匹配。如果我们期望 8:EGROUP
却遇到了 7: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 消息
foo
和bar
可能序列化为不同字节输出的示例场景:bar
由一个将某些字段视为未知的旧服务器序列化。bar
由一个用不同编程语言实现并以不同顺序序列化字段的服务器序列化。bar
有一个以非确定性方式序列化的字段。bar
有一个字段存储了另一个被不同方式序列化的 Protocol Buffer 消息的序列化字节输出。bar
由一个新服务器序列化,由于实现更改,该服务器以不同的顺序序列化字段。foo
和bar
是相同单个消息以不同顺序连接而成的。
编码后的 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 语言参考。
Key
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
数据类型。第一个值之后的标签会被省略,这将标签的成本分摊到每个字段一次,而不是每个元素一次。