文章目录
- 一、序列化与反序列化
- 1.1 序列化
- 1.2 反序列化
- 1.3 序列化与反序列化的使用场景
- 二、初识 Protobuf
- 三、Protobuf 的安装
- 四、Protobuf 的使用案例
- 4.1 创建并编写 .proto 文件的基本规范与语法
- 4.2 编译 .proto 文件
- 4.3 序列化与反序列化的使用
- 五、总结 ProtoBuf 的使用特点
一、序列化与反序列化
序列化和反序列化是在计算机科学中常见的概念,用于将对象或数据结构转换为字节流的过程,以便在网络上传输或存储在磁盘上,并在需要时重新构建成原始对象。
1.1 序列化
序列化(Serialization)是指将对象或数据结构转换为字节流的过程。在序列化过程中,对象的状态被转换为一系列的字节,可以包括对象的属性值、方法等信息。这些字节可以被存储在磁盘上或通过网络传输。
1.2 反序列化
反序列化(Deserialization)是指将字节流转换回对象或数据结构的过程。在反序列化过程中,字节流被解析,并恢复为原始的对象或数据结构,使其可以被程序进一步使用。
1.3 序列化与反序列化的使用场景
序列化和反序列化在许多应用场景中都是很有用的。一些常见的应用包括:
-
对象持久化:通过序列化,可以将对象保存到磁盘上,以便在以后的时间点重新加载和使用。这在应用程序重启或数据传输的情况下非常有用。
-
远程通信:当不同的系统或应用程序需要进行通信时,可以通过序列化将对象转换为字节流并在网络上传输。接收方可以通过反序列化将字节流重新构建成对象并进行处理。
-
缓存和消息队列:序列化和反序列化可以在缓存系统或消息队列中使用,以便将对象存储在内存中或在不同的应用程序之间传递消息。
不同的编程语言和框架通常提供了序列化和反序列化的库或工具,用于简化这些过程。常见的序列化格式包括 JSON(JavaScript Object Notation)
、XML(eXtensible Markup Language)
、Protocol Buffers
等。选择适当的序列化格式取决于应用的需求和使用场景。
二、初识 Protobuf
-
Protobuf (Protocol Buffers) 是一种用于序列化结构化数据的语言无关、平台无关的开源数据交换格式。它由Google开发,并在许多不同的应用程序和系统中广泛使用。
-
Protobuf 的主要目标是提供一种高效、灵活和可扩展的方式来序列化结构化数据,使其可以在不同的平台和语言之间进行通信和存储。与其他序列化格式(如XML和JSON)相比,Protobuf具有更高的性能和更小的数据尺寸。
-
使用Protobuf,我们可以定义数据的结构和格式,然后使用
protoc
编译器生成与不同编程语言兼容的类或结构体。这些生成的类或结构体提供了一组方法,用于将数据序列化为 Protobuf 格式,以及从 Protobuf 格式反序列化回原始数据。 -
Protobuf 支持包括C++、Java、Python、Go、C#等多种编程语言,使得不同语言的应用程序能够通过序列化和反序列化Protobuf 消息来进行跨平台和跨语言的通信。
-
总结起来,Protobuf 是一种用于序列化结构化数据的高性能、可扩展和跨平台的数据交换格式,它提供了一种定义数据结构的方式,并生成与多种编程语言兼容的代码。
三、Protobuf 的安装
关于 ProtoBuf 的安装可以参考我的另一篇博客:Windows 和 Linux 环境下 ProtoBuf 的安装。
四、Protobuf 的使用案例
这里我们以通讯录中的联系人为例:
- 对一个联系人的简单信息使用 Protobuf 进行序列化,并将结果保存到文件中。
- 对序列化后的内容使用 Protobuf 进行反序列化,解析出联系⼈信息并打印出来。
4.1 创建并编写 .proto 文件的基本规范与语法
文件规范:
- 创建
.proto
文件时,文件命名应该使用全小写字母进行命名,多个字母之间使用_
连接,例如:lower_snake_case.proto
。
添加注释:
- 向⽂件添加注释,可使用
//
或者/* ... */
。
指定 proto3 语法:
- Protocol Buffers 语⾔版本3,简称
proto3
,是.proto
文件最新的语法版本。proto3
简化了 Protocol Buffers 语⾔,既易于使用,⼜可以在更⼴泛的编程语⾔中使⽤。即允许使用 Java、C++、Python等多种语言生成 Protocol Buffer 代码。 - 在
.proto
文件中,要使用syntax = "proto3";
来指定文件语法为proto3
,并且必须写在除去注释内容的第一行。如果没有指定,编译器会使用proto2
语法。
package 声明符:
package
是⼀个可选的声明符,能表示.proto
文件的命名空间,在项目中要有唯⼀性。它的作用是为了避免我们定义的消息出现冲突。- 其语法为
package 作用域名称;
。
定义消息(message):
- 消息(message)即要定义的结构化对象,可以在这个结构化对象中定义其对应的属性内容。
- 为什么要定义消息?
- 在网络传输中,我们需要为传输双方定制协议。定制协议其实就是定义结构体或者结构化数据,比如
TCP
、UDP
报文就是结构化的数据。- 另外将数据持久化存储到数据库时,也会将⼀系列元数据统⼀使用对象组织起来,再进行存储。
所以 Protobuf 就是以定义 message
的方式来支持我们定制协议字段,后期帮助我们形成类和方法来使用的。
定义消息字段:
在 message
中我们可以定义其属性字段,字段定义格式为:字段类型 字段名 = 字段唯⼀编号;
- 字段名称命名规范:全小写字⺟,多个字⺟之间⽤
_
连接。 - 字段类型分为:标量数据类型 和 特殊类型(包括枚举、其他消息类型等)。
- 字段唯⼀编号:⽤来标识字段,⼀旦开始使⽤就不能够再改变。
以下表格展示了定义于消息体中的标量数据类型,以及编译.proto
文件之后自动生成的类中与之对应的 C++语言 中的字段类型。
.proto Type | Notes | C++ Type |
---|---|---|
double | 双精度浮点数。 | double |
float | 单精度浮点数。 | float |
int32 | 使用变长编码。负数的编码效率较低,若字段可能为负值,应使用 sint32 代替。 | int32 |
int64 | 使用变长编码。负数的编码效率较低,若字段可能为负值,应使用 sint64 代替。 | int64 |
uint32 | 使用变长编码。 | uint32 |
uint64 | 使用变长编码。 | uint64 |
sint32 | 使用变长编码。符号整型,负值的编码效率⾼于常规的 int32 类型。 | int32 |
int64 | 使用变长编码。符号整型,负值的编码效率⾼于常规的 int64 类型。 | int64 |
fixed32 | 定长 4 字节。若值常大于 2^28 则会比 uint32 更高效。 | uint32 |
fixed64 | 定长 8 字节。若值常大于 2^56 则会比 uint64 更高效。 | uint64 |
sfixed32 | 定长 4 字节。 | int32 |
sfixed64 | 定长 8 字节。 | int64 |
bool | 布尔类型 | bool |
string | 包含 UTF-8 和 ASCII 编码的字符串,长度不能超过 2^32。 | string |
bytes | 可包含任意的字节序列,但长度不能超过 2^32。 | string |
值得⼀提的是,范围为 1 ~ 15 的字段编号需要⼀个字节进行编码,16 ~ 2047 内的数字需要两个字节进行编码。编码后的字节不仅只包含了编号,还包含了字段类型。所以 1 ~ 15 要用来标记出现非常频繁的字段,因此这些字段编号要为将来有可能添加的,频繁出现的字段预留⼀些出来。
创建并编写contacts.proto
文件:
syntax = "proto3"; // 首行:语法指定
package contacts2; //指定作用域
message PeopleInfo
{
string name = 1; //姓名
int32 age = 2; //年龄
// repeated string phone_numbers = 3;
// 嵌套定义message
message Phone
{
string number = 1;
}
repeated Phone phone = 3; //电话信息
}
//通讯录message
message Contacts
{
repeated PeopleInfo contacts = 1;
}
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
编译 contacts.proto
文件后会生成什么?
- 编译
contacts.proto
文件后,会生成所选择语⾔的代码,这里选择的是C++,所以编译后生成了两个文件:contacts.pb.h
和contacts.pb.cc
。 - 对于编译生成的 C++ 代码,包含了以下内容:
- 对于每个
message
,都会⽣成⼀个对应的消息类。- 在消息类中,编译器为每个字段提供了获取和设置方法,以及其他能够操作字段的方法。
- 编辑器会针对于每个
.proto
文件生成.h
和.cc
文件,分别用来存放类的声明与类的实现。
contacts.pb.h
部分代码展式:
class PeopleInfo final : public ::PROTOBUF_NAMESPACE_ID::Message {
public:
using ::PROTOBUF_NAMESPACE_ID::Message::CopyFrom;
void CopyFrom(const PeopleInfo& from);
using ::PROTOBUF_NAMESPACE_ID::Message::MergeFrom;
void MergeFrom( const PeopleInfo& from) {
PeopleInfo::MergeImpl(*this, from);
}
static ::PROTOBUF_NAMESPACE_ID::StringPiece FullMessageName() {
return "PeopleInfo";
}
// string name = 1;
void clear_name();
const std::string& name() const;
template <typename ArgT0 = const std::string&, typename... ArgT>
void set_name(ArgT0&& arg0, ArgT... args);
std::string* mutable_name();
PROTOBUF_NODISCARD std::string* release_name();
void set_allocated_name(std::string* name);
// int32 age = 2;
void clear_age();
int32_t age() const;
void set_age(int32_t value);
};
上述的代码中:
• 每个字段都有设置和获取的⽅法,getter
的名称与小写字段完全相同,setter
方法以set_
开头。
• 每个字段都有⼀个clear_
方法,可以将字段重新设置回empty
状态。
contacts.pb.cc
中的代码就是对类声明方法的实现,这里就不进行展示了。
到这里我们可能就有疑惑了,那之前提到的序列化和反序列化方法在哪⾥呢?其实通过不断的翻阅生成的源代码,可以发现在消息类Message
的⽗类 MessageLite
中,里面提供了读写消息实例的方法,包括序列化和反序列化方法。
class MessageLite {
public:
//序列化:
bool SerializeToOstream(ostream* output) const; // 将序列化后数据写⼊⽂件流
bool SerializeToArray(void *data, int size) const;
bool SerializeToString(string* output) const;
//反序列化:
bool ParseFromIstream(istream* input); // 从流中读取数据,再进⾏反序列化动作
bool ParseFromArray(const void* data, int size);
bool ParseFromString(const string& data);
};
注意:
- 序列化的结果为⼆进制字节序列,而非文本格式。
- 以上三种序列化的⽅法没有本质上的区别,只是序列化后输出的格式不同,可以供不同的应用场景使用。
- 序列化的 API 函数均为 const 成员函数,因为序列化不会改变类对象的内容,而是将序列化的结果保存到函数入参指定的地址中。
- 详细
message API
可以参考 Protobuf 官方文档。
4.3 序列化与反序列化的使用
分别创建write.cc
和read.cc
源文件:
- 其中
write.cc
负责将输入的联系人信息序列化,任何保存到文件中。 read.cc
负责从文件中读取数据,然后将其反序列化为联系人对象,然后打印出来。
write.cc:
#include <iostream>
#include <fstream>
#include "contacts.pb.h"
using namespace std;
void AddPeopleInfo(contacts2::PeopleInfo *people)
{
cout << "-------------新增联系⼈-------------" << endl;
cout << "请输入联系人姓名:";
string name;
getline(cin, name);
people->set_name(name);
cout << "请输入联系人年龄:";
int age;
cin >> age;
people->set_age(age);
cin.ignore(256, '\n'); // 清除缓冲区中的回车等数据,直到遇到‘\n’;
// 如果清除个256字符还没遇到‘\n’,也会停下来;
// 缓冲区内容不足256,也没有遇到‘\n’,也会停止
for (int i = 0;; ++i)
{
cout << "请输⼊联系⼈电话" << i + 1 << "(只输⼊回⻋完成电话新增): ";
string number;
getline(cin, number);
if (number.empty())
{
break;
}
contacts2::PeopleInfo_Phone *phone = people->add_phone();
phone->set_number(number);
}
cout << "-----------添加联系⼈成功-----------" << endl;
}
main()
{
contacts2::Contacts contacts;
// 先读取本地已经存在的通讯录文件
fstream input("contacts.bin", ios::in | ios::binary);
if (!input)
{
cout << "contacts.bin not found, create a new file!" << std::endl;
}
else if (!contacts.ParseFromIstream(&input))
{
cerr << "parse error!" << endl;
input.close();
return -1;
}
// 向通讯录中添加联系人
AddPeopleInfo(contacts.add_contacts());
// 将通讯录写入本地文件
fstream output("contacts.bin", ios::out | ios::trunc | ios::binary); // 覆盖写入,前面已经读取通讯录内容
if (!contacts.SerializeToOstream(&output))
{
cerr << "write error!" << endl;
input.close();
output.close();
return -1;
}
cout << "write sucess!" << endl;
input.close();
output.close();
return 0;
}
read.cc:
#include <iostream>
#include <fstream>
#include "contacts.pb.h"
using namespace std;
void PrintContacts(contacts2::Contacts &contacts)
{
for (int i = 0; i < contacts.contacts_size(); ++i)
{
cout << "--------------------联系人" << i + 1 << "--------------------"<<endl;
const ::contacts2::PeopleInfo &people = contacts.contacts(i);
cout << "联系人姓名:" << people.name() << endl;
cout << "联系人年龄:" << people.age() << endl;
for (int j = 0; j < people.phone_size(); ++j)
{
const ::contacts2::PeopleInfo_Phone &phone = people.phone(j);
cout << "联系人电话" << j + 1 << ": "<< phone.number() << endl;
}
}
}
int main()
{
contacts2::Contacts contacts;
// 先读取本地已经存在的通讯录文件
fstream input("contacts.bin", ios::in | ios::binary);
if (!contacts.ParseFromIstream(&input))
{
cerr << "parse error!" << endl;
input.close();
return -1;
}
// 打印通讯录列表
PrintContacts(contacts);
return 0;
}
注意,最后在使用g++
编译源代码的时候,需要加上lprotobuf
和-std=c++11
。若不加前者,会链接报错;后者使用C++11
语法进行编译。
首先执行write
,输入联系人信息,可见将数据序列化后保存到文件Contacts.bin
文件中:
由于 Protobuf 是把联系人对象序列化成了二进制序列,因此直接查看文件就是一堆乱码。可以执行read
文件,读取后进行反序列化并打印出来:
五、总结 ProtoBuf 的使用特点
Protobuf 的使用方法可以用以下图片进行概括:
其使用步骤可描述如下:
- 首先编写
.proto
文件,目的就是结构对象(message)及其属性内容。 - 使用
protoc
编译器编译.proto
文件,生成一系列的相关的接口代码,存放在新的头文件和源文件中。 - 依赖生成的接口,将编译生成的头文件包含进我们自己的代码中,实现对
.proto
文件中定义的字段进行设置和获取,以及对message
/对象进行序列化和反序列化。
总的来说,Protobuf 就是是需要依赖通过编译生成的头文件和源文件来使用的。有了这种代码生成机制,开发人员再也不用辛辛苦苦地编写那些协议解析的代码了。