[翻译] ProtoBuf 官方文档(五)- 编码

翻译查阅外网资料过程中遇到的比较优秀的文章和资料,一是作为技术参考以便日后查阅,二是训练英文能力。

此文翻译自 Protocol Buffers 官方文档 Encoding 部分

翻译为意译,不会照本宣科的字字对照翻译
以下为原文内容翻译

编码

本文档描述了 Protocol Buffer 的 Message 的二进制格式。你无需了解这个知识点也能够在你的应用程序中顺利使用 protocol buffers,但了解不同 protocol buffer 的格式如何影响最终编码消息的大小这一点非常有用。

一个简单的 Message

假设你有以下一个非常简单的消息定义:

message Test1 {
  optional int32 a = 1;
}

在一个应用程序中,你创建一个 Test1 message,并将 a 设置为150。然后,将 message 序列化为输出流。如果你能够查看相应的编码后的结果,你会看到以下三个字节:

08 96 01

到目前为止,是如此的小巧和简单-但是这几个字节具体代表什么含义?请往下读...

Base 128 Varints (编码方法)

要理解上述涉及到的简单编码,你首先需要理解 varints 的概念。所谓的 varints 是一种用一个或多个字节序列化(编码)整数的方法。较小的数字将占用较少的字节数。

varint 中的每个字节都设置了一个标识位(msb) - msb 表示后面还有字节需要读取(当前编码还未结束)。每个字节的低 7 位用于以 7 位一组的方式存储数字的二进制补码,二进制补码的低位排在编码序列的前头。

译者注:
可以总结出 varint 编码最主要的三点:

  • 存储数字对应的二进制补码
  • 在每个字节开头设置了 msb,标识是否需要继续读取下一个字节(这种标识实际上代替长度的作用)
  • 补码的低位排在前面

另外这里原文为 Each byte in a varint, except the last byte, has the most significant bit (msb) set,即 varint 中的每个字节,除了最后一个字节,都设置了最高有效位 msb。

原文的意思不容易让人理解,实际上应该是每个字节的最高 bit 都作为一个标识作用,1 标识需要继续读取下一个字节,0 标识当前字节为最后一个字节。

而原文中 msb 可能仅仅指得是最高比特位等于 1 的情况,所以它有个 “除了最后一个字节” 这样的表述。

我们来看一个例子:数字 1 该如何编码 – 这只需要单个字节,所以无需设置 msb:

0000 0001

译者注:
第一个 bit 位还是需要用来标识是否还有后续字节,这里为 0 表示无后续字节。

因为 msb 的作用相当于长度,所以哪怕只有一个字节,也是需要设置 msb 的,不然解码的时候无法识别该读多少个字节。这里的 “无需设置 msb” 估计又是将 msb 寓意成 bit = 1 的 msb。

来看一个稍微复杂点的例子,数字 300 该如何编码:

1010 1100 0000 0010

以上编码如何得出是整型 300?首先我们将每个字节的 msb 位去掉,因为这只是告诉我们是否已达到数字的末尾(如你所见,它在第一个字节中设置成 1 ,因为后面还有字节(第二个字节)需要继续读取):

1010 1100 0000 0010
→ 010 1100  000 0010

译者注:
这里去掉 msb 位时又将第二个字节的首位比特 0 当做 msb 去掉了。说实话 PB 的官方文档在很多细节还是容易让人产生误解的。

接下来你可以反转这两组 7 位,因为如前所述,varints 会将补码的低位排在前面。反转过程如下所示:

000 0010  010 1100            // 将两个 7 位组反转
→  000 0010 + 010 1100
→  100101100                  // 去掉计算时多余的 0 
→  256 + 32 + 8 + 4 = 300     // 计算对应的整型

译者注:
varints 之所以将低位的放在前头,是为了进行位操作(还原数值)的时候更加方便。

Message 结构

如我们所知,一个 protocol buffer message 实际上是一系列的键值对。消息的二进制版本只使用字段的数字作为 key - 而每个字段的名称和声明的类型只能通过引用 message 类型的定义(即 .proto 文件)在解码端确定。

在对一个 message 进行编码时,其键值将连接成字节流。在解码消息时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏那些无法识别(新字段)的旧程序。为此,识别 message 编码中每个字段的 key 实际上是两个值 - 来自 .proto 文件的字段编号,以及一个提供足够信息以查找 “值的(字节)长度”类型。在大多数语言实现中,该 key 被称为一个 tag (标记)。

可用的类型如下:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated,遗弃)
4 End group groups (deprecated,遗弃)
5 32-bit fixed32, sfixed32, float

message 消息流中的每个 Tag (field_number + wire_type) 都使用 varint 进行编码,且最后三位 bit 存储类型 wire_type(其它位存储字段编号 field_number)。

