【ProtoBuf】快速上手
文章目录
1. 目标
在快速上手中,会编写第一版本的通讯录。在通讯录(1.0)版本中,将实现:
- 对一个联系人的信息使用 protobuf 进行序列化,并将结果打印出来。
- 对序列化后的内容使用 protobuf 进行反序列,解析出联系人信息并打印出来。
- 联系人包含以下信息:姓名、年龄。
通过通讯录(1.0)版本,我们便能了解使用 protobuf 初步要掌握的内容,以及体验到 protobuf 的完整使用流程。
2. 创建 .proto 文件
2.1 文件规范
- 创建
.proto文件时,文件命名应该使用全小写字母命名,多个字母之间用_连接。例如:lower_snake_case.proto。 - 书写
.proto文件代码时,应使用 2 个空格的缩进。
我们为通讯录 1.0 新建文件:contacts.proto,如下所示:

2.2 添加注释
向文件添加注释,可使用 // 或者 /* ... */
// 这是一行注释
/* 这是另外一行注释 */
2.3 指定 proto3 语法
Protocol Buffers 语言版本 3,简称 proto3,是 .proto 文件最新的语法版本。proto3 简化了 Protocol Buffers 语言,既易于使用,又可以在更广泛的编程语言中使用。它允许你使用 Java,C++,Python 等多种语言生成 protocol buffer 代码。
在 .proto 文件中,要使用 syntax = "proto3"; 来指定文件语法为 proto3,并且必须写在除去注释内容的第一行。如果没有指定,编译器会使用 proto2 语法。
在通讯录 1.0 的 contacts.proto 文件中,可以为文件指定 proto3 语法,内容如下:
syntax = "proto3";
2.3 package 声明符
package 是一个可选的声明符,能表示 .proto 文件的命名空间,在项目中要有唯一性。它的作用是为了避免我们定义的消息出现冲突。
在通讯录 1.0 的 contacts.proto 文件中,可以声明其命名空间,内容如下:
syntax = "proto3";
package contacts;
2.5 定义消息(message)
消息(message)是指要定义的结构化对象,我们可以给这个结构化对象中定义其对应的属性内容。
这里再提一下为什么要定义消息?
- 在网络传输中,我们需要为传输双方定制协议。定制协议说白了就是定义结构体或者结构化数据,比如,tcp,udp 报文就是结构化的。
- 再比如将数据持久化存储到数据库时,会将一系列元数据统一用对象组织起来,再进行存储。
所以 ProtoBuf 就是以 message 的方式来支持我们定制协议字段,后期帮助我们形成类和方法来使用。在通讯录 1.0 中我们就需要为联系人定义一个 message。
.proto 文件中定义一个消息类型的格式为:
// 消息类型命名规范:使⽤驼峰命名法,⾸字母大写。
message 消息类型名{
}
为 contacts.proto(通讯录 1.0)新增联系人 message,内容如下:
syntax = "proto3";
package contacts;
// 定义联系人消息
message PeopleInfo {
}
2.6 定义消息字段
在 message 中我们可以定义其属性字段,字段定义格式为:字段类型 字段名 = 字段唯一编号;
- 字段名称命名规范:全小写字母,多个字母之间用
_连接。 - 字段类型分为:标量数据类型 和 特殊类型(包括枚举、其他消息类型等)。
- 字段唯一编号:用来标识字段,一旦开始使用就不能够再改变。
下面的表格展示了定义于消息体中的标量数据类型,以及编译 .proto 文件之后自动生成的类中与之对应的字段类型。在这里展示了与 C++ 语言对应的类型。
| .proto Type | Notes | C++ Type |
|---|---|---|
| double | double | |
| float | float | |
| 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 |
| bool | bool | |
| string | 包含 UTF-8 和 ASCII 编码的字符串,长度不能超过 2^32。 | string |
| bytes | 可包含任意的字节序列但长度不能超过 2^32。 | string |
其中,变长编码是指:经过 protobuf 编码后,原本 4 字节或 8 字节的数可能会被变为其他字节数。
接着,继续更新 contacts.proto (通讯录 1.0),新增姓名、年龄字段:
syntax = "proto3";
package contacts;
// 定义联系人消息
message PeopleInfo {
string name = 1;
int32 age = 2;
}
在这里还要特别讲解一下字段唯一编号的范围:
1 ~ 536,870,911(2^29 - 1),其中19000 ~ 19999不可用。
19000 ~ 19999 不可用是因为:在 Protobuf 协议的实现中,对这些数进行了预留。如果非要在 .proto 文件中使用这些预留标识号,例如将 name 字段的编号设置为 19000,编译时就会报警:
// 消息中定义了如下编号,代码会告警:
string name = 19000;
结果如下:

