Protobuf消息格式

Posted 绝世好阿狸

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Protobuf消息格式相关的知识,希望对你有一定的参考价值。

protobuf是一种平台语言无关的消息序列化协议,相比于传统的json、xml,序列后的空间更小,但是无法自解释,需要结合额外的proto定义文件才能反序列化,当然这样也更安全。下面记录一下protobuf消息格式。

protobuf消息序列化后是多个key-value对,每一个字段对应一个key-value对。key-value遵循如下格式:

tag|type (length) data

tag|type:tag指的是字段的序号,这个一旦定义就不能更改,否则无法解析。type用于标识key-value对的类型,不同类型的解析方式及格式有区别。总共有这些type:

6种类型,需要3个bit。不过后来3和4废弃掉了,所以实际在用的只有0,1,2,5

0:表示这个字段是一个使用Varints编码的整数值;

1:64bit的定长数值;

2:string,嵌入式消息或者开启了packed模式的repeated字段;

4:32bit的定长数值;

这里着重看0和2两种type。tag|type的计算方式是(tab << 3) | type,最终的数值也按照Varints编码。

length:如果type是2,会有这个部分;

data:存放数据;

简单类型

看一个简单的例子:

message TestMsg1 
    int32 a = 1;
    string b = 2;


@Test
public void test4() 
    MyProto.TestMsg1 msg = MyProto.TestMsg1.newBuilder()
            .setA(8)
            .setB("123")
            .build();
    printHex(msg.toByteArray());

消息为:8, 8, 18, 3, 49, 50, 51

a字段(前两个字节):

(tag << 3 | type) = (0000001 << 3) | 0 = 0000 1000 = 8
data = 8

b字段:

(tag << 3 | type) = (0000010 << 3) | 2 = 0001 0010 = 18
length = 3
data = "123" = 49, 50, 51

 

嵌套消息

message TestMsg1 
    int32 a = 1;
    string b = 2;


message TestMsg2 
    TestMsg1 msg = 1;


@Test
public void test4() 
    MyProto.TestMsg1 msg = MyProto.TestMsg1.newBuilder()
            .setA(8)
            .setB("123")
            .build();
    printHex(msg.toByteArray());

10, 7, 8, 8, 18, 3, 49, 50, 51

前两个字节是外层消息的字段,也就是TestMsg1 msg,后面部分和上面的例子一样。

(tag << 3 | type) = (0000001 << 3) | 2 = 0000 1010 = 10
length = 7

所以,对于type=2的嵌套消息,length部分是消息所占用的总总字节数。

 

重复字段

对于整数类型的repeated字段,比如int32。如果开启了[packed=true]模式,那么会对重复数据格式做压缩。使用type=2的类型编码,重复元素的tag|type部分只出现一次,其后的length部分标识重复元素所占用的总字节数。在proto3中,packed模式默认开启开proto2中需要手动打开。如果没有开启,则重复元素会按照其本身的字段格式重复,tag|type部分会出现多次,空间利用率不太高,所以开启packed模式很有必要。

message TestMsg3 
    repeated int32 a = 1 [packed = false];
    repeated int32 b = 2;


@Test
public void test5() 
    MyProto.TestMsg3 msg = MyProto.TestMsg3.newBuilder()
            .addA(1)
            .addA(2)
            .addA(3)
            .addB(1)
            .addB(2)
            .addB(3)
            .build();
    printHex(msg.toByteArray());

字段a:8, 1, 8, 2, 8, 3。由于是int32,所以tag|type = 1 << 3 | 0 = 8,data部分都是1,重复了3次。总共需要6字节。

字段b:18, 3, 1, 2, 3。tag | type = 1 << 3 | 2 = 18,数据占用3字节,length=3,data是1,2,3。总共需要5字节。

对于其他类型的repeated字段,就是(tag << 3 | type) length data结构了,其中的data部分是多个key-value对。

为啥标量尅packed但是嵌套消息不行?

因为标量使用Varints编码,不需要length部分指定数据长度,从MSB位就可以知道读多少字节;但是嵌套消息不行,必须通过length指定长度,所以需要重复tag|type length部分。

 

map类型消息

其实,protobuff里并没有单独为map结构定义序列化协议,map结构与repeated一个对应的entry结构等价。来看例子:

