这篇文章介绍protobuf消息的二进制格式。你在你的应用中使用protobuf不必了解这些,但是它可以更好的让你知道protobuf的编码格式是怎样影响你编码后消息的大小的。

一个简单的消息

比方说,你有一个非常简单的消息定义:

1
2
3
message Test1 {
required int32 a = 1;
}

在一个应用中,如果你创建一个Test1的消息,将a设置成150,然后序列化到流中,如果你能看到序列化的消息,会看到三个字节:

1
08 96 01

这是什么意思呢?继续阅读。。。

Base 128 Varints

为了理解这个简单的protobuf编码,你首先需要了解varints,varints是一个使用一个字节或多个字节编码整数的方法。较小的数字使用较少的字节。

在varint中,除了最后一个字节,都会设置最高位(most significant bit,msb),这个最高位指示后边仍然有字节。低7bits用来存储数字补码表示,低的7bits组会优先存储。

因此,这里是数字1的表示,它是单个字节的因此,msb不会被设置:

1
0000 0001

下边是比较复杂的300:

1
1010 1100 0000 0010

怎样才能得出这个是300呢?首先你丢弃每个字节的msb,它仅仅是用来告诉我们数字字节表示的结尾。

1
2
1010 1100 0000 0010
010 1100 000 0010

然后交换这两个7bits的组,因为varint先存储低7bits组,组合之后就能得到最终的数字:

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

消息结构

正如你了解到的,protobuf消息是一系列key-value的组合,二进制格式的消息,仅仅使用字段数字作为key,这个key的名称和声明的类型可以在解码时引用消息类型定义得到,如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

在序列化后的消息中,每一个key是类似这样的varint:(field_number << 3) | wire_type,换句话说,低3位用来存储二进制类型。

现在让我们来看看开始提到的那个例子,现在知道序列化后的消息中,第一个数字永远是varint,这里是08,丢弃msb之后:

1
000 1000

取出低3位得到二进制类型0,右移3位后得到字段数字1。因此,我们知道了标签为1的值是varint,也就是说接下来的序列是varint,使用varint编码规则我们可以得到接下来的两个字节96 01存储的是150:

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

更多值得类型

有符号整数

正如前面提到的,和wire类型0关联的值都被编码成varint。然而,对于有符号整数(sint32和sint64)与一般整数(int32和int64)的一个很重要的不同点是它们对负数编码的处理。如果你使用int32或int64表示负数,那么这个varint总是10个字节长——它被当做一个非常大的无符号数。如果你使用任意一个有符号数,那么varint将会使用ZigZag编码,它对负数编码更有效率。

ZigZag编码将有符号整数映射到无符号整数,这样它的绝对值就很小,相应的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在sint32的时候被编码为(n << 1) ^ (n >> 31),在sint64的时候被编码为(n << 1) ^ (n >> 63)。

运算的第二部分,(n>>31)是算术右移,也就是说,如果n是正数,右移补的位为0,否则是1。

当sint32或sint64解析的时候,会解析成原来得数字。

非varint数字

这个数字类型很简单——double和fixed64的wire类型是1,这告诉解析器接下来是固定的64bits数据,相应的float和fixed32的wire类型时5,说明拥有32bits数据。这两种情况下的值都以小端字节序存储。

strings

wire类型为2意味着值是一个varint编码的长度,接下来是指定字节长度的数据。

1
2
3
message Test2 {
required string b = 2;
}

将b设置成testing的编码为:

1
12 07 74 65 73 74 69 6e 67

后七个字节是UTF-8编码的testing,key是0x12,wire类型是2,tag是2,0x07表示值的长度是7,然后是7个字节的字符串。

嵌套消息

现在定义一个消息,其字段是Test1类型的消息:

1
2
3
message Test3 {
required Test1 c = 3;
}

我们将a设置成150,下边是编码后的消息:

1
1a 03 08 96 01

正如你看到的,最后三个字节和第一个例子是一样的,它前边有一个值为3的varint标识长度——嵌套消息被当做和字符串一样处理。

可选和可重复元素

在proto2中定义的repeated字段(没有[packed=true]选项),编码后的消息是零个或多个拥有相同标签的键值对。这些重复的值不一定连续出现,可能会和其他字段交错出现。解析的时候,各个元素之间的顺序被保留,尽管其他字段的顺序是不确定的。在proto3中,repeated字段使用packed encoding,下边将会介绍。

在proto3中非repeated字段和proto2中的optional字段,编码后的消息中可能有也可能没有包含标签数字的key-value对。

一般的,编码后的消息不会超过一个非repeated的字段,不过,我们期望解析器能够处理这种情况。对于数值类型和字符串类型,如果相同的数字标签出现了多次,那么解析器会保存最后一个出现的值。对于嵌套消息字段,解析器将多个相同数字标签的值合并,就像调用Message::MergeFrom一样——对于普通字段,用后边实例的值代替前边实例的值,对于嵌套消息字段进行合并,重复字段进行连接。这个规则使得解析连续两个编码后的消息一样和分别解析两个消息然后把它们合并的效果是一样的:

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

等价于:

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

这个属性偶尔会用到,它允许在你不知道他们类型的情况下进行合并消息。

打包重复字段

2.1.0版本引入了打包重复字段,在proto2中需要在重复字段中添加[packed=true]选项。在proto3中,重复字段默认就是打包的。它的功能和重复字段类似,但采取了不同的编码。当一个打包的重复字段不包含任何元素时,它不会出现在编码后的消息中。否则,这个字段的全部元素被打包成wrie类型为2的单个键值对。除了没有标签数字前缀外,每个元素被编码成它本来的样子。

假设你又个消息

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

现在,你定义一个Test4变量,给重复字段d提供值3,170和86942,那么它的编码为:

1
2
3
4
5
22        // tag (field number 4, wire type 2)
06 // payload size (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)的重复字段才能够声明为packed。

需要注意的是,虽然有通常没有理由为打包重复字段编码多个键值对,但编码器必须要能够接受多个键值对。在这种情况下,有效数据应该串联起来。每个键值对必须包含全部的元素。

编译器必须能够像它们没有packed一样解析packed的重复字段,反之亦然。为这样的字段添加[packed=true]是一种向前向后兼容的方式。

字段顺序

虽然你能够在proto文件中以任何顺序使用字段数字,当消息序列化的时候,它应该按照字段数字的顺序写入,就像C++,Java和Python序列化代码那样。这就允许解析代码依赖字段数字的顺序进行优化。然而,protobuf解析器必须能够以任何顺序解析它们,并不是所有的消息是通过简单序列化得到的——举例来说,一个合并两个消息的简单方法是将它们序列化后的消息串联。

如果一个消息中包含未知的字段,母线的Java和C++实现会将它们以任意的顺序写入已知字段的后边。当前的Python实现并不追踪未知字段。

参考

1) Google Protocol Buffer Encoding