protobuffer的前世今生(二)——编码

时间:2022-07-25
本文章向大家介绍protobuffer的前世今生(二)——编码,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

一个简单的message

首先看一个简单的消息定义:

 message Test1 { 
      optional int32 a = 1;  
      }

在一个应用中,你创建一个Test1message 并且设置 a为150.你可以序列化这个消息到输出流,你可以得到3个字节 08 96 01 到此为止,如此之小,如此数字化——但是这意味着什么呢?请继续读下去…

base 128 Varints

为了简单理解pb的编码,你首先要理解varints。varints 是一个使用一个或多个字节来序列化整数的方法。小数字则使用字节少。 所以,举个栗子,这里有一个数字1——这个是一个单字节,所以编码如下: 0000 0001 300 如下——这个稍微复杂点: 1010 1100 0000 0010 你如何知道这就是以代表300呢? 首先,你需要去掉每个字节的最高有效位,因为这个最高有效位只是告诉我们是否到了数字的末尾(如你所见,它设置在第一个字节,因为使用varint编码300不止一个字节)

1010 1100 0000 0010  
   → 010 1100  000 0010

接下来你反转 这两组7位的bit,记着,varint 存数字是首先存意义最小的那组(相比最高有效位)。 然后将他们连接起来得到如下的结果:

  000 0010  010 1100
→  000 0010 ++ 010 1100
→  100101100
→  256 + 32 + 8 + 4 = 300

Message 结构

众所周知,一个pb message 就一系列的key-value对。二进制版本的message就是用字段的编号作为key. 字段的名字和声明类型呢,是根据message类型定义文件,例如.proto文件定义反编码完毕决定的。 当一个message被编码候,key - value 对就会被连接起来成为一个字节流。当message被解码时,解析器需要跳过不认识的字段。这样,新的字段可以被添加进消息,而无需更改老的代码(老代码不认识新字段)。一个key既要对应.proto文件中定义的数字,也要对应这个字段的类型,以便决定这个value 的长度。 wire type 类型如下:

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

在message流中每个key都是一个varint类型的,它的值是 字段的数字左移3位,再或上wire type,换句话说,字节的最后三位存储的是wire type。 公式如下:

(field_number << 3) | wire_type

回过头来,再看下之前那个例子。 流中的第一个数字总是一个varint key,如下的是08(去掉最高有效位)

000 1000

从后三位得知,wire type 等于 0 ,然后向右移动3位得到字段编号,等于1. 结合之前的知识,分析 01 96 01 这三个字节存储的内容为:

96 01 = 1001 0110  0000 0001
       → 000 0001  ++  001 0110 (去掉最高有效位,然后反转7位的一组的bits)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

二进制数据流

再次结合上面分析的可以得到这个表格

更多的值类型

有符号整数

刚刚我们看到,wire type 为0 的都作为varints进行编码。 然而,有符号int 类型(sint32 and sint64)和“标准的”int类型(int32 and int64) 有很大的不同,尤其是遇到编码负数的时候。 如果使用int32 and int64类型进行编码负数的话,varint的结果往往是10个字节的那么长——被当做一个非常大的无符号整数来对待。 如果使用有符号类型的话,结果会使用ZigZag进行编码,这个有效的多。 ZigZag 编码存在一个有符号整数到无符号整数的映射。因此,绝对值小的数(如-1) 也会有一个比较小的varint编码值。就这样“zig-zags”来回穿梭于负数和正数之间。 因此,-1被编码为1,1被编码为2.2被编码为4。 列一个表格,我们会发现规律。

Signed Original

Encoded As

0

0

-1

1

1

2

-2

3

2147483647

4294967294

-2147483648

4294967295

n

2n

-(n+1)

2n+1

其中n为自然数(即,0和正整数) 换句话说,每个值n使用了如下公式进行编码: 对sint32位的:

(n << 1) ^ (n >> 31)  ①(注意是有符号右移)

对64位的sint64:

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

可以对2这个值举个栗子: 2=0010 采用公式①

(28个0)....0100 ^ 0000...(32个0)
=0100
=4

注意,在右移31位的这部分,这个移动的结果要么是一个全0的数字(如果n是正数),要么是一个全是1的bits(如果n是负数)

当解析sint32 或 sint64 这类数值时,就会解码回有符号的版本,如-1。

非-varint 的数(非固定int类型的数)

