您现在的位置是:首页 >学无止境 >使用C++快速上手ProtoBuf (一)网站首页学无止境

使用C++快速上手ProtoBuf (一)

2023框框 2024-07-11 12:01:05
简介使用C++快速上手ProtoBuf (一)

课程目标

  • 认识ProtoBuf是什么,写demo了解ProtoBuf的使用流程。
  • 学习proto3语法,对最新的语法进行深度学习
  • 实战ProtoBuf,在网络传输过程中的使用。
  • 总结,对比多种序列化协议,分析ProtoBuf适合那些场景的使用。

一、初始ProtoBuf

在这里插入图片描述

1. 序列化概念

序列化和反序列化:

  • 序列化:把对象转换为字节序列的过程称为对象的序列化。
  • 反序列化:把字节序列恢复为对象的过程称为对象的反序列化。

什么情况下需要序列化:

  • 存储数据:当你想把的内存中的对象状态保存到⼀个⽂件中或者存到数据库中时。
  • ⽹络传输:⽹络直接传输数据,但是⽆法直接传输对象,所以要在传输前序列化,传输完成后反序列化成对象。例如我们之前学习过socket编程中发送与接收数据。

如何实现序列化:

  • xml、json、ProtoBuf。

2.ProtoBuf是什么

将结构化数据进行序列化的一种方式。

文档解释:

  • ProtocolBuffers是Google的⼀种语⾔⽆关、平台⽆关、可扩展的序列化结构数据的⽅法,它可⽤于(数据)通信协议、数据存储等。
  • ProtocolBuffers类⽐于XML,是⼀种灵活,⾼效,⾃动化机制的结构数据序列化⽅法,但是⽐XML更⼩、更快、更为简单。
  • 你可以定义数据的结构,然后使⽤特殊⽣成的源代码轻松的在各种数据流中使⽤各种语⾔进⾏编写和读取结构数据。你甚⾄可以更新数据结构,⽽不破坏由旧数据结构编译的已部署程序。

简单来讲,ProtoBuf(全称为ProtocolBuffer)是让结构数据序列化的⽅法,其具有以下特点

  • 语言⾔⽆关、平台⽆关:即ProtoBuf⽀持Java、C++、Python等多种语⾔,⽀持多个平台例如linux或者windows。
  • ⾼效:即⽐XML更⼩、更快、更为简单。-- 序列化的数据更小,数据小意味着传输更快,操作更简单。
  • 扩展性、兼容性好:你可以更新数据结构,⽽不影响和破坏原有的旧程序。-- 序列化组件的共同特点

3.ProtoBuf的使⽤特点

程序员手动编写网络协议,最原始的方式:

  • 网络通信就是端对端通信,由请求协议,响应协议,协议的反序列化和序列方法构成。
  • 请求协议和响应协议就是数据结构。
  • 网络传输的是二进制序列,序列化最原始的方法就是将数据结构以二进制的形式转换为序列。
  • 反序列化最原始的方法就是使用数据结构进行反序列化。
  • 最原始的方式的优点:
    • 操作简单
  • 最原始的方式的缺点:
    • 程序员需要手动编写序列化和反序列化方法,例如编写时需要解决粘包问题。
    • 没有扩展性,兼容性:一旦一端数据结构更新,影响和破坏旧程序。

ProtoBuf的使⽤特点:

  • 如下图,
  • 一开始,程序员定制协议时
  • 对于程序员来说,属性字段必须写的,后两项开发起来繁琐耗时。
    在这里插入图片描述
  • 如下图,
  • 有了ProtoBuf我们定义的是message而不是class。
  • 程序员只需要定义一系列的属性字段。
  • 处理字段的方法和处理类的方法由Protoc编译器生成。

在这里插入图片描述

  • 如下图,
  • 编写.proto⽂件,⽬的是为了定义结构对象(message)及属性内容。
  • 使⽤protoc编译器编译.proto⽂件,⽣成⼀系列接⼝代码,存放在新⽣成头⽂件和源⽂件中。
  • 依赖⽣成的接⼝,将编译⽣成的头⽂件包含进我们的代码中,实现对.proto⽂件中定义的字段进⾏设置和获取,和对message对象进⾏序列化和反序列化。
    在这里插入图片描述

总的来说:ProtoBuf是需要依赖通过编译⽣成的头⽂件和源⽂件来使⽤的。有了这种代码⽣成机制,开发⼈员再也不⽤吭哧吭哧地编写那些协议解析的代码了(⼲这种活是典型的吃⼒不讨好)。

二、安装ProtoBuf

https://editor.csdn.net/md/?articleId=130808175

三、教学思路

对ProtoBuf的完整学习,将使⽤项⽬推进的⽅式完成教学:即对于ProtoBuf知识内容的展开,会对⼀个项⽬进⾏⼀个版本⼀个版本的升级去讲解ProtoBuf对应的知识点。

在后续的内容中,将会实现⼀个通讯录项⽬。对通讯录⼤家应该都不陌⽣,⼀般,通讯录中包含了⼀批的联系⼈,每个联系⼈⼜会有很多的属性,例如姓名、电话等等。

