使用ProtocolBuffer实现网络协议二进制格式

Posted tyler_download

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用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实现网络协议二进制格式的主要内容,如果未能解决你的问题,请参考以下文章

融联云通讯的底层实现协议简介

在java程序中使用protobuf

ProtocolBuffer浅析

Protocol Buffer 原理 学习笔记

Protocol Buffer 原理 学习笔记

协议缓冲区 - 将具有相同 .proto 文件的二进制数据文件合并到一个文件中