Protocol Buffer 基础知识:C++

C++ 程序员使用 Protocol Buffers 的基本入门介绍。

本教程为 C++ 程序员提供了使用 Protocol Buffers 的基本入门介绍。通过创建一个简单的示例应用程序,它向您展示如何

  • .proto 文件中定义消息格式。
  • 使用 protocol buffer 编译器。
  • 使用 C++ protocol buffer API 写入和读取消息。

这不是关于在 C++ 中使用 protocol buffers 的综合指南。有关更详细的参考信息,请参阅 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 文件中的定义很简单:您为您要序列化的每个数据结构添加一个消息,然后为消息中的每个字段指定名称和类型。这是定义您的消息的 .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++ 中,您生成的类将放置在与包名称匹配的命名空间中。

接下来,您有消息定义。消息只是包含一组类型化字段的聚合。许多标准的简单数据类型可用作字段类型,包括 boolint32floatdoublestring。您还可以通过使用其他消息类型作为字段类型来向消息添加更多结构——在上面的示例中,Person 消息包含 PhoneNumber 消息,而 AddressBook 消息包含 Person 消息。您甚至可以定义嵌套在其他消息中的消息类型——如您所见,PhoneNumber 类型是在 Person 内部定义的。如果您希望您的字段之一具有预定义的值列表之一,您还可以定义 enum 类型——在这里,您希望指定电话号码可以是以下电话类型之一:PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK

每个元素上的“ = 1”、“ = 2”标记标识该字段在二进制编码中使用的唯一字段编号。字段编号 1-15 比更高的编号需要少一个字节来编码,因此作为一种优化,您可以决定将这些编号用于常用或重复的元素,将字段编号 16 和更高的编号留给不太常用的可选元素。重复字段中的每个元素都需要重新编码字段编号,因此重复字段特别适合这种优化。

每个字段都必须使用以下修饰符之一进行注释

  • optional:该字段可能已设置也可能未设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以指定自己的默认值,就像我们在示例中为电话号码 type 所做的那样。否则,将使用系统默认值:数字类型为零,字符串为空字符串,布尔值为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,它没有设置任何字段。调用访问器以获取尚未显式设置的可选(或必需)字段的值始终返回该字段的默认值。
  • repeated:该字段可以重复任意次数(包括零次)。重复值的顺序将在 protocol buffer 中保留。将重复字段视为动态大小的数组。
  • required:必须提供字段的值,否则消息将被视为“未初始化”。如果 libprotobuf 在调试模式下编译,则序列化未初始化的消息将导致断言失败。在优化的构建中,将跳过检查,并且仍将写入消息。但是,解析未初始化的消息始终会失败(通过从解析方法返回 false)。除此之外,必需字段的行为与可选字段完全相同。

您将在 Protocol Buffer 语言指南 中找到编写 .proto 文件的完整指南——包括所有可能的字段类型。不过,不要寻找类似于类继承的工具——protocol buffers 不会这样做。

编译您的 Protocol Buffers

现在您有了一个 .proto,接下来您需要做的是生成读取和写入 AddressBook(因此也包括 PersonPhoneNumber)消息所需的类。为此,您需要在您的 .proto 上运行 protocol buffer 编译器 protoc

  1. 如果您尚未安装编译器,请下载软件包并按照 README 中的说明进行操作。

  2. 现在运行编译器,指定源目录(您的应用程序源代码所在的位置——如果您不提供值,则使用当前目录)、目标目录(您希望生成的代码去往的位置;通常与 $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 类,您可以看到编译器为每个字段生成了访问器。例如,对于 nameidemailphones 字段,您有以下方法

  // 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 字段只有上面描述的基本访问器集,但 nameemail 字段有一些额外的方法,因为它们是字符串——一个 mutable_ getter,允许您获取指向字符串的直接指针,以及一个额外的 setter。请注意,即使尚未设置 email,您也可以调用 mutable_email();它将被自动初始化为空字符串。如果您在此示例中有一个重复的消息字段,它也将具有一个 mutable_ 方法,但没有 set_ 方法。

重复字段也有一些特殊方法——如果您查看重复的 phones 字段的方法,您会看到您可以

  • 检查重复字段的 _size(换句话说,与此 Person 关联的电话号码数量)。
  • 使用其索引获取指定的电话号码。
  • 更新指定索引处的现有电话号码。
  • 向消息添加另一个电话号码,然后您可以编辑该号码(重复的标量类型有一个 add_,它只允许您传入新值)。

有关协议编译器为任何特定字段定义生成的成员的更多信息,请参阅 C++ 生成的代码参考

枚举和嵌套类

生成的代码包含一个 PhoneType 枚举,它对应于您的 .proto 枚举。您可以将此类型称为 Person::PhoneType,将其值称为 Person::PHONE_TYPE_MOBILEPerson::PHONE_TYPE_HOMEPerson::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 类的实例,然后将它们写入输出流。

这是一个程序,它从文件中读取 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 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 参考,以了解您还可以使用它们做什么。

协议消息类提供的一个关键特性是反射。您可以迭代消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一种非常有用的方法是将协议消息转换为其他编码格式,例如 XML 或 JSON。反射更高级的用途可能是查找同一类型的两条消息之间的差异,或者开发一种“协议消息的正则表达式”,您可以在其中编写与某些消息内容匹配的表达式。如果您发挥想象力,可以将 Protocol Buffers 应用于比您最初预期的更广泛的问题!

反射由 Message::Reflection 接口提供。