Protocol Buffer 基础知识:C++
本教程为 C++ 程序员提供了 Protocol Buffers 入门基础介绍。通过逐步创建一个简单的示例应用程序,它向您展示如何:
- 在
.proto
文件中定义消息格式。 - 使用 protocol buffer 编译器。
- 使用 C++ protocol buffer API 编写和读取消息。
这并非 Protocol Buffers 在 C++ 中使用的全面指南。如需更详细的参考信息,请参阅Protocol Buffer 语言指南 (proto2)、Protocol Buffer 语言指南 (proto3)、C++ API 参考、C++ 生成代码指南和编码参考。
问题域
我们将使用的示例是一个非常简单的“地址簿”应用程序,它可以将联系人详细信息读写到文件中。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
如何序列化和检索这种结构化数据?有几种方法可以解决这个问题:
- 原始的内存数据结构可以以二进制形式发送/保存。随着时间的推移,这是一种脆弱的方法,因为接收/读取代码必须使用完全相同的内存布局、字节序等进行编译。此外,随着文件以原始格式累积数据以及为该格式编写的软件副本散布开来,扩展格式变得非常困难。
- 您可以发明一种临时方法将数据项编码成单个字符串——例如将 4 个 int 编码为“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
文件中的定义很简单:您为每个要序列化的数据结构添加一个消息,然后为消息中的每个字段指定名称和类型。以下是定义您的消息的 .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
所做的那样。否则,将使用系统默认值:数字类型为零,字符串为空字符串,bool 类型为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其中所有字段都未设置。调用访问器以获取未显式设置的可选(或必需)字段的值,始终返回该字段的默认值。repeated
(重复):字段可以重复任意次数(包括零次)。重复值的顺序将在 protocol buffer 中保留。可以将重复字段视为动态大小的数组。required
(必需):必须提供字段的值,否则消息将被视为“未初始化”。如果libprotobuf
在调试模式下编译,序列化未初始化的消息将导致断言失败。在优化构建中,会跳过检查,并且仍然会写入消息。但是,解析未初始化的消息总是会失败(解析方法返回false
)。除此之外,必需字段的行为与可选字段完全相同。
重要提示
必需字段是永久的 您应该非常小心地将字段标记为required
。如果在某个时刻您希望停止编写或发送某个必需字段,将其更改为可选字段会带来问题——旧的读取器会将没有此字段的消息视为不完整,并可能无意中拒绝或丢弃它们。您应该考虑为您的缓冲区编写特定于应用程序的自定义验证例程来代替。在 Google 内部,强烈不鼓励使用 required
字段;proto2 语法中定义的大多数消息仅使用 optional
和 repeated
。(Proto3 完全不支持 required
字段。)您可以在Protocol Buffer 语言指南中找到编写 .proto
文件的完整指南——包括所有可能的字段类型。但是,请不要寻找类似于类继承的设施——protocol buffers 不支持这些。
编译 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_
方法,只允许您传入新值)。
有关 protocol 编译器为任何特定字段定义生成的成员的更详细信息,请参阅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
文件的设计(例如,您正在重用来自另一个项目的 .proto
文件),包装 protocol buffers 也是个好主意。在这种情况下,您可以使用包装类来设计更适合应用程序独特环境的接口:隐藏一些数据和方法,公开便利函数等。您绝不应通过继承生成类来向其添加行为。这会破坏内部机制,而且无论如何也不是好的面向对象实践。编写消息
现在让我们尝试使用您的 protocol buffer 类。您的地址簿应用程序希望首先能够将个人详细信息写入地址簿文件。为此,您需要创建和填充您的 protocol buffer 类的实例,然后将它们写入输出流。
这是一个程序,它从文件中读取一个 AddressBook
,根据用户输入向其中添加一个新的 Person
,然后将新的 AddressBook
再次写回到文件中。直接调用或引用由 protocol 编译器生成的代码的部分已突出显示。
#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 Buffers 清理所有内容。
读取消息
当然,如果您无法从中获取任何信息,地址簿就不会有什么用处!此示例读取由上述示例创建的文件,并打印其中的所有信息。
#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 参考,看看您还可以用它们做什么。
protocol 消息类提供的一个关键功能是反射。您可以迭代消息的字段并操作它们的值,而无需针对任何特定消息类型编写代码。反射的一个非常有用的用途是将 protocol 消息与其他编码(例如 XML 或 JSON)相互转换。反射的一种更高级用法可能是查找相同类型的两个消息之间的差异,或者开发一种“protocol 消息的正则表达式”,您可以在其中编写匹配某些消息内容的表达式。如果您发挥想象力,可以将 Protocol Buffers 应用于比您最初预期的更广泛的问题范围!
反射由Message::Reflection
接口提供。