Protocol Buffer 基础:C++
本教程为 C++ 程序员提供了使用 Protocol Buffer 的基础入门。通过创建一个简单的示例应用程序,它将向你展示如何:
- 在
.proto
文件中定义消息格式。 - 使用 Protocol Buffer 编译器。
- 使用 C++ Protocol Buffer API 来写入和读取消息。
这不是一份在 C++ 中使用 Protocol Buffer 的综合指南。有关更详细的参考信息,请参阅Protocol Buffer 语言指南 (proto2)、Protocol Buffer 语言指南 (proto3)、C++ API 参考、C++ 生成代码指南以及编码参考。
问题领域
我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入人们的联系方式。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
你如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题:
- 原始的内存中数据结构可以以二进制形式发送/保存。随着时间的推移,这是一种脆弱的方法,因为接收/读取代码必须使用完全相同的内存布局、字节序等进行编译。此外,随着文件以原始格式累积数据,以及为该格式编写的软件副本四处传播,扩展格式变得非常困难。
- 你可以发明一种特殊的方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单灵活的方法,尽管它需要编写一次性的编码和解析代码,并且解析会带来很小的运行时成本。这最适合编码非常简单的数据。
- 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML(某种程度上)是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 是出了名的空间密集型,并且对其进行编码/解码可能会给应用程序带来巨大的性能损失。此外,导航 XML DOM 树比通常在类中导航简单字段要复杂得多。
除了这些选项,你可以使用 Protocol Buffers。Protocol Buffers 是解决这个问题的灵活、高效、自动化的解决方案。使用 Protocol Buffers,你编写一个 .proto
文件来描述你希望存储的数据结构。据此,Protocol Buffer 编译器会创建一个类,该类实现了使用高效二进制格式自动编码和解析 Protocol Buffer 数据。生成的类为构成 Protocol Buffer 的字段提供了 getter 和 setter,并负责将 Protocol Buffer 作为一个单元进行读写的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式,这样代码仍然可以读取用旧格式编码的数据。
在哪里找到示例代码
示例代码包含在源代码包的 “examples” 目录下。
定义你的协议格式
要创建你的地址簿应用程序,你需要从一个 .proto
文件开始。.proto
文件中的定义很简单:为你想要序列化的每个数据结构添加一个 *message*,然后为消息中的每个字段指定名称和类型。这是定义你的消息的 .proto
文件,addressbook.proto
。
syntax = "proto2";
package tutorial;
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = PHONE_TYPE_HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
正如你所见,语法类似于 C++ 或 Java。让我们逐一查看文件的每个部分,看看它们的作用。
.proto
文件以包声明开始,这有助于防止不同项目之间的命名冲突。在 C++ 中,你生成的类将被放置在一个与包名匹配的命名空间中。
接下来,是你的消息定义。消息只是一个包含一组类型化字段的聚合。许多标准的简单数据类型都可以作为字段类型,包括 bool
、int32
、float
、double
和 string
。你还可以通过使用其他消息类型作为字段类型来为你的消息添加更多结构——在上面的示例中,Person
消息包含 PhoneNumber
消息,而 AddressBook
消息包含 Person
消息。你甚至可以在其他消息内部定义消息类型——正如你所见,PhoneNumber
类型是在 Person
内部定义的。如果你希望某个字段的值是预定义列表中的一个,你也可以定义 enum
类型——这里你希望指定一个电话号码可以是以下电话类型之一:PHONE_TYPE_MOBILE
、PHONE_TYPE_HOME
或 PHONE_TYPE_WORK
。
每个元素上的“ = 1”、“ = 2”标记标识了该字段在二进制编码中使用的唯一字段编号。字段编号 1-15 比更高的编号少用一个字节来编码,因此作为一种优化,你可以决定将这些编号用于常用或重复的元素,将字段编号 16 及以上留给不常用的可选元素。重复字段中的每个元素都需要重新编码字段编号,因此重复字段特别适合这种优化。
每个字段必须用以下修饰符之一进行注解:
optional
:该字段可以设置也可以不设置。如果一个可选字段的值未设置,则使用默认值。对于简单类型,你可以指定自己的默认值,就像我们在示例中为电话号码的type
所做的那样。否则,使用系统默认值:数字类型为零,字符串为空字符串,布尔值为 false。对于内嵌消息,默认值始终是该消息的“默认实例”或“原型”,其所有字段均未设置。调用访问器以获取未显式设置的可选(或必需)字段的值时,总是返回该字段的默认值。repeated
:该字段可以重复任意次数(包括零次)。重复值的顺序将在 Protocol Buffer 中保留。可以把重复字段看作是动态大小的数组。required
:必须为该字段提供一个值,否则该消息将被视为“未初始化”。如果libprotobuf
在调试模式下编译,序列化一个未初始化的消息将导致断言失败。在优化构建中,该检查被跳过,消息仍将被写入。但是,解析一个未初始化的消息将始终失败(通过从解析方法返回false
)。除此之外,必需字段的行为与可选字段完全相同。
重要
必需是永久的 你应该非常小心地将字段标记为required
。如果在某个时候你希望停止写入或发送一个必需字段,将其更改为可选字段将是有问题的——旧的读取器会认为没有这个字段的消息是不完整的,并可能无意中拒绝或丢弃它们。你应该考虑为你的缓冲区编写特定于应用程序的自定义验证例程。在谷歌内部,强烈不推荐使用 required
字段;大多数在 proto2 语法中定义的消息仅使用 optional
和 repeated
。(Proto3 完全不支持 required
字段。)你可以在 Protocol Buffer 语言指南中找到编写 .proto
文件的完整指南——包括所有可能的字段类型。但不要去寻找类似于类继承的功能——Protocol Buffer 不支持这个。
编译你的 Protocol Buffers
现在你有了一个 .proto
文件,下一步你需要生成读写 AddressBook
(以及 Person
和 PhoneNumber
)消息所需的类。为此,你需要在你的 .proto
文件上运行 Protocol Buffer 编译器 protoc
:
如果你还没有安装编译器,请下载软件包并按照 README 中的说明进行操作。
现在运行编译器,指定源目录(你的应用程序源代码所在的位置——如果不提供值,则使用当前目录)、目标目录(你希望生成的代码存放的位置;通常与
$SRC_DIR
相同),以及你的.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
,可以看到你为在 addressbook.proto
中指定的每个消息都有一个类。仔细查看 Person
类,你可以看到编译器为每个字段生成了访问器。例如,对于 name
、id
、email
和 phones
字段,你有这些方法:
// name
inline bool has_name() const;
inline void clear_name();
inline const ::std::string& name() const;
inline void set_name(const ::std::string& value);
inline void set_name(const char* value);
inline ::std::string* mutable_name();
// id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);
// email
inline bool has_email() const;
inline void clear_email();
inline const ::std::string& email() const;
inline void set_email(const ::std::string& value);
inline void set_email(const char* value);
inline ::std::string* mutable_email();
// phones
inline int phones_size() const;
inline void clear_phones();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
inline const ::tutorial::Person_PhoneNumber& phones(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
inline ::tutorial::Person_PhoneNumber* add_phones();
如你所见,getter 的名称与字段的小写名称完全相同,而 setter 方法以 set_
开头。对于每个单一(必需或可选)字段,还有一个 has_
方法,如果该字段已设置,则返回 true。最后,每个字段都有一个 clear_
方法,将字段恢复到其空状态。
虽然数字 id
字段只有上面描述的基本访问器集,但 name
和 email
字段因为是字符串而有一些额外的方法——一个 mutable_
getter,让你获得字符串的直接指针,以及一个额外的 setter。请注意,即使 email
尚未设置,你也可以调用 mutable_email()
;它会自动初始化为空字符串。如果在此示例中你有一个重复的消息字段,它也会有一个 mutable_
方法,但没有 set_
方法。
重复字段也有一些特殊的方法——如果你查看重复的 phones
字段的方法,你会看到你可以:
- 检查重复字段的
_size
(换句话说,与此Person
关联的电话号码有多少个)。 - 使用索引获取指定的电话号码。
- 更新指定索引处的现有电话号码。
- 向消息中添加另一个电话号码,然后你可以对其进行编辑(重复的标量类型有一个
add_
方法,只让你传入新值)。
有关协议编译器为任何特定字段定义生成的成员的更多信息,请参阅 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 Buffers 和面向对象设计 Protocol Buffer 类基本上是数据持有者(类似于 C 中的结构体),不提供额外的功能;它们在对象模型中不是良好的一等公民。如果你想为生成的类添加更丰富的行为,最好的方法是将生成的 Protocol Buffer 类包装在特定于应用程序的类中。如果你无法控制.proto
文件的设计(例如,你正在重用另一个项目的文件),包装 Protocol Buffer 也是一个好主意。在这种情况下,你可以使用包装类来创建一个更适合你应用程序独特环境的接口:隐藏一些数据和方法,公开便利函数等。你绝不应该通过继承生成的类来为其添加行为。这会破坏内部机制,并且也不是好的面向对象实践。写入消息
现在让我们尝试使用你的 Protocol Buffer 类。你希望你的地址簿应用程序能够做的第一件事是将个人详细信息写入你的地址簿文件。为此,你需要创建并填充你的 Protocol Buffer 类的实例,然后将它们写入输出流。
这是一个从文件中读取 AddressBook
,根据用户输入向其中添加一个新的 Person
,然后将新的 AddressBook
写回文件的程序。直接调用或引用协议编译器生成的代码的部分已高亮显示。
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
cout << "Enter person ID number: ";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');
cout << "Enter name: ";
getline(cin, *person->mutable_name());
cout << "Enter email address (blank for none): ";
string email;
getline(cin, email);
if (!email.empty()) {
person->set_email(email);
}
while (true) {
cout << "Enter a phone number (or leave blank to finish): ";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
tutorial::Person::PhoneNumber* phone_number = person->add_phones();
phone_number->set_number(number);
cout << "Is this a mobile, home, or work phone? ";
string type;
getline(cin, type);
if (type == "mobile") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_MOBILE);
} else if (type == "home") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_HOME);
} else if (type == "work") {
phone_number->set_type(tutorial::Person::PHONE_TYPE_WORK);
} else {
cout << "Unknown phone type. Using default." << endl;
}
}
}
// Main function: Reads the entire address book from a file,
// adds one person based on user input, then writes it back out to the same
// file.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
// Read the existing address book.
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ": File not found. Creating a new file." << endl;
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
// Add an address.
PromptForAddress(address_book.add_people());
{
// Write the new address book back to disk.
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed to write address book." << endl;
return -1;
}
}
// Optional: Delete all global objects allocated by 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;
// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
for (int i = 0; i < address_book.people_size(); i++) {
const tutorial::Person& person = address_book.people(i);
cout << "Person ID: " << person.id() << endl;
cout << " Name: " << person.name() << endl;
if (person.has_email()) {
cout << " E-mail address: " << person.email() << endl;
}
for (int j = 0; j < person.phones_size(); j++) {
const tutorial::Person::PhoneNumber& phone_number = person.phones(j);
switch (phone_number.type()) {
case tutorial::Person::PHONE_TYPE_MOBILE:
cout << " Mobile phone #: ";
break;
case tutorial::Person::PHONE_TYPE_HOME:
cout << " Home phone #: ";
break;
case tutorial::Person::PHONE_TYPE_WORK:
cout << " Work phone #: ";
break;
}
cout << phone_number.number() << endl;
}
}
}
// Main function: Reads the entire address book from a file and prints all
// the information inside.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
// Read the existing address book.
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
ListPeople(address_book);
// Optional: Delete all global objects allocated by libprotobuf.
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
扩展 Protocol Buffer
在你发布使用 Protocol Buffer 的代码后,迟早你会无疑地想要“改进” Protocol Buffer 的定义。如果你希望你的新缓冲区向后兼容,并且你的旧缓冲区向前兼容——你几乎肯定希望这样——那么你需要遵循一些规则。在新版本的 Protocol Buffer 中:
- 你*不得*更改任何现有字段的字段编号。
- 你*不得*添加或删除任何必需字段。
- 你*可以*删除可选或重复字段。
- 你*可以*添加新的可选或重复字段,但必须使用新的字段编号(即,此 Protocol Buffer 中从未使用过的字段编号,即使是已删除的字段也未使用过)。
(这些规则有一些例外,但很少使用。)
如果你遵循这些规则,旧代码将愉快地读取新消息并简单地忽略任何新字段。对于旧代码,被删除的可选字段将简单地具有其默认值,而已删除的重复字段将为空。新代码也将透明地读取旧消息。但是,请记住,新的可选字段将不会出现在旧消息中,因此你需要使用 has_
显式检查它们是否已设置,或者在你的 .proto
文件中通过字段编号后的 [default = value]
提供一个合理的默认值。如果未为可选元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数字类型,默认值为零。另请注意,如果你添加了一个新的重复字段,你的新代码将无法判断它是被留空(由新代码)还是从未设置过(由旧代码),因为它没有 has_
标志。
优化技巧
C++ Protocol Buffers 库经过了极其深入的优化。然而,正确的使用可以进一步提高性能。这里有一些榨取库中每一滴速度的技巧:
- 尽可能重用消息对象。消息会尝试保留它们分配的任何内存以供重用,即使它们被清除了。因此,如果你连续处理许多具有相同类型和相似结构的消息,最好每次都重用同一个消息对象,以减轻内存分配器的负担。然而,对象可能会随着时间的推移而变得臃肿,特别是如果你的消息在“形状”上有所不同,或者你偶尔构建一个比平常大得多的消息。你应该通过调用
SpaceUsed
方法来监控消息对象的大小,并在它们变得太大时删除它们。 - 你的系统的内存分配器可能没有为从多个线程分配大量小对象进行很好的优化。请尝试使用 Google 的 TCMalloc。
高级用法
Protocol Buffers 的用途超出了简单的访问器和序列化。请务必探索 C++ API 参考,看看你还能用它们做什么。
协议消息类提供的一个关键特性是*反射*。你可以遍历消息的字段并操纵它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一个非常有用的方法是将协议消息与其他编码(如 XML 或 JSON)进行相互转换。反射的更高级用法可能是找出同一类型的两个消息之间的差异,或者开发一种“协议消息的正则表达式”,在其中你可以编写匹配特定消息内容的表达式。如果你发挥想象力,可以将 Protocol Buffers 应用于比你最初预期的更广泛的问题范围!
反射由 Message::Reflection
接口提供。