本教程简要介绍了如何使用 C++ 程序员处理协议缓冲区。通过演示如何创建简单的示例应用,您可以了解如何
- 在
.proto
文件中定义消息格式。 - 使用协议缓冲区编译器。
- 使用 C++ 协议缓冲区 API 写入和读取消息。
这不是有关使用 C++ 中的协议缓冲区的全面指南。如需更详细的参考信息,请参阅协议缓冲区语言指南 (proto2)、协议缓冲区语言指南 (proto3)、C++ API 参考文档、C++ 生成的代码指南和编码参考。
问题网域
我们要使用的示例是一个非常简单的“地址簿”应用,该应用可以在文件中读取和写入联系人详细信息。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
如何序列化和检索此类结构化数据?解决此问题的方法有以下几种:
- 原始内存数据结构可以二进制形式发送/保存。随着时间的推移,这是一种易碎的方法,因为接收/读取代码必须使用完全相同的内存布局、字节顺序等进行编译。此外,随着文件以原始格式累积数据且为该格式连接的软件副本是分散的,因此很难扩展该格式。
- 您可以制定临时方式将数据项编码为单个字符串,例如将 4 个整数编码为“12:3:-23:67”。这是一种简单灵活的方法,但确实需要编写一次性编码和解析代码,而且解析过程会产生少量的运行时开销。这最适合编码非常简单的数据。
- 将数据序列化为 XML。此方法可能极具吸引力,因为 XML 有点通俗易懂,而且针对许多语言都有绑定库。如果您想与其他应用/项目共享数据,这是一个不错的选择。不过,众所周知,XML 需要占用大量空间,编码/解码可能会给应用带来巨大的性能损失。此外,导航 XML DOM 树比在类中浏览简单字段要复杂得多。
您可以使用协议缓冲区来代替这些选项。协议缓冲区是一种灵活、高效、自动化的解决方案,正好能够解决这一问题。使用协议缓冲区,您可以为要存储的数据结构编写 .proto
说明。然后,协议缓冲区编译器会创建一个类,以实现高效的二进制格式,自动实现和解析协议缓冲区数据。生成的类会为构成协议缓冲区的字段提供 getter 和 setter,并负责以协议形式读取和写入协议缓冲区的详细信息。重要的是,协议缓冲区格式支持逐步扩展该格式,以使代码仍然能够读取使用旧格式编码的数据。
在哪里可以找到示例代码
示例代码包含在源代码包中的“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 { MOBILE = 0; HOME = 1; WORK = 2; } message PhoneNumber { optional string number = 1; optional PhoneType type = 2 [default = 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
类型。在这里,您希望指定电话号码可以是以下电话号码类型之一:MOBILE
、HOME
或 WORK
。
每个元素上的“ = 1”和“ = 2”标记用于标识该字段在二进制编码中使用的“标记”。与编号相比,编号 1-15 需要的字节数要少一个,因此为了进行优化,您可以决定将这些标签用于常用元素或重复元素,将标记 16 及更高版本用于不太常用的可选元素。重复字段中的每个元素都需要对代码重新编码,因此重复字段特别适合此优化。
每个字段都必须使用以下修饰符之一进行注解:
optional
:字段不一定会设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以自行指定默认值,就像我们在示例中的电话号码type
所做的那样。否则,使用系统默认值:对于数值类型,使用零;对于字符串,使用空字符串;对于布尔值,则返回 false。对于嵌入消息,默认值始终是消息的“默认实例”或“原型”,未设置任何字段。调用访问器以获取尚未明确设置的可选(或必需)字段的值,始终返回该字段的默认值。repeated
:该字段可以重复任意次数(包括零)。重复值的顺序将在协议缓冲区中保留。将重复字段视为动态大小的数组。required
:必须提供此字段的值,否则系统会将消息视为“未初始化”。 如果在调试模式下编译libprotobuf
,则序列化未初始化的消息将导致断言失败。在优化 build 中,系统会跳过检查,并且仍将写入消息。不过,解析未初始化的消息将始终失败(通过从解析方法返回false
)。 除此之外,必填字段的行为方式与可选字段完全相同。
必需是永久状态
将字段标记为 required
时应格外小心。在某些情况下,如果您想停止写入或发送必填字段,那么将此字段更改为选填字段会很麻烦。旧读者会认为不包含此字段的消息不完整,并无意间拒绝或丢弃它们。您应考虑为缓冲区编写特定于应用的自定义验证例程。在 Google 中,required
字段被强烈废弃;proto2 语法中定义的大多数消息仅使用 optional
和 repeated
。(Proto3 完全不支持 required
字段。)
在协议缓冲区语言指南中,您可以找到编写 .proto
文件的完整指南,包括所有可能的字段类型。但不要寻找与类继承类似的工具:协议缓冲区不会这样做。
编译协议缓冲区
现在,您已经有了 .proto
,接下来需要生成生成 AddressBook
(以及 Person
和 PhoneNumber
)消息所需的类。为此,您需要在 .proto
上运行协议缓冲区编译器 protoc
:
- 如果您尚未安装编译器,请下载“protoc”软件包,然后按照自述文件中的说明操作。
- 现在运行编译器,指定源代码目录(应用的源代码所在的位置 - 如果您不提供值,则使用当前目录)、目标目录(您希望生成的代码所在的位置;通常与
$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(可让您获取指向字符串的直接指针)和 extra setter。请注意,即使 email
尚未设置,您也可以调用 mutable_email()
;它会自动初始化为空字符串。在此示例中,如果您有重复的消息字段,则它还会包含 mutable_
方法,但没有 set_
方法。
重复字段还有一些特殊方法 - 如果您查看重复 phones
字段的方法,就会发现
- 检查重复字段的
_size
(即与此Person
关联的电话号码数量)。 - 使用其索引获取指定的电话号码。
- 更新指定索引处的现有电话号码。
- 向消息中添加另一个电话号码,您随后可以对其进行修改(重复的标量类型具有
add_
,它仅允许您传入新值)。
如需详细了解协议编译器究竟针对哪些特定字段定义会生成哪些成员,请参阅 C++ 生成的代码参考。
枚举和嵌套类
生成的代码包含与 .proto
枚举对应的 PhoneType
枚举。您可以将此类型称为 Person::PhoneType
,并将其值称为 Person::MOBILE
、Person::HOME
和 Person::WORK
(实现详情要稍微复杂一些,但您无需了解它们才能使用枚举)。
编译器还为您生成了名为 Person::PhoneNumber
的嵌套类。如果您查看代码,会发现“实际”类实际上被称为 Person_PhoneNumber
,但 Person
中定义的类型定义可让您将其视为一个嵌套类。唯一一种不同的情况是,如果您要在另一个文件中前向声明该类,则无法使用 C++ 向前声明嵌套类型,但可以向前声明 Person_PhoneNumber
。
标准消息方法
每个消息类还包含多种其他方法,可让您检查或操纵整条消息,包括:
bool IsInitialized() const;
:检查是否已设置所有必填字段。string DebugString() const;
:返回人类可读的消息表示形式,对调试特别有用。void CopyFrom(const Person& from);
:使用指定消息的值覆盖消息。void Clear();
:将所有元素清除回空状态。
这些以及下文所述的 I/O 方法会实现所有 C++ 协议缓冲区类共享的 Message
接口。如需了解详情,请参阅 Message
的完整 API 文档。
解析和序列化
最后,每个协议缓冲区类都有使用协议缓冲区二进制格式写入和读取所选类型消息的方法。其中包括:
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 参考文档。
协议缓冲区和面向对象的设计:协议缓冲区类基本上是不提供其他功能的数据存储器(如 C 中的结构体);它们并不能成为对象模型中的优秀一等公民。如果要向生成的类添加更丰富的行为,最好的方法是将生成的协议缓冲区类封装在一个应用专用的类中。如果您无法控制 .proto
文件的设计,(例如,您在重复使用另一个项目中的一个文件)时,也非常适合封装协议缓冲区。在这种情况下,您可以使用封装容器类来打造更适合应用独特环境的接口:隐藏某些数据和方法、公开便捷函数等。切勿通过继承生成的类向生成的类添加行为。这会破坏内部机制,无论如何都不是良好的对象导向做法。
撰写消息
现在,让我们尝试使用您的协议缓冲区类。您希望地址簿应用能够执行的第一项操作是将个人详细信息写入地址簿文件。为此,您需要创建并填充协议缓冲区类的实例,然后将其写入输出流。
以下程序会从文件中读取 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::MOBILE); } else if (type == "home") { phone_number->set_type(tutorial::Person::HOME); } else if (type == "work") { phone_number->set_type(tutorial::Person::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++ 协议缓冲区库,但并非必须严格执行此宏。它会验证您是否意外链接到了与您编译的头文件版本不兼容的库版本。如果检测到版本不一致,程序将会中止。请注意,每个 .pb.cc
文件都会在启动时自动调用此宏。
另请注意,在项目结束时对 ShutdownProtobufLibrary()
的调用。该操作只是删除协议缓冲区库分配的所有全局对象。对于大多数程序来说,这并不必要,因为进程无论如何都会退出,而操作系统将收回其所有内存。但是,如果您使用需要释放最后一个对象的内存泄漏检查器,或者您编写的库可能通过单个进程多次加载和卸载,则可能需要强制协议缓冲区清除所有内容。
阅读邮件
当然,如果您无法从中获取任何信息,通讯簿就没什么用了!本示例会读取上述示例创建的文件,并输出其中的所有信息。
#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::MOBILE: cout << " Mobile phone #: "; break; case tutorial::Person::HOME: cout << " Home phone #: "; break; case tutorial::Person::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; }
扩展协议缓冲区
不久之后,在发布使用协议缓冲区的代码后,您肯定会想“改进”协议缓冲区的定义。如果您希望新缓冲区可向后兼容,而旧缓冲区为向前兼容,并且您几乎肯定希望这样做,则需要遵循一些规则。在新版本的协议缓冲区中:
- 您不得更改任何现有字段的代码编号。
- 您不得添加或删除任何必填字段。
- 您可以删除选填字段或重复字段。
- 您可以添加新的可选字段或重复的字段,但您必须使用新的代码编号(即,从未在此协议缓冲区中使用的标签编号,即使已删除的字段也不例外)。
(这些规则有一些例外情况,但很少使用)。
如果您遵守这些规则,旧代码会欣然接受新消息,并直接忽略任何新字段。对于旧代码,已删除的可选字段将只有默认值,而已删除的重复字段将为空。新代码还会透明地读取旧消息。不过请注意,新的可选字段不会出现在旧消息中,因此您需要明确检查其是否设置为 has_
,或在 .proto
文件中的标记编号后添加 [default = value]
作为默认值。如果没有为可选元素指定默认值,则使用类型专用默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数值类型,默认值为零。另请注意,如果您添加了新的重复字段,则新代码将无法判断是将其留空(由新代码设置)还是从不设置(通过旧代码),因为它没有 has_
标志。
优化技巧
C++ 协议缓冲区库已得到极大优化。不过,适当使用可能会进一步提升性能。下面提供了一些提示,可帮助您从库中提取出最后的速度:
- 尽可能重复使用消息对象。消息会尝试保留分配给它们以供重复使用的任何内存,即使被清除也是如此。因此,如果您要连续处理许多相同类型和相似结构的消息,那么最好每次都重复使用相同的消息对象来移除内存分配器。不过,对象可能会随着时间推移而变得膨胀,特别是当您的邮件在形状上有所不同或者您偶尔构建了一个比平常大得多的邮件时。您应该通过调用
SpaceUsed
方法监控消息对象的大小,并在其变得过大时将其删除。 - 您系统的内存分配器可能没有针对从多个线程分配大量小对象进行优化。请尝试改用 Google 的 tcmalloc。
高级用法
协议缓冲区的用途并不仅限于简单的访问器和序列化。请务必浏览 C++ API 参考文档,了解您还可以利用它们做些什么。
协议消息类提供的一项关键功能是反射。您可以遍历消息字段并操纵其值,而无需针对任何特定消息类型编写代码。反射的一种使用方式是,在 XML 或 JSON 等其他编码之间来回传输协议消息。反射的高级用法可能是发现两类同类型消息之间的差异,或者开发某种“协议消息的正则表达式”,您可以编写与特定消息内容匹配的表达式。您可以尽情发挥想象力,将协议缓冲区应用到您最初可能想象的更广泛范围的问题。
反射由 Message::Reflection
接口提供。