protobuf介绍

protobuf介绍


protobuf简介


Protobuf是Google发布的消息序列化工具。Protobuf定义了消息描述语法(proto语法)和消息编码格式,并且提供了主流语言的代码生成器(protoc)。本文主要讨论Protobuf消息编码格式.


基本编码规则


Protobuf消息由字段(field)构成,每个字段有其规则(rule)、数据类型(type)、字段名(name)、tag,以及选项(option)。比如下面这段代码描述了由10个字段构成的Test消息:

  1. message Test { 
  2. optional int32 a = 1; 
  3. optional int32 b = 2; 
  4. optional int32 c = 3; 
  5. optional sint32 d = 4; 
  6. optional fixed32 e = 5; 
  7. optional string f = 6; 
  8. repeated int32 g = 7; 
  9. repeated int32 h = 8 [packed=true]; 
  10. optional Test i = 9; 
  11. optional int32 j = 16; 
  12. } 

序列化时,消息字段会按照tag顺序,以key+val的格式,编码成二进制数据。以下面这段Java代码为例:

  1. byte[] data = Test.newBuilder() 
  2. .setA(3).setB(2).setC(1) 
  3. .build().toByteArray(); 

序列化后
—|---|—
<k1,v1>|<k2,v2>|<k3,v3>
proto2语法定义了3种字段规则:required、optional、repeated。


二进制细节


Protobuf消息序列化之后,会产生二进制数据。这些数据(精确到bit)按照含义不同,可以划分为6个部分:MSB flag、tag、编码后数据类型(wire type)、长度(length)、字段值(value)、以及填充(padding)。如图中消息各部分使用的颜色:
各个域名


Key+Value

前面说过,消息的每一个字段,都会以key+val的形式,序列化为二进制数据。val比较好猜测,那么key具体是什么呢?答案是这样:key = tag << 3 | wire_type。也就是说,key的前3个比特是wire type,剩下的比特是tag值。Protobuf支持丰富的数据类型,但是编码之后,只剩下Varint(0)、64-bit(1)、Length-delimited(2)和32-bit(5)这4种类型,用3个比特来表示,足够了。以前面定义的Test消息为例:

  1. byte[] data = Test.newBuilder() 
  2. .setA(3).setB(2).setC(1) 
  3. .build().toByteArray(); 

序列化之后的数据有6个字节,是下面这个样子:
序列化后


Varint

用3个bit来表示wire type是够了,但是tag是用剩下的5个bit来表示显然不够用啊?为了用尽可能少的字节编码消息,Protobuf在多处都使用了Varint这种格式。比如数据类型里的int32、int64,以及tag值和后面将要解释的length值,都使用Varint类型存储。那么Varint到底有什么神奇之处呢?其实就是用每个字节的前7个bit来表示数据,而最高位的bit(MSB,Most Significant Bit)则用作记号(flag)。看一个例子:

  1. byte[] data2 = Test.newBuilder() 
  2. .setJ(1) // tag=16 
  3. .build().toByteArray(); 

由于tag是按Varint编码的,所以要扣掉一个bit(MSB)。再减去wire type占用的3个比特,那么第一个字节里,留给tag值的,实际只剩下4个比特,只能表示0到15。
由于Test消息j字段的tag值是16,所以需要两个字节才能表示j字段的key。data2如下图所示
data2


64-bit和32-bit

为了节省字节数,tag、length,以及int32、int64等数据类型都是用Varint编码的。那么这种编码方式有两个坏处:第一,不利于表示大数。对于比较小的数来说,以0到127为例,用Varint很划算。以浪费1bit和少量额外的计算为代价,只要1个字节就可以表示。但是对于比较大的数,就不划算了。以int32为例,大于2^(4*7) - 1的数,需要用5个字节来表示。看一个例子:

  1. byte[] data3 = Test.newBuilder() 
  2. .setA(268435456) // 2^28 
  3. .build() 
  4. .toByteArray(); 

序列化之后的数据如下图所示:
例子3
也就是说,如果某个消息的某个int字段大部分时候都会取比较大的数,那么这个字段使用Varint这种变长类型来编码就没什么好处。


对于这种情况,Protobuf定义了64-bit和32-bit两种定长编码类型。使用64-bit编码的数据类型包括fixed64、sfixed64和double;使用32-bit编码的数据类型包括fixed32、sfixed32和float。


以Test消息e字段(fixed32)为例:
序列化之后的数据如下图所示:
例子4


ZigZag

Varint编码格式的第二缺点是不适合表示负数,以int32和-1为例:

  1. byte[] data5 = Test.newBuilder() 
  2. .setA(-1) 
  3. .build() 
  4. .toByteArray(); 

Protobuf想让int32和int64在编码格式上兼容,所以-1需要占用10个字节,如下图所示:
例子5


为了克服这个缺陷,Protobuf提供了sint32和sint64两种数据类型。如果某个消息的某个字段出现负数值的可能性比较大,那么应该使用sint32或sint64。这两种数据类型在编码时,会先使用ZigZig编码将负数映射成正数,然后再使用Varint编码。ZigZag编码规则如下图所示:
—|---|—|---|—|---|—|---
原值|0|-1|1|-2|2|-3|3
编码值|0|1|2|3|4|5|6


以Test消息的d字段(sint32)为例:

  1. byte[] data6 = Test.newBuilder() 
  2. .setD(-2) // sint32 
  3. .build() 
  4. .toByteArray(); 

以Test消息的d字段(sint32)为例:
例子6


Length-delimited

如前所述,64-bit和32-bit是定长编码格式,长度固定。Varint是变长编码格式,长度由字节的MSB决定。Length-delimited编码格式则会将数据的length也编码进最终数据,使用Length-delimited编码格式的数据类型包括string、bytes和自定义消息。以string为例:

  1. byte[] data7 = Test.newBuilder() 
  2. .setF("hello") // string 
  3. .build() 
  4. .toByteArray(); 

序列化之后的数据如下图所示:
例子7


如果是自定义消息

  1. byte[] data8 = Test.newBuilder() 
  2. .setI(Test.newBuilder().setA(1)) 
  3. .build() 
  4. .toByteArray(); 

序列化之后的数据如下图所示:
例子8


repeated

前面讨论的字段都是optional类型,最多只有一个val,但是repeated字段却可以有多个val。那么repeated字段是如何序列化的呢?以Test消息的g字段为例

  1. byte[] data9 = Test.newBuilder() 
  2. .addG(1).addG(2).addG(3) 
  3. .build() 
  4. .toByteArray(); 

序列化之后的数据如下图所示:
例子9
可见,repeated字段就是简单的把每个字段值依次序列化而已。


packed

如果repeated字段包含的val比较多,那么每个val都带上key是不是比较浪费呢?是的,所以Protobuf提供了packed选项,以Test消息的h字段为例:

  1. byte[] data10 = Test.newBuilder() 
  2. .addH(1).addH(2).addH(3) // packed 
  3. .build() 
  4. .toByteArray(); 

序列化之后的数据如下图所示:
例子10
可见,如果repeated字段设置了packed选项,则会使用Length-delimited格式来编码字段值。


nicephil@gmail.com

posted on 2019-08-10 22:02  nicephil  阅读(452)  评论(0编辑  收藏  举报

导航