message TestMsg4 
    map<string, int32> data = 1;


message TestEntry 
    string key = 1;
    int32 value = 2;


message TestMsg5 
    repeated TestEntry data = 1;

也就是这的TestMsg4 等价于TestMsg5 + TestEntry,写一个例子:

@Test
public void test7() 
    Map<String, Integer> map = new HashMap<>();
    map.put("a", 1);
    map.put("b", 2);
    map.put("c", 3);

    MyProto.TestMsg4 msg = MyProto.TestMsg4.newBuilder()
            .putAllData(map)
            .build();

    printHex(msg.toByteArray());
    System.out.println();

    MyProto.TestEntry entry1 = MyProto.TestEntry.newBuilder()
            .setKey("a")
            .setValue(1)
            .build();
    MyProto.TestEntry entry2 = MyProto.TestEntry.newBuilder()
            .setKey("b")
            .setValue(2)
            .build();
    MyProto.TestEntry entry3 = MyProto.TestEntry.newBuilder()
            .setKey("c")
            .setValue(3)
            .build();
    MyProto.TestMsg5 msg1 = MyProto.TestMsg5.newBuilder()
            .addData(entry1)
            .addData(entry2)
            .addData(entry3)
            .build();
    printHex(msg1.toByteArray());

输出:

10, 5, 10, 1, 97, 16, 1, 10, 5, 10, 1, 98, 16, 2, 10, 5, 10, 1, 99, 16, 3 
10, 5, 10, 1, 97, 16, 1, 10, 5, 10, 1, 98, 16, 2, 10, 5, 10, 1, 99, 16, 3

可以看到,序列化后的字节流完全一致。

以repeated的视角:

看一下“10, 5, 10, 1, 97, 16, 1, 10, 5, 10, 1, 98, 16, 2, 10, 5, 10, 1, 99, 16, 3” 这个字节流,分为3部分,按颜色划分,对应3个entry元素。

TestEntry消息是一个嵌套消息,type是2。(tag << 3 | type) = (1 << 3 | 2) = 10,length是5。data部分就是string+int32,也就是10,1,97,16,1。其中的10,1,97表示string,16,1表示int32。这是一个repeated元素的字节流。

例子里重复了3次,所以后面的两个元素类似。所以,map确实是等价于repeated entry。

因为是repeated嵌套消息,所以每一个元素都需要将meta部分重复编码,也就是外侧消息和嵌套消息里的(tag<<3|type)这部分数据,都是重复的,有一定的空间浪费。

如何优化?

message TestMsg6 
    repeated string key = 1;
    repeated int32 value = 2;

直接定义一个消息类型,里面有两个repeated的字段,分别是key和value。这样有两个好处:

一是repeated字段不再是嵌套消息,较少了嵌套消息meta定义的开销;

二是如果是标量类型,还可以利用packed模式进一步优化空间;

@Test
public void test8() 
    Map<String, Integer> map = new HashMap<>();
    map.put("a", 1);
    map.put("b", 2);
    map.put("c", 3);

    MyProto.TestMsg6 data = MyProto.TestMsg6.newBuilder()
            .addKey("a")
            .addKey("b")
            .addKey("c")
            .addValue(1)
            .addValue(2)
            .addValue(3)
            .build();

    printHex(data.toByteArray());

输出:10, 1, 97, 10, 1, 98, 10, 1, 99, 18, 3, 1, 2, 3。总共14个字节,比之前的21字节少了7字节!!!

当然这样定义也有弊端,在序列化与反序列化时需要额外处理map数据,转成两个list,使用起来不那么直观易懂。需要权衡。

所以如果在开发过程中如果更看重性能,可以使用优化后的map结构,反之使用原始的map即可。

 

 

https://ngtzeyang94.medium.com/go-with-examples-protobuf-encoding-mechanics-54ceff48ebaa

https://juejin.im/post/6844903955776929806

以上是关于Protobuf消息格式的主要内容,如果未能解决你的问题,请参考以下文章

Protobuf从入门到“顺手”

Protobuf语法介绍

十五.ProtoBuf3的基础总结

Protobuf3语言指南

Protobuf 语法 - 史上最简教程

如何在 gRPC 中使用标量类型作为函数参数?