值得一提的是,范围为 1 ~ 15 的字段编号需要一个字节进行编码,16 ~ 2047 内的数字需要两个字节进行编码。编码后的字节不仅只包含了编号,还包含了字段类型。所以 1 ~ 15 要用来标记出现非常频繁的字段,要为将来有可能添加的、频繁出现的字段预留一些出来。
3. 编译 contacts.proto 文件,生成 C++ 文件
3.1 编译命令
编译命令行格式为:
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
结果如下:

3.2 编译 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 成员函数,因为序列化不会改变类对象的内容,而是将序列化的结果保存到函数入参指定的地址中。
- 详细 message API 可以参见 完整列表。
4. 序列化与反序列化的使用
创建一个测试文件 main.cc,方法中我们实现:
- 对一个联系人的信息使用 PB 进行序列化,并将结果打印出来。
- 对序列化后的内容使用 PB 进行反序列,解析出联系人信息并打印出来。
main.cc 代码如下:
#include <iostream>
// 引⼊编译⽣成的头⽂件
#include "contacts.pb.h"
int main()
{
std::string people_str;
// 对⼀个联系⼈的信息使⽤PB进⾏序列化,并将结果打印出来。
{
contacts::PeopleInfo people;
people.set_name("李四");
people.set_age(18);
// 调⽤序列化方法,将序列化后的二进制序列存入string中
if (!people.SerializeToString(&people_str))
{
std::cerr << "序列化联系人失败!" << std::endl;
return -1;
}
// 走到这里, 序列化成功, 打印结果
std::cout << "序列化成功, 结果为: " << people_str << std::endl;
}
// 对序列化后的内容使⽤PB进⾏反序列,解析出联系⼈信息并打印出来。
{
contacts::PeopleInfo people;
// 调用反序列化方法,读取string中存放的二进制序列,并反序列化出对象
if (!people.ParseFromString(people_str))
{
std::cerr << "反序列化出联系⼈失败!" << std::endl;
return -1;
}
// 走到这里, 反序列化成功, 打印结果
std::cout << "反序列化成功!" << std::endl;
std::cout << "姓名: " << people.name() << std::endl;
std::cout << "年龄: " << people.age() << std::endl;
}
return 0;
}
代码书写完成后,编译 main.cc,生成可执行程序 TestPb:
g++ -o TestPb main.cc contacts.pb.cc -std=c++11 -lprotobuf
- -lprotobuf:必加,不然会有链接错误。
- -std=c++11:必加,使用 C++11 语法。
执行 ./TestPb,可以看见 people 经过序列化和反序列化后的结果:

由于 ProtoBuf 是把联系人对象序列化成了二进制序列,这里用 string 来作为接收二进制序列的容器。所以在终端打印的时候会有换行等一些乱码显示。
所以相对于 xml 和 JSON 来说,因为被编码成二进制,破解成本增大,ProtoBuf 编码是相对安全的。
5. protobuf 使用流程总结
如下图所示:

其中:
- 编写
.proto文件,目的是为了定义结构对象(message)及属性内容。 - 使用 protoc 编译器编译
.proto文件,生成一系列接口代码,存放在新生成头文件和源文件中。 - 依赖生成的接口,将编译生成的头文件包含进我们的代码中,实现对
.proto文件中定义的字段进行设置和获取,和对 message 对象进行序列化和反序列化。
总的来说:ProtoBuf 是需要依赖通过编译生成的头文件和源文件来使用的。有了这种代码生成机制,开发人员再也不用吭哧吭哧地编写那些协议解析的代码了。
更多推荐



所有评论(0)