protobuf编码规则浅析

557 阅读4分钟

引言

本文档简要介绍了protobuf中基础的编码规则。

Varint编码

参考链接

字节序

  1. 大端序:高字节地址,低字节地址(符合书写习惯:从左到右)
  2. 小端序:高字节地址,低字节地址

定长编码

16位整型——2个字节

32位整型——4个字节

64位整型——8个字节

变长编码

🔔为什么用变长编码: 比如对于一个64位整数来说,需要使用8字节来存储。但是通常情况下我们存储这8个字节的数据通常不会占用8个字节这么多。例如:140(二进制为:1000 1100),只需要占用两个字节,剩余高位全是0,其实是可以不用存储。

  • 所以变长编码的本质就是:根据待编码的数据的大小,动态使用不同大小的空间来存储,从而达成减少占用的效果。

  • 如何实现:将一个字节(8位)分成1 + 7

    • 最高位位标志位(continuation bit) ,表示当前字节的负载已经结束。(0表示结束,1表示为结束)
    • 低7位为负载(payload) ,存储的就是实际的数字的比特位。
  • 对于无符号64bit整型,使用变长编码的话,占用的字节数为1~10字节。

protobuf编码.png

有符号数的编码

对于负数来说,protobuf首先将其转换为无符号数,再进行varint的编码。 (称作:zigzag编码

转换的公式为: n<02×n1n < 0 \rightarrow 2\times|n|-1

Encoding

字段编码原则

TLV(Tag-Length-Value) ,也就是每一个字段都是按照TLV的原则来进行编码。

Tag

由field number和wire type组成,field number就是我们定义proto文件的时候,每个字段后面的=后面的数字。

由这两个组成Tag的公式为 (field_number << 3) | wire_type

例子解析

message Address {
  string addr = 1;
  int32 number = 2;
}

// 创建一个Address对象
addr := &Address{
    Addr: "XiaRoad",
    Number: 523000,
}

对上面的addr的编码过程为(TLV规则):

1. 首先对Addr进行编码:

  • 第一个字段Addr

    • T: 首先计算field-1的tag:tag1 = (1 << 3) | 2 = 10 = 0x0Astringwire_type=2。 对tag1进行变长编码,得到的结果依然是0x0A
    • L: 随后计算字符串的长度,len("XiaRoad")=7,对7进行变长编码,得到的结果为0x07
    • V: 接着拼接上字符串"XiaRoad"的二进制表示(此处为ASCII):0x58 0x69 0x61 0x52 0x6F 0x61 0x64
  • 最终第一个字段的编码结果为(十六进制) 0A 07 58 69 61 52 6F 61 64

2. 随后对Number进行编码:

  • 第二个字段Number

    • T: 首先计算field-2的tag:tag2 = (2 << 3) | 0 = 16 = 0x10,int32的wire_type=0。对tag2进行变长编码,得到的结果依然是0x10;
    • L: 由于是整型,所以没有不像字符串那样有长度,所以此处就没有Length
    • V: 随后是对Number的值,也就是523000进行变长编码,得到的结果为0xF8 0xF5 0x1F(小端序)
  • 最终将上述的结果拼接,得到第二个字段的编码结果为(十六进制):10 F8 F5 1F

  1. 最终得到整个的addr对象的编码结果为:

    0A 07 58 69 61 52 6F 61 64 10 F8 F5 1F,一共占用13个字节。

字段嵌套的情况

📌protobuf中规定,字段嵌套的wire_type=2,也就是和string的情况类似的处理。T-L-V,L就是被嵌套的字段编码后的长度。

message Address {
  string addr = 1;
  int32 number = 2;
}

message People {
  int32 age = 1;
  string name = 2;
  Address addr = 3;
}

// 对对象p进行序列化
p := &pb.People{
    Age: 20,
    Name: "HelloWps",
    Addr: &pb.Address{
        Addr: "XiaRoad",
        Number: 523000,
    },
}

例如上面的例子:Address被嵌套进People中。 protobuf编码例子1.png

repeated字段

  1. 被repeated标识的字段的wire_type=2。 (1 << 3 | 2 = 0x0A)
  2. Length表示的是后续每个element编码叠加后的长度。(例如下面例子中的长度为 0x09
// repeat的情况
message IntSlice {
  repeated int32 elems = 1;
}

//
ele := &pb.IntSlice{
    Elems: []int32{100, 200, 300, 400, 500},
}

例如下图所示: protobuf编码例子2.png

注意

  1. protobuf中的编码顺序是按照字段的序号来排序的,字段序号小的编码后放在前面。
message Location {
  string loc = 3489;
  int32 number = 189;
}
// 上面的message中,number首先被编码,然后loc再被编码

例如下图所示:

protobuf编码例子3.png

  1. 为什么推荐使用1~15的字段序号?

    因为1~15的字段序号编码后的tag只占用一个字节。(依据: (field_number << 3) | wire_type

  2. 为什么序号不能有0?

    同样是依据:(field_number << 3) | wire_type,因为field_number要左移3位,对数字0进行左移没有意义。

参考资料

  1. protobuf官方文档
  2. go标准库中的变长编码实现
  3. go中的proto序列化实现