ホームページ  >  記事  >  バックエンド開発  >  C++ プログラマーのためのプロトコル バッファーの基本ガイド

C++ プログラマーのためのプロトコル バッファーの基本ガイド

高洛峰
高洛峰オリジナル
2016-11-08 10:02:331916ブラウズ

このチュートリアルでは、C++ プログラマー向けにプロトコル バッファーの基本的な概要を説明します。簡単なサンプル アプリケーションを作成すると、次のことがわかります。

.proto ファイルでのメッセージ形式の定義

プロトコル バッファー コンパイラーの使用

C++ プロトコル バッファー API を使用したメッセージの読み取りと書き込み

これは書き込みに関する質問ではありませんC++ プロトコル バッファーの使用に関する包括的なガイド。詳細については、「プロトコル バッファ言語ガイドおよびエンコーディング リファレンス」を参照してください。

プロトコル バッファーを使用する理由

次に使用する例は、ファイルから連絡先の詳細を読み取る非常に単純な「アドレス帳」アプリケーションです。アドレス帳内の各個人には、名前、ID、電子メール アドレス、連絡先番号があります。

構造化データをシリアル化して取得する方法は次のとおりです:

ネイティブのメモリ内データ構造をバイナリ形式で送受信します。一般に、受信/読み取りコードはメモリ レイアウト、ビッグ エンディアンとスモール エンディアンなどのまったく同じ環境に対してコンパイルする必要があるため、これは脆弱なアプローチです。同時に、ファイルが増加すると、その形式に関連するソフトウェアとともに独自形式のデータも急速に増殖し、ファイル形式の拡張が困難になります。

データ項目を文字列としてエンコードするアドホック メソッドを作成できます。たとえば、4 つの整数を 12:3:-23:67 としてエンコードできます。エンコードとデコードのコードを 1 回だけ記述する必要があり、デコードの実行時間コストはわずかですが、シンプルで柔軟な方法です。これは、非常に単純なデータをエンコードするのに最適です。

データを XML にシリアル化します。 XML は人間が読める形式であり、多くの言語用に開発されたライブラリがあるため、このアプローチは非常に魅力的です。これは、他のプログラムやプロジェクトとデータを共有したい場合に適したオプションです。ただし、XML はスペースを大量に消費し、エンコードおよびデコード時にプログラムのパフォーマンスに多大な影響を与えることが知られています。同時に、XML DOM ツリーの操作は、クラスの単純なフィールドを操作するよりも複雑であると考えられています。

プロトコル バッファーは、この問題に対する柔軟で効率的な自動化されたソリューションです。プロトコル バッファーを使用するには、保存するデータ構造を説明する .proto 仕様を作成する必要があります。プロトコル バッファー コンパイラーは、.proto ファイルを使用して、効率的なバイナリ形式でプロトコル バッファー データのエンコードとデコードを自動化するクラスを作成できます。生成されたクラスは、プロトコル バッファーのフィールドを構築するためのゲッターとセッターを提供し、ユニットとしてプロトコル バッファーの読み取りと書き込みの詳細を処理します。重要なのは、プロトコル バッファー形式は形式の拡張をサポートしており、コードは古い形式でエンコードされたデータを読み取ることができることです。

サンプル コードの場所

サンプル コードは、「examples」フォルダーにあるソース コード パッケージに含まれています。コードはここからダウンロードできます。

プロトコル形式を定義します

独自のアドレス帳アプリケーションを作成するには、.proto から始める必要があります。 .proto ファイルの定義は単純です。シリアル化する必要があるデータ構造ごとにメッセージを追加し、メッセージ内の各フィールドの名前と型を指定します。これは、メッセージを定義する .proto ファイル addressbook.proto です。

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 phone = 4; 
} 
message AddressBook { 
  repeated Person person = 1; 
}

ご覧のとおり、構文は C++ または Java に似ています。まず、ファイルの各部分が何を行うのかを見てみましょう。

.proto ファイルはパッケージ宣言で始まります。これにより、異なるプロジェクト間の名前の競合を回避できます。 C++ では、生成されたクラスはパッケージ名と同じ名前空間に配置されます。

