Protobuf使用基础教程(C++)

本文是一篇译文,原文地址为:https://developers.google.com/protocol-buffers/docs/cpptutorial

Protocol Buffer Basics: C++

本教程为 C++ 程序员如何使用 protocol buffers 做一个基本介绍。通过创建一个简单的示例应用程序,我们可以学会:

  • 如何在一个 .proto 文件中定义 message
  • 如何使用 protocol buffer 编译器
  • 如何使用 C++ protocol buffer 的 API 读写 message

这不是一篇 protocol 的综合性的C++教程。如果想获取更详细的参考信息,请参阅我的另两篇博文 Protobuf语法指南(proto2) 、Protobuf语法指南(proto3) 以及 C++ API、C++ 生成代码 和 Protocol Buffer 编码。

为什么需要 Protocol Buffers?

我们将要使用的示例是一个非常简单的 “地址簿” 应用程序,可以在文件中读写联系人的详细信息。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话。

你该如何序列化和反序列化如上结构的数据呢?这里有几种解决方案:

  • 可以以二进制形式发送/保存原始内存中数据结构。随着时间的推移,这是一种脆弱的方法,因为接收/读取代码必须使用完全相同的内存布局、字节顺序等进行编译。此外,由于文件以原始格式累积数据,并且解析该格式的代码分散各处,因此很难扩展格式。
  • 你可以发明一种特殊的方法将数据项编码为单个字符串 - 例如将 4 个整数编码为 “12:3:-23:67”。这是一种简单而灵活的方法,虽然它确实需要编写一次性编码和解析的代码,并且解析会产生一些小的运行时成本。但这非常适合非常简单的数据的编码。
  • 将数据序列化为 XML。这种方法非常有吸引力,因为 XML(差不多)是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,众所周知 XML 需要更多的空间,并且编码/解码 XML 会对应用程序造成巨大的性能损失。此外,解析 XML DOM 树通常比解析类中的简单字段要复杂得多。

而 Protocol buffers 是灵活,高效,自动化的解决方案。采用 protocol buffers,你可以写一个 .proto 文件描述你想要读取的数据的结构。由此, protocol buffer 编译器将创建一个类,该类使用有效的二进制格式实现 protocol buffer 数据的自动编码和解析。生成的类为构成 protocol buffer 的字段提供 getter 和 setter,并负责读写 protocol buffer 单元。更重要的是,protocol buffer 支持格式扩展,使得代码仍然可以读取用旧格式编码的数据。

示例代码

示例代码包含在源代码包中的 “examples” 目录下。

定义protocol 格式

要创建地址簿应用程序,你需要从 .proto 文件开始。.proto 文件中的定义很简单:为要序列化的每个数据结构添加 message 定义,然后为 message 中的每个字段指定名称和类型。下面就是定义相关 message 的 addressbook.proto 文件 。

syntax = "proto2";

package tutorial;

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

可以看出,其语法类似于 C++ 或 Java。让我们浏览文件的每个部分,看看它们的作用。

.proto 文件以 package 声明开头,这有助于解决不同项目之间的命名冲突。在 C++ 中,生成的类将放在与package 的名字匹配的 namespace 中。

接下来,你将看到相关的 message 定义。message 只是包含一组类型字段的集合。许多标准的简单数据类型都可用作字段类型,包括 bool、int32、float、double 和 string。你还可以使用其他 message 类型作为字段类型在消息中添加更多结构 - 在上面的示例中,Person 包含 PhoneNumber message ,而 AddressBook 包含 Person message。你甚至可以定义嵌套在其他 message 中的 message 类型 -​​ 如你所见,PhoneNumber 类型在 Person 中定义。如果你希望其中一个字段具有预定义值列表中的值,你还可以定义枚举类型 - 此处我们指定PhoneType,它的值可以是 MOBILE,HOME 或 WORK 之一。