随着对通讯录项⽬的升级,我们对ProtoBuf的学习与使⽤就越深⼊

四、快速上⼿

在快速上⼿中,会编写第⼀版本的通讯录1.0。在通讯录1.0版本中,将实现:

  • 对⼀个联系⼈的信息使⽤PB进⾏序列化,并将结果打印出来。
  • 对序列化后的内容使⽤PB进⾏反序列,解析出联系⼈信息并打印出来。
  • 联系⼈包含以下信息:姓名、年龄。
    通过通讯录1.0,我们便能了解使⽤ProtoBuf初步要掌握的内容,以及体验到ProtoBuf的完整使⽤流程。

步骤1:创建.proto文件

⽂件规范

  • 创建.proto⽂件时,⽂件命名应该使⽤全⼩写字⺟命名,多个字⺟之间⽤ _ 连接。例如:
    lower_snake_case.proto
  • 书写.proto⽂件代码时,应使⽤2个空格的缩进。

我们为通讯录1.0新建⽂件: contacts.proto

添加注释

  • 向⽂件添加注释,可使⽤ // 或者 /* ... */

指定proto3语法

  • ProtocolBuffers语⾔版本3,简称proto3,是.proto⽂件最新的语法版本。proto3简化了ProtocolBuffers语⾔,既易于使⽤,⼜可以在更⼴泛的编程语⾔中使⽤。它允许你使⽤Java,C++,Python等多种语⾔⽣成protocolbuffer代码。
  • 在.proto⽂件中,要使⽤ syntax = "proto3"; 来指定⽂件语法为proto3,并且必须写在除去注释内容的第⼀⾏。如果没有指定,编译器会使⽤proto2语法。在通讯录1.0的contacts.proto⽂件中,可以为⽂件指定proto3语法,内容如下:
syntax = "proto3";

package声明符

  • package是⼀个可选的声明符,能表⽰.proto⽂件的命名空间,在项⽬中要有唯⼀性。它的作⽤是为了避免我们定义的消息出现冲突。
  • 在通讯录1.0的contacts.proto⽂件中,可以声明其命名空间,内容如下:
syntax = "proto3";
package contacts;

定义消息(message)

消息(message):要定义的结构化对象,我们可以给这个结构化对象中定义其对应的属性内容。这⾥再提⼀下为什么要定义消息?

  • 在⽹络传输中,我们需要为传输双⽅定制协议。定制协议说⽩了就是定义结构体或者结构化数据,⽐如,tcp,udp报⽂就是结构化的。
  • 再⽐如将数据持久化存储到数据库时,会将⼀系列元数据统⼀⽤对象组织起来,再进⾏存储。

所以ProtoBuf就是以message的⽅式来⽀持我们定制协议字段,后期帮助我们形成类和⽅法来使⽤。在通讯录1.0中我们就需要为联系⼈定义⼀message。

.proto⽂件中定义⼀个消息类型的格式为:

message 消息类型名{

}

注意事项:`消息类型命名规范:使⽤驼峰命名法,⾸字⺟⼤写`

为contacts.proto(通讯录1.0)新增联系⼈message,内容如下:

syntax = "proto3";
package contacts;

// 定义联系人消息
message PeopleInfo{

}

定义消息字段

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

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

该表格展⽰了定义于消息体中的标量数据类型,以及编译.proto⽂件之后⾃动⽣成的类中与之对应的字段类型。在这⾥展⽰了与C++语⾔对应的类型。

.proto TypeNotesC++ Type
doubledouble
floatfloat
int32使⽤变⻓编码[1]。负数的编码效率较低⸺——若字段可能为负值,应使⽤sint32代替。int32
int64使⽤变⻓编码[1]。负数的编码效率较低⸺——若字段可能为负值,应使⽤sint64代替。int64
uint32使⽤变⻓编码[1]。uint32
uint64使⽤变⻓编码[1]。uint64
sint32使⽤变⻓编码[1]。符号整型。负值的编码效率⾼于常规的int32类型。int32
sint64使⽤变⻓编码[1]。符号整型。负值的编码效率⾼于常规的int64类型。int64
fixed32定⻓4字节。若值常⼤于2^28则会⽐uint32更⾼效。uint32
fixed64定⻓8字节。若值常⼤于2^56则会⽐uint64更⾼效。uint64
sfixed32定⻓4字节。int32
sfixed64定⻓8字节。int64
boolbool
string包含UTF-8和ASCII编码的字符串,⻓度不能超过2^32。string
bytes可包含任意的字节序列但⻓度不能超过2^32。string

详细解释如上表格:

名字解释说明
使⽤变⻓编码[1]经过protobuf编码后,原本4字节或8字节的数可能会被变为其他字节数。

更新contacts.proto(通讯录1.0),新增姓名、年龄字段:

// 首行:语法指定行
syntax = "proto3";
package contacts;

// 定义联系人message
message PeopleInfo {
  string name = 1;  // 姓名
  int32 age = 2;    // 年龄  
}

在这⾥还要特别讲解⼀下字段唯⼀编号的范围:

