图解Protobuf编码

图解Protobuf编码

Protobuf是Google发布的消息序列化工具。Protobuf定义了消息描述语法(proto语法)和消息编码格式,并且提供了主流语言的代码生成器(protoc)。本文仅讨论Protobuf消息编码格式,并且假定读者已经熟悉Protobuf消息描述语法(proto2或者proto3)。


基本编码规则

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

test.proto

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

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

序列化之后,可以把data里的数据想象成下面这样:

这里写图片描述

proto2语法定义了3种字段规则:required、optional、repeated。proto3语法去掉了required规则,只剩下optional(默认)和repeated两种。由上图可知,如果没有给optional和repeated字段赋值,那么字段是不会出现在序列化后的数据中的。详细的编码规则,请继续阅读。

数据划分

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

colors

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消息为例:

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

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

abc

Varint

用3个bit来表示wire type是够了,但是tag是用剩下的5个bit来表示吗?tag难道不能超过32(2^5)吗?由上图已经知道,答案是否!为了用尽可能少的字节编码消息,Protobuf在多处都使用了Varint这种格式。比如数据类型里的int32、int64,以及tag值和后面将要解释的length值,都使用Varint类型存储。那么Varint到底有什么神奇之处呢?也没有,其实就是用每个字节的前7个bit来表示数据,而最高位的bit(MSB,Most Significant Bit)则用作记号(flag)。文字不太好描述,看一个例子:

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

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

tag16

64-bit和32-bit

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

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

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

268435456

也就是说,如果某个消息的某个int字段大部分时候都会取比较大的数,那么这个字段使用Varint这种变长类型来编码就没什么好处。对于这种情况,Protobuf定义了64-bit和32-bit两种定长编码类型。使用64-bit编码的数据类型包括fixed64、sfixed64和double;使用32-bit编码的数据类型包括fixed32、sfixed32和float。以Test消息e字段(fixed32)为例:

byte[] data4 = Test.newBuilder()
  .setE(268435456) // 2^28
  .build()
  .toByteArray();

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

fixed32

ZigZag

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

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

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

n1

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

zigzag

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

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

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

这里写图片描述

Length-delimited

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

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

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

hello

下面是自定义消息的例子:

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

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

i

repeated

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

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

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

repeated

可见,repeated字段就是简单的把每个字段值依次序列化而已。

packed

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

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

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

packed

可见,如果repeated字段设置了packed选项,则会使用Length-delimited格式来编码字段值。


结束。

相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页