每个元素中的 “=1”,"=2" 表示该字段在二进制编码中使用的唯一 “标识”。[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。repeated 字段中的每个(string等类型)元素都需要重新编码 Tag,因此 repeated 字段特别适合使用此优化。

必须使用以下修饰符之一修饰每个字段:

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

required是永久性的:在将一个字段标识为required的时候,应该特别小心。如果在某些情况下不想写入或者发送一个required的字段,将原始该字段修饰符更改为optional可能会遇到问题——旧版本的使用者会认为不含该字段的消息是不完整的,从而可能会无目的的拒绝解析。在这种情况下,你应该考虑编写特别针对于应用程序的、自定义的消息校验函数。Google的一些工程师得出了一个结论:使用required弊多于利;他们更 愿意使用optional和repeated而不是required。当然,这个观点并不具有普遍性。

编译Protocol Buffers

既然你已经有了一个 .proto 文件,那么你需要做的下一件事就是生成你需要读写AddressBook(以及 Person 和 PhoneNumber ) message 所需的类。为此,你需要在 .proto 上运行 protocol buffer 编译器 protoc:

如果尚未安装编译器,请 下载 protocol buffer的源码 并按照 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:类实现

The Protocol Buffer API

让我们看看一些生成的代码,看看编译器为你创建了哪些类和函数。如果你查看 addressbook.pb.h,你会发现你在 addressbook.proto 中指定的每条 message 都有一个对应的类。仔细观察 Person 类,你可以看到编译器已为每个字段生成了访问器。例如,对于 name ,id,email 和 phone 字段,你可以使用以下方法:

  // 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_ 开头。每个singular(required 或 optional)字段也有 has_ 方法,如果设置了该字段,则返回 true。最后,每个字段都有一个 clear_ 方法,可以将字段重新设置回 empty 状态。

虽然数字 id 字段只有上面描述的基本访问器集,但是 name 和 email 字段因为是字符串所以有几个额外的方法:一个 mutable_ 的 getter,它允许你获得一个指向字符串的指针,以及一个额外的 setter。请注意,即使尚未设置 email ,也可以调用 mutable_email(), 它将自动初始化为空字符串。如果在这个例子中你有一个singular的 message 字段,它也会有一个 mutable_ 方法,但没有 set_ 方法。

repeated 字段也有一些特殊的方法 - 如果你看一下 repeated phones 字段的相关方法,你会发现你可以:

  • 检查 repeated 字段长度(与此人关联的电话号码数)
  • 使用索引获取指定的电话号码
  • 更新指定索引处的现有电话号码
  • 在 message 中添加另一个电话号码(repeated 的标量类型有一个 add_方法,允许你传入新值)

枚举和嵌套类

生成的代码包含 PhoneType 类型的枚举类型,以Person::PhoneType的形式来访问该类型,其值为 Person::MOBILE,Person::HOME 和 Person::WORK(实现细节稍微复杂一些,但你如果仅仅只是使用不需要理解里面的实现原理)。

编译器还为你生成了一个名为 Person::PhoneNumber 的嵌套类。如果查看代码,可以看到该类实际上被表示为 Person_PhoneNumber,但在 Person 中定义的 typedef 允许你将其视为嵌套类。唯一会造成一点差异的情况是,如果你想在另一个文件中前向声明该类 - 你不能在 C ++ 中前向声明嵌套类型,但你可以前向声明 Person_PhoneNumber。

标准 Message 方法

每个 message 类还包含许多其他方法,可用于检查或操作整个 message,包括:

  • bool IsInitialized() const;: 检查是否所有 required 字段已被初始化
  • string DebugString() const;: 返回 message 的人类可读的表达形式,对调试特别有用
  • void CopyFrom(const Person& from);: 用给定的 message 的值覆盖 message
  • void Clear();: 将所有元素清除回 empty 状态

这些和下一节中描述的 I/O 方法实现了所有 C++ protocol buffer 类共享的 Message 接口。更多的更详细的有关信息,请参阅 Message 的 API 文档。

解析和序列化

最后,每个 protocol buffer 类都有提供通过使用 protocol buffer 的二进制格式来读写message 的方法。包括:

  • bool SerializeToString(string* output) const;:序列化消息并将二进制字节存储在给定的字符串中。请注意,字节是二进制的,而不是文本;我们只使用 string 类作为容器。
  • bool ParseFromString(const string& data);: 将给定字符串解析为message
  • bool SerializeToOstream(ostream* output) const;: 将 message 写入给定的 C++ 的 ostream
  • bool ParseFromIstream(istream* input);: 将给定 C++ istream 解析为message

这些只是解析和序列化提供的几个选项。请参阅 Message 的 API 文档。

Protocol Buffers 和 OO 设计的 Protocol Buffers 类基本上是 dumb data 持有者(如 C 中的结构); 他们没有在对象模型中成为优秀的一等公民。如果要为生成的类添加更丰富的行为,最好的方法是将生成的 Protocol Buffers 类包装在特定于应用程序的类中。如果你无法控制 .proto 文件的设计(如果你正在重用另一个项目中的一个),那么包装 Protocol Buffers 的类也是一个好主意。在这种情况下,你可以使用包装器类来创建更适合应用程序的独特环境的接口:隐藏一些数据和方法,公开便利功能等。永远不应该通过继承它们来为生成的类添加行为。这将打破内部机制,无论如何这都不是良好的面向对象的实践。(可以使用组合代替继承)

写一个Message

现在让我们尝试使用 Protocol Buffer 类。你希望地址簿应用程序能够做的第一件事可能是将个人详细信息写入的地址簿文件。为此,你需要创建并填充 Protocol Buffer 类的实例,然后将它们写入输出流。

这是一个从文件中读取 AddressBook 的程序,根据用户输入向其添加一个新 Person,并将新的 AddressBook 重新写回文件。其中直接调用或引用 protocol 编译器生成的代码部分将高亮显示。

#include 
#include 
#include 
#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++ Protocol Buffer 库之前执行此宏是一种很好的做法 - 尽管不是绝对必要的。它验证你没有意外链接与你编译的头文件不兼容的库版本。如果检测到版本不匹配,程序将中止。请注意,每个 .pb.cc 文件在启动时都会自动调用此宏。

另请注意在程序结束时调用 ShutdownProtobufLibrary()。所有这一切都是删除 Protocol Buffer 库分配的所有全局对象。对于大多数程序来说这是不必要的,因为该过程无论如何都要退出,并且操作系统将负责回收其所有内存。但是,如果你使用了内存泄漏检查程序,该程序需要释放每个最后对象,或者你正在编写可以由单个进程多次加载和卸载的库,那么你可能希望强制使用 Protocol Buffers 来清理所有内容。

读取一个 Message

当然,如果你无法从中获取任何信息,那么地址簿就不会有多大用处!此示例读取上面示例创建的文件并打印其中的所有信息。

#include 
#include 
#include 
#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;
}

