在快速上手中,会编写第一版本的通讯录 1.0。在通讯录 1.0 版本中,将实现:
- 对⼀个联系⼈的信息使⽤
PB进⾏序列化,并将结果打印出来。 - 对序列化后的内容使⽤
PB进⾏反序列,解析出联系⼈信息并打印出来。 - 联系⼈包含以下信息: 姓名、年龄。
通过通讯录 1.0,我们便能了解使⽤ ProtoBuf 初步要掌握的内容,以及体验到 ProtoBuf 的完整使用流程。
Ⅰ. 创建 .proto 文件
下面先给出通讯录 1.0 中 contacts.proto 文件的内容:
// 非注释的首行:声明proto的语法版本
syntax = "proto3";
// 指定对应的包名,防止冲突
package contacts;
// 定义联系人消息(下面的等号其实不是赋值,而是指定标记,实现protobuf的高效率特点)
message PeopleInfo
{
string name = 1;
int32 age = 2;
} 下面我们来解释一下上述内容!
1、文件规范
- 创建
.proto⽂件时,文件命名应该使用全小写字母命名,多个字⺟之间⽤_连接。 例如:lower_snake_case.proto - 书写
.proto⽂件代码时,应使⽤两个空格的缩进,而不是一个TAB的缩进 - 向⽂件添加注释,可使⽤
//或者/* ... */
2、指定proto3语法
Protocol Buffers 语言版本3,简称 proto3,是 .proto 文件最新的语法版本。proto3 简化了 Protocol Buffers 语言,既易于使用,又可以在更广泛的编程语言中使用。
在 .proto 文件中,要使用 syntax = "proto3"; 来指定文件语法为 proto3,并且必须写在除去注释内容的第一行。 如果没有指定,编译器会使用 proto2 语法。内容如下:
// 非注释的首行:声明proto的语法版本
syntax = "proto3";3、package声明符
package 声明的作用是为了指定生成的代码的命名空间。这在防止消息类型之间的命名冲突是非常重要的。考虑到 Protocol Buffers 可以用于定义通信协议,而在这些协议中可能会定义相同的消息类型名称,因此必须有一种机制来确保不同的消息类型之间不会发生命名冲突。
通过指定 package,你可以为消息类型指定一个命名空间。这意味着不同的消息类型可以在不同的命名空间中定义相同的名称而不会发生冲突,因为它们在不同的包中。
例如,假设有两个文件 A.proto 和 B.proto,它们都定义了一个名为 Message 的消息类型。如果这两个文件没有使用 package 声明来指定命名空间,那么生成的代码可能会因为命名冲突而出现问题。但是,如果它们分别使用了不同的 package 声明,比如 package A 和 package B,那么生成的代码会将它们放置在不同的命名空间中,从而避免了冲突。
所以我们在通讯录 1.0 版本中可以指定以下命名空间:
// 指定对应的包名,防止冲突
package contacts;4、定义消息
消息(message): 表示要定义的结构化对象,我们可以给这个结构化对象中定义其对应的属性内容。
这⾥再提⼀下为什么要定义消息?在⽹络传输中,我们需要为传输双⽅定制协议。定制协议说⽩了就是定义结构体或者结构化数据,⽐如
tcp、udp报⽂就是结构化的。 再⽐如将数据持久化存储到数据库时,会将⼀系列元数据统⼀⽤对象组织起来,再进⾏存储。
所以
ProtoBuf就是以message的⽅式来⽀持我们定制协议字段,后期帮助我们形成类和⽅法来使⽤。
在通讯录 1.0 中我们就需要为“联系人”定义⼀个 message。
message 消息类型名 {
消息类型字段
} 消息类型命名规范:使用驼峰命名法,首字母大写。如下所示:
// 定义联系人消息
message PeopleInfo {
...
} 此外,一个 .proto 文件中是可以有多个 message 类的,并且每个 message 类中的字段唯一编号是可以相同,因为这是互不影响的!
5、定义消息字段
在 message 中我们可以定义其属性字段,字段定义格式为:字段类型 字段名 = 字段唯一编号;
- 字段名称命名规范:全小写字母,多个字母之间用
_连接。 - 字段类型:标量数据类型 和 特殊类型(包括枚举、其他消息类型等,这个后面讲语法会学到)
- 字段唯⼀编号:⽤来标识字段,⼀旦开始使⽤就不能够再改变。
该表格展示了定义于消息体中的标量数据类型,以及编译 .proto 文件之后自动生成的类中与之对应的字段类型。在这里展示了与 C++ 语言对应的类型:(下面提到的变长编码,是指经过 protobuf 编码后,原本 4 字节或 8 字节的数可能会被变为其他字节数)
.proto 类型 |
强调内容 | C++ 类型 |
|---|---|---|
| double | double | |
| float | 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^56 则会比 uint64 更高效 | uint64 |
| sfixed32 | 定长 4 字节 | int32 |
| sfixed64 | 定长 8 字节 | int64 |
| bool | bool | |
| string | 包含 UTF-8 和 ASCII 编码的字符串,长度不能超过 2^32 | string |
| bytes | 可包含任意的字节序列但长度不能超过 2^32 | string |
更新 contacts.proto 文件,新增姓名、年龄字段:
// 定义联系人消息(下面的等号其实不是赋值,而是指定标记,实现protobuf的高效率特点)
message PeopleInfo
{
string name = 1;
int32 age = 2;
} 在这里还要特别讲解⼀下字段唯一编号的范围:1 ~ 2^29-1,其中 19000 ~ 19999 是不可用的。(19000 ~ 19999 不可用是因为:在 Protobuf 协议的实现中,对这些数进行了预留。如果非要在 .proto 文件中使用这些预留标识号,例如将 name 字段的编号设置为 19000,编译时就会报警)
还值得一提的是,范围为 1 ~ 15 的字段编号需要一个字节进行编码, 16 ~ 2047 内的数字需要两个字节进行编码。编码后的字节不仅只包含了编号,还包含了字段类型。所以 1 ~ 15 要用来标记出现非常频繁的字段,要为将来有可能添加的、频繁出现的字段预留一些出来。
Ⅱ. 编译 contacts.proto ⽂件,⽣成 C++ ⽂件
1、编译命令
编译命令的格式如下所示:
protoc [--proto_path=IMPORT_PATH] --cpp_out=OUT_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,这两个不是连在一起的,中间有空格隔开的!
此外,我们可以通过 protoc --help 查看其他命令选项以及选项的解释!
2、编译 .proto 文件后发生什么
编译 contacts.proto 文件后,会生成所选择语言的代码,我们选择的是 C++,所以编译后生成了两个文件:**contacts.pb.h**文件以及 contacts.pb.cc 文件。
它们包含了以下内容 :
- 编辑器会针对于每个
.proto⽂件⽣成.h和.cc⽂件,分别⽤来存放类的声明与类的实现。 - 对于每个
message,都会⽣成⼀个对应的消息类。 - 在消息类中,编译器为每个字段提供了获取和设置⽅法,以及⼀下其他能够操作字段的⽅法,如
set、get函数等。
下面是 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);
};上述的例子中:(contacts.pb.cc 中的代码就是对类声明方法的一些实现,在这里就不展开了)
- 每个字段都有设置和获取的⽅法,
getter的名称与⼩写字段完全相同,setter⽅法以set_开头。 - 每个字段都有一个
clear_⽅法,可以将字段重新设置回empty状态。
到这里有同学可能就有疑惑了,那之前提到的序列化和反序列化方法在哪里呢?
其实是在消息类的父类 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可以参⻅完整列表。
3、序列化和反序列化的使用
创建一个测试文件 main.cc,方法中我们实现:
- 对⼀个联系⼈的信息使⽤
PB进⾏序列化,并将结果打印出来。 - 对序列化后的内容使⽤
PB进⾏反序列,解析出联系⼈信息并打印出来。
#include <iostream>
#include <string>
#include "contacts.pb.h"
int main()
{
std::string people_str;
{
// 对⼀个联系⼈的信息使⽤ PB 进行序列化,并将结果打印出来。
contacts::PeopleInfo people;
people.set_age(20);
people.set_name("lirendada杨桐");
if(!people.SerializeToString(&people_str))
{
std::cerr << "序列化联系人失败" << std::endl;
return -1;
}
std::cout << "序列化联系人成功,结果为:" << people_str << std::endl;
}
std::cout << "----------------------------------------" << 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;
} 代码书写完成后,编译 main.cc 和 contacts.pb.cc,生成可执行程序 main:
g++ -o main main.cc contacts.pb.cc -std=c++11 -lprotobuf注意事项:
-lprotobuf:必加,不然会有链接错误。-std=c++11:必加,需要用到c++11的语法。
最后看看运行结果:
// 运行结果:
序列化联系人成功,结果为:
lirendada杨桐
----------------------------------------
反序列化联系人成功,结果为:
姓名:lirendada杨桐
年龄:20 由于 ProtoBuf 是把联系人对象序列化成了二进制序列,这里用 string 来作为接收二进制序列的容器。所以在终端打印的时候会有换行等一些乱码显示。
所以相对于 xml 和 JSON 来说,因为 ProtoBuf 被编码成二进制,破解成本增大,ProtoBuf 编码是相对安全的。