快速上手Protobuf:从.proto文件创建到序列化反序列化实战
快速上手Protobuf:从.proto文件创建到序列化反序列化实战
Protocol Buffers(简称Protobuf)是一种语言中立、平台中立、可扩展的序列化结构数据格式,主要用于数据存储和通信协议。本文将详细介绍如何快速上手Protobuf,包括创建.proto文件、编译.proto文件以及序列化与反序列化的使用。
ProtoBuf简介
Protobuf是Google开发的一种数据序列化格式,用于结构化数据的序列化和反序列化。它具有以下特点:
- 语言中立:支持多种编程语言,如C++、Java、Python等。
- 平台中立:可以在不同操作系统和硬件平台上使用。
- 可扩展性:可以轻松添加新的字段和消息类型。
- 高效性:序列化后的数据体积小,传输速度快。
快速上手Protobuf
在上一节学习了Protobuf的基本概念后,现在我们将通过一个通讯录1.0版本的实现来小试牛刀。这个通讯录将实现以下功能:
- 联系人涵盖以下信息:姓名、年龄。
- 运用PB对一个联系人的信息进行序列化,并将所得结果予以打印。
- 针对序列化后的内容,使用PB执行反序列化操作,解析出联系人的信息并进行打印。
通过这个示例,我们将掌握使用Protobuf的基本要点,并亲身体验Protobuf的完整使用流程。
4.1 创建.proto文件
.proto文件是什么?
.proto文件是Protocol Buffers中用于定义数据结构的文件。它使用特定的语法来描述数据的格式和字段信息。通过在.proto文件中定义message,可以明确数据包含的字段、字段的数据类型(如int32、string等)以及一些属性(如required、optional等)。
简单来说:就是我们期望用于客户端和服务端之间进行传递的信息(.proto用结构体定义)。
命名规范:
- 创建.proto文件时,文件命名应该使用全小写字母命名,多个字母之间用_连接。例如:lower_snake_case.proto。
- 书写.proto文件代码时,应使用2个空格的缩进。
- 添加注释:向文件添加注释,可使用//或者/.../优化文档。
指定proto3语法:
Protocol Buffers语言版本3,简称proto3,是.proto文件的最新语法版本。proto3对Protocol Buffers语言予以了简化,不仅易用,而且能够在更为广泛的编程语言中得以运用。它支持您使用Java、C++、Python等众多语言生成protocol buffer代码。
在.proto文件里,需通过
syntax = "proto3";
来指定文件语法为proto3,并且此语句必须置于除去注释内容后的第一行。倘若未进行指定,编译器将会采用proto2语法。
package声明符:
package是一个可选的声明符,用于表示.proto文件的命名空间。在项目中,package应当具有唯一性,其主要作用在于避免我们所定义的消息产生冲突。
syntax = "proto3";
package contacts;
定义消息(message):
消息(message):它是要定义的结构化对象,我们能够为这个结构化对象设定其相应的属性内容。
这里再谈一下为何要定义消息?
在网络传输中,我们需要为传输的双方制定协议。所谓定制协议,直白地说就是定义结构体或者结构化数据,例如,tcp、udp报文就是结构化的。
再比如,在将数据持久化存储到数据库时,会把一系列元数据统一用对象组织起来,然后再进行存储。
message内容如下:
syntax = "proto3";
package contacts;
// 定义联系⼈消息
message PeopleInfo {
// 自定义信息
}
定义消息字段:
在message中我们可以定义其属性字段,字段定义格式为:字段类型 字段名 = 字段唯一编号;
字段名称命名规范:采用全小写字母,多个字母之间用_连接。例如:first_name、last_name。
字段类型分为:标量数据类型和特殊类型(包括枚举、其他消息类型等)。标量数据类型如int32、string等。特殊类型中的枚举例如:
字段唯一编号:用于标识字段,一旦开始使用就不能再更改。编号通常在1至536870911之间,其中19000~19999不可用,1至15的编号占用较少字节进行编码。
19000~19999不可用的原因在于:在Protobuf协议的实现过程中,对这些数字进行了预留。倘若非要在.proto文件中使用这些预留的标识号,例如将name字段的编号设置为19000,在编译时就会发出警报。
syntax = "proto3";
package contacts;
message PeopleInfo {
string name = 1;
int32 age = 19000;
}
范围在1至15的字段编号只需一个字节编码,16至2047内的数字则需两个字节编码。编码后的字节不仅包含编号,还涵盖字段类型。因此,1至15的编号应用于标记出现非常频繁的字段,并且要为将来可能添加且频繁出现的字段预留一些
以下表格展示了在消息体中定义的标量数据类型,以及编译.proto文件之后自动生成的类中与之对应的字段类型。此处展示的是与C++语言对应的类型。变长编码是指:经过protobuf编码后,原本4字节或8字节的数可能会被变为其他字节数。
.proto Type | Notes | C++ Type |
---|---|---|
double | null | double |
float | null | float |
int32 | 使用变成编码。负数的编码效率比较低(若字段出现负数的频率比较高,可以考虑使用sint32代替) | int32 |
int64 | 使用变长编码。负数的编码效率比较低(若字段出现负数的频率比较高,可以考虑使用sint64代替) | int64 |
uint32 | 使用变长编码 | uint32 |
uint64 | 使用变长编码 | uint64 |
sint32 | 使用变长编码。符号整型。负数编码效率高于常规int32 | int32 |
sint64 | 使用变长编码。符号整型。负数编码效率高于常规int64 | int64 |
fixed32 | 定长4字节。若值常大于2^28则会比uint32更高效 | uint32 |
fixed64 | 定长8字节。若常值大于2^28则会比uint64更高效 | uint64 |
sfixed32 | 定长4字节 | int32 |
sfixed64 | 定长8字节 | int64 |
bool | null | bool |
string | 包含UTF-8和ASCII编码的字符串,长度不能超过2^32 | string |
bytes | 可以包含任意的字节序列但长度不能超过2^32 | string |
新增姓名、年龄字段:
syntax = "proto3";
package contacts;
message PeopleInfo {
string name = 1;
int32 age = 2;
}
这样就完成了我们的通讯录1.0版本的消息字段,如果后面的客户端和服务端要进行通信,就会基于我们的消息字段:
4.2 编译.proto文件
编译命令:
protoc [--proto_path=IMPORT_PATH] --cpp_out=DST_DIR path/to/file.proto
protoc:是Protocol Buffer提供的命令行编译工具。
–proto_path:用于指定被编译的.proto文件所在目录,可多次指定,也可简写成-I IMPORT_PATH。若未指定该参数,则在当前目录进行搜索。当某个.proto文件import其他.proto文件,或者需要编译的.proto文件不在当前目录下时,这时就要用-I来指定搜索目录。
–cpp_out=:表示编译后的文件为C++文件。
OUT_DIR:是编译后生成文件的目标路径。
path/to/file.proto:是要编译的.proto文件。
编译contacts.proto命令:
protoc --cpp_out=. contacts.proto
编译成功,生成.pb.h和.pb.cc文件:
// 首行:语法指定行
syntax = "proto3";
package contacts; // 命名空间
// 定义联系⼈消息
message PeopleInfo {
string name =1; // 姓名
int32 age = 2; // 年龄
}
// 在fast_use下编译
//protoc --cpp_out=. contacts.proto
// 在fast_use当前目录下编译
//protoc -I fast_start/ --cpp_out=fast_start/ contacts.proto
编译contacts.proto文件后会生成什么:
编译contacts.proto文件后,会生成所选语言的代码,我们选择的是C++,所以编译后生成了两个文件:contacts.pb.h和contacts.pb.cc。
对于编译生成的C++代码,包含了以下内容:
(1)对于每个message,都会生成一个对应的消息类。
(2)在消息类中,编译器为每个字段提供了获取和设置方法,以及一些其他能够操作字段的方法。
编辑器会针对每个.proto文件生成.h和.cc文件,分别用于存放类的声明与类的实现。
之后我们就可以在contacts.pb.h这个头文件中找到有关使用contacts类的序列化和反序列化函数了:
变量名以name为例:
void clear_name();:用于清除name字段的值,将其重置为默认或空状态。
const std::string& name() const;:获取name字段当前的值,返回一个常量引用。
template<typename ArgT0 = const std::string&, typename... ArgT>
void set_name(ArgT0&& arg0, ArgT... args);:用于设置name字段的值,支持不同类型的参数传递。
std::string* mutable_name();:获取name字段的可修改地址。
PROTOBUF_NODISCARD std::string* release_name();:释放name字段的所有权并返回其指针。
void set_allocated_name(std::string* name);:通过传入一个已分配的std::string指针来设置name字段的值。
这些还是字段的处理方式,那序列化和反序列化方法在哪里?
在消息类的父类MessageLite中,提供了读写消息实例的方法,包括序列化方法和反序列化方法。
PeopleInfo消息字段继承自Message类:
再MessageLite类中就定义了一系列序列化和反序列化方法:
序列化方法:
反序列化方法:
序列化的结果为二进制字节序列,而非文本格式。所以ProtoBuf序列化后的结果的破译成本相比于JSON序列化后的结果的破译成本更高,更加安全。
以上序列化的方法没有本质上的区别,只是序列化后输出的格式不同,可供不同的应用场景使用。
序列化的API函数均为const成员函数,因为序列化不会改变类对象的内容,而是将序列化的结果保存到函数入参指定的地址中。
完整Message API
4.3 序列化与反序列化的使用
创建一个测试文件main.cc,在方法中我们实现:
对一个联系人的信息使用PB进行序列化,并将结果打印出来。
对序列化后的内容使用PB进行反序列化,解析出联系人信息并打印出来。
#include <iostream>
#include "contacts.pb.h" // 引⼊编译⽣成的头⽂件
using namespace std;
int main()
{
string people_str;
{
// .proto⽂件声明的package,通过protoc编译后,会为编译⽣成的C++代码声明同名的命名空间
// 其范围是在.proto ⽂件中定义的内容
contacts::PeopleInfo people;
people.set_age(20);
people.set_name("张三");
// 调⽤序列化⽅法,将序列化后的⼆进制序列存⼊string中
if (!people.SerializeToString(&people_str))
{
cout << "序列化联系⼈失败." << endl;
}
// 打印序列化结果
cout << "序列化后的 people_str: " << people_str << endl;
}
{
contacts::PeopleInfo people;
// 调⽤反序列化⽅法,读取string中存放的⼆进制序列,并反序列化出对象
if (!people.ParseFromString(people_str))
{
cout << "反序列化出联系⼈失败." << endl;
}
// 打印结果
cout << "Parse age: " << people.age() << endl;
cout << "Parse name: " << people.name() << endl;
}
return 0;
}
// 编译
//g++ -o TestPb main.cc contacts.pb.cc -std=c++11 -lprotobuf
g++ -o TestPb main.cc contacts.pb.cc -std=c++11 -lprotobuf
编译的时候一定不要忘记链接protobuf库,不然会报连接错误。
小结 ProtoBuf 使用流程:
(1)编写.proto文件,其目的在于定义结构对象(message)及属性内容。
(2)使用protoc编译器编译.proto文件,生成一系列接口代码,存放在新生成的头文件和源文件中。
(3)依赖生成的接口,将编译生成的头文件包含进我们的代码中,以实现对.proto文件中定义的字段进行设置和获取,以及对message对象进行序列化和反序列化。
总的来说:ProtoBuf是需要依赖通过编译生成的头文件和源文件来使用的。有了这种代码生成机制,开发人员再也不用费力地编写那些协议解析的代码了。
本文原文来自CSDN