编码
本文档描述了协议缓冲区*线路格式*,它定义了消息在线路上传输以及在磁盘上占用多少空间的详细信息。您可能不需要理解这一点就可以在应用程序中使用协议缓冲区,但对于进行优化来说,这是有用的信息。
如果您已经了解了这些概念,但想要参考,请跳到精简参考卡部分。
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 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 对于协议缓冲区至关重要,因此在 protoscope 语法中,我们将它们称为纯整数。150 与 `9601` 相同。
消息结构
协议缓冲区消息是一系列键值对。消息的二进制版本仅使用字段的编号作为键 - 每个字段的名称和声明的类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。Protoscope 无权访问此信息,因此它只能提供字段编号。
当消息被编码时,每个键值对都转换为一个*记录*,该记录由字段编号、*线路类型*和有效负载组成。线路类型告诉解析器其后的有效负载有多大。这允许旧的解析器跳过他们不理解的新字段。这种类型的方案有时称为 *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, embedded messages, packed repeated fields |
3 | SGROUP | 组开始 (已弃用) |
4 | EGROUP | 组结束 (已弃用) |
5 | I32 | fixed32, 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 中,false 和 true 是这些字节字符串的别名。
有符号整数
正如您在上一节中看到的,与线路类型 0 关联的所有协议缓冲区类型都编码为 varints。但是,varints 是无符号的,因此不同的有符号类型,sint32 和 sint64 与 int32 或 int64 相比,以不同的方式编码负整数。
intN 类型将负数编码为*二进制补码*,这意味着,作为无符号的 64 位整数,它们的最高位已设置。因此,这意味着必须使用所有十个字节。例如,-2 由 protoscope 转换为
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
这是 2 的*二进制补码*,在无符号算术中定义为 ~0 - 2 + 1
,其中 ~0
是全一的 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,它告诉解析器期望一个固定的八字节数据块。我们可以通过编写 5: 25.4
来指定一个 double 记录,或者通过 6: 200i64
来指定一个 fixed64 记录。在这两种情况下,省略显式线路类型都意味着 I64 线路类型。
类似地,float 和 fixed32 具有线路类型 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;
}
如果 Test1 的 a 字段(即 Test3 的 c.a 字段)设置为 150,我们将得到 ``1a03089601``
。分解它
1a 03 [08 96 01]
最后三个字节(在 []
中)与我们第一个示例中的字节完全相同。这些字节前面有一个 LEN 类型的标签,长度为 3,与字符串的编码方式完全相同。
在 Protoscope 中,子消息非常简洁。``1a03089601``
可以写为 3: {1: 150}
。
可选和重复元素
缺少*可选*字段很容易编码:如果它不存在,我们只需省略记录。这意味着只有少量字段设置的“巨大”proto 非常稀疏。
*重复*字段有点复杂。普通的(未*packed*)*重复*字段为字段的每个元素发出一个记录。因此,如果我们有
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
并且我们构建一个 Test4 消息,其中 d 设置为 “hello”,e 设置为 1、2 和 3,这可以编码为 `220568656c6c6f280128022803`
,或者写成 Protoscope,
4: {"hello"}
5: 1
5: 2
5: 3
但是,e 的记录不需要连续出现,并且可以与其他字段交错;仅保留同一字段的记录相对于彼此的顺序。因此,这也可以编码为
5: 1
5: 2
4: {"hello"}
5: 3
Oneofs
*Oneof* 字段的编码方式与字段不在 *oneof* 中时相同。应用于 *oneof* 的规则与它们在线路上的表示方式无关。
最后一个胜出
通常,编码后的消息永远不会有多个非*重复*字段的实例。但是,解析器应处理它们确实存在的情况。对于数字类型和字符串,如果同一字段多次出现,则解析器接受它看到的最后一个值。对于嵌入式消息字段,解析器合并同一字段的多个实例,就像使用 Message::MergeFrom
方法一样 - 也就是说,后一个实例中的所有奇异标量字段都替换前一个实例中的字段,奇异嵌入式消息被合并,*重复*字段被连接。这些规则的效果是,解析两个编码消息的连接产生的结果与您分别解析两个消息并合并结果对象完全相同。也就是说,这
MyMessage message;
message.ParseFromString(str1 + str2);
等同于此
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
此属性有时很有用,因为它允许您合并两个消息(通过连接),即使您不知道它们的类型。
Packed 重复字段
从 v2.1.0 开始,原始类型的*重复*字段(任何不是 string 或 bytes 的标量类型)可以声明为“packed”。在 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”。这些类型通常会使用 VARINT、I32 或 I64 线路类型。
请注意,尽管通常没有理由为*packed* *重复*字段编码多个键值对,但解析器必须准备好接受多个键值对。在这种情况下,应连接有效负载。每对必须包含整数个元素。以下是上述同一消息的有效编码,解析器必须接受
6: {3 270}
6: {86942}
协议缓冲区解析器必须能够解析编译为 *packed* 的*重复*字段,就好像它们不是 *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;
}
因此,映射的编码方式与*重复*消息字段完全相同:作为 LEN 类型记录的序列,每个记录有两个字段。
组
组是一个已弃用的功能,不应使用,但它们仍然存在于线路格式中,值得一提。
组有点像子消息,但它由特殊标签而不是 LEN 前缀分隔。消息中的每个组都有一个字段编号,该编号用于这些特殊标签。
字段编号为 8 的组以 8:SGROUP
标签开头。SGROUP 记录具有空有效负载,因此所有这些操作都只是表示组的开始。一旦列出了组中的所有字段,相应的 8:EGROUP
标签表示其结束。EGROUP 记录也没有有效负载,因此 8:EGROUP
是整个记录。组字段编号需要匹配。如果我们遇到 7:EGROUP
,而我们期望 8:EGROUP
,则消息格式错误。
Protoscope 提供了一种方便的语法来编写组。而不是编写
8:SGROUP
1: 2
3: {"foo"}
8:EGROUP
Protoscope 允许
8: !{
1: 2
3: {"foo"}
}
这将生成适当的开始和结束组标记。!{}
语法只能在未类型化的标签表达式(如 8:
)之后立即出现。
字段顺序
字段编号可以在 .proto 文件中以任何顺序声明。选择的顺序对消息的序列化方式没有影响。
当消息被序列化时,无法保证其已知或未知字段的写入顺序。序列化顺序是实现细节,任何特定实现的细节将来都可能更改。因此,协议缓冲区解析器必须能够以任何顺序解析字段。
含义
- 不要假设序列化消息的字节输出是稳定的。对于具有表示其他序列化协议缓冲区消息的传递 bytes 字段的消息,尤其如此。
- 默认情况下,在同一协议缓冲区消息实例上重复调用序列化方法可能不会产生相同的字节输出。也就是说,默认序列化是不确定的。
- 确定性序列化仅保证特定二进制文件的字节输出相同。字节输出可能会在不同版本的二进制文件之间更改。
- 以下检查可能对协议缓冲区消息实例 foo 失败
foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
- 以下是一些示例场景,其中逻辑上等效的协议缓冲区消息 foo 和 bar 可能会序列化为不同的字节输出
- bar 由旧服务器序列化,该服务器将某些字段视为未知字段。
- bar 由以不同编程语言实现并在不同顺序中序列化字段的服务器序列化。
- bar 有一个以不确定方式序列化的字段。
- bar 有一个字段,用于存储以不同方式序列化的协议缓冲区消息的序列化字节输出。
- 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;
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) 位或 线路类型
- 标签是线路类型的组合,存储在最低有效的三位中,以及在 .proto 文件中定义的字段编号。
value := varint 对于 线路类型 == VARINT, ...
- 值的存储方式因标签中指定的线路类型而异。
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
- 您可以使用 varint 存储任何列出的数据类型。
i32 := sfixed32 | fixed32 | float
- 您可以使用 fixed32 存储任何列出的数据类型。
i64 := sfixed64 | fixed64 | double
- 您可以使用 fixed64 存储任何列出的数据类型。
len-prefix := 大小 (message | string | bytes | packed)
- 长度前缀值存储为长度(编码为 varint),然后是列出的数据类型之一。
string := 有效的 UTF-8 字符串 (例如 ASCII)
- 如所述,字符串必须使用 UTF-8 字符编码。字符串不能超过 2GB。
bytes := 任何 8 位字节序列
- 如所述,bytes 可以存储自定义数据类型,大小高达 2GB。
packed := varint* | i32* | i64*
- 当您存储协议定义中描述类型的连续值时,请使用 packed 数据类型。对于第一个之后的值,标签被删除,这会将标签的成本分摊到每个字段一个,而不是每个元素一个。