次に、メッセージを定義する必要があります。メッセージは、あるタイプのフィールドの単なるコレクションです。 bool、int32、float、double、string など、ほとんどの標準的な単純なデータ型をフィールド型として使用できます。他のメッセージ タイプをフィールド タイプとして使用して、メッセージにさらにデータ構造を追加することもできます。上記の例では、Person メッセージには PhoneNumber メッセージが含まれ、AddressBook メッセージには Person メッセージが含まれています。他のメッセージ内にネストされたメッセージ タイプを定義することもできます。ご覧のとおり、PhoneNumber タイプは Person 内で定義されています。いずれかのフィールドの値を事前定義された値リストの値にしたい場合は、列挙型を定義することもできます。ここでは、電話番号が MOBILE、HOME、または WORK のいずれかであることを指定できます。

各要素の = 1、= 2 マークは、バイナリ エンコードに使用される一意の「タグ」を決定します。タグ番号 1 ~ 15 は、それより大きな番号よりもエンコードに必要なバイトが 1 バイト少ないため、最適化として、頻繁に使用される要素または繰り返し使用される要素にこれらのタグを使用し、頻繁に使用されない要素またはオプションの要素にはタグ 16 以上を残すことができます。繰り返しフィールドの各要素はラベル番号を再コーディングする必要があるため、繰り返しフィールドはこの最適化に適しています。

各フィールドには次の修飾子を使用して注釈を付ける必要があります:

required:必须提供该字段的值,否则消息会被认为是 “未初始化的”(uninitialized)。如果 libprotobuf 以调试模式编译,序列化未初始化的消息将引起一个断言失败。以优化形式构建,将会跳过检查,并且无论如何都会写入该消息。然而,解析未初始化的消息总是会失败(通过 parse 方法返回 false)。除此之外,一个 required 字段的表现与 optional 字段完全一样。

optional:字段可能会被设置,也可能不会。如果一个 optional 字段没被设置,它将使用默认值。对于简单类型,你可以指定你自己的默认值,正如例子中我们对电话号码的 type 一样,否则使用系统默认值:数字类型为 0、字符串为空字符串、布尔值为 false。对于嵌套消息,默认值总为消息的“默认实例”或“原型”,它的所有字段都没被设置。调用 accessor 来获取一个没有显式设置的 optional(或 required) 字段的值总是返回字段的默认值。

repeated:字段可以重复任意次数(包括 0 次)。repeated 值的顺序会被保存于 protocol buffer。可以将 repeated 字段想象为动态大小的数组。

你可以查找关于编写 .proto 文件的完整指导——包括所有可能的字段类型——在 Protocol Buffer Language Guide 里面。不要在这里面查找与类继承相似的特性,因为 protocol buffers 不会做这些。

required 是永久性的

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

编译你的 Protocol Buffers

既然你有了一个 .proto,那你需要做的下一件事就是生成一个将用于读写 AddressBook 消息的类(从而包括 Person 和 PhoneNumber)。为了做到这样,你需要在你的 .proto 上运行 protocol buffer 编译器 protoc:

如果你没有安装编译器,请下载这个包,并按照 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,包含你的类的实现。

Protocol Buffer API

让我们看看生成的一些代码,了解一下编译器为你创建了什么类和函数。如果你查看 addressbook.pb.h,你可以看到有一个在 addressbook.proto 中指定所有消息的类。关注 Person 类,可以看到编译器为每个字段生成了读写函数(accessors)。例如,对于 name、id、email 和 phone 字段,有下面这些方法:(LCTT 译注:此处原文所指文件名有误,径该之。)

// 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(); 
// phone 
inline int phone_size() const; 
inline void clear_phone(); 
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phone() const; 
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phone(); 
inline const ::tutorial::Person_PhoneNumber& phone(int index) const; 
inline ::tutorial::Person_PhoneNumber* mutable_phone(int index); 
inline ::tutorial::Person_PhoneNumber* add_phone();

