Protocol Buffer 基础:C++

一篇面向 C++ 程序员的 Protocol Buffer 入门介绍。

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

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

这不是一份在 C++ 中使用 Protocol Buffer 的综合指南。如需更详细的参考信息,请参阅 Protocol Buffer 语言指南C++ API 参考C++ 生成代码指南编码参考

问题领域

我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入人们的联系方式。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。

你如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题:

  • 原始的内存中数据结构可以以二进制形式发送/保存。长远来看,这是一种脆弱的方法,因为接收/读取代码必须使用完全相同的内存布局、字节序等进行编译。此外,随着文件以原始格式累积数据,以及为该格式定制的软件副本四处传播,扩展格式变得非常困难。
  • 您可以发明一种特殊的方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单而灵活的方法,但它需要编写一次性的编码和解析代码,并且解析会带来一些运行时成本。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML(某种程度上)是人类可读的,而且有许多语言的绑定库。如果您想与其他应用/项目共享数据,这可能是一个不错的选择。然而,XML 是出了名的占用空间,并且编码/解码它可能会给应用带来巨大的性能损失。此外,遍历 XML DOM 树比通常情况下遍历类中的简单字段要复杂得多。

除了这些选项,您还可以使用 Protocol Buffer。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 声明开始。Editions(版本)取代了旧的 syntax = "proto2"syntax = "proto3" 声明,并为语言的长期演进提供了更灵活的方式。

接下来是包声明(package declaration),这有助于防止不同项目之间的命名冲突。在 C++ 中,您生成的类将被放置在与包名匹配的命名空间中。

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

每个元素上的 " = 1"、" = 2" 标记标识了该字段在二进制编码中使用的唯一字段编号。字段编号 1-15 编码时比更高的编号少用一个字节,因此作为一种优化,您可以决定将这些编号用于常用或重复的元素,将字段编号 16 及以上留给不常用的元素。

字段可以是以下之一:

  • singular(单一):默认情况下,字段是可选的,意味着该字段可以设置也可以不设置。如果一个单一字段未设置,则使用特定于类型的默认值:数字类型为零,字符串为空字符串,布尔值为 false,枚举为第一个定义的枚举值(该值必须为 0)。请注意,您不能显式地将字段设置为 singular。这是对非重复字段的描述。

  • repeated:该字段可以重复任意次数(包括零次)。重复值的顺序将被保留。可以把 repeated 字段看作是动态大小的数组。

在旧版本的 protobuf 中,存在一个 required 关键字,但它被发现很脆弱,在现代 protobuf 中不再支持(尽管 editions 确实有一个功能可以启用它,以实现向后兼容)。

您可以在 Protocol Buffer 语言指南中找到编写 .proto 文件的完整指南——包括所有可能的字段类型。但不要去寻找类似类继承的功能——Protocol Buffer 不支持这个。

编译你的 Protocol Buffers

现在您有了一个 .proto 文件,接下来需要做的就是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,您需要在您的 .proto 文件上运行 Protocol Buffer 编译器 protoc

  1. 如果您还没有安装编译器,请按照Protocol Buffer 编译器安装中的说明进行操作。

  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
  bool has_name() const; // Only for explicit presence
  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();

如您所见,getter 的名称与字段的小写名称完全相同,setter 方法以 set_ 开头。对于具有显式存在跟踪的单一字段,还有 has_ 方法,如果该字段已设置,则返回 true。最后,每个字段都有一个 clear_ 方法,将字段恢复到其默认状态。

虽然数字类型的 id 字段只有上面描述的基本访问器集,但 nameemail 字段因为是字符串而有一些额外的方法——一个 mutable_ getter,让您获得一个指向字符串的直接指针,以及一个额外的 setter。请注意,即使 email 尚未设置,您也可以调用 mutable_email();它会自动初始化为空字符串。如果在此示例中有一个 repeated 消息字段,它也会有一个 mutable_ 方法,但没有 set_ 方法。

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

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

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

枚举和嵌套类

生成的代码包含一个与您的 .proto 枚举相对应的 PhoneType 枚举。您可以将此类型称为 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 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 (const tutorial::Person& person : address_book.people()) {
    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (!person.has_email()) {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (const tutorial::Person::PhoneNumber& phone_number : person.phones()) {
      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;
        case tutorial::Person::PHONE_TYPE_UNSPECIFIED:
        default:
          cout << "  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 中:

  • 绝不能更改任何现有字段的字段编号。
  • 可以删除 singular 或 repeated 字段。
  • 可以添加新的 singular 或 repeated 字段,但必须使用新的字段编号(即,此 Protocol Buffer 中从未使用过的字段编号,包括已删除的字段)。

(这些规则有一些例外,但很少使用。)

如果您遵循这些规则,旧代码将很乐意读取新消息,并简单地忽略任何新字段。对于旧代码,已删除的字段将仅具有其默认值,而已删除的 repeated 字段将为空。新代码也将透明地读取旧消息。但是,请记住,新字段不会出现在旧消息中,因此在使用前需要通过检查它们是否具有默认值(例如,空字符串)来检查它们是否存在。

优化技巧

C++ Protocol Buffers 库经过了极度优化。然而,正确的使用可以进一步提高性能。以下是一些从库中榨取最后一点速度的技巧:

  • 使用 Arena 进行内存分配。 当您在短期操作(如解析单个请求)中创建许多 Protocol Buffer 消息时,系统的内存分配器可能成为瓶颈。Arena 旨在缓解此问题。通过使用 arena,您可以用较低的开销执行多次分配,并在最后一次性全部释放。这可以显著提高消息密集型应用程序的性能。

    要使用 arena,您需要在 google::protobuf::Arena 对象上分配消息:

    google::protobuf::Arena arena;
    tutorial::Person* person = google::protobuf::Arena::Create<tutorial::Person>(&arena);
    // ... populate person ...
    

    当 arena 对象被销毁时,所有在其上分配的消息都会被释放。更多详情,请参阅 Arena 指南

  • 尽可能重用非 arena 的消息对象。 消息会尝试保留它们分配的任何内存以供重用,即使在它们被清除后也是如此。因此,如果您连续处理许多类型相同且结构相似的消息,最好每次都重用同一个消息对象,以减轻内存分配器的负担。然而,对象可能会随着时间的推移而变得臃肿,特别是如果您的消息“形状”各不相同,或者您偶尔构造一个比平常大得多的消息。您应该通过调用 SpaceUsed 方法来监控消息对象的大小,并在它们变得太大时删除它们。

    重用 arena 消息可能导致内存无限增长。重用堆消息更安全。然而,即使使用堆消息,您仍然可能遇到字段高水位线的问题。例如,如果您看到消息:

    a: [1, 2, 3, 4]
    b: [1]
    

    a: [1]
    b: [1, 2, 3, 4]
    

    并重用这些消息,那么这两个字段都将拥有足够容纳它们所见过的最大值的内存。因此,如果每个输入只有 5 个元素,重用的消息将拥有容纳 8 个元素的内存。

  • 您的系统的内存分配器可能没有为从多个线程分配大量小对象进行很好的优化。请尝试使用 Google 的 TCMalloc

高级用法

Protocol Buffer 的用途超出了简单的访问器和序列化。请务必探索 C++ API 参考,看看您还能用它们做什么。

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

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