ProtocolBuffer浅析
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了ProtocolBuffer浅析相关的知识,希望对你有一定的参考价值。
参考技术AProtocolBuffer是google 定义的一种数据交换的格式,它独立于语言,独立于平台。google 提供了多种语言的实现:java、c#、c++、go 和 python,每一种实现都包含了相应语言的编译器以及库文件。ProtocolBuffer类似于xml、json,不过它更小、更快、也更简单。
目前使用最广泛的数据传输协议为JSON,JSON是一种轻量级的数据交换格式而且层次和结构比较简单和清晰,这里主要对比一下Protocol Buffer和JSON的对比,给出优势和劣势:
优势
劣势
实际数据对比
Protocol Buffer的使用流程总体可以分为三步,如下图所示:
google推荐在Android项目中使用lite版,lite版本生成的java文件更加轻量,其配置如下:
首先创建一个.proto文件,并且在文件中声明如下内容:
在整个proto文件中数据类型分为基本类型和结构类型,其中结构类型主要为:
下面分别介绍一下不同结构的作用及规定:
message表示一个结构,类似于java中类,一个proto文件中可以声明多个message结构:
message可以引用不同proto文件中的message,只要在proto文件中的最上面声明import即可,如下所示:
enum使用很简单,直接在message中声明enum结构体并且将属性声明为对应的enum即可:
在proto3中,enum第一个值必须为0,主要是为了和基础类型的默认值保持一致
map是proto3新加的,使用也很简单:
如下
repeated修饰的属性类似于jsonArray,也类似于java中的List,该修饰符在格式正确的消息中可以重复任意次(包括0次)
日常开发过程中,由于需求的变更,往往需要增加字段,这就涉及到字段的扩充,字段扩充需要达到一个目的: 兼容
所以Protocol Buffer在字段扩充中定义了如下规则:
只要记住上述规则,就能完成字段扩充且老版本也能兼容
Protocol Buffer 更快更小的主要原因如下:
上面这个例子中,在序列化时,"name" 、"count"的key值不会参与,由编号1、2代替,这样在反序列化的时候直接通过编号找到对应的key就可以。需要注意的是编号一旦确定就不可以更改,服务端和客户端通过proto通信的时候需要提前定义号数据格式。
其中Length不一定有,依据Tag确定,例如int类型的数据就只有Tag-Value,string类型的数据就必须是Tag-Length-Value。
Protocol Buffer定义了如下的数据类型,其中部分数据类型已经不再使用:
上面已经介绍了Protocol Buffer的数据结构及Tag的类型,但是Tag块并不是只表示数据类型,其中数据编号也在Tag块中,Tag的生成规则如下:
其中Tag块的后3位表示数据类型,其他位表示数据编号
Java中整数类型的长度都是确定的,如int类型的长度为4个字节,可表示的整数范围为-2 31——2 31-1,但是实际开发中用到的数字均比较小,会造成字节浪费,可变长度编码就能很好的解决这个问题,可变长度编码规则如下:
举个例子:
其中第一个字节由于最高位为1,则后面的字节也是前面的数据的一部分,第二个字节最高位为0,则表示数据计算终止,由于Protocol Buffer是低位在前,整体的转换过程如下:
10000001 00000011 ——> 00000110000001 表示的10进制数为:2^0 + 2^7 + 2^8 = 385 通过上面的例子可以知道一个字节表示的数的范围0-128,上面介绍的Tag生成算法中由于后3位表示数据类型,所以Tag中1-15编号只占用1个字节,所以确保编号中1-15为常用的,减少数据大小。
可变长度编码唯一的缺点就是当数很大的时候int32需要占用5个字节,但是从统计学角度来说,一般不会有这么大的数.
上面介绍了Protocol Buffer的原理,现在通过实例来展示分析过程,我们定义的proto文件如下:
其序列化后的字节数据如下:
前面介绍过Protocol Buffer的 数据结构为TLV,其中L不是必须的,根据T的类型来确定 先看下第一个字节:
这里字节最高位为0,所以该Tag就用这一个字节表示,其中后3位表示类型,前面表示字段编号,所以:
这里字节最高位为0,所以该Tag就用这一个字节表示,其中后3位表示类型,前面表示字段编号,所以: file_num = 0001 = 1 type = 010 = 2 上面介绍过type=2,则后面有Length,按照可变长度编码规则,知道表示长度的字节为:
所以Length=4,则value的长度是4个字节,直接取出后面4个字节:
这4个字节对应的就是test 再看下一组:
由上面的Tag知道: file_num=2 type=0 前面介绍过type=0,后面没有Length,直接就是value,
value=1,通过上面的解析可以知道
上面介绍了Protocol Buffer的原理,解释了为什么Protocol Buffer更快,更小,这里再总结一下:
参考资料:
proto3官网指南: https://developers.google.com/protocol-buffers/docs/proto3
protobuf-gradle-plugin: https://github.com/google/protobuf-gradle-plugin
博客: https://juejin.im/post/5dcbf630e51d451bfe5bb21b
使用ProtocolBuffer实现网络协议二进制格式
1.综述
客户端与服务器交互时都需要双方协商,确定消息的二进制格式。客户端在向服务器发起请求时会根据协议创建二进制数据块,然后依托tcp, udp, http等协议将二进制内容传递给服务器,后者根据协议的规则按照特定次序从接收到的二进制内存块中读取给定字段。
这种做法存在一些问题。一是自定义的协议往往缺乏好的可扩展性,例如以后需要添加新字段,特别是字段要插入到以前字段的中间时,客户端和服务端协议解析代码得做相同修改。随着业务的发展,原先某些字段得删除时,协议解析代码又得修改,因此自定义协议解析在面对协议变化上因为需要重新编码因此会提升工作量降低效率,特别时代码的修改非常容易引入错误。
目前业内也有一些通用协议格式,例如jason, xml等,他们也存在一些问题。各种编程语言都有既定接口或模块之间解析这些格式,但是存在一个问题就是效率低下。当协议中的字段增多时,这些格式的解析耗时较长,我个人觉得这些格式存在一个不好使之处在于他们在发送二进制数据上。当协议字段对应字符串或是int这类长度较短的二进制数据时,他们的使用很方便,但如果使用他们传递图片内容能长度较长的二进制数据,那么我们需要进行base64编码后才方便将数据存储在这些格式中。
因此我们最好能找到一种可扩展性强,也就是协议格式能灵活的应对字段的删减而不必引入过多的代码修改;同时字段的查询效率高,二进制数据发送接收也方便的协议格式,那么就能大大提升我们制定网络协议的效率。
2.Protocol Buffer安装
1,首先下载源码,下载目录如下:
链接:https://pan.baidu.com/s/15YW8vC7qeB6V5RVk4OSBug
提取码:hlh0
在本地解压后需要使用vs编译,在我本机安装的是vs2017,因此打开其对应控制台,cd到解压后代码包里对应的cmake路径,然后在控制台输入如下命令:
mkdir build && cd build
2,接下来安装cmake程序,该程序的安装包也在给定百度盘共享目录下。
3,步骤1的命令会在对应目录下创建一个build目录并进入该目录,然后执行命令:
cmake -G “Visual Studio 15 2017” -DCMAKE_INSTALL_PREFIX=…/…/…/install -Dprotobuf_BUILD_TESTS=OFF …/
因为我使用的是2017,所以-G 后面对应为给定字符串,如果你使用的是vs2019,则对应字符串应该为”Visual Studio 16 2019”。命令执行后会在本地目录生成一系列vs工程文件,如图1所示:
图1 cmake命令生成vs工程文件
4,找到protobuf.sln,使用vs打开,在工程中有一个名为INSTALL的项目,选中它,右键选择“生成”编译protobuf可执行文件和对应的头文件和lib库。
5,完成后在路径上几层会生成一个install文件夹,里面会有编译好的结果,如图2所示:
图2 protobuf 编译结果。
bin文件夹中包含protoc.exe,将该文件夹对应目录设置到环境变量path中,这样我们可以直接在命令行使用该程序。
3.Protocol Buffer使用方法
Protocol Buffer 是谷歌发明的一种高效二进制协议制定方法,其使用基本流程如图3所示:
图3 protocol buffer 基本使用流程
从图3可以看到,首先我们需要使用protocol buffer提供的语法来定义要使用的通信协议格式,它的语法不是编程语言,只是功能有限,特别用于描述数据结构的脚本语言,我们看个来自谷歌说明文档的具体例子:
syntax = ‘proto2’;
package example;
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
optional bytes bin_data = 4;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type= 2 [default = HOME];
}
repeated PhoneNumber phones = 5;
message AddressBook {
repeated Person people = 1;
}
}
从上面定义语言可以看出,protocol buffer用于定义二进制结构的语法跟编程语言非常像,其中message,required, optional, repeated都属于语法的关键字,string, enum, int32都属于字段的数据类型,这里需要提前说明的是,类似name=1;这种写法不是赋初值,一定要注意这点,这种写法的作用后面再解释。
message对应C语言中的struct关键字,它描述一块二进制内存中的字段分布情况。protocol buffer定义数据字段时能支持所有编程语言中使用到的数据类型,例如int, byte, string, float,double等,这里需要注意的是,如果我们想在协议中发送二进制数据串,那么对应类型就是bytes,当使用protocol buffer编译器将类似如上的二进制协议定义文件编译成c++代码时,bytes对应类型为string, 在java中则对应ByteString。
这里需要注意两个关键字,required表示在设置二进制协议字段时,required关键字修饰的字段必须要设置,如果你不设置但在代码中又去读取这些字段,那么代码就会抛出异常。而optional修饰的字段表示如果你不给该字段赋值,那么protocol buffer会自动帮你赋初值,例如int32类型会自动设置为0,string类型自动设置为空字符串等。如果不是非常必要,通常情况下我们使用optional修饰字段就比较灵活。
在上面例子中字段还可以赋初值,例如optional PhoneType type= 2 [default = HOME];它表示PhoneNumber中有一个字段名为type,它初始化值为2,也就是WORK,如果没有赋初值那么他就默认为HOME。
我们还能看到message可以间套,这就类似struct内部还能定义struct一样。同时关键字repeated对应编程语言的for,被该关键字修饰的字段可以重复0或多次。
前面强调过string name=1;后面的”=1“不是赋值,它是的作用是标签,我们可以认为这些数值是字段的牌号,在编译成二进制内容后protocol buffer知道如何去读取这些字段。
将上面描述的数据定义内容存储成以.proto为后缀的文件然后就可以使用protocol buffer将其编译成给定编程语言对应的代码文件,如图4所示:
图4 编译.proto协议定义文件
protoc就是编译器, -I指定要编译的proto文件所在目录, --cpp_out表示将其编译成c++代码,我们可以将定义文件编译成所有当前主流编程语言,如果我们客户端用c++开发,服务器用java开发,那么就可以将该定义文件再编译出一份对应的java代码,于是客户端和服务器都可以用生成的代码对同一定义的协议数据进行读写操作。
图4命令执行后,在本地可以看到生成的对应语言的代码文件,如图5所示:
图5 编译出的代码文件
其中.cc和.h里面的代码提供了接口让我们读写.proto文件定义的数据结构,如果你打开.cc和.h文件查看可以发现里面代码非常复杂,因此我们千万不要去改动里面的内容要不然会造成难以理解的错误,我们只需要引用代码里面给出的接口来读写proto文件定义的协议数据结构即可。
对应每个字段,在.h中都定义了set和get接口,例如对应name字段它里面就有set_name这种接口让我们设置字段内容。对于被repeated关键字修饰的字段例如people,它还生成了people_size(),用户获取people字段中有多少个实例对象,如图6所示:
图6 protoc生成代码
接下来我们看看如何使用代码来读取或生成proto协议文件规定的数据结构。
4.使用代码读写协议字段
要使用代码来读写协议规定好的数据结构,我们先使用vs创建一个工程,然后先执行如下配置:
1,在工程目录右键选中属性,在c/c+±>常规中设置包含目录,如所示:
图7 设置包含目录
2,在链接器->常规->附加库目录输入lib对应路径,如图8所示:
图8 设置lib路径
3,在连接器->输入->附加依赖项输入如图9所示内容:
图9 附加依赖项内容
4,在c/c++ ->代码生成->运行库,设置如图10所示:
图9 设置运行时库
完成后将前面编译的.h和.cc文件拷贝到工程的本地目录并加入到项目,接下来编写如下代码:
#include
#include
#include
#include
#include “address_book.pb.h”
using namespace std;
void writeIntoPerson(example::Person& person, int id) {
person.set_id(id);
string name = "my_name" ;
person.set_name(name);
if (person.has_email() == false) {
person.set_email("my_email@163.com:" );
}
//设置二进制数据块
int bin_data_len = 4;
unique_ptr<char[]> p_bin_data = make_unique<char[]>(bin_data_len);
for (int i = 0; i < bin_data_len; i++) {
p_bin_data[i] = char('0' + i);
}
//注意这里使用string来封装二进制数据块后再将其传给person数据结构
person.set_bin_data(string(p_bin_data.get(), bin_data_len));
//repeated 对应数组类型
int phone_number = 3;
for (int i = 0; i < phone_number; i++) {
string phone_num = "1234";
//生成repeated修饰的对象时对应接口就是add_xxx , xxx就是被修饰的对象名
example::Person::PhoneNumber* p_phone_number = person.add_phones();
p_phone_number->set_number(phone_num);
//这里对应定义中的枚举类型
if (i == 0) {
p_phone_number->set_type(example::Person::MOBILE);
}
if (i == 1) {
p_phone_number->set_type(example::Person::HOME);
}
if (i == 2) {
p_phone_number->set_type(example::Person::WORK);
}
}
}
void ReadAddressBook(const example::AddressBook& address_book) {
for (int i = 0; i < address_book.people_size(); i++) {
const example::Person& person = address_book.people(i);
cout << "person id: " << person.id() << endl;
cout << "person name: " << person.name() << endl;
for (int j = 0; j < person.phones_size(); j++) {
const example::Person::PhoneNumber& phone_number = person.phones(j);
switch (phone_number.type()) {
case example::Person::MOBILE:
cout << "Mobile phone #: ";
break;
case example::Person::HOME:
cout << "Home phone#: ";
break;
case example::Person::WORK:
cout << "Work phone#: ";
break;
}
cout << phone_number.number() << endl;
}
}
}
int main()
{
//初始化作用,.h和.cc对应的版本与我们链接的lib版本一致
GOOGLE_PROTOBUF_VERIFY_VERSION;
example::AddressBook address_book;
int people_count = 3;
for (int i = 0; i < people_count; i++) {
example::Person* p_person = address_book.add_people();
writeIntoPerson(*p_person, i);
}
//将数据输出为二进制内存块
{
size_t size = address_book.ByteSizeLong();
auto p_binary_data = make_unique<char[]>(size);
//p_binary_data中的数据时经过精心压缩优化后可以直接从网络发送的数据
address_book.SerializePartialToArray(p_binary_data.get(), size);
}
string bin_file = "adress_book.dat";
{
fstream output(bin_file, ios::out | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed t owrite address book";
return -1;
}
}
//将二进制文件重新读入
{
example::AddressBook new_address_book;
fstream input(bin_file, ios::in | ios::binary);
if (!new_address_book.ParseFromIstream(&input)) {
cerr << "Fail to parse address book." << endl;
return -1;
}
ReadAddressBook(new_address_book);
}
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
代码运行后可以看到,数据结构可以转换成二进制数组,它们可以直接从网络发出,同时还能序列号到本地文件并从本地文件读入内存,然后将数据读取出来。
以上是关于ProtocolBuffer浅析的主要内容,如果未能解决你的问题,请参考以下文章
Google protocol buffer 使用和原理浅析 - 附带进阶使用方式
WCF扩展之实现ZeroMQ绑定和protocolBuffer消息编码实现ReplyChannel(2016-03-15 12:35)