扩展一个 Protocol Buffer

在发布使用 protocol buffer 的代码之后,无疑早晚有一天你将会想要 “改进” protocol buffer 的定义。如果你希望你的新 buffer 向后兼容,并且你的旧 buffer 是向前兼容的(实际上你一定想要这种兼容性) - 那么你需要遵循一些规则。在新版本的 protocol buffer 中:

  • 你不得更改任何现有字段的字段编号
  • 你不得添加或删除任何 required 字段
  • 你可以删除 optional 或 repeated 的字段
  • 你可以添加新的 optional 或 repeated 字段,但必须使用新的标记号(即从未在此协议缓冲区中使用的编号,甚至包括那些已删除的字段的编号)

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

如果你遵循这些规则,旧代码将很乐意解析新消息并简单地忽略任何新字段。对于旧代码,已删除的可选字段将只具有其默认值,删除的重复字段将为空。新代码也将透明地读取旧消息。但是,请记住旧的 message 中不会出现新的可选字段,因此你需要明确通过调用 has_ 方法来检查它们是否被设置,或者在字段编号后面使用 [default = value] ,从而在 .proto 文件中提供合理的默认值。如果未为 optional 元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数字类型,默认值为零。另请注意,如果添加了新的 repeated 字段,则新代码将无法判断它是否为空(通过新代码)或从未设置(通过旧代码),因为它没有 has_ 标志。

优化技巧

C++ Protocol Buffers 已经做了极大优化。但是,正确使用可以进一步提高性能。

  • 尽可能重用 message 对象。message 会为了重用尝试保留它们分配的任何内存,即使它们被清除。因此,如果你连续处理许多具有相同类型和类似结构的message,则每次重新使用相同的 message 对象来加载内存分配器是个好主意。但是,随着时间的推移,对象会变得臃肿,特别是如果你的 message 在 “形状” 上有所不同,或者你偶尔构造的 message 比平时大得多。你应该通过调用 SpaceUsed 方法来监控邮件对象的大小,一旦它们变得太大就删除它们。
  • 你的系统内存分配器可能没有针对从多个线程分配大量小对象这种情况进行良好优化。请尝试使用 Google 的 tcmalloc。

高级用法

Protocol buffers 的用途不仅仅是简单的访问器和序列化。请务必浏览 C++ API,以了解你可以使用它们做些什么。

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

反射功能由 Message::Reflection 提供。

你可能感兴趣的:(Protobuf)