Protocol Buffer 基础知识:C++

为C++程序员提供的Protocol Buffers基础介绍。

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

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

这不是C++中使用Protocol Buffers的全面指南。有关更详细的参考信息,请参阅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"声明,并提供了一种更灵活的方式来随时间演进语言。

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

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

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

字段可以是以下之一:

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

  • repeated:该字段可以重复任意次数(包括零次)。重复值的顺序将保持不变。将重复字段视为动态大小的数组。

在旧版本的protobuf中,存在一个required关键字,但它已被证明是脆弱的,并且在现代protobuf中不受支持(尽管版本确实有一个功能可以启用它,用于向后兼容)。

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

编译你的 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` 字段只有上面描述的基本访问器集,但 `name` 和 `email` 字段有几个额外的(因为它们是字符串):一个 `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++ 协议缓冲区类共享的 `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 (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 中:

  • 不得更改任何现有字段的字段编号。
  • 可以删除单一或重复字段。
  • 可以添加新的单一或重复字段,但必须使用新的字段编号(也就是说,在这个协议缓冲区中从未被使用过的字段编号,即使是被删除的字段也一样)。

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

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

优化技巧

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

  • 使用 Arena 进行内存分配。当您在短期操作中(例如解析单个请求)创建许多 Protocol Buffer 消息时,系统的内存分配器可能会成为瓶颈。Arenas 的设计就是为了缓解这个问题。通过使用 Arena,您可以以低开销执行多次分配,并一次性释放所有这些分配。这可以显著提高消息密集型应用程序的性能。

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

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

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

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

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

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

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

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

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

高级用法

Protocol Buffers 的用途远不止简单的访问器和序列化。请务必探索C++ API 参考,看看您还可以用它们做些什么。

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

反射通过Message::Reflection接口提供。