正如你所见到,getters 的名字与字段的小写名字完全一样,并且 setter 方法以 set_ 开头。同时每个单一(singular)(required 或 optional)字段都有 has_ 方法,该方法在字段被设置了值的情况下返回 true。最后,所有字段都有一个 clear_ 方法,用以清除字段到空(empty)状态。

数字型的 id 字段仅有上述的基本读写函数(accessors)集合,而 name 和 email 字段有两个额外的方法,因为它们是字符串——一个是可以获得字符串直接指针的mutable_ 的 getter ,另一个为额外的 setter。注意,尽管 email 还没被设置(set),你也可以调用 mutable_email;因为 email 会被自动地初始化为空字符串。在本例中,如果你有一个单一的(required 或 optional)消息字段,它会有一个 mutable_ 方法,而没有 set_ 方法。

repeated 字段也有一些特殊的方法——如果你看看 repeated 的 phone 字段的方法,你可以看到:

检查 repeated 字段的 _size(也就是说,与 Person 相关的电话号码的个数)

使用下标取得特定的电话号码

更新特定下标的电话号码

添加新的电话号码到消息中,之后你便可以编辑。(repeated 标量类型有一个 add_ 方法,用于传入新的值)

为了获取 protocol 编译器为所有字段定义生成的方法的信息,可以查看 C++ generated code reference。

枚举和嵌套类

与 .proto 的枚举相对应,生成的代码包含了一个 PhoneType 枚举。你可以通过 Person::PhoneType 引用这个类型,通过 Person::MOBILE、Person::HOME 和 Person::WORK 引用它的值。(实现细节有点复杂,但是你无须了解它们而可以直接使用)

编译器也生成了一个 Person::PhoneNumber 的嵌套类。如果你查看代码,你可以发现真正的类型为 Person_PhoneNumber,但它通过在 Person 内部使用 typedef 定义,使你可以把 Person_PhoneNumber 当成嵌套类。唯一产生影响的一个例子是,如果你想要在其他文件前置声明该类——在 C++ 中你不能前置声明嵌套类,但是你可以前置声明 Person_PhoneNumber。

标准的消息方法

所有的消息方法都包含了许多别的方法,用于检查和操作整个消息,包括:

bool IsInitialized() const; :检查是否所有 required 字段已经被设置。

string DebugString() const; :返回人类可读的消息表示,对调试特别有用。

void CopyFrom(const Person& from);:使用给定的值重写消息。

void Clear();:清除所有元素为空的状态。

上面这些方法以及下一节要讲的 I/O 方法实现了被所有 C++ protocol buffer 类共享的消息(Message)接口。为了获取更多信息,请查看 complete API documentation for Message。

解析和序列化

最后,所有 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 reference 完整的列表。

Protocol Buffers 和面向对象设计

Protocol buffer 类通常只是纯粹的数据存储器(像 C++ 中的结构体);它们在对象模型中并不是一等公民。如果你想向生成的 protocol buffer 类中添加更丰富的行为,最好的方法就是在应用程序中对它进行封装。如果你无权控制 .proto 文件的设计的话,封装 protocol buffers 也是一个好主意(例如,你从另一个项目中重用一个 .proto 文件)。在那种情况下,你可以用封装类来设计接口,以更好地适应你的应用程序的特定环境:隐藏一些数据和方法,暴露一些便于使用的函数,等等。但是你绝对不要通过继承生成的类来添加行为。这样做的话,会破坏其内部机制,并且不是一个好的面向对象的实践。

写消息

现在我们尝试使用 protocol buffer 类。你的地址簿程序想要做的第一件事是将个人详细信息写入到地址簿文件。为了做到这一点,你需要创建、填充 protocol buffer 类实例,并且将它们写入到一个输出流(output stream)。

这里的程序可以从文件读取 AddressBook,根据用户输入,将新 Person 添加到 AddressBook,并且再次将新的 AddressBook 写回文件。这部分直接调用或引用 protocol buffer 类的代码会以“// pb”标出。

