认识 Protobuf 及其简单使用


一、序列化与反序列化

序列化和反序列化是在计算机科学中常见的概念,用于将对象或数据结构转换为字节流的过程,以便在网络上传输或存储在磁盘上,并在需要时重新构建成原始对象。

1.1 序列化

序列化(Serialization)是指将对象或数据结构转换为字节流的过程。在序列化过程中,对象的状态被转换为一系列的字节,可以包括对象的属性值、方法等信息。这些字节可以被存储在磁盘上或通过网络传输。

1.2 反序列化

反序列化(Deserialization)是指将字节流转换回对象或数据结构的过程。在反序列化过程中,字节流被解析,并恢复为原始的对象或数据结构,使其可以被程序进一步使用。

1.3 序列化与反序列化的使用场景

序列化和反序列化在许多应用场景中都是很有用的。一些常见的应用包括:

  1. 对象持久化:通过序列化,可以将对象保存到磁盘上,以便在以后的时间点重新加载和使用。这在应用程序重启或数据传输的情况下非常有用。

  2. 远程通信:当不同的系统或应用程序需要进行通信时,可以通过序列化将对象转换为字节流并在网络上传输。接收方可以通过反序列化将字节流重新构建成对象并进行处理。

  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)即要定义的结构化对象,可以在这个结构化对象中定义其对应的属性内容。
  • 为什么要定义消息?
  • 在网络传输中,我们需要为传输双方定制协议。定制协议其实就是定义结构体或者结构化数据,比如TCPUDP 报文就是结构化的数据。
  • 另外将数据持久化存储到数据库时,也会将⼀系列元数据统⼀使用对象组织起来,再进行存储。

所以 Protobuf 就是以定义 message 的方式来支持我们定制协议字段,后期帮助我们形成类和方法来使用的。

定义消息字段:
message 中我们可以定义其属性字段,字段定义格式为:字段类型 字段名 = 字段唯⼀编号;

  • 字段名称命名规范:全小写字⺟,多个字⺟之间⽤_连接。
  • 字段类型分为:标量数据类型 和 特殊类型(包括枚举、其他消息类型等)。
  • 字段唯⼀编号:⽤来标识字段,⼀旦开始使⽤就不能够再改变。

以下表格展示了定义于消息体中的标量数据类型,以及编译.proto文件之后自动生成的类中与之对应的 C++语言 中的字段类型。

.proto TypeNotesC++ 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.hcontacts.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.ccread.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 的使用方法可以用以下图片进行概括:

其使用步骤可描述如下:

  1. 首先编写.proto 文件,目的就是结构对象(message)及其属性内容。
  2. 使用 protoc 编译器编译 .proto 文件,生成一系列的相关的接口代码,存放在新的头文件和源文件中。
  3. 依赖生成的接口,将编译生成的头文件包含进我们自己的代码中,实现对 .proto 文件中定义的字段进行设置和获取,以及对 message /对象进行序列化和反序列化。

总的来说,Protobuf 就是是需要依赖通过编译生成的头文件和源文件来使用的。有了这种代码生成机制,开发人员再也不用辛辛苦苦地编写那些协议解析的代码了

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BriaGriffin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值