现在让我们再看一下上面提到的简单例子。你现在知道 message 消息流中的第一个数字总是一个 varint 编码的 tag ,这里是 08,即(删除 msb):

000 1000

我们取最后三位 bit 从而得到类型为 0 (varint),右移 3 位得到 varint 编码的 1。所以我们现在知道字段编号为 1,并且接下来要读取的 value 的类型为 varint。使用上一节讲解的 varint 编码相关知识,我们可以得到接下来的两个字节代表 150。

96 01 = 1001 0110  0000 0001
       → 000 0001  +  001 0110 (drop the msb and reverse the groups of 7 bits)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

更多的值类型

Signed Integers

正如我们在上一节中看到的,类型 0 对应的各种 protocol buffer types 会被编码为 varints。但是,在对负数进行编码时,signed int 类型(sint32 和 sint64)与标准的 int 类型(int32 和 int64)之间存在着重要差异。如果使用 int32 或 int64 作为负数的类型,则生成的 varint 总是十个字节长-它实际上被视为一个非常大的无符号整数。如果使用 signed int 类型(sint32 和 sint64),则生成的 varint 将使用 ZigZag 编码,这样效率更高。

译者注:
如果是负数,那么必须有最高位表示符号位,也就是说天然的会用尽所有字节。

如果是 4 个字节的 int,那么负数总是要占 4 个字节。可以为什么 int32 会占十个字节呢?不是应该占 5 个字节吗(每个字节还需要拿出一个 bit 位做 msb,所以要 5 个字节)?

这里是 protobuf 为了兼容性做的一种措施,为了 int32 和 int64 能够兼容(比如在 .proto 文件中将 int32 改成 int64),所以 int32 的负数会扩展到 int64 的长度。

那么正数的兼容呢?请仔细品味上述的 varints 编码,这种编码天然使得 int32 和 int64 的正数兼容。

ZigZag 编码将有符号整数映射到无符号整数,因此具有较小绝对值(例如 -1)的数字也具有较小的 varint 编码值。它通过正负整数来回 “zig-zags” 的方式做到这一点,因此 -1 被编码为 1, 1 被编码为 2,-2 被编码为 3,依此类推,如同下表所示:

Signed Original Encoded As
0
-1 1
1 2
-2 3
2 4
... ...
2147483647 4294967294
-2147483648 4294967295

换句话说,每个 sint32 类型的 n 编码处理如下:

(n << 1) ^ (n >> 31)

而对于每个 sint64 类型的 n:

(n << 1) ^ (n >> 63)

注意,第二个移位(n >> 31)部分是算术移位。因此,换句话说,移位的结果将会是一个全 0(如果 n 为正)或是全 1(如果n为负)。

解析 sint32 或 sint64 时,其值将被解码回原始的 signed 版本。

Non-varint Numbers

non-varint 数字类型很简单 - double 和 fixed64 对应的类型(wire type)为 1,它告诉解析器期读取一个固定的 64 位数据块。类似地,float 和 fixed32 对应的类型(wire type)为 5,这意味着它期望 32 位。在这两种情况下,值都以 little-endian (二进制补码低位在前)字节顺序存储。

Strings

类型(wire type)为 2(长度划分)表示接下来的字节将是 varint 编码的长度,之后跟指定长度的数据字节。

message Test2 {
  optional string b = 2;
}

将 b 的值设置为 "testing" 后得到如下编码:

12 07 | 74 65 73 74 69 6e 67

后七个字节为 "testing" 的 UTF8 编码。第一个字节 0x12 为 key → 字段编码 field_number = 2, 类型 wire type = 2。 varint 值的长度为 7,并且看到它后面有七个字节 -即为我们需要读取的值(字符串)。

译者注:
0x12 -> 0001 0010,先进行 varints 解码,解码结果为 001 0010,如果还是不清楚这个结果是怎么得出来的,可能需要重新看一遍上面的内容。取后面三位 010 得出 wire_type = 2。剩下的位表示 field_number,容易看出 0010 = 2。

Embedded Messages(内嵌 message)

下面的 message,内嵌了我们之前的简单例子 Test1:

message Test3 {
  optional Test1 c = 3;
}

设置其中的 Test1 的 a = 150,最终编码如下:

1a 03 08 96 01

正如我们所见,后三个字节和我们的第一个例子结果相同(08 96 01),在这三个字节之前为 03 编码(代表着字节长度)-嵌入消息的处理方式与字符串完全相同(wire type = 2)。

Optional 和 Repeated 元素