非 varint 的数类型很简单——double或者fixed64 的wire type 为1,这就告诉了解析器期望的是一个固定的64位的块数据,类似的,floatfixed的wire type 为5,这就告诉了解析器期望的是一个32位的数据。在这两种情况下,值都是以小端字节序列进行存储的。

Strings

wire type 为2 的(长度限定的),意味着值 是一个varint编码的长度后面紧跟着指定的字节数的长度数字。举个栗子:

message Test2 {
  optional string b = 2;
}

如果将b的值设置为“testing”,那么结果为:

12 07 74 65 73 74 69 6e 67

红色的字节是UTF8格式的"testing",这里key就是0x12, 0001 0010 由前面知识可以得到: wire type = 2 (后三位) 字段编号 = 2. 值中的int长度指定为7位(黑色的加粗部分),你瞧,我们这不就发现了后面有7个字节(红色部分)——我们的string。

嵌入的Messages

举个栗子,Test3 消息体内,嵌入我们上面定义的Test1:

message Test3 {
  optional Test1 c = 3;
}

这个是编码后的结果,再次与之前的a呼应,我们 还是设置a字段为150。 1a 03 08 96 01 如你所见, 后三个字节 和我们之前第一个例子中的一模一样(08 96 01),在前面有一个03——嵌入式的消息和strings的计算方法一致。

1a = 0001 1010

wire type =2 字段编号 = 0001 1010->右移三位 = 3 。符合我们的定义。

可选的 和重复的元素

在proto2中,message定义有repeated元素(没有[packed=true]选项),编码的message有0个或多个key-value对,它们拥有相同的字段编号。这些重复的值没有必要连续出现,他们可能和其他字段交错出现。 在解析的时候,每个元素相对的顺序是可以得到保留的。 在proto3中重复的元素使用packed编码。 在proto3中对于非重复的字段,或者在proto2中的optional 字段,编码的消息可能有也有可能没有那个字段编码的key-value对。 一般地,一个编码的消息,对于一个非重复的字段,绝不会有超过一个实例。然而,人人都有犯错的时候,我们也期望解析器能够处理这种情况。

  • 对于数字类型和字符串类型的字段,如果相同的字段出现多次,解析器只接受最后看到的那个字段的值。
  • 对于嵌入的message字段,相同字段的所有实例都会被合并起来,就像这个方法Message::MergeFrom一样。(这个方法,对单标量的字段,后面相同字段的会覆盖前者;对于嵌入的message,则合并;对于重复的字段则连接起来) 以上这种规则有一个好处: 当你解析两个连接在一起编码的messages时,产生的结果就像你分别解析了两个独立的message一样。 举个例子:
MyMessage message;
message.ParseFromString(str1 + str2);

和下面的结果是等效的:

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

这个特点有时候很有用,因为它可以允许你在不知道另一个message的类型下进行合并结果。

Packed Repeated Fields

2.1的版本引入了Packed Repeated Fields, 在proto3中,数字类型的repeated Fields是默认包装的。这个功能和repeated fields有点类似,但是编码方式不同。如果一个打包的repeated 字段里面一个元素都没有,那么 编码中是不会出现这个字段的编码信息。否则,这个字段里面所有的元素都会被打包成一个key-value对,并且它的wire type是2(长度指定的)。每个元素都会按照正常的方式编码,除非它前面没有key。 例如,加入你有一个这样的message :

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

现在,让我们看下这个Test4,假如给这个字段d赋值3270,还有86942。那么,编码后的形式如下:

22        // key (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”。 用在其他字段,编译 .proto 文件时会报错

注意,尽管通常并没有理由对packed repeated field去编码超过一个key-value对,编码器必须要准好能接受多个键值对。这种情况下,payloads信息将会被连接起来。每个键值对必须包含所有元素。

pb的解析器也必须有能力解析packed repeated field 就像它们没有被打包过一样,反之亦然。 这就允许你给已存在的字段添加 [packed=true],提供一种向前,或者向后兼容的方式。

字段顺序

.proto文件中,顺序的选择并不对message的序列化产生任何影响。 当一个message被序列化时,不保证字段被写入的顺序。 序列化的实现是一个细节,而且该细节未来有可能会变化。 因此,pb的解析器必须能够以任何方式的顺序解析字段。

启发

  • 不要想着每次序列化出来的字节输出都是一样的。这一点对那些有其他pb message byte字段的message特别成立。
  • 下面这些方法可能不成立
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())