这里写自定义目录标题
本教程为 C++ 程序员提供了使用 Protocol Buffer 的入门指南。通过创建一个简单的示例应用,将向你展示如何:
- 在
.proto文件中定义消息格式。 - 使用 Protocol Buffer 编译器。
- 利用 C++ Protocol Buffer API 读写消息。
如需更详细的参考信息,可查阅《Protocol Buffer 语言指南》《C++ API 参考》《C++ 生成代码指南》和《编码参考》。
问题场景
我们将以一个极简的“地址簿”应用为例,该应用能将人们的联系信息读写到文件中。地址簿中的每个人都包含姓名、ID、电子邮箱和联系电话。
如何对这类结构化数据进行序列化和反序列化?有以下几种解决方案:
- 以二进制形式发送或保存内存中的原始数据结构。这种方式长期来看较为脆弱,因为接收或读取代码必须按照完全相同的内存布局、字节序等进行编译。而且,当原始格式的文件不断积累,且适配该格式的软件副本广泛传播时,想要扩展格式会十分困难。
- 自定义编码方式,将数据项编码为单个字符串,例如把 4 个整数编码为“12:3:-23:67”。这种方式简单灵活,但需要编写专门的编码和解码代码,且解析过程会带来少量运行时开销,仅适用于编码极简单的数据。
- 将数据序列化为 XML。由于 XML 具有一定的人类可读性,且多种语言都有对应的绑定库,这种方式颇具吸引力,适合需要与其他应用或项目共享数据的场景。但 XML 的空间占用量大是出了名的,编码和解码过程会给应用带来巨大的性能损耗,而且导航 XML DOM 树也比访问类中的普通字段复杂得多。
相比之下,Protocol Buffer 是更灵活、高效且自动化的解决方案。使用 Protocol Buffer 时,你只需编写一个 .proto 文件来描述要存储的数据结构。随后,Protocol Buffer 编译器会生成一个类,该类以高效的二进制格式实现 Protocol Buffer 数据的自动编码和解析。生成的类为构成 Protocol Buffer 的字段提供了访问器(getter)和修改器(setter),并负责处理 Protocol Buffer 整体的读写细节。重要的是,Protocol Buffer 格式支持随时间扩展,扩展后的代码仍能读取采用旧格式编码的数据。
示例代码位置
示例代码包含在源代码包的“examples”目录下。
定义协议格式
要创建地址簿应用,首先需要编写一个 .proto 文件。.proto 文件中的定义简洁明了:为每个要序列化的数据结构添加一个消息(message),然后为消息中的每个字段指定名称和类型。以下是定义消息的 .proto 文件 addressbook.proto:
edition = "2023";
package tutorial;
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
该语法与 C++ 或 Java 类似,下面逐一解析文件各部分的作用:
版本声明
.proto 文件以 edition 声明开头。版本声明取代了旧版的 syntax = "proto2" 和 syntax = "proto3" 声明,为语言的后续演进提供了更灵活的方式。
包声明
接下来是包声明(package),用于避免不同项目之间的命名冲突。在 C++ 中,生成的类会被放置在与包名对应的命名空间中。
消息定义
包声明之后是消息定义。消息本质上是一个包含一组类型化字段的聚合结构。Protocol Buffer 支持多种标准简单数据类型作为字段类型,包括 bool、int32、float、double 和 string 等。你还可以使用其他消息类型作为字段类型,为消息添加更复杂的结构——在上述示例中,Person 消息包含 PhoneNumber 消息,而 AddressBook 消息包含 Person 消息。你甚至可以在一个消息内部定义嵌套消息,如示例中 PhoneNumber 类型就定义在 Person 内部。如果希望某个字段只能取预定义的一组值,还可以定义枚举类型(enum),例如示例中指定电话号码的类型。
每个字段后的“= 1”“= 2”等标记是该字段在二进制编码中使用的唯一字段编号。字段编号 1-15 编码时比更高编号少占用一个字节,因此为了优化性能,可以将这些编号分配给常用字段或重复字段,而将 16 及以上的编号留给不常用字段。
字段类型
字段主要分为以下两类:
- 单数字段(singular):默认情况下,字段为可选字段,即该字段可能被设置,也可能不被设置。如果单数字段未被设置,会使用该类型的默认值:数值类型为 0,字符串类型为空字符串,布尔类型为 false,枚举类型为第一个定义的值(必须为 0)。注意,不能显式将字段声明为
singular,它仅用于描述非重复字段。 - 重复字段(repeated):该字段可重复任意次数(包括 0 次),重复值的顺序会被保留。可以将重复字段理解为动态大小的数组。
在旧版本的 Protocol Buffer 中,存在 required 关键字,但实践证明该关键字不够灵活,现代版本的 Protocol Buffer 已不再支持(不过为了向后兼容,版本声明提供了启用该关键字的功能)。
关于编写 .proto 文件的完整指南(包括所有可用的字段类型),可参考《Protocol Buffer 语言指南》。需要注意的是,Protocol Buffer 不支持类继承相关的特性。
编译 Protocol Buffer
编写好 .proto 文件后,下一步需要生成用于读写 AddressBook(以及 Person 和 PhoneNumber)消息的类。为此,需要使用 Protocol Buffer 编译器 protoc 处理 .proto 文件:
编译器安装
如果尚未安装编译器,请按照《Protocol Buffer 编译器安装指南》的说明进行安装。
编译命令
运行编译器时,需要指定源目录(应用源代码所在的目录,若不指定则使用当前目录)、目标目录(生成代码的存放目录,通常与源目录相同)以及 .proto 文件的路径。示例命令如下:
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto
由于需要生成 C++ 类,因此使用 --cpp_out 选项——其他支持的语言也有对应的类似选项。
生成文件
编译后会在指定的目标目录中生成以下两个文件:
addressbook.pb.h:声明生成类的头文件。addressbook.pb.cc:包含生成类的实现代码。
Protocol Buffer API
让我们看看生成的代码,了解编译器为我们创建了哪些类和函数。查看 addressbook.pb.h 可以发现,.proto 文件中定义的每个消息都对应一个类。以 Person 类为例,编译器为每个字段生成了访问器方法。例如,针对 name、id、email 和 phones 字段,生成的方法如下:
// name
bool has_name() const; // 仅用于显式检查字段是否存在
void clear_name();
const ::std::string& name() const;
void set_name(const ::std::string& value);
::std::string* mutable_name();
// id
bool has_id() const;
void clear_id();
int32_t id() const;
void set_id(int32_t value);
// email
bool has_email() const;
void clear_email();
const ::std::string& email() const;
void set_email(const ::std::string& value);
::std::string* mutable_email();
// phones
int phones_size() const;
void clear_phones();
const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
const ::tutorial::Person_PhoneNumber& phones(int index) const;
::tutorial::Person_PhoneNumber* mutable_phones(int index);
::tutorial::Person_PhoneNumber* add_phones();
可以看到,访问器的名称与字段名(小写)一致,修改器方法以 set_ 开头。对于支持显式存在跟踪的单数字段,还提供了 has_ 方法,用于判断该字段是否已被设置。此外,每个字段都有一个 clear_ 方法,用于将字段重置为默认状态。
数值类型的 id 字段仅提供上述基本访问器集合,而字符串类型的 name 和 email 字段多了两个方法:mutable_ 访问器(用于获取字符串的直接指针)和一个额外的修改器。需要注意的是,即使 email 字段尚未被设置,也可以调用 mutable_email(),它会自动将 email 初始化为空字符串。如果是重复消息字段,则只会生成 mutable_ 方法,不会生成 set_ 方法。
重复字段还有一些特殊方法,以重复字段 phones 为例,你可以:
- 查看重复字段的大小(即该
Person关联的电话号码数量)。 - 通过索引获取指定的电话号码。
- 更新指定索引处的现有电话号码。
- 向消息中添加新的电话号码并进行编辑(重复标量类型的
add_方法可直接传入新值)。
关于 Protocol Buffer 编译器为特定字段定义生成的具体成员,可参考《C++ 生成代码参考》。
枚举和嵌套类
生成的代码包含与 .proto 文件中枚举对应的 PhoneType 枚举。你可以通过 Person::PhoneType 引用该类型,其值可表示为 Person::PHONE_TYPE_MOBILE、Person::PHONE_TYPE_HOME 和 Person::PHONE_TYPE_WORK(实现细节较为复杂,但使用时无需深入了解)。
编译器还生成了一个名为 Person::PhoneNumber 的嵌套类。查看代码会发现,其实际类名为 Person_PhoneNumber,但 Person 内部定义的类型别名(typedef)允许你将其视为嵌套类。唯一需要注意的是,如果你想在其他文件中前置声明该类——C++ 不支持前置声明嵌套类型,但可以前置声明 Person_PhoneNumber。
标准消息方法
每个消息类还包含多个用于检查或操作整个消息的方法,包括:
bool IsInitialized() const;:检查所有必需字段是否已被设置。string DebugString() const;:返回消息的人类可读形式,对调试非常有用。void CopyFrom(const Person& from);:用给定消息的值覆盖当前消息。void Clear();:将所有元素重置为空状态。
这些方法以及下一节将要介绍的 I/O 方法,都实现了所有 C++ Protocol Buffer 类共享的 Message 接口。如需了解更多信息,可参考 Message 的完整 API 文档。
解析与序列化
最后,每个 Protocol Buffer 类都提供了使用 Protocol Buffer 二进制格式读写指定类型消息的方法,包括:
bool SerializeToString(string* output) const;:将消息序列化,并将字节数据存储到指定字符串中。注意,这些字节是二进制数据,而非文本,这里使用string类仅作为便捷的容器。bool ParseFromString(const string& data);:从指定字符串中解析消息。bool SerializeToOstream(ostream* output) const;:将消息写入指定的 C++ostream。bool ParseFromIstream(istream* input);:从指定的 C++istream中解析消息。
以上仅为部分解析和序列化选项,完整列表可参考 Message API 参考。
重要提示:Protocol Buffer 与面向对象设计
Protocol Buffer 类本质上是数据容器(类似 C 语言中的结构体),不提供额外功能,不适合作为对象模型中的一等公民。如果希望为生成的类添加更丰富的行为,最佳方式是将生成的 Protocol Buffer 类包装在应用特定的类中。如果无法控制 .proto 文件的设计(例如复用其他项目的 .proto 文件),包装也是一个好办法。通过包装类,你可以设计更适合应用独特环境的接口,例如隐藏部分数据和方法、提供便捷函数等。
不能通过继承生成的类来添加行为,因为这些类是最终类(final)。这一设计是为了避免破坏内部机制,同时从面向对象设计的角度来看,继承也并非最佳实践。
编写消息
现在让我们尝试使用生成的 Protocol Buffer 类。地址簿应用首先需要实现的功能是将个人信息写入地址簿文件。要实现这一功能,需要创建并填充 Protocol Buffer 类的实例,然后将其写入输出流。
以下程序从文件中读取 AddressBook,根据用户输入添加一个新的 Person,再将新的 AddressBook 写回文件。其中直接调用或引用 Protocol Buffer 编译器生成代码的部分已高亮显示:
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
// 根据用户输入填充 Person 消息
void PromptForAddress(tutorial::Person& person) {
cout << "请输入人员 ID:";
int id;
cin >> id;
person.set_id(id);
cin.ignore(256, '\n'); // 忽略换行符
cout << "请输入姓名:";
getline(cin, *person.mutable_name());
cout << "请输入电子邮箱(留空则不填):";
string email;
getline(cin, email);
if (!email.empty()) {
person.set_email(email);
}
while (true) {
cout << "请输入电话号码(留空结束):";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
tutorial::Person::PhoneNumber* phone_number = person.add_phones();
phone_number->set_number(number);
cout << "该电话是移动电话、家庭电话还是工作电话?";
string type;
getline(cin, type);
if (type == "移动电话") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_MOBILE);
} else if (type == "家庭电话") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_HOME);
} else if (type == "工作电话") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_WORK);
} else {
cout << "未知电话类型,使用默认值。" << endl;
}
}
}
// 主函数:从文件中读取整个地址簿,
// 根据用户输入添加一个人员,然后写回原文件
int main(int argc, char* argv[]) {
// 验证链接的库版本与编译时使用的头文件版本是否兼容
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "用法:" << argv[0] << " 地址簿文件" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
// 读取现有地址簿
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ":文件不存在,创建新文件。" << endl;
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "解析地址簿失败。" << endl;
return -1;
}
}
// 添加一个地址
PromptForAddress(*address_book.add_people());
{
// 将新地址簿写回磁盘
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "写入地址簿失败。" << endl;
return -1;
}
}
// 可选:删除 libprotobuf 分配的所有全局对象
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
注意 GOOGLE_PROTOBUF_VERIFY_VERSION 宏。在使用 C++ Protocol Buffer 库之前执行该宏是一种良好的实践(尽管并非严格必需)。它会验证你是否不小心链接了与编译时头文件版本不兼容的库版本。如果检测到版本不匹配,程序会中止。需要注意的是,每个 .pb.cc 文件在启动时都会自动调用该宏。
另外,程序末尾调用了 ShutdownProtobufLibrary() 函数。该函数的作用是删除 Protocol Buffer 库分配的所有全局对象。对于大多数程序来说,这并非必需,因为程序退出时操作系统会回收所有内存。但如果使用的内存泄漏检查工具要求释放所有对象,或者正在编写一个可能被单个进程多次加载和卸载的库,则需要强制 Protocol Buffer 清理所有资源。
读取消息
当然,地址簿如果无法读取信息就没什么用处了!以下示例读取上述程序创建的文件,并打印其中的所有信息:
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
// 遍历地址簿中的所有人员并打印信息
void ListPeople(const tutorial::AddressBook& address_book) {
for (const tutorial::Person& person : address_book.people()) {
cout << "人员 ID:" << person.id() << endl;
cout << " 姓名:" << person.name() << endl;
if (person.has_email()) {
cout << " 电子邮箱:" << person.email() << endl;
}
for (const tutorial::Person::PhoneNumber& phone_number : person.phones()) {
switch (phone_number.type()) {
case tutorial::Person::PHONE_TYPE_MOBILE:
cout << " 移动电话:";
break;
case tutorial::Person::PHONE_TYPE_HOME:
cout << " 家庭电话:";
break;
case tutorial::Person::PHONE_TYPE_WORK:
cout << " 工作电话:";
break;
case tutorial::Person::PHONE_TYPE_UNSPECIFIED:
default:
cout << " 电话号码:";
break;
}
cout << phone_number.number() << endl;
}
}
}
// 主函数:从文件中读取整个地址簿并打印所有信息
int main(int argc, char* argv[]) {
// 验证链接的库版本与编译时使用的头文件版本是否兼容
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "用法:" << argv[0] << " 地址簿文件" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
// 读取现有地址簿
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "解析地址簿失败。" << endl;
return -1;
}
}
ListPeople(address_book);
// 可选:删除 libprotobuf 分配的所有全局对象
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
扩展 Protocol Buffer
发布使用 Protocol Buffer 的代码后,你很可能会想要“改进”Protocol Buffer 的定义。如果希望新的 Protocol Buffer 向后兼容,且旧的 Protocol Buffer 向前兼容(通常你都会有这样的需求),需要遵循以下规则:
在新版本的 Protocol Buffer 中:
- 不得修改任何现有字段的字段编号。
- 可以删除单数字段或重复字段。
- 可以添加新的单数字段或重复字段,但必须使用全新的字段编号(即从未在该 Protocol Buffer 中使用过的编号,即使是已删除字段的编号也不能复用)。
(这些规则存在一些例外情况,但极少使用。)
遵循以上规则后,旧代码可以正常读取新消息,只是会忽略所有新增字段。对于旧代码来说,已删除的字段会显示为默认值,已删除的重复字段会为空。新代码也能透明地读取旧消息。但需要注意的是,旧消息中不会包含新增字段,因此使用前需要检查这些字段是否为默认值(例如空字符串)。
优化技巧
C++ Protocol Buffer 库经过了极致优化,但正确的使用方式能进一步提升性能。以下是一些充分发挥库性能的技巧:
使用内存池(Arenas)进行内存分配
如果在短时间内创建大量 Protocol Buffer 消息(例如解析单个请求),系统的内存分配器可能会成为性能瓶颈。内存池(Arenas)正是为缓解这一问题而设计的。通过使用内存池,可以以低开销执行多次分配,并在最后一次性释放所有内存。这能显著提升消息密集型应用的性能。
使用内存池时,需在 google::protobuf::Arena 对象上分配消息:
google::protobuf::Arena arena;
tutorial::Person* person = google::protobuf::Arena::Create<tutorial::Person>(&arena);
// ... 填充 person 消息 ...
当 arena 对象被销毁时,所有在其上分配的消息都会被释放。如需了解更多细节,可参考《内存池指南》。
尽可能复用非内存池消息对象
消息会保留分配的内存以供复用,即使调用了 clear() 方法也是如此。因此,如果连续处理多个类型相同、结构相似的消息,复用同一个消息对象有助于减轻内存分配器的负担。但需要注意的是,消息对象可能会随着时间变得臃肿,尤其是当消息结构多变,或偶尔创建远大于常规大小的消息时。你可以通过调用 SpaceUsed 方法监控消息对象的大小,当对象过大时及时删除。
复用内存池消息可能会导致内存无限增长,复用堆消息则更安全。但即使是堆消息,也可能出现字段内存占用峰值过高的问题。例如,若先后处理以下消息:
a: [1, 2, 3, 4]和b: [1]a: [1]和b: [1, 2, 3, 4]
复用消息对象后,两个字段都会保留能容纳历史最大数据量的内存。因此,即使每个输入消息仅包含 5 个元素,复用的消息对象也会占用能容纳 8 个元素的内存。
使用优化的内存分配器
系统默认的内存分配器在多线程环境下分配大量小对象时,性能可能不够理想。可以尝试使用 Google 的 TCMalloc 替代。
高级用法
Protocol Buffer 的用途远不止简单的访问器和序列化。建议查阅《C++ API 参考》,了解其更多功能。
Protocol Buffer 消息类的一个关键特性是反射(Reflection)。通过反射,你可以遍历消息的字段并修改其值,而无需针对特定消息类型编写代码。反射的一个重要用途是将 Protocol Buffer 消息与其他编码格式(如 XML 或 JSON)相互转换。更高级的用法包括比较两个同类型消息的差异,或开发一种“Protocol Buffer 正则表达式”,用于匹配特定的消息内容。发挥想象力,你会发现 Protocol Buffer 能应用于比最初预期更广泛的场景!
反射功能通过 Message::Reflection 接口提供。


4万+

被折叠的 条评论
为什么被折叠?