1 ~ 536,870,911(2^29-1),其中 19000 ~ 19999不可⽤。

19000~19999不可⽤是因为:在Protobuf协议的实现中,对这些数进⾏了预留。如果⾮要在.proto⽂件中使⽤这些预留标识号,例如将name字段的编号设置为19000,编译时就会报警:

// 消息中定义了如下编号,代码会告警:
// Field numbers 19,000 through 19,999 are reserved for the protobuf implementation

string name = 19000;

值得⼀提的是,范围为1~15的字段编号需要⼀个字节进⾏编码,16 ~ 2047内的数字需要两个字节进⾏编码。编码后的字节不仅只包含了编号,还包含了字段类型。所以1~15要⽤来标记出现⾮常频繁的字段,要为将来有可能添加的、频繁出现的字段预留⼀些出来。

步骤2:编译contacts.proto⽂件,⽣成C++⽂件

编译命令
编译命令⾏格式为:

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⽂件命令如下:

# 第一种方式,在当前contacts.proto文件目录下执行。
protoc --cpp_out=. contacts.proto
# 第二种方式,在contacts.proto文件父目录下执行。
protoc -I ./fast_start --cpp_out=./fast_start/ 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中的代码就是对类声明⽅法的⼀些实现,在这⾥就不展开了。

到这⾥有同学可能就有疑惑了,那之前提到的序列化和反序列化⽅法在哪⾥呢?在消息类的⽗类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成员函数,因为序列化不会改变类对象的内容,⽽是将序列化的结果保存到函数⼊参指定的地址中。
  • 详细messageAPI可以参⻅完整列表。

步骤3:序列化与反序列化的使⽤

创建⼀个测试⽂件test.cc,⽅法中我们实现:

  • 对⼀个联系⼈的信息使⽤PB进⾏序列化,并将结果打印出来。
  • 对序列化后的内容使⽤PB进⾏反序列,解析出联系⼈信息并打印出来。

test.cc 文件

#include <iostream> 
#include "contacts.pb.h"
 

int main() { 
    std::string people_str; 

    {
        // 对⼀个联系⼈的信息使⽤ PB 进⾏序列化,并将结果打印出来。
        contacts::PeopleInfo people; 
        people.set_name("张珊"); 
        people.set_age(20); 
        if (!people.SerializeToString(&people_str)) { 
            std::cerr << "序列化联系⼈失败!" << std::endl; 
            return -1;
        }
        std::cout << "序列化成功,结果:" << people_str << std::endl; 
    }
    
    {
        // 对序列化后的内容使⽤ PB 进⾏反序列,解析出联系⼈信息并打印出来。
        contacts::PeopleInfo people; 
        if (!people.ParseFromString(people_str)) { 
            std::cerr << "反序列化联系⼈失败!" << std::endl; 
            return -1;
        } 
        std::cout << "反序列化成功!" << std::endl
                  << "姓名: " << people.name() << std::endl
                  << "年龄: " << people.age() << std::endl;
    }

    return 0;
} 

contacts.proto 文件

// 首行:语法指定行
syntax = "proto3";
package contacts;

// 定义联系人message
message PeopleInfo {
  string name = 1;  // 姓名
  int32 age = 2;    // 年龄  
}

2.执行一下语句


# 1. 编译 contacts文件 
g++ -o testPB test.cc contacts.pb.cc -lprotobuf -std=c++11
# 2. 编译 test.cc, 生成执行程序 testPB
protoc --cpp_out=. contacts.proto
  • -lprotobuf:依赖的库, 必加,不然会有链接错误。
  • std=c++11: 使用c++语法。

执⾏ TestPB ,可以看⻅people经过序列化和反序列化后的结果:

./testPB

# 打印如下说明成功了

[YYK@VM-8-7-centos fast_start]$ ./testPB
序列化成功,结果:
张珊
反序列化成功!
姓名: 张珊
年龄: 20

由于ProtoBuf是把联系⼈对象序列化成了⼆进制序列,这⾥⽤string来作为接收⼆进制序列的容器。
所以在终端打印的时候会有换⾏等⼀些乱码显⽰。
所以相对于xml和JSON来说,因为被编码成⼆进制,破解成本增⼤,ProtoBuf编码是相对安全的。

⼩结ProtoBuf使⽤流程

在这里插入图片描述

  • 1.编写.proto⽂件,⽬的是为了定义结构对象(message)及属性内容。
  • 2.使⽤protoc编译器编译.proto⽂件,⽣成⼀系列接⼝代码,存放在新⽣成头⽂件和源⽂件中。
  • 3.依赖⽣成的接⼝,将编译⽣成的头⽂件包含进我们的代码中,实现对.proto⽂件中定义的字段进⾏ 设置和获取,和对message对象进⾏序列化和反序列化。
  • 总的来说:ProtoBuf是需要依赖通过编译⽣成的头⽂件和源⽂件来使⽤的。有了这种代码⽣成机制,开发⼈员再也不⽤吭哧吭哧地编写那些协议解析的代码了(⼲这种活是典型的吃⼒不讨好)
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。