编码

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

简单地消息

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

  1. message Test1 {
  2. optional int32 a = 1;
  3. }

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

  1. 08 96 01

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

基本128 Varints

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

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

因此,例如,这里是数字1 - 它是单个字节,因此msb未设置:

  1. 0000 0001

这是300 - 这有点复杂:

  1. 1010 1100 0000 0010

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

  1. 1010 1100 0000 0010
  2. 010 1100 000 0010

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

  1. 000 0010 010 1100
  2. 000 0010 ++ 010 1100
  3. 100101100
  4. 256 + 32 + 8 + 4 = 300

消息结构体

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

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

可用的在线类型如下:

类型 含义 用途
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

流式消息中的每个键都是带有值的varintfield_number << 3)|varint wire_type - 换句话说,数字的最后三位存储在线类型。

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

  1. 000 1000

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

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

更多的值类型

签名整数

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

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

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

换句话说,每个值n使用以下编码

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

sint32或者64位版本使用:

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

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

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

非-varint数字

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

字符串

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

  1. message Test2 {
  2. optional string b = 2;
  3. }

将b的值设置为”testing”可以为您提供:

  1. 12 07 74 65 73 74 69 6e 67

红色字节是”testing”的UTF8。 这里的关键是0x12→字段编号= 2,类型= 2.值中的长度varint是7并且看到,我们发现它后面的七个字节 - 我们的字符串。

嵌套消息

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

  1. message Test3 {
  2. optional Test1 c = 3;
  3. }

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

  1. 1a 03 08 96 01

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

可选和重复元素

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

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

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

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

相当于:

  1. MyMessage message, message2;
  2. message.ParseFromString(str1);
  3. message2.ParseFromString(str2);
  4. message.MergeFrom(message2);

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

打包重复字段

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

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

  1. message Test4 {
  2. repeated int32 d = 4 [packed=true];
  3. }

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

  1. 22 // key (field number 4, wire type 2)
  2. 06 // payload size (6 bytes)
  3. 03 // first element (varint 3)
  4. 8E 02 // second element (varint 270)
  5. 9E A7 05 // third element (varint 86942)

只有原始数字类型的重复字段(使用varint,32位或64位线类型的类型)才能声明为”packed”。

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

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

字段排序

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

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

启示

  • 不要假设序列化消息的字节输出是稳定的。 对于具有表示其他序列化协议缓冲区消息的传递字节字段的消息尤其如此。
  • 默认情况下,在同一协议缓冲区消息实例上重复调用序列化方法可能不会返回相同的字节输出; 即默认序列化不是确定性的。
    • 确定性序列化仅保证特定二进制文件的相同字节输出。 字节输出可能会在不同版本的二进制文件中发生变化。
  • 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消息foobar可以序列化为不同的字节输出。
    • bar由旧服务器序列化,将某些字段视为未知。
    • bar由服务器序列化,该服务器以不同的编程语言实现,并以不同的顺序序列化字段。
    • bar有一个以非确定性方式序列化的字段。
    • bar具有存储protocol buffer消息的序列化字节输出的字段,该消息被不同地序列化。
    • bar由新服务器序列化,该服务器由于实现更改而以不同顺序序列化字段。
    • foobar都是单个消息的串联,但顺序不同。