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消息格式的主要内容,如果未能解决你的问题,请参考以下文章