#include <iostream> 
#include <fstream> 
#include <string> 
#include "addressbook.pb.h" // pb 
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);   // pb 
  cin.ignore(256, &#39;\n&#39;); 
  cout << "Enter name: "; 
  getline(cin, *person->mutable_name());    // pb 
  cout << "Enter email address (blank for none): "; 
  string email; 
  getline(cin, email); 
  if (!email.empty()) { // pb 
    person->set_email(email);   // pb 
  } 
  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_phone();  //pb 
    phone_number->set_number(number);   // pb 
    cout << "Is this a mobile, home, or work phone? "; 
    string type; 
    getline(cin, type); 
    if (type == "mobile") { 
      phone_number->set_type(tutorial::Person::MOBILE); // pb 
    } else if (type == "home") { 
      phone_number->set_type(tutorial::Person::HOME);   // pb 
    } else if (type == "work") { 
      phone_number->set_type(tutorial::Person::WORK);   // pb 
    } 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;   // pb 
  if (argc != 2) { 
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl; 
    return -1; 
  } 
  tutorial::AddressBook address_book;   // pb 
  { 
    // 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)) {    // pb 
      cerr << "Failed to parse address book." << endl; 
      return -1; 
    } 
  } 
  // Add an address. 
  PromptForAddress(address_book.add_person());  // pb 
  { 
    // Write the new address book back to disk. 
    fstream output(argv[1], ios::out | ios::trunc | ios::binary); 
    if (!address_book.SerializeToOstream(&output)) {    // pb 
      cerr << "Failed to write address book." << endl; 
      return -1; 
    } 
  } 
  // Optional:  Delete all global objects allocated by libprotobuf. 
  google::protobuf::ShutdownProtobufLibrary();  // pb 
  return 0; 
}

注意 GOOGLE_PROTOBUF_VERIFY_VERSION 宏。它是一种好的实践——虽然不是严格必须的——在使用 C++ Protocol Buffer 库之前执行该宏。它可以保证避免不小心链接到一个与编译的头文件版本不兼容的库版本。如果被检查出来版本不匹配,程序将会终止。注意,每个 .pb.cc 文件在初始化时会自动调用这个宏。

同时注意在程序最后调用 ShutdownProtobufLibrary()。它用于释放 Protocol Buffer 库申请的所有全局对象。对大部分程序,这不是必须的,因为虽然程序只是简单退出,但是 OS 会处理释放程序的所有内存。然而,如果你使用了内存泄漏检测工具,工具要求全部对象都要释放,或者你正在写一个 Protocol Buffer 库,该库可能会被一个进程多次加载和卸载,那么你可能需要强制 Protocol Buffer 清除所有东西。

读取消息

当然,如果你无法从它获取任何信息,那么这个地址簿没多大用处!这个示例读取上面例子创建的文件,并打印文件里的所有内容。

#include <iostream> 
#include <fstream> 
#include <string> 
#include "addressbook.pb.h" // pb 
using namespace std; 
// Iterates though all people in the AddressBook and prints info about them. 
void ListPeople(const tutorial::AddressBook& address_book) {    // pb 
  for (int i = 0; i < address_book.person_size(); i++) {        // pb 
    const tutorial::Person& person = address_book.person(i);    // pb 
    cout << "Person ID: " << person.id() << endl;   // pb 
    cout << "  Name: " << person.name() << endl;    // pb 
    if (person.has_email()) {   // pb 
      cout << "  E-mail address: " << person.email() << endl;   // pb 
    } 
    for (int j = 0; j < person.phone_size(); j++) { // pb 
      const tutorial::Person::PhoneNumber& phone_number = person.phone(j);  // pb 
      switch (phone_number.type()) {    // pb 
        case tutorial::Person::MOBILE:  // pb 
          cout << "  Mobile phone #: "; 
          break; 
        case tutorial::Person::HOME:    // pb 
          cout << "  Home phone #: "; 
          break; 
        case tutorial::Person::WORK:    // pb 
          cout << "  Work phone #: "; 
          break; 
      } 
      cout << phone_number.number() << endl;    // ob 
    } 
  } 
} 
// 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;   // pb 
  if (argc != 2) { 
    cerr << "Usage:  " << argv[0] << " ADDRESS_BOOK_FILE" << endl; 
    return -1; 
  } 
  tutorial::AddressBook address_book;   // pb 
  { 
    // Read the existing address book. 
    fstream input(argv[1], ios::in | ios::binary); 
    if (!address_book.ParseFromIstream(&input)) {   // pb 
      cerr << "Failed to parse address book." << endl; 
      return -1; 
    } 
  } 
  ListPeople(address_book); 
  // Optional:  Delete all global objects allocated by libprotobuf. 
  google::protobuf::ShutdownProtobufLibrary();  // pb 
  return 0; 
}

