04-编码

0.1. 一个简单的消息

本文档描述了protocol buffers消息的二进制传输格式。不需要了解这一点就可以在应用程序中使用protocol buffers,但了解不同的protocol buffers格式如何影响编码消息的大小是非常有用。

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

message Test1 {
  optional int32 a = 1;
}

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

08 96 01

到目前为止,这么小的数字,它是什么意思?继续阅读......

0.2. Base 128 Varint

要了解简单的protocol buffers编码,首先需要了解varints,它是一种使用一个或多个字节序列化整数的方法。较小的数字占用较少的字节数

varint中的每个字节(最后一个字节除外)都设置了最高有效位(msb,most significant bit):这表示还有很多字节。每个字节的低7位用于存储7位组中的二进制补码表示,最低有效组优先

因此,如下所示:

0000 0001   // 这里是数字1,它是单个字节,因此`msb`未设置

1010 1100 0000 0010     // 这是300,有点复杂:

怎么知道这是300?首先从每个字节中删除msb,因为这只是告诉我们是否已到达数字的末尾(如所见,它在第一个字节中设置,因为varint中有多个字节) :

1010 1100 0000 0010010 1100  000 0010

可以反转这两组7位,因为varints会将具有最低有效组的数字存储起来。然后连接它们以获得最终的值:

000 0010  010 1100000 0010 ++ 010 1100100101100256 + 32 + 8 + 4 = 300

0.3. 消息结构

protocol buffers消息是一系列键值对。消息的二进制版本只使用字段的数字编号作为键值。每个字段的名称和声明的类型只能通过引用消息类型的定义(即.proto文件)在解码端确定。

对消息进行编码时,键和值将连接成字节流。在解码消息时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏不了解它们的旧程序。因此,在传输格式消息中每对的“键”实际上是两个值:

  • 来自.proto文件的字段编号
  • 提供足够信息以查找以下值的长度的传输类型

在大多数语言实现中,该键称为标记。

可用的传输类型如下:

TypeMeaningUsed For
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated fields
3Start groupgroups (deprecated)
4End groupgroups (deprecated)
532-bitfixed32, sfixed32, float

流式消息中的每个键都是带有值(field_number<<3)| wire_typevarint。换句话说,数字的最后三位存储传输类型。

现在让再看一下上面的例子。现在知道流中的第一个数字总是一个varint键,这里是08,或者(删除msb):

000 1000

取最后三位得到传输类型(0),然后右移三次得到字段编号(1)。所以现在知道字段编号是1,以及下一个值是varint。使用上一节中的varint-decoding知识,可以看到接下来的两个字节存储值150。

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

0.4. 更多值类型

0.4.1. 有符号整数

正如在上一节中看到的,与传输类型0关联的所有protocol buffers类型都被编码为varints。但是,在编码负数时,signed int类型(sint32sint64)与“standard”int类型(int32int64)之间存在重要差异。如果使用int32int64作为负数的类型,则生成的varint总是十个字节长,实际上,它被视为一个非常大的无符号整数。如果使用其中一种有符号类型,则生成的varint使用ZigZag编码,这样效率更高。

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

Signed OriginalEncoded As
00
-11
12
-23
21474836474294967294
-21474836484294967295

换句话说,每个值n被编码:

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

成为sint32s,或者:

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

成为64为的版本。

注意,第二个移位(n >> 31)部分是算术移位。因此,移位的结果是一个全为零的数字(如果n为正)或全部为一位(如果n为负)。

0.4.2. 非varint数字

varint数字类型很简单:doublefixed64有传输类型1,它告诉解析器期望一个固定的64位数据块; 类似地,floatfixed32具有传输类型5,这告诉它期望32位。在这两种情况下,值都以little-endian字节顺序存储。

0.4.3. Strings

传输类型为2(长度分隔)表示该值是varint编码长度,后跟指定的数据字节数。

message Test2 {
  optional string b = 2;
}

b的值设置为“testing”可以得到:

12 07 74 65 73 74 69 6e 67

第三个开始的字节是“testing”的UTF8编码。这里的关键是0x12→字段编号=2,类型=2.值中的长varint是7,它后面的七个字节是所需要的字符串。

