Protobuf 编码详解及持久化追加

目的

调研 protobuf 磁盘追加方案

Protobuf 为什么不能磁盘追加

Protobuf 把数据转成二进制进行序列化保存,二进制文件 append 后并不能解析正确

背景知识 - 通信协议之序列化——TLV / TTLV

参考资料1
http://blog.chinaunix.net/uid-27105712-id-3266286.html
可以看下这篇对通讯协议 TLV / TTLV的讲解
参考资料2
https://my.oschina.net/maxid/blog/206546?tdsourcetag=s_pctim_aiomsg
其中一种 TLV 的实现介绍

Protobuf 编码协议

先看官网对于 Protobuf 编码的介绍
一个简单的消息
假设您有以下非常简单的消息定义:

message Test1 {
  optional int32 a = 150;
}

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

08 96 01

127 及以下

08 7F

127 以上,例如128

08 80 01

Base 128 Varints

要了解Protobuf编码,您首先需要了解varint。 Varints是一种使用一个或多个字节序列化整数的方法。 较小的数字占用较少的字节数。(TLV 的变种)

除了最后一个字节外,varint中的每个字节都设置了最高有效位(msb)-这表明还有其他字节要来。 每个字节的低7位用于以7位为一组存储数字的二进制补码表示,最低有效组在前。
以下是 300 的二进制码,看起来有点复杂 :

1010 1100 0000 0010

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

 1010 1100 0000 0010
→ 010 1100  000 0010

反转两组7位,因为varint存储数字的有效位最低。 然后,将它们连接起来以获得最终值:

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

Message Structure

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

对消息进行编码时,键和值被串联到一个字节流中。 对消息进行解码时,解析器需要能够跳过无法识别的字段。 这样,可以将新字段添加到消息中,而不会破坏不知道它们的旧程序。 为此,wire-format Message 中每对的“键”实际上是两个值-.proto文件中的字段编号,加上wire type,该类型仅提供足够的信息来查找以下值的长度。 在大多数语言实现中,此键称为标签(Tag)。

例如 08

08 - 0000 1000

抛去后三位的 wire type,field number = 1

可用的 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

流式消息中的每个键都是具有值(field_number << 3)|的varint。 wire_type – 换句话说,数字的最后三位存储 wire type。

image.png

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

08 = 000 1000

您使用最后三位(last three bits)获得 (wire type) (0),然后右移三位以获得字段编号(1)。 因此,您现在知道字段号为1,并且以下值为varint。 使用上一节中的varint解码知识,您可以看到接下来的两个字节存储值150。

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

More Value Types

Signed Integers

正如您在上一节中所看到的,与wire type 0 关联的所有 protobuf 类型都被编码为varint。但是,在对负数进行编码时,带符号的int类型(sint32和sint64)与“标准” int类型(int32和int64)之间存在重要区别。如果将int32或int64用作负数的类型,则结果varint总是十个字节长–实际上,它被视为非常大的无符号整数。如果使用带符号类型之一,则生成的varint使用ZigZag编码,效率更高。

ZigZag编码将有符号整数映射为无符号整数,以便具有较小绝对值(例如-1)的数字也具有较小的varint编码值。这样做的方式是通过正整数和负整数来回“曲折”,以便将-1编码为1,将1编码为2,将-2编码为3,依此类推,可以在下表中看到:

签名原始编码为

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

换句话说,每个值n使用

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

对于sint32,或

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

适用于64位版本。

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

解析sint32或sint64时,其值将解码回原始的带符号版本。

Non-varint Numbers

非varint数值类型很简单 – double和fixed64的wire type为1,它告诉解析器期望固定的64位数据块。同样,float和fixed32的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

以上使 testing 的 UTF8, 关键是0x12

0001 0010
00010 010

→field_number = 2,wire_type =2。该值中的长度varint为7,lo and behold,我们在其后找到七个字节–我们的字符串。

Embedded Messages

message Test3 {
   Test1 c = 3;
}

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

 1a 03 08 96 01

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

关键看前面这个 1a 03

1A 03 -> 0001 1010 0000 0011
      ->  001 1010 0000 0011

首字节,
4~7 位 0011 = 3,field number
末三位 010 = 2,代表 wire type (string, bytes, embedded messages, packed repeated fields)
后面跟一个长度 03 -> 0000 0011 (也就是 08 96 01)

这样,Embedded Messages 就出来了。

Optional And Repeated Elements

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

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

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

MyMessage消息;

message.ParseFromString(str1 + str2);

等效于此:

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

该属性有时很有用,因为即使您不知道它们的类型,它也允许您合并两个消息。

Packed Repeated Fields

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

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

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

现在,假设您构造一个Test4,为重复字段d提供值3、270和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位或64位线型的类型)的重复字段声明为“打包”。

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

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

Field Order

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

序列化消息时,对于如何写入其已知字段或未知字段没有保证的顺序。 序列化顺序是一个实现细节,将来任何特定实现的细节都可能更改。 因此,protobuf解析器必须能够以任何顺序解析字段。

Implications (启示)

不要假定序列化消息的字节输出是稳定的。对于具有传递性字节字段(transitive bytes fields)表示其他序列化protocol buffer messages 的消息,尤其如此。
默认情况下,在同一protocol buffer messages 实例上重复调用序列化方法可能不会返回相同的字节输出;即默认序列化不是确定性的。
确定性序列化只能保证特定二进制文件的字节输出相同。字节输出可能会在二进制的不同版本之间变化。
对于protocol buffer messages 实例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 messages foo和bar可能序列化为不同的字节输出。

  • bar由一台旧服务器序列化,该服务器将某些字段视为未知字段。
  • bar由以不同编程语言实现的服务器序列化,并以不同顺序序列化字段。
  • bar的字段以不确定的方式进行序列化。
  • bar有一个字段,用于存储protocol buffer messages的序列化字节输出,该消息以不同的顺序进行序列化。
  • bar由新服务器序列化,该新服务器由于实现更改而以不同顺序序列化字段。
  • foo和bar都是单个消息的串联,但是顺序不同。

进入正题

目标是 持久化追加
测试1

message Test4 {
    repeated string content = 1;
}

生成两个简单的 pb 数据

分别生成 test1 和 test2 的 content 数据

0A 05 74 65 73 74 31 
0A 05 74 65 73 74 32   

可以直接合并

0A 05 74 65 73 74 31 0A 05 74 65 73 74 32 

反序列化后,得到 test1和 test2

TODO:测试复杂对象的序列化数据合并

你可能感兴趣的:(Protobuf 编码详解及持久化追加)