Protobuf编码规则详解

一、基础

1、序列化与反序列化

序列化:指将结构化的数据按一定的编码规范转成指定格式的过程;

反序列化:指将转成指定格式的数据解析成原始的结构化数据的过程;

举个例子:Person是一个表示人的对象类型,person是一个Person类型的对象,将person存到一个对应的XML文档中的过程就是一种序列化,而解析XML生成对应Person类型对象person的过程,就是一个反序列化的过程。在这里结构化数据指的就是Person类型的数据,一定的编码规范指的就是XML文档的规范。XML是一种简单的序列化方式,用XML序列化的好处是,XML的通用性比较好,另外,XML是一种文本格式,对人阅读比较友好,但是XML方式比较占空间,效率也不是很高。通常,比较高效的序列化都是采用二进制方式的;将要序列化的结构化数据,按一定的编码规范,转成为一串二进制的字节流存储下来,需要用的时候再从这串二进制的字节流中反序列化出对应的结构化的数据。

2、TLV编码格式:即Tag-Length-Value(其中Length可选)的编码格式。

每个字段都使用TLV的方式进行序列化,一个消息就可以看成是多个字段的TLV序列拼接成的一个二进制字节流。其实这种方式很像Key-Value的方式,所以Tag一般也可以看做Key。显然,这种方式组织的数据并不需要额外的分隔符来划分数据,所以序列化的效率非常高(空间效率)。

二、Protobuf编码基础

1、Varints:varints是一种将一个整数序列化为一个或者多个Bytes的方法。越小的整数,使用的Bytes越少。Varints规则如下:

1)每个Bytes的最高位(msb)是标志位。如果该值为1,表示该Bytes后面还有其他Byte;如果该位为0,表示该Byte是最后一个Byte。

2)每个Byte的低7位是用来存数值的位。

3)Varints方法使用小端字节序(反解数值的时候后面的字节方前面)。

举几个例子:

a)以数字1为例:二进制是0000 0001,最高位是0代表后面没有更多字节,剩下的7位就是数值位,"000 0001"显然就是1。

b)以数字300为例:它在Varints规则下的表示形式是1010 1100 0000 0010。

第一个字节是 1010 1100,最高位是1,表示后面还有更多字节;第一个字节内容是后7位即 010 1100;

第二个字节是 0000 0010,最高危是0,表示后面没有更多字节;第二个字节内容是后7位即 000 0010;

因为是“低字节序”,所以实际字节是 000 0010 010 1100=1 0010 1100=300。

2、字段编号(field_num):就是.proto文件中每个字段都有的顺排的那个编号;

3、传输类型(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

4、field:message由一个个字段组成,一个字段的完整的二进制描述即<<编号,传输类型>,值>通常称为一个field,如下图。

5、具体而言每个field的构成为Tag-[Length]-Value;这里的[Length]是否需要是依据Tag最后三位的wire_type来决定的。

wire_type含义二进制结构
0VarintsTag-Value
1、564-bits/32bitsTag-Value
2string/嵌套/repeatedTag-[Length]-Value

6、Tag:Tag的组成是“field_num << 3 | wire_type”(也称为每个filed的key或者键)。

4、pb编解码关键点

 


1)在消息流中每个Tag(key/键)都是varint,编码方式为:field_num << 3 | wire_type。即,Tag(key/键)由 .proto文件中字段的编号(field_num) 传输类型(wire_type)两部分组成。

注:Tag也是Varints编码,其后三位是传输类型(wire_type),之前的数值为是字段编号(field_num)。

注意并不是说Tag只能是一个字节,这里说了Tag也是用Varint编码,显然使用Varint编码方式几千/几万的字段序号(field_num)都是可以被表示的。

2)在对一条消息(message)进行编码的时候是把该消息中所有的key-value对序列化成二进制字节流key和value分别采用不同的编码方式。

3)消息的二进制格式只使用消息字段的字段编号(field_num)作为Tag(key/键)的一部分,字段名和声名类型只能在解析端通过引用参考消息类型的定义(即.proto文件)才能确定。

4)解码的时候解码程序(解码器)读入二进制的字节流,解析出每一个key-value对;如果解码过程中遇到识别不出来的filed_num就直接跳过这样的机制保证了即使该消息(message)添加了新的字段,也不会影响旧的编/解码程序正常工作。

举例说明:

对于如下message,如果应用程序创建了一个Test1的对象,并把a字段赋值150,那么protobuf会把它编码成这样三个字节:“08 96 01”。接下来解析:

message Test1 {
  required int32 a = 1;
}

(1)在二进制流里面第一个数字都是key(至少是key的一部分;若首位为1表示后面还有字节,为0表示后面没有字节了),即key是08,对应二进制0000 1000;根据前面所说知道后三位是000,代表传输类型是0即Varint;前五位是0000 1就是数字1,代表序号field_num是1。

注:因此通过传输类型知道后面传输的是Varint,通过序号知道在message里面的tag是1。

(2)接下来就使用Varint方式对后面的“96 01”解码即可。对应二进制是1001 0110 0000 0001:

第一个字节是1001 0110,最高位是1,代表后面还有更多字节;第一个字节内容是后7位即 001 0110;

第二个字节是0000 0001,最高位是0,代表后面没有更多字节;第二个字节内容是后7位即 000 0001;

因为是采用“低字节序”,所以实际的字节是: 000 0001 001 0110 = 1001 0110 = 150。

编码方式其他的问题:

1、有符号整数编码的问题与zigzag优化

