Protobuf 序列化原理

137 阅读6分钟

Protobuf 序列化

Protocol Buffers(简称protobuf或pb)是由Google开发的一种语言中立、平台无关、可扩展的序列化结构数据的方式。它主要用于通信协议、数据存储等场景,旨在提供一种高效且灵活的数据交换格式。

fn & wt

field number(简写为fn)以及wire type(简写为wt)
其中field number是proto文件中标注的该字段数字代号,而wire type表示本字段的数据类型属于哪种类型,这些类型主要用于提醒反序列化程序如何判断本字段值占据几个字节。

image.png

因为wire type种类很少,最大仅为5(101),为了进一步节约字节,write type只用了3个bits来表示,而fn则使用更高位来表示:

image.png

在使用一个字节的情况下,fn-max=0b01111 15 ,第一位如果是1,代表后面还有,所以一字节第一位必须为0,最大fn=15,超过15需要两个字节

大部分整数类型的wire type都是varint变长整型。Varint简单说就是每个字节最高位不用来表示具体数值,只用来表示“本字节是不是这个数字的最后一个字节”。0表示最后一个字节,1表示不是最后一字节、后面还有。因此如果要把varint还原为普通的二进制表示,需要去掉最高bit,把剩下的7个bit组合起来看。

image.png

因为fn大于15的字段需要至少2个字节来存储fn+wt,protobuf建议,把fn小于16的值留给最常用字段,以节约字节数。

varint

image.png

需要注意protobuf的varint采用类似小端模式,因此图中第1行第3列存的是高位,第2列是低位,转化十进制过程中需要把他们调换一下位置,其他使用varint的类型也是类似机制。

pb varint为什么使用小端

  1. 我仔细想了一下,好像没什么大小端都差不多,无论大小端pb都是软件解码的,两者性能没太大差别,伪代码如下(pb会根据位数进行优化,如32位int,64位long)
  2. 流行用小端

byte[] buffer={0b10000001 0b10000010 0b10001000 0b00010001}

如果是大端,伪代码如下

    int index
    int i
    while index < bytes.len && bytes[index] & 0b10000000 != 0 {
        i+=bytes[index] & 0b01111111
        i<<7
        index++
    }
    i+=bytes[index] & 0b01111111

如果是小端

    int index
    int shift = 0
    for ; index < buffer.length; index++ {
        byte currentByte = buffer[index]
        if index == buffer.length - 1 || (currentByte & 0b10000000) == 0 { 
            i |= (currentByte & 0b01111111) << shift
            break // 结束循环,因为遇到了最后一个字节
        } else {
            i |= (currentByte & 0b01111111) << shift
            shift += 7
        }
    }

sint

负数在计算机中采用补码存储,在varint中,负数会占满所有位数,无法发挥变长整数的优势,varint还把每字节8bits最高位用来表示是不是最后一位

因此protobuf中出现了sint32和sint64类型,该类型使用ZigZag来优化。

ZigZag规则为,如果是负数,则存储其绝对值的2倍减1;如果为非负数,则存储其绝对值的2倍。这样就可以把int类型1对1映射为unsigned int类型。

ZigZag的优化主要基于一个事实:我们在传输数据时,所传的整数大多是和0比较接近的小正数或者小负数,很少传输绝对值特别大的负数或正数。满足这一事实的场景下,推荐把protobuf中的int32和int64都替换为sint32和sint64,节约字节数。

fix

  • fixed32, sfixed32, float
  • fixed64, sfixed64, double

Varint和ZigZag方法其实没有优化绝对值特别大的数。例如如果要传输的数字是int32最大值2147483647,本来是4个字节,使用varint反而需要5个字节了。因此,fixed32和fixed64类型就是为这种场景设计。这种情况下,数字直接按照它的二进制表示进行序列化,固定占用4字节或8字节。

len (需显式告知长度)

string, bytes, 嵌套类型(embedded messages),repeated字段

使用varint标明需要读取的长度

  repeated string hobbies = 5;
  repeated int32 ids = 6;
.addAllHobbies(List.of("a", "b", "c"))
.addAllIds(List.of(1, 2, 3))
bytes
[42, 1, 97, 42, 1, 98, 42, 1, 99, 50, 3, 1, 2, 3]

42 ⇒ 0010 1010 ⇒ fn=5,wt=2
1 ⇒ len
97 ⇒ a
42 ⇒ 0010 1010 ⇒ fn=5,wt=2
1 ⇒ len
98 ⇒ b
42 ⇒ 0010 1010 ⇒ fn=5,wt=2
1 ⇒ len
99 ⇒ c

50 ⇒ 0011 0010 ⇒ fn=6,wt=2
3 ⇒ len
1,2,3 => list.of(1,2,3)

Any

message Any {
  string type_url = 1;
  bytes value = 2;
}

Any就是一个message,包含类型和值

  google.protobuf.Any any = 4;
.setAny(Any.pack(Int32Value.of(2)))
    bytes
    [34, 52, 10, 46, 116, 121, 112, 101, 46, 103, 111, 111, 103, 108, 101, 97, 112, 105, 115, 46, 99, 111, 109, 47, 103, 111, 111, 103, 108, 101, 46, 112, 114, 111, 116, 111, 98, 117, 102, 46, 73, 110, 116, 51, 50, 86, 97, 108, 117, 101, 18, 2, 8, 2]
    34 ⇒ 0010 0010 ⇒ fn=4,wt=2
    52 ⇒ len
    10 ... 8 ⇒ .type.googleapis.com/google.protobuf.Int32Value
    2 => int32 value

这里也能看出Any的性能不好,相当于序列化了全类名进去,尽量不用Any

other

Protobuf序列化时会直接忽略为空值的字段

example

在protobuf-Java中,反序列化代码在pb生成的代码中,Builder.mergeFrom

message User {
  string name = 1;
  int32 age = 2;
}
    switch (tag) {
      case 0:
        done = true;
        break;
      case 10: {
        name_ = input.readStringRequireUtf8();
        bitField0_ |= 0x00000001;
        break;
      } // case 10
      case 16: {
        age_ = input.readInt32();
        bitField0_ |= 0x00000002;
        break;
      } // case 16
      default: {
        if (!super.parseUnknownField(input, extensionRegistry, tag)) {
          done = true; // was an endgroup tag
        }
        break;
      } // default:
    }
  • case 10 ⇒ 0000 1010 ⇒ fn=1,wt=2
  • case 16 ⇒ 0001 0000 ⇒ fn=2,wt=0

在Protobuf协议定义时,怎样选取合适的整数类型

  1. 有符号整型,大多数值都不算很大(4字节绝对值小于2^27,8字节绝对值小于2^55),使用sint32和sint64
  2. 有符号整型,大多数值都特别大(4字节绝对值大于2^27,8字节绝对值大于2^55),使用sfixed32,sfixed64
  3. 无符号整型,大多数值都不算很大(4字节绝对值小于2^28,8字节绝对值小于2^56),使用uint32和uint64
  4. 无符号整型,大多数值都特别大(4字节绝对值大于2^28,8字节绝对值大于2^56),使用fixed32和fixed64
  5. 有符号整型,绝大多数数值都是不算很大的正数(4字节绝对值小于2^27,8字节绝对值小于2^55),但在极少数情况下可能出现负值,使用int32和int64。