前言
Protocol Buffers(简称ProtoBuf)是一种语言无关、平台无关、可扩展的数据序列化格式。
它由Google开发,最初被用于解决大规模分布式系统中的数据存储与通信问题,现在也是gRPC协议的默认序列化方式。
在RPC框架中,数据序列化是非常关键的一环,序列化性能的高低直接影响RPC的调用性能。
ProtoBuf的设计目标是提供一种高效、简单、可扩展的数据交换格式。与其他常见的数据序列化格式(如XML和JSON)相比,ProtoBuf在数据大小和解析速度上都有很大的优势。
如下一段数据,使用JSON传输需要消耗25个字节,ProtoBuf仅仅需要11字节,缩小了一半多。
{
"name":"Jackson",
"id":1
}
消息定义
不管是服务还是消息,均通过.proto
文件定义,如下是一个最简单的消息定义:
syntax = "proto3";
message Person {
int32 id = 1;
string name = 2;
}
第一行指定了proto文件的版本号。
第二行开始定义了一个Person
消息类,它由2个字段组成,整形id和字符串name。
笔者使用Java语言,Maven引入Compiler插件,就可以把proto文件编译成Java类。
编译后的代码很长就不贴了,这里给出编译后的Person类图:
实现了MessageLite接口,方法MessageLite#toByteArray()
可以把对象序列化成字节数组。
Person#parseFrom()
方法可以把字节数组反序列化成对象。
序列化
如下是一个简单的序列化&反序列化示例:
public static void main(String[] args) throws Exception {
PersonOuterClass.Person person = PersonOuterClass.Person.newBuilder()
.setId(1).setName("Jackson")
.build();
// 序列化
byte[] bytes = person.toByteArray();
System.err.println(bytes.length);// 11
// 反序列化
person = PersonOuterClass.Person.parseFrom(bytes);
System.err.println(person);
}
ProtoBuf采用TLV的数据存储方式,即Tag Length Value。其中Length部分是可选的,只有变长类型才需要,例如字符串、字节数组等。
序列化后的字节数组就是由若干个TLV紧凑的排列在一起的。
以生成的Person#writeTo()
序列化代码,按照字段定义的顺序往Output写。
public void writeTo(com.google.protobuf.CodedOutputStream output)
throws java.io.IOException {
if (id_ != 0) {
output.writeInt32(1, id_);
}
if (!getNameBytes().isEmpty()) {
com.google.protobuf.GeneratedMessageV3.writeString(output, 2, name_);
}
if (sex_ != false) {
output.writeBool(3, sex_);
}
unknownFields.writeTo(output);
}
Tag用来标识字段序号和类型,采用无符号的Int32表示,Tag被设计用来表示两部分数据:
- 低3位表示WIRE_TYPE
- 高位表示字段序号
通过Tag就可以知道,当前这段数据是什么类型的,属于哪个字段。
生成Tag的代码:WireFormat#makeTag()
static int makeTag(int fieldNumber, int wireType) {
return fieldNumber << 3 | wireType;
}
WIRE_TYPE的类型有:
WIRE_TYPE只占用3Bit,最多可以表示8种类型,够用吗???
目前WIRE_TYPE一共也只有6种类型,完全够用。因为ProtoBuf会把多种数据类型合并为一种类型,例如WIRE_TYPE=0,可以表示为int32,int64,uint32,unint64,bool,enum以及sint32和sint64等类型。
拿bool举例,ProtoBuf用1字节表示,0代表false,1代表true。
public final void writeBool(final int fieldNumber, final boolean value) throws IOException {
this.writeTag(fieldNumber, 0);
this.write((byte)(value ? 1 : 0));
}
对于变长类型,例如字符串,字节数组这些长度不确定的类型,则需要一个单独的Length字段来标记实际的Value长度。
以字节数组为例,在写入Tag后,紧接着会写一个无符号的Int32来记录长度。
public final void writeBytesNoTag(final ByteString value) throws IOException {
this.writeUInt32NoTag(value.size());
value.writeTo(this);
}
为了节省空间,ProtoBuf采用变长整形(Varint)的编码方式来表示整数。
每个字节的低7位用来表示数字,最高位用来表示整形是否结束。如果为1代表整形还没结束,还需要读取下一个字节,直到最高位为0才代表一个完整的整形。
- 对于小于等于127的数值,使用一个字节表示,最高位标识为0,低7位存储数值本身;
- 对于大于127的数值,使用多个字节表示,每个字节的最高位标识为1,低7位存储数值的一部分,最后一个字节的最高位为0。
基于以上规则,虽然单个字节表示的数字范围小了,但是基于概率学统计,大数出现的概率往往很小,采用变长整形来表示数字可以有效节省空间。