如果一个 proto2 message 定义有 repeated 字段(没有使用 [packed=true] 选项),则对应的 message 编码将具有零个或多个相同字段编号的键值对。这些重复值不必连续出现。它们可能与其他字段交错。解析时将保留这些 repeated 元素彼此的相对顺序,虽然相对于其他字段的排序将会丢失。 而在 proto3 中,repeated 字段默认使用 packed 编码,下面将讲解该方法。

译者注:
这里官方文档的描述又不太严谨,到目前为止,proto3 只会对 primitive 类型的 repeated 字段默认进行打包处理,string 之类的 repeated 字段是不会默认进行打包的。详见下面 [打包 Repeated 字段] 部分。

对于 proto3 中的任何 non-repeated 字段或 proto2 中的 optional 字段,message 编码可能具有或不具有该字段编号对应的键值对。

通常,编码消息永远不会有多个 non-repeated 字段的实例。但是,解析器应该能够处理这个情况。对于数字类型和字符串,如果多次出现相同的字段,则解析器接受它 “看到” 的最后一个值。
对于嵌入式消息字段,解析器合并同一字段的多个实例,就像使用 Message::MergeFrom 方法一样 - 也就是说,后面实例中的所有单个标量字段都替换前者,单个嵌入消息被合并,而 repeated 字段将被串联起来。这些规则的作用是使得两个消息(字符串)串联起来解析产生的结果与分别解析两个消息(字符串)后合并结果对象产生的结果完全相同。也就是:

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

等价于:

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

此属性偶尔会有用,因为它允许我们合并两条消息,即使我们不知道它们的类型。

打包 Repeated 字段

protobuf 版本 2.1.0 引入了对打包的 repeated 字段,在 proto2 中声明为具有特殊的 [packed = true] 选项的 repeated 字段。在 proto3 中,repeated 字段默认被打包。packed repeated 和 repeated 在编码方式上会有所不同。没有包含任何元素的 packed repeated 字段不会出现在编码消息中。否则(包含有元素),该字段的所有元素都被打包成一个键值对,其中类型(wire type)为 2(length-delimited)。每个元素的编码方式与正常情况相同,只是前面没有键。

译者注:
string 类型不会被打包,为什么?

译者认为的原因:
将 primitive 进行打包后编码格式为 tag-length-value-value...,因为 primitive 使用 varints 进行编码,读取 length 长度的字节后有 msb 作为读取 value 的边界标识,因此可以对 value-value-value 进行正确的边界识别,正确的一个个取出 value。

如果 value 是 string 类型,对应的 packed 结果如果是 tag-length-value-value-value...(其中 length 是后续的字节数)因为 string 直接 UTF-8 编码,读取出对应的字节后,无法正确划分 value 边界。
如果 packed 结果为 tag-length-[length-value-length-value],那么和不打包的编码结果 tag-length-value-tag-length-value-tag-length-value.... 相比,packed 结果多出了一个 length (第一个出现的 length,这个 length 表达后续所有的字节数,这个可能会很大),同时少了后续每个字节 tag (tag 的大小通常很小,最大不会超过 5 字节)的开销,那么最后 packed 的结果是增大开销还是减少开销就不一定了。所以不对 Length-delimited 系列的类型进行打包。

例如,假设我们有如下消息类型:

message Test4 {
  repeated int32 d = 4 [packed=true];
}

现在假设你构造一个 Test4,为重复的字段 d 分别赋值为 3、270 和 86942。那么对应的编码将是:

22        // key (或称 tag,字段编码 4, wire type 2)
06        // payload 长度 (即后面需要读取的所有值的长度6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)

只有原始数字类型(使用 Varint,32-bit 或 64-bit)的 repeated 字段才能声明为 “packed”。

请注意,虽然通常没有理由为打包的 repeated 字段编码多个键值对,但编码器必须准备好接受多个键值对。在这种情况下,应该连接 payload (对应的 value)。每对必须包含许多元素。

protocol buffer 解析器必须能够解析打包的 repeated 字段,就好像它们没有打包一样,反之亦然。这允许以前向和后向兼容的方式将 [packed = true] 添加到现有字段。

字段排序

虽然我们可以在 .proto 中以任何顺序使用字段编号,但在序列化消息时,其已知字段应按字段编号顺序编写,如提供的 C ++,Java 和 Python 序列化代码实现的那样。这允许解析代码能够依赖于字段序列进行优化。但是,protocol buffer 解析器必须能够以任何顺序解析字段,因为并非所有 message 都是通过简单地序列化对象来创建的-例如,通过简单连接来合并两个消息(有时很有用)。

如果一个 message 具有 [未知字段],当前的 Java 和 C++ 实现将在按顺序排序的已知字段之后以任意顺序写入它们,而当前的 Python 实现不会记录(跟踪)未知字段。

推荐阅读更多精彩内容