Protocol Buffer编码

ProtoBuf数据类型

一个简单的Message

让我们看一下下面这个简单的Message的定义:

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

在一个应用中,你创建了一个名为Test1的Message,并且设置a为150。然后你将这个Message序列化后得到输出流如下三个字节:

1
08 96 01

如此小的数字表示,但这些都意味着什么 呢

在protobuf中message是一系列的键值对,上面三个字节中的08(000 1000)表示了message中的数据类型和关键字编号,其中,数据类型是此字节编码的后三位,即0(000),然后根据下面的表格确定类型;关键字(上述例子中的a)的编号是此字节右移三位,即1(000 0001)。此时就容易有一个疑问了,那a是怎么表示的?这就是protobuf的另一个优点了——安全,如果消息被截取是没办法知道消息代表了什么意思,只有拥有.proto文件才能对应上关键字。

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

上面例子中的96 01就是代表着我们给a设置的值——150,下面介绍了protobuf中message的数据类型是如何编码的。

Varints

Varint从名字就能看出点什么var(可变的) int,有点MySQL中varchar那种感觉,这种类型是使用一个或多个字节去序列化integer的方法,越小的数字会占用越小的字节。

变长的类型需要解决的一个问题是确定编码的边界,varints为了解决这个问题,为每个字节都设置了一个标志位,如果下一个字节还是我的就是1,如果下一个字节不是我的了就是0。每个字节的低七位用来以二进制补码来存储一组数据,采用小端字节序(这里有篇文章介绍了字节序 [https://www.ruanyifeng.com/blog/2016/11/byte-order.html]),本排前面的字节在后面。举个例子300的编码

300的补码为

1
100101100

根据低七位为用来存储数据,则可分解为:

1
000 0010  010 1100

再根据是小端字节序,转化后为

1
010 1100 000 0010

再根据每个字节都需要设置一个标志位,下一个字节还是我的就是1,不是我的就是0,则转化后为:

1
1010 1100 0000 0010

这就是300的varints的字节编码,一共占用了两个字节。如我们所了解,例如Java中的int是固定长度,占用4个字节的,而varints表示的数字越小占用的空间也就越小,但是当足够大的时候,varints是比固定长度需要的空间多的。但是我们用的数的范围一般较小。

Signed Integers

在计算机中,整数类型都是用补码来存储的,varints也一样,补码的计算是原码所有位取反,然后再加1,例如int32 的-64的原码为

1
10000000 00000000 00000000 01000000

-64的补码为

1
11111111 11111111 11111111 11000000

占用4个字节,如果转换为varints的话,则需要占用5个字节,不论负数的数值多大,都会稳定的占用5个字节(实际上protobuf中,会稳定占用10个字节,因为是按照long来编码,是为了int32改为in64的时候仍可以兼容

image-20210302204711254

为了解决这个不够高效的问题,最后引入了Zigzag编码,这个编码的主要做的是将有符号数,通过一个公式转成无符号数,然后再根据varints编码进行处理。这个公式如下:

1
sint32:(n<<1)^(n>>31) sint64 (n<<1)^(n>>63)
Signed Original Encoded As
0 0
-1 1
1 2
-2 3
2147483647 4294967294
-2147483648 4294967295

Non-varint Numbers

Non-varint就比较简单了,当算出来数据类型为1的时候,就直接需要一个64位大小的数据块,数据类型为5的时候,需要一个32位大小的数据块。这两种情况也是和上面一样采用小端字节序

String(字符串)

先看一个带有string的Message,并且设置b的值为”testing”

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

编码后为:

1
12 07 74 65 73 74 69 6e 67

字符串的编码和之前不同的是,第一个字节依旧代表类型和关键字编号,第二个字节表示后面有多少位是这个字段的,即编码方式为:key + length + content

12 ( 0001 0010),后三位 010 为 数据类型为 2,0001 0010 右移三位为 0000 0010,即关键字编号为2。length为7则后面跟着7个字节是这个字段的,即我们设置的值

Embedded Messages(嵌入式Message)

同样的先来看个带有嵌入式的Message,设置值为上面的Test1

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

编码后为:

1
1a 03 08 96 01

先来看一下数据类型1a(0001 1010)后三位010为2,跟String一样,03代表长度,那08 96 01就很眼熟了,就是开头说的Test1。

Packed Repeated Fields(打包重复元素)

在protobuf2.1.0引入了一个[packed=true]的东西,如下面的例子

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

在不使用[packed=true]的时候,repeated的字段会被编译成关键字编号一样的多个键值对,并且不是连续的,也没有什么顺序;在解析时,元素之间的顺序会被保留下来,但是其他字段的顺序会丢失。在protobuf2.1.0中引入了[packed=true],protobuf3.0中已经默认使用这个东西,当用上这个东西的时候,这个重复字段会被单独打包到一个键值对中,并且数据类型为2。就如上面那个Test4例子,当给重复字段赋值为3,270和86942后,编码后为:

1
2
3
4
5
6
7
8
9
22 // tag 0010 0010(关键字编码 010 0 = 4, 数据类型 010 = 2)

06 // payload size (设置的length = 6 bytes)

03 // first element (varint 3)

8E 02 // second element (varint 270)

9E A7 05 // third element (varint 86942)

Field Order(字段顺序)

在.proto文件中字段编码可以随便写,对Message的序列化顺序是没有任何影响的。当Message被序列化的时候,对于字段是没有保证的顺序。序列化的顺序是一个实现细节,将来任何特定的实现细节都可以被更改,因此protobuf解析器必须能够以任何顺序解析字段。