扩展 Protocol Buffer

或早或晚在你发布了使用 protocol buffer 的代码之后,毫无疑问,你会想要 "改善" protocol buffer 的定义。如果你想要新的 buffers 向后兼容,并且老的 buffers 向前兼容——几乎可以肯定你很渴望这个——这里有一些规则,你需要遵守。在新的 protocol buffer 版本:

你绝不可以修改任何已存在字段的标签数字

你绝不可以添加或删除任何 required 字段

你可以删除 optional 或 repeated 字段

你可以添加新的 optional 或 repeated 字段,但是你必须使用新的标签数字(也就是说,标签数字在 protocol buffer 中从未使用过,甚至不能是已删除字段的标签数字)。

(对于上面规则有一些例外情况,但它们很少用到。)

如果你能遵守这些规则,旧代码则可以欢快地读取新的消息,并且简单地忽略所有新的字段。对于旧代码来说,被删除的 optional 字段将会简单地赋予默认值,被删除的 repeated 字段会为空。新代码显然可以读取旧消息。然而,请记住新的 optional 字段不会呈现在旧消息中,因此你需要显式地使用 has_ 检查它们是否被设置或者在 .proto 文件在标签数字后使用 [default = value] 提供一个合理的默认值。如果一个 optional 元素没有指定默认值,它将会使用类型特定的默认值:对于字符串,默认值为空字符串;对于布尔值,默认值为 false;对于数字类型,默认类型为 0。注意,如果你添加一个新的 repeated 字段,新代码将无法辨别它被留空(被新代码)或者从没被设置(被旧代码),因为 repeated 字段没有 has_ 标志。

优化技巧

C++ Protocol Buffer 库已极度优化过了。但是,恰当的用法能够更多地提高性能。这里是一些技巧,可以帮你从库中挤压出最后一点速度:

尽可能复用消息对象。即使它们被清除掉,消息也会尽量保存所有被分配来重用的内存。因此,如果我们正在处理许多相同类型或一系列相似结构的消息,一个好的办法是重用相同的消息对象,从而减少内存分配的负担。但是,随着时间的流逝,对象可能会膨胀变大,尤其是当你的消息尺寸(LCTT 译注:各消息内容不同,有些消息内容多一些,有些消息内容少一些)不同的时候,或者你偶尔创建了一个比平常大很多的消息的时候。你应该自己通过调用 SpaceUsed 方法监测消息对象的大小,并在它太大的时候删除它。

对于在多线程中分配大量小对象的情况,你的操作系统内存分配器可能优化得不够好。你可以尝试使用 google 的 tcmalloc。

高级用法

Protocol Buffers 绝不仅用于简单的数据存取以及序列化。请阅读 C++ API reference 来看看你还能用它来做什么。

protocol 消息类所提供的一个关键特性就是反射(reflection)。你不需要针对一个特殊的消息类型编写代码,就可以遍历一个消息的字段并操作它们的值。一个使用反射的有用方法是 protocol 消息与其他编码互相转换,比如 XML 或 JSON。反射的一个更高级的用法可能就是可以找出两个相同类型的消息之间的区别,或者开发某种“协议消息的正则表达式”,利用正则表达式,你可以对某种消息内容进行匹配。只要你发挥你的想像力,就有可能将 Protocol Buffers 应用到一个更广泛的、你可能一开始就期望解决的问题范围上。


声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。