[toc]
本文翻译自: https://developers.google.com/protocol-buffers/docs/encoding
注:1、本文并非逐字逐句翻译,仅仅按照原文结构,以及知识点进行翻译,文章某些顺序以及描述方式将会被本人修改。
2、阅读本文之前需要对protocol buffer有一定认识,参见protocol buffer语法
主要介绍protocol buffer的二进制编码格式。在使用protocol buffer的时候这些可以不用理会,但是这些知识会让我们了解protocol buffer的编码格式是如何影响到我们编码之后的文件大小。
一个简单的Message
现在用一个最简单的Message举例:
message Test1{
required int32 a = 1;
}
现在我们在一个应用程序中将a设置为150。然后利用输出流序列化这些信息。当我们打开这个序列化文件的时候可以看到一共利用3个bytes表示的这个Message:
08 96 01
这时如何编码的呢?我们将稍后展示。
基于128 Varints(整型变量)
想要了解protocol buffer的编码方式,我们首先要了解什么是varints,varints是一种将整数用1个或者多个bytes表示的一种序列化方法。越小的数使用越少的bytes。
除了最后一个byte之外,每一个varint中的byte的MSB(most significant bit)都是置位(set)的,这表示还有后续byte。低7位以二进制补码(the two’s complement)的形式表示这个数。
如:数字1,仅用1个byte表示即可。所以MSB没有置位。
0000 0001
如果1个byte不够,仅仅将MSB置位是不够的,还需要知道这几个bytes之间的关系,比如7bits仅仅可以表示0~127(这里考虑非负数)。则我们需要2个bytes来表示128。那么这2个byte到底哪一个表示高位哪一个表示低位呢?
protocol buffer将这个问题描述为一个LSB群(least significant group)的问题。protocol的做法是,least significant group first。这个如何理解呢?我看看一个例子。
现在我们表示数字300,这将比1更复杂:
1010 1100 0000 0010
我们主要关注两点:
- 首先,由于有了多个bytes因此,除了最后一个以外其余的MSB均要置位。
- 其次,least significant group first
010 100 000 0010//我们去掉MSB得到 000 0010 010 100//将左右调换 10010100//拼接(为了方便观察,删除了前面的0),这个就是300
least significant group first机制相比都能领悟到:其实就是将数字的二进制补码的每7位分为一组, 低7位先输出,编码在前面,在输出下一组,依次类推。
Message结构
本小节将描述protocol如何将Message类型等信息进行编码的。
protocol的Message实际上就是一系列的key-value pair。Message的二进制形式其实是使用域的数字作为key,这个域的名字以及类型需要在译码端通过引用该Message的定义来确定。
当一个Message进行编码时,key(注意,这里是tag number)和对应的值级联输出。在译码端译码时候,解析器需要跳过那些不能识别的域(嘿嘿,靠什么识别?)。这样,我们在添加新域的时候也不会影响旧代码的使用。事实上,key保存着两个值。其一,是.proto文件中定义的tag。其二,就是线型(wire type),它是用来提供下一个值长度的信息。
可用的线型:
类型 | 意义 | 用途 |
---|---|---|
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 | group开始 | group(已经弃用) |
4 | group结束 | group(已经弃用) |
5 | 32-bit | fixed32, sfixed32, float |
我们可以看出其实每一个key都编码成另一种整型变量,要将最后的3位用来保存线型,因此要做如下操作:
(field_number << 3) | wire_type。
现在我们再来看一个简单的例子,我们现在都知道第一个数字是Key的一个整型变量,我们先表示一个数08(去掉MSB)
000 1000
从这个数字的低三位我们取出它的线型(0),然后向右边移动三位可以得到它的域tag(1)。所以现在我们知道tag为1,接下来的数是一个varint。接下来我们就可以利用上一节中介绍的varint译码方法进行译码。拿出我们最开始的例子:
96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (丢弃MSB,按照7bits进行反转)
→ 10010110
→ 2 + 4 + 16 + 128 = 150
可以看出存储的数据是150。
可见 08 96 01表示的是保存了一个1=150的键值对。既没有保存类型,也没有保存名字。
其他类型
有符号整数(signed integers)
从前几节我们了解到,线型为0的所有protocol buffer类型都会按照varints进行编码。然而,对于有符号类型(sint32和sint64)同“标准的”整型(int32 和int64)在编码负数时有着很大差异。如果用int32或者int64编码负数,那么就会按照长度为10bytes的varint方式进行编码,因为对于编码而言,把负数按照一个大的整数来处理更有效。如果我们使用有符号整数来对负数进行编码,那么它就会按照ZigZag方式进行,这会更加有效。
ZigZag将一个有符号数映射成为一个无符号数,这个无符号数是该有符号数的绝对值形式,因此,ZigZag的varint编码长度会更加短。这种编码方式是按照“锯齿状”往返于正数和负数之间,即
0->0
-1->1
1->2
-2->3
2->4
很容易看出规律,编码之前的序列是在整数和负数之间对称跳转,编码之后的序列是在正半轴递增。
我们也就可以利用一个很简单的编码方式来进行编码:
对于sint32:
(n << 1) ^ (n >> 31)
对于sint64:
(n << 1) ^ (n >> 61)
如何理解这个编码代码呢?我们通过’^’将其分为左边部分和右边部分。从映射关系可以看出,我们将(0,-1)映射到[0,1],(1,-2)映射到[2,3],相当于把正半轴的数每一个变为原来两倍,这样整个空间看起来“扩大”了一倍,因此有足够的空间存放负数。将对于绝对值n,2n+1表示-n,而2n表示n。
左边部分就是绝对值扩大一倍,而右边部分是数学右移(最高位补原来的值),判断n的最高位是不是1。
若n是负数,由于异或的关系,左边部分和右边部分的前导1会全部清零。变为非负数,最低位根据n的最高位是否为1(即n是否是负数来确定),中间部分则是将补码变为原码(思考一下这时为什么)。
在解析sint32或者sint64时,就会按照上述过程的逆过程变为原来有符号数(通过译码端引用.proto文件知道是否使用ZigZag方式)。
非整型变量
非整型变量十分简单,double和fixed64的线型是1,在解析是这个会告诉解析器需要提取一块64bits的数据。类似的float和fixed32的线型是5,这告诉解析器需要提取一块32bits数据。这些数据都是按照小端方式存放的。
string
线型2(长度确定),这个表示会将string编码成为长度+特定字符串形式。先按照varint方式编码字符串长度(n bytes),后面紧接着n个bytes的数据。
message Test2{
required string b = 2;
}
我们现在将b的值设定为”testing”
那么就会得到如下序列:
12 07 74 65 73 74 69 6e 67
红色的序列就是UTF8编码的”testing”,这里的Key是 0x12→tag=2, type=2。后面紧跟的数表示序列长度,为7。
嵌套的Message
这里我们利用本文开始处定义的test1来定义test3:
message Test3{
required Test1 c =3;
}
下面是编码之后的序列,同样的,我们也将Test1的域a设置为150。那么我们就得到这个序列:
1a 03 08 96 01
可以看出此时编码序列的最后三个bytes和我们第一个例编码出来的相同(08 96 01)。通过对1a和03可知,嵌套的Message编码方式是和string一致的(如何区分嵌套Message和string?,也要靠.proto)。
optional和repeated元素
proto2编码一个repeated域(没有[packed=true]选项)。编码之后的序列有0个或者多个key-value对,并且使用的是同一个tag。这些repeated域中的值不用连续出现,它们可以交错出现在其它域之间。解析时,这些元素相互之间的顺序是会保存下来,但是相对于其他域的顺序将会丢失(丢就丢咯~)。而proto3会利用打包编码(packed encoding)的方式对repeated域进行编码,之后会介绍。
在proto3的非repeated域以及proto2的optional域可能会出现对于一个tag没有key-value对的情况。
通常对于一个非repeated域不会出现多于一个实例。但是,解析器是被设计为可以处理多于一个实例的情况。
- 对于数字以及string类型:如果一个域的实例出现多次,那么解析结果是解析器看到的最后一个值。
- 对于嵌套Message域:如果一个域的实例出现多次,那么解析器将会“合并”这些域的实例。就像在执行Message::MergeFrom方法一样。所有的标准域都会被后来的覆盖,对于一个嵌套Message来说也会进行“合并”,对于repeated域来说,多出的实例会级联起来。所以解析两个级联的Message和独立解析两个Message然后将其“融合”得到的结果是一致的。如下:
Message message;
message.ParseFromString(str1 + str2);
上面的代码和下面是等价的:
MyMessage message,message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这一个性质有的时候挺有用的,我们可以I在不知道他们具体类型的情况下合并两个Message
packed repeated域
protocol buffer在2.1.0版本引入了packed repeated域,这个域在proto2中的声明仅仅需要在repeated域后面添加[packed=true]。在proto3里面repeated域会默认声明为packed。经过这个声明之后的repeated域编码方式与proto2中的不同(更有效了)。如果packed repeated域中没有元素,那么它将不会被编码。否则,其中所有的元素将会统一打包编码为一个线型为2(长度确定)的key-value对。其中的每一个元素都将按照正常方式在这个包中编码(但是编码中不会出现tag)。
例如:
message Test4{
repeated int32 d=4 [packed=true];
}
假设我们已经构造了一个Test4并且,设置repeated域d的值为3,270和86942。然后proto将会编码成为如下格式:
22 //tag(域d的tag序列号为4,线型为2),注意这个是16进制数
06 //表示接下来的长度(bytes)
03 //第一个数(varint 3)
8E 02 //第二个数(varint 270)
9E A7 05 //第三个数(varin 86942)
当且仅当repeated域中的元素是数字变量(varint,32-bit或者64-bit)的时候才可以声明为packed。
虽然packed repeated域通常不会编码出超过一个key-value对。但是编码器也要准备好接收多个key-value对。这种情况下有效的域应该被级联。每一个对都必须包含所有的元素。
当一个repeated域没有被声明为packed,但是是按照packed方式编译的,那么protocol解析器也要求可以解析这个域,反之亦然。这就保证了[packed=true]选项的前后向兼容性。
域的顺序
在protocol提供的C++,JAVA,PYTHON序列化代码中,当一个Message进行序列化时,可知的域是按照tag顺序序列化写入的,这些tag序号是我们在.proto文件中定义的。这允许解析器基于tag序列进行一些优化。但是protocol解析器是要满足,可以按照任意顺序解析的要求。因为不是所有的Message都是序列化一个对象,如,有时我们会通过一个简单的级联方式“合并”两个Message。
如果一个Message有一个“不可知”域,那么java和C++应用将会在顺序的域后边,按照不确定的顺序写入。