浅谈Google Protobuf
1、前言
信息与物质传递的速度,决定了人类社会的发展速度。
更少的信息产生更多的价值,是信息传播时代人们一直所追求的。
2、 Google Protocol Buffer初识
回归正题,对于熟悉RPC和HTTP技术的同学来说,使用RPC框架或者HTTP Restful方案能有效降低网络应用程序的开发量,降低系统模块之间的耦合度,使得开发分布式网络应用程序更加容易。随着微服务架构的流行,在很多RPC的设计中,都采用了高性能的编解码技术,数据交互格式的重要性不言而喻。
对于一门优秀的数据交互格式,我认为有以下几大关键要素:
(1) 格式简单,易于人阅读;
(2) 足够轻量化,易于计算机解析处理;
(3) 兼容性强,易于多平台多语言间传输;
简而言之,就是要 “更快、更小、更灵活”。说到主流的数据交互格式,一定少不了JSON和XML,对于两者的优劣高低,有兴趣的同学可以加入到这场“国家德比”式的争论中《为什么都反对 XML 而支持使用 JSON?》。
google protocol buffer (以下统一简称protobuf) 是google发明的一款平台无关,语言无关,可扩展的序列化结构数据格式。
鲁迅曾说过:”天下武功,唯快不破”,而”近乎变态的性能”正是protobuf的最大优势之一。相比其他类似的数据交互技术,有性能的测试结果如下:
序列化测试结果
字节数对比测试结果如下:(对性能测试感兴趣的同学可以移步 《几种序列化协议测试对比》)
对于protobuf而言,最为特别的一点在于——它的结构即为文件。以 .proto
结尾命名(常用的版本有proto2和proto3,本文将以新版本proto3为例)。前排吃瓜,近距离体会下protobuf的定义:
syntax = "proto3";
message MyPerson {
int32 id = ;
string name = 2;
}
与创建一个struct很相似,我们用关键词message
定义了一个MyPerson的结构:第一个字段id的类型为int32,第二个字段name是string类型。等号的右侧的数字,不是对字段初始化,是表示定义的字段在message中的序号。
3、Protubuf的序列化原理
protobuf能拥有如此高效的性能,主要得益于它的序列化方式设计
。下面我将用一个最简单的例子,来尝试剖析其中的原理:
(1)字节码示例
基于上面MyPerson这个结构,我们用下面的一段简单的Java代码,用基于protobuf定义的message创建一个实例,将其序列化为字节码。
public static void main(String[] args) {
Person.MyPerson.Builder pBuilder = Person.MyPerson.newBuilder();
pBuilder.setId(1); // 设置id为1
pBuilder.setName("messi"); // 名字设置为梅西
Person.MyPerson person1 = pBuilder.build();
// 打印序列化字节码
for (byte b : person1.toByteArray()) {
System.out.println(b);
}
}
这里类似于声明了一个名为MyPerson的结构体,然后在代码中生成实际的对象并管理使用该对象。翻译成我们熟悉的Json格式,你可以认为它对应的Json数据为:
{
"id": 1,
"name": "messi"
}
上面for循环中打印出的字节码Byte如下:
8
1
18
5
109
101
115
115
105
由于protobuf是二进制格式
传输的,让我们来把他们翻译成计算机最喜欢的二进制数,瞅瞅里面有什么玄机:
10进制:8 2进制:0 0001 000 key 1 & 0
10进制:1 2进制:00000001
10进制:18 2进制:0 0010 010 key 2 & 2
10进制:5 2进制:00000101
10进制:109 2进制:01101101
10进制:101 2进制:01100101
10进制:115 2进制:01110011
10进制:115 2进制:01110011
10进制:105 2进制:01101001
(2)TLV存储
一头雾水?让我们先来科普下protobuf的序列化原理:
protobuf经过序列化后的字节码是很紧凑的,是 key-value
的形式。以上面定义的MyPerson为例子,我们定义的的数据结构大概就如下图这样:
我把其称之为TLV(Tag - Length - Value)存储方式
,即 标识 - 长度 - 字段值 存储方式。意味着我们无需用多余的分隔和终止符来分隔字段,有很高的空间利用率,甚至当字段没有赋值时,序列化的数据内容中都不会包含这个字段和内容。
也可以把上面的图画的再具体一些,序列化后每个字段的字节码可以划分为6个主要部分:MSB flag、tag、wire-type、length、value、padding
,字节码结构可能会根据编码方式的不同而有所差异,如下图所示:
(3)Varint编码基础
按照国际惯例,我们有必要先理解一下最基本的varints编码
方式。我们以这个varint编码后的二进制数为例:
00000001
很简单吧,就是1,没有套路,纯天然无污染的。
那我们换一个大一些的,400?
10010000 00000011
什么鬼?这两个字节码无论怎么看,怎么算都弄不出400吧?
varint编码重要且巧妙的一点在于,它能让字节码尽量的紧凑,不浪费一点能压榨的剩余空间,堪称encoding届的周扒皮。每个字节码中的最高位我们都称之为MSB(Most Significant Bit),用来标识后续的byte是否为当前数值的一部分,如果是1,说明下一个byte也是表示当前数值的一部分。知道了这些,现在我们分析下上面那个400的字节码:
1 0010000 最高位为1,说明下一个byte也是数值的一部分
0 0000011 最高位为0,说明当前byte是数值的最后一个部分 对于MSB而言,他本身只是一个标志位,不具备任何数值意义,所以我们在解析时要去掉这一位:
0010000 0000011 而protobuf字节序采用 little-endian的排放方式,所以我们要把两个byte的位置交换:
0000011 0010000
2 ** (8) + 2 ** (7) + 2 ** (4) = 256 + 128 +16 = 400,终于知道400是这样计算出来的了。
(4)key的剖析
那问题又来了,我们了解了上面varint的编码方式,有啥用呢?回到(1)中的字节码,刚才你也许也注意到了,我把key的那行字符码分成了几个部分:
10进制:8 2进制:0 0001 000 key 1 & 0
没错了,看到最高位的0被我隔离出来了,你也许已猜到了 —— protobuf的key正是用varint方式编码的。但需要注意的一点是,对于上面这个8位的key,字节码是这样来划分的:前5位代表了对应protobuf中的字段序号,后3位代表了这个字段的类型。
field_number << 3 | wire_type
( 5位field_number + 3 位 wire_type)
那么问题又来了—— What?我们只有5位来表示字段序号?去掉最大位的MSB,那protobuf的一个message里岂不是只能定义不超过 2 的4次方 = 16 个字段?
所以(3)中的知识点又派上用场了,假设某一个类型为int32的字段,在protobuf中定义的序号是16,那么它的key的字节码一定是这样的:
10000000 00000001
我们写成这样你会更有感觉了:
1 0000 000
0 0000001 第一行字节码的头是MSB,代表下一个字节码也是数值的一部分,去掉MSB,像上面一样交换字节码,我们得到了:
0000001 0000 | 000
field_number | wire_type
2 的4次方 = 16,这个字段的序号就是16,他的wire_type是0对应的varint。所以,当你定义的某个字段的序号较大时,我们只需要扩充后面的字节码就好了。需要通过MSB来获取数值的字节码部分,拼接后得到字段的真实序号 —— 这里也恰恰也反应了varint的优点,很适合作为整数尤其是较小整数的编码,大数或者负数则是使用ZigZag的编码方式。篇幅有限,对编码部分感兴趣的同学,推荐仔细阅读下这篇官方的文章 《Protobuf Encoding》。
(5)wire-type
protobuf支持丰富的数据类型,wire_type类型有如下几种:
(6)解读字节码
大脑都快宕机了?是不是只想右上角关闭,并送上尴尬而不失礼貌的微笑?
不要放弃,我觉得你还能抢救一下。回到上面的字节码,你会发现,将上面的知识点串联起来对字节码进行剖析,一切将变得明朗。
以第一个key 0 0001 000为例:
<filed_number: 0 0001 & wire_type: 000> ---> <filed_number: 1 & wire_type: 0> 根据Varint的编码方式:最大位MSB为0,key只占一个字节码,filed_number为1,代表这个key是我们在message定义的第一个字段。
而后三位wire_type是0,查看上面的wire_type类型表,查到0对应Varint,而其中包含了int32这个基本类型。所以我们要用Varint的编码方式解析value:MSB为0,所以只有一个字节码表示这个字段的value。0 000 0001去掉MSB为 000 0001 = 1。所以第一个字段id的值就是1。
同理,我们看到key 00010 010 ,field_number是2,代表这是proto中定义的第二个字段。wire_type是2,对应到了Length-delimited中的string类型。而根据Length-delimited编码方式,key的下一个字节码是表示这个字段的长度,0 0000101是5,这样我们从key往下数5行,就可以解析出每一行字节码对应的字符串 messi了。
10进制:8 2进制:00001 000 key: 1 & 0
field_number为1,对应id字段
wire_type为0,对应类型为varint
10进制:1 2进制:0 0000001 value: 1 id的值为1
10进制:18 2进制:00010 010 key: 2 & 2
field_number为2,对应name字段
wire_type为2,对应类型为Length-delimited
10进制:5 2进制:0 0000101 length: 5 代表string的长度为5
10进制:109 2进制:0 1101101 value: 109 ASCII对应字母为m
10进制:101 2进制:0 1100101 value: 101 ASCII对应字母为e
10进制:115 2进制:0 1110011 value: 115 ASCII对应字母为s
10进制:115 2进制:0 1110011 value: 115 ASCII对应字母为s
10进制:105 2进制:0 1101001 value: 105 ASCII对应字母为i
4、牛刀小试
看完上面一堆原理,简单实战运动一下,假设我们现在要设计一个简单的球员信息注册服务。
使用protobuf的流程我大致总结为下:
protobuf支持多种语言和平台的使用,c++、java、python与golang等语言都有对应的使用tutorials。不同平台与语言之间的使用大同小异,下面测试实例的软件版本信息如下:
jdk 1.8.0_144
protobuf-java 3.5.0
protoc protoc-3.6.0-win32
(1)下载protoc-3.x.x-win32.zip
解压后bin目录下,有执行文件protoc.exe,用来将你写的.proto文件进行编译生成指定的文件。
(2)定义并编译proto文件
我们要简单设计一个用于消息通讯的 Msg.proto:
syntax = "proto3";
// 消息的整体结构
message CMsg
{
string msghead = 1;
string msgbody = 2;
}
// 消息的head,对应CMsg的msghead
message CMsgHead
{
int32 msglen = 1;
int32 msgseq = 2;
int32 msgres = 3;
string termid = 4;
}
// 消息的Body,对应CMsg的msgbody,球员注册的主要信息和结果返回
message CMsgReg
{
int32 id = 1;
string name = 2;
int32 age = 3;
int32 ret = 4;
string termid = 5;
}
将Msg.proto文件放在同级bin目录下,确保在环境变量配置好的情况下,在cmd中,用一条编译命令执行编译,生成编译器编译后的Msg.java文件。
protoc --proto_path=F:\pb\protoc-3.5.0-win32\bin --java_out=F:\pb\protoc-3.5.0-win32\bin F:\pb\protoc-3.5.0-win32\bin\Msg.proto
其他语言同理,例如c++,将上面的 –java_out改成–cpp_out,就能生成对应的Msg.pb.h和Msg.pb.cc文件了。
如果你只是一个使用者,并不需要过多关注其中的细节(省略实现细节),你也无需改动它,你要做的,就是把它拖入你的工程内使用即可。
package com.tencent.xxx;
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: Msg.proto
public final class Msg {
private Msg() {
}
public static void registerAllExtensions(
com.google.protobuf.ExtensionRegistryLite registry) {
}
public static void registerAllExtensions(
com.google.protobuf.ExtensionRegistry registry)
public interface CMsgOrBuilder extends
com.google.protobuf.MessageOrBuilder {...}
public static final class CMsg extends
com.google.protobuf.GeneratedMessageV3 implements
CMsgOrBuilder {...}
public interface CMsgHeadOrBuilder extends
com.google.protobuf.MessageOrBuilder {...}
public static final class CMsgHead extends
com.google.protobuf.GeneratedMessageV3 implements
CMsgHeadOrBuilder {...}
public interface CMsgRegOrBuilder extends
com.google.protobuf.MessageOrBuilder {...}
public static final class CMsgReg extends
com.google.protobuf.GeneratedMessageV3 implements
CMsgRegOrBuilder {...}
private static final com.google.protobuf.Descriptors.Descriptor
internal_static_CMsg_descriptor;
private static final
com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
internal_static_CMsg_fieldAccessorTable;
private static final com.google.protobuf.Descriptors.Descriptor
internal_static_CMsgHead_descriptor;
private static final
com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
internal_static_CMsgHead_fieldAccessorTable;
private static final com.google.protobuf.Descriptors.Descriptor
internal_static_CMsgReg_descriptor;
private static final
com.google.protobuf.GeneratedMessageV3.FieldAccessorTable
internal_static_CMsgReg_fieldAccessorTable;
public static com.google.protobuf.Descriptors.FileDescriptor
getDescriptor() {
return descriptor;
}
private static com.google.protobuf.Descriptors.FileDescriptor
descriptor;
static {...}
// @@protoc_insertion_point(outer_class_scope)
}
(3)添加相关依赖库
我使用了 Maven3来管理我的Java工程,我们在pom.xml中添加一个关于protobuf工具库的使用依赖:
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>3.5.0</version>
</dependency>
(4)编写服务端代码
现在我们来编写我们的注册服务器,主要逻辑为读取客户侧发送的请求,并将stream解析为CMsg的实例对象:
DataOutputStream dataOutputStream;
InputStream istream = client.getInputStream();
dataOutputStream = new DataOutputStream(client.getOutputStream());
byte len[] = new byte[1024];
// 读取客户端发来的stream
int cnt = istream.read(len);
byte[] temp = new byte[cnt];
for (int i = 0; i < cnt; i++) {
temp[i] = len[i];
}
// 解析为protobuf中定义的CMsg对象
CMsg msg = CMsg.parseFrom(temp);
在有了CMsg的对象之后,我们可以通过Msg.java中提供的方法,从而进一步获取head和body中的具体信息,并将其解析为protobuf中定义的CMsgHead和CMsgReg对象。同理,我们又可以进一步通过get方法,获取head和body中定义的具体信息了。
// 头信息
CMsgHead head = CMsgHead.parseFrom(msg.getMsghead().getBytes());
// Body信息
CMsgReg body = CMsgReg.parseFrom(msg.getMsgbody().getBytes());
完整的Server端代码 PlayerRegisterServer.java 如下:
package com.tencent.xxx;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import com.tencent.sniper.Msg.CMsg;
import com.tencent.sniper.Msg.CMsgHead;
import com.tencent.sniper.Msg.CMsgReg;
public class PlayerRegisterServer implements Runnable {
private int id = 0;
public void run() {
try {
System.out.println("Begin to register Football Player...");
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
System.out.println("waiting for new connection...");
Socket client = serverSocket.accept();
DataOutputStream dataOutputStream;
try {
InputStream istream = client.getInputStream();
dataOutputStream = new DataOutputStream(client.getOutputStream());
byte len[] = new byte[1024];
//读取客户端发来的stream
int cnt = istream.read(len);
byte[] temp = new byte[cnt];
for (int i = 0; i < cnt; i++) {
temp[i] = len[i];
}
// 解析获取客户端的msg
CMsg msg = CMsg.parseFrom(temp);
CMsgHead head = CMsgHead.parseFrom(msg.getMsghead().getBytes());
System.out.println("head len: " + head.getMsglen());
System.out.println("head res: " + head.getMsgres());
System.out.println("head seq: " + head.getMsgseq());
System.out.println("head termid: " + head.getTermid());
registerPlayer(dataOutputStream, CMsgReg.parseFrom(msg.getMsgbody().getBytes()));
istream.close();
} catch (Exception ex) {
System.out.println(ex.getMessage());
ex.printStackTrace();
} finally {
client.close();
System.out.println("server close\n");
}
}
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
private void registerPlayer(DataOutputStream dataOutputStream, CMsgReg body) {
System.out.println("start to register palyer...");
System.out.println("player name: " + body.getName());
System.out.println("player age: " + body.getAge());
CMsgHead rhead = CMsgHead.newBuilder().setMsglen(10).
.setMsgseq(100).setMsgres(0)
.setTermid("Register Client: Head").build();
int ret = 0;
int pid = 0;
// 只允许31岁以下的球员注册成功
if (body.getAge() < 31) {
pid = ++id;
} else {
ret = 1001;
}
CMsgReg rbody = CMsgReg.newBuilder().setId(pid).setName(body.getName())
.setAge(body.getAge()).setRet(ret).setTermid("Register Client: Body").build();
CMsg rmsg = CMsg.newBuilder()
.setMsghead(rhead.toByteString().toStringUtf8())
.setMsgbody(rbody.toByteString().toStringUtf8()).build();
byte[] backBytes = rmsg.toByteArray();
try {
dataOutputStream.write(backBytes, 0, backBytes.length);
dataOutputStream.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Thread desktopServerThread = new Thread(new PlayerRegisterServer());
desktopServerThread.start();
}
}
(5)编写客户端代码:
客户端相对要简单一些,我们要做的就是创建protobuf定义的结构实例,然后发送到服务器注册信息:
// 按照定义的protobuf格式组装head
CMsgHead head = CMsgHead.newBuilder().setMsglen(5)
.setMsgseq().setMsgres(1001)
.setTermid("Register Client: Head").build();
// 按照定义的protobuf格式组装body
CMsgReg body = CMsgReg.newBuilder().setId(1).setName(name)
.setAge(age).setRet(1001).setTermid("Register Client: Body").build();
// 按照定义的protobuf格式,将head和body拼接成msg
CMsg msg = CMsg.newBuilder()
.setMsghead(head.toByteString().toStringUtf8())
.setMsgbody(body.toByteString().toStringUtf8()).build();
我们还可以做个简单的测试,将protobuf序列化后字节码的大小与存储了相同信息的json串大小做个比较:
public void printSizeOfData(CMsg g) {
System.out.println("\n" + "bytes size: " + g.toByteString().size());
String pJsonData = "";
try {
pJsonData = JsonFormat.printer().print(g);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("json size:" + pJsonData.getBytes().length);
}
完整的客户端代码 PlayerRegisterClient.java 如下:
package com.tencent.xxx;
import java.io.InputStream;
import java.net.Socket;
import com.google.protobuf.util.JsonFormat;
import com.tencent.sniper.Msg.CMsg;
import com.tencent.sniper.Msg.CMsgHead;
import com.tencent.sniper.Msg.CMsgReg;
public class PlayerRegisterClient {
public void run() {
try {
// 这里我们模拟注册三位球员
registerPlayer("Cristiano Ronaldo", 33);
registerPlayer("Leon Messi", 30);
registerPlayer("Kylian Mbappé", 20);
} catch (Exception e) {
System.out.println(e.toString());
}
}
public void registerPlayer(String name, int age) throws Exception {
Socket socket = null;
socket = new Socket("localhost", 12345);
// 按照定义的protobuf格式组装head
CMsgHead head = CMsgHead.newBuilder().setMsglen(5)
.setMsgseq(100).setMsgres(1001)
.setTermid("Register Client: Head").build();
// 按照定义的protobuf格式组装body
CMsgReg body = CMsgReg.newBuilder().setId(1).setName(name)
.setAge(age).setRet(1001).setTermid("Register Client: Body").build();
// 按照定义的protobuf格式,将head和body拼接成msg
CMsg msg = CMsg.newBuilder()
.setMsghead(head.toByteString().toStringUtf8())
.setMsgbody(body.toByteString().toStringUtf8()).build();
// 向服务器发送请求
System.out.println("sendMsg to register player...\n");
msg.writeTo(socket.getOutputStream());
// 接收返回结果并解析
InputStream input = socket.getInputStream();
byte[] by = recvMsg(input);
getRegisterResult(CMsg.parseFrom(by));
printSizeOfData(CMsg.parseFrom(by));
input.close();
socket.close();
}
public void getRegisterResult(CMsg g) {
try {
StringBuffer sb = new StringBuffer();
CMsgReg body = CMsgReg.parseFrom(g.getMsgbody().getBytes());
if (body.getRet() == 0) {
System.out.println("successfully register player:");
System.out.println("player id: " + body.getId());
System.out.println("player name: " + body.getName());
System.out.println("player age: " + body.getAge());
System.out.println("player ret: " + body.getRet());
System.out.println("player termid: " + body.getTermid());
System.out.println(sb.toString());
} else {
System.out.println("Fail to register player [" + body.getName() +
"], he's too old to join the club!");
}
} catch (Exception e) {
e.printStackTrace();
}
}
public byte[] recvMsg(InputStream inpustream) {
System.out.println("[recvMsg]");
byte[] temp = null;
try {
byte len[] = new byte[1024];
int cnt = inpustream.read(len);
temp = new byte[cnt];
for (int i = 0; i < cnt; i++) {
temp[i] = len[i];
}
return temp;
} catch (Exception e) {
System.out.println(e.toString());
return temp;
}
}
public void printSizeOfData(CMsg g) {
System.out.println("\n" + "bytes size:" + g.toByteString().size());
String pJsonData = "";
try {
pJsonData = JsonFormat.printer().print(g);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("json size:" + pJsonData.getBytes().length);
}
public static void main(String[] args) {
PlayerRegisterClient pc = new PlayerRegisterClient();
pc.run();
}
}
(6)运行结果
客户端测试结果:
sendMsg to register player...
[recvMsg]
Fail to register player [Cristiano Ronaldo], he's too old to join the club!
bytes size:84
json size:163
sendMsg to register player...
[recvMsg]
successfully register player:
player id: 1
player name: Leon Messi
player age: 30
player ret: 0
player termid: Client:Register Body
bytes size:74
json size:155
sendMsg to register player...
[recvMsg]
successfully register player:
player id: 2
player name: Kylian Mbappé
player age: 20
player ret: 0
player termid: Client:Register Body
bytes size:78
json size:163
服务端测试结果:
Begin to register Football Player...
waiting for new connection...
head len: 5
head res: 0
head seq: 100
head termid: Register Client: Head
start to register palyer...
player name: Cristiano Ronaldo
player age: 33
server close
waiting for new connection...
head len: 5
head res: 0
head seq: 100
head termid: Register Client:Head
start to register palyer...
player name: Leon Messi
player age: 30
server close
waiting for new connection...
head len: 5
head res: 0
head seq: 100
head termid: Register Client:Head
start to register palyer...
player name: Kylian Mbappé
player age: 20
server close
waiting for new connection...
传输同样的数据和信息,与json的size相比,protobuf的威力可见一斑。至于xml。。。就不提了吧。
5、尾声闲谈
如果需要进行频繁的数据交互,特别是对延迟敏感的场景(例如游戏),或者是在要在错综分布的系统间低成本交互(例如云),protobuf都是一个不错的数据交互格式。
当然古人有云,程序员最忌讳盲目崇拜,PHP也不一定是世界上最好的语言。根据场景的不同,还是要更加全面的看待其利与弊。protobuf作为一项用二进制数据交互格式,相对于其他数据格式,可读性是较差的。或者当网络服务类型跨度大、变化频繁,特别是你的数据需要直接与Web交互时,或者需要与偏向底层的接口进行交互,用json/xml或者其他格式相对是明智之选。
再说到protobuf,不得不提下基于protobuf 和 HTTP2的 “下一代RPC框架” —— grpc。传输协议和数据的交互格式是RPC框架中非常重要的两个因素,不得不说google是下了一招很秒的棋:既可以使用HTTP2(多路复用技术和对header的压缩技术值得推敲),又可以大力推广普及自创的protobuf。当然,提到优秀的RPC框架,有鹅厂的Tars,有grpc的劲敌Apache Thrift,还有阿里的Dubbo等等,都是值得学习研究的。上文中如有错误或不当的地方,也欢迎各位指正与交流。