由上面可以知道protocol buffer中所有与传输类型0关联的类型都会被编码为Varints。但是在编码负数的时候,带符号的int类型(sint32和sint64)与“标准”int类型(int32和int64)之间存在着巨大区别。如果将int32或int64用作负数的类型,则结果varint总是十个字节;也就是说像-1、-2这样的负数也会占用比较多的Bytes。实际上他被视为一个非常大的无符号整数。如果使用有符号类型(sint32和sint64)之一,则生成的varint会采用一种改进的ZigZag编码,效率更高。ZigZag编码将有符号数映射到无符号数以便具有较小绝对值的数字(如-1)也具有较小的varint编码值。这样做的方式是通过正整数和负整数来回“曲折”,将-1编码为1,将1编码为2,将-2编码为3…………以此类推。如下表所示:

Signed Original

Encoded As

0

0

-1

1

1

2

-2

3

2

4

-3

5

2147483647

4294967294

-2147483648

4294967295

2、64-bit(wire_type=1)和32-bit(wire_type=5) 等非varint数字的编码

这两种的编码方式比较简单,直接在key后面跟上64bits或32bits,采用“小端”字节序。

3、Length-delimited(wire_type=2)字符串的编码。

key-value的格式为 key+length+content ;key的编码方式就是前面所说的,length采用varints编码方式,content就是有length指定的长度的Bytes。

message Test2 {
  optional string b = 2;
}

如上结构:实例化一个对象并设置b的值为“testing”,我们可以得到这条消息(message)对应的二进制数据为 “12 07 74 65 73 74 69 6e 67

1)key是12,二进制为0001 0010;后三位是010,即传输类型为2(Length-delimited);前五位是00010即2,表示序号为2。

2)length是07,代表value的长度是7;

3)再后面代表的就是value值了;例如74(Hex)对应116(D),就是字符‘t’。

4、内嵌消息

如下图是一个拥有内嵌消息的结构Test3,内嵌的消息类型就是上面定义的Test1。

message Test3 {
  optional Test1 c = 3;
}

如果对其实例化一个对象并将Test1中的a设置为150,对象编码后的二进制流为“1a 03 08 96 01”。

1)1a二进制为0001 1010,后三位是010,即传输类型为2(Length-delimited);前五位是11100即3表示序号为3。

2)03就表示后面value的长度为3字节;

3)“08 96 01”和第一个例子中编码后的结果一样了。

结论:也就是说内嵌消息会和字符串一样被视为Length-delimited;编码方式亦相同(key+length+content)。

5、可选和可重复元素

这里再剖析一个protobuf编码实例:

对于如下结构 我们实例化一个对象给num1字段赋值10、num2字段赋值1073741824。序列化后的结果十六进制输出和二进制输出分别如下:(过程见 这里)

message Test
{
    required int32 num1 = 1;
    required fixed32 num2 = 2;
}
十六进制表示为:
080a 1500 0000 40
二进制表示为:
0000‭1000 00001010 00010101 00000000 00000000 00000000 01000000

利用上面说的编码规则对得到的二进制数据进行分析,如下:

第一个字段的Tag解码:

由上面知道Tag也是采用Varint编码的因此最开始依据第一个msb位读取第一个字节(00001000)为第一个字段的Tag,最后3位(000)表示wire_type=0指示了接下来Value的编码采用Varint方式。剩余5位(00001)表示field_number=1表示第一个字段的编号为1;

第一个字段的Value解码:

由wire_type=0可知Value是采用Varint编码。故读取下一个字节(00001010),该字节的第一位msb位为0。故接下来的7位(0001010)表示第一个字段的值。因为只有一个字节因此逆序还是0001010,二进制0001010表示数字为:10

第二个字段的Tag解码:

由同样的办法得第二个字段field_numer=2,wire_type=5。wire_type=5对应fixed32类型的编码方式。

第二个字段的Value解码:

fixed32类型的编码方式因为已经固定取32位,因此不需要msb位。但为了移位方便,还是有按字节逆序编码。因此解码的时候也要逆序回来。

fixed32的编码如下:
00000000 00000000 00000000 01000000
按字节逆序回来:
01000000‬ 00000000 00000000 00000000
二进制表示的值为:‭1073741824

更多case参见:Protobuf编码 - 只取一瓢饮 - 博客园

Protocol Buffers编码详解,例子,图解_fullsail的博客-CSDN博客

序列化与反序列化

序列化

protobuf生成的类中,其继承体系涉及的主要是::google::protobuf::MessageLite和Message这两个类,其中Message是::google::protobuf::MessageLite的子类。我们自动生成的类可能继承自这两个类中的一个,这取决于在proto描述文件中的配置,如果设置option optimize_for = LITE_RUNTIM,则编译生成的类继承自::google::protobuf::MessageLite。这两个类都拥有基本的功能的代码,而Message是扩展出来的子类,增加了一些特性功能,然而实际中如果用不到这些功能,则开启这个优化可以使得我们生成的文件更小。

反序列化

对于反序列化而言其实其本质就是从一个输入流里一次读取tag值然后根据wire_type判断他是那种类型类型的数据,然后再调用对应的方法读取对应的值。整个处理过程其实就在一个while循环中,直到数据出来完毕才终止。

重点参考: https://www.136.la/tech/show-149798.html

常见问题:

(1)不要随便调整已有字段的顺序。

(2)结尾某几个字段是序号,靠上的字段都正常。

原因:这种一看就是打包方新增pb字段了,但是解包方的pb依赖没更新。

(3)反序列化后发现所有的字段名称都变成字段序号了。

原因:打包方所用的pb和解包所用的根本不是一个pb。原因很多。除了考虑原始打包方和解包方pb文件是否一致外,还应该考虑中间会不会被别人改动过。举个例子,中间某环节把打包方打包后的buf又作为某个结构的字段值打包传过来了。这时候解包方用原始和打包方约定的pb来反序列化肯定是有问题的了。

  • 5
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

焱齿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值