0.5. 嵌入式消息

这是一个消息定义,带有示例类型Test1的嵌入消息:

message Test3 {
  optional Test1 c = 3;
}

这是编码版本,再次将Test1a字段设置为150:

1a 03 08 96 01

如上所示,最后三个字节与第一个示例(08 96 01)完全相同,并且它们前面是数字3,嵌入式消息的处理方式与字符串完全相同(传输类型= 2) 。

0.6. 可选和重复元素

如果proto2消息定义具有repeated元素(没有[packed = true]选项),则编码消息具有零个或多个具有相同字段编号的键值对。这些重复值不必连续出现; 它们可能与其他字段交错。解析时保留元素相对于彼此的顺序,尽管丢失了关于其他字段的顺序。

proto3中,repeated字段使用压缩编码,可以在下面阅读。

对于proto3中的任何非repeated字段或proto2中的optional字段,编码消息可能具有或不具有该字段编号的键值对。

通常,编码消息永远不会有多个非repeated字段的实例。但是,解析器应该处理这些情况。

  • 对于数字类型和字符串,如果多次出现相同的字段,则解析器接受它看到的最后一个值。
  • 对于嵌入式消息字段,解析器合并同一字段的多个实例,就像使用Message::MergeFrom方法一样。也就是说,后一个实例中的所有单个标量字段都替换前者,单个嵌入消息被合并,并且连接重复字段。这些规则的作用是解析两个连接的编码消息产生的结果与分别解析两个消息后并合解析结果所得到的结果完全相同。就是这样:
MyMessage message;
message.ParseFromString(str1 + str2);

相当于:

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

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

0.6.1. 压缩重复字段

版本2.1.0引入压缩重复字段,在proto2中声明为重复字段,但具有特殊的[packed = true]选项。在proto3中,默认情况下会压缩标量数字类型的重复字段。这些功能类似于重复的字段,但编码方式不同。包含零元素的压缩重复字段不会出现在编码消息中。否则,该字段的所有元素都被压缩到一个键值对中,其中传输类型为2(长度分隔)。每个元素的编码方式与正常情况相同,只是前面没有键。

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

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

现在假设构造一个Test4,为重复字段d提供值3,270和86942。然后,编码的形式将是:

22        // key (字段编号4, 传输类型2)
06        // payload size (6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)

只有原始数字类型的重复字段(使用varint32位或64位传输类型的类型)才能声明为“packed”

请注意,虽然通常没有理由为压缩重复字段编码多个键值对,但编码器必须准备好接受多个键值对。在这种情况下,应该连接有效负载(payloads)。每个键值对必须包含大量元素。

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

0.7. 字段顺序

字段编号可以在.proto文件中以任何顺序使用。选择的顺序对消息的序列化方式没有影响。

当序列化消息时,不能保证其已知或未知字段的写入顺序。序列化顺序是一个实现细节,任何特定实现的细节可能在将来发生变化。因此,protocol buffers解析器必须能够以任何顺序解析字段。

0.8. 启示

  • 不要假设序列化消息的字节输出是稳定的。对于具有表示其他序列化protocol buffers消息的传递字节字段的消息尤其如此。
  • 默认情况下,在同一protocol buffers消息实例上重复调用序列化方法可能不会返回相同的字节输出;即默认序列化不是确定性的。
    • 确定性序列化仅保证特定对于特定的二进制文件输出相同的字节。字节输出可能会在不同版本的二进制文件中发生变化。
  • 对于protocol buffers消息实例foo,以下检查可能会失败。
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • 以下是一些示例场景,其中逻辑上等效的protocol buffers消息foobar可以序列化为不同的字节输出。
    • bar由旧服务器序列化,将某些字段视为未知。
    • bar由服务器序列化,该服务器以不同的编程语言实现,并按不同顺序序列化字段。
    • bar有一个以非确定性方式序列化的字段。
    • bar有一个字段,用于存储protocol buffers消息的序列化字节输出,该消息以不同方式序列化。
    • bar由新服务器序列化,该服务器由于实现更改因此以不同顺序序列化字段。
    • foobar都是单个消息的串联,但顺序不同。
上次修改: 14 April 2020