Protocol Bufferの基礎: C++

C++プログラマ向けのProtocol Bufferの基本的な入門。

このチュートリアルでは、C++プログラマ向けにProtocol Bufferの基本的な使用法を紹介します。簡単なサンプルアプリケーションの作成を通して、以下の方法を示します。

  • `.proto`ファイルでメッセージ形式を定義する。
  • Protocol Bufferコンパイラを使用する。
  • C++ Protocol Buffer APIを使用してメッセージを書き込み、読み込む。

これはC++でProtocol Bufferを使用するための包括的なガイドではありません。より詳細なリファレンス情報については、Protocol Buffer言語ガイド (proto2)Protocol Buffer言語ガイド (proto3)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を構成するフィールドのゲッターとセッターを提供し、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++では、生成されたクラスはパッケージ名と一致する名前空間に配置されます。

次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集約です。`bool`、`int32`、`float`、`double`、`string`など、多くの標準的な単純なデータ型がフィールド型として利用できます。他のメッセージ型をフィールド型として使用することで、メッセージにさらに構造を追加することもできます。上記の例では、`Person`メッセージは`PhoneNumber`メッセージを含み、`AddressBook`メッセージは`Person`メッセージを含んでいます。他のメッセージ内にネストされたメッセージ型を定義することもできます。ご覧のとおり、`PhoneNumber`型は`Person`内に定義されています。また、フィールドのいずれかが事前に定義された値のリストのいずれかを持つようにしたい場合は、`enum`型を定義することもできます。ここでは、電話番号が以下の電話タイプのうちのいずれかであることを指定したいと考えています: `PHONE_TYPE_MOBILE`、`PHONE_TYPE_HOME`、または`PHONE_TYPE_WORK`。

各要素の「= 1」、「= 2」のマーカーは、そのフィールドがバイナリエンコーディングで使用する一意のフィールド番号を識別します。フィールド番号1-15は、それ以上の番号よりも1バイト少なくエンコードできるため、最適化として、これらを一般的に使用される要素や繰り返し要素に使い、フィールド番号16以降はあまり使用されないオプション要素に残すことができます。繰り返しフィールドの各要素はフィールド番号の再エンコードを必要とするため、繰り返しフィールドはこの最適化に特に適しています。

各フィールドは、以下のいずれかの修飾子で注釈を付ける必要があります。

  • optional: そのフィールドは設定されていてもいなくてもよいです。オプションフィールドの値が設定されていない場合、デフォルト値が使用されます。単純な型の場合、例の電話番号typeのように、独自のデフォルト値を指定できます。それ以外の場合、システムデフォルトが使用されます。数値型の場合はゼロ、文字列の場合は空の文字列、真偽値の場合はfalseです。組み込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは何も設定されていません。明示的に設定されていないオプション(または必須)フィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。
  • repeated: そのフィールドは任意の回数(ゼロ回を含む)繰り返すことができます。繰り返し値の順序はProtocol Buffer内で保持されます。繰り返しフィールドを動的にサイズ変更可能な配列と考えるとよいでしょう。
  • required: そのフィールドの値は必ず指定されなければなりません。そうでない場合、メッセージは「未初期化」とみなされます。libprotobufがデバッグモードでコンパイルされている場合、未初期化のメッセージをシリアライズするとアサーション失敗が発生します。最適化ビルドでは、チェックはスキップされ、メッセージはそのまま書き込まれます。しかし、未初期化のメッセージを解析しようとすると常に失敗します(解析メソッドからfalseが返されます)。これを除けば、必須フィールドはオプションフィールドとまったく同じように動作します。

.protoファイルの書き方に関する完全なガイド(利用可能なすべてのフィールド型を含む)は、Protocol Buffer言語ガイドで見つけることができます。ただし、クラス継承に似た機能を探さないでください。Protocol Bufferはそのような機能を提供していません。

Protocol Bufferのコンパイル

.protoファイルができたので、次に行うべきことは、AddressBook(ひいてはPersonPhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、Protocol Bufferコンパイラprotoc.protoファイルに対して実行する必要があります。

  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();

ご覧のとおり、ゲッターはフィールド名とまったく同じ小文字の名前を持ち、セッターメソッドはset_で始まります。また、各単一(必須またはオプション)フィールドにはhas_メソッドがあり、そのフィールドが設定されている場合にtrueを返します。最後に、各フィールドには、フィールドを空の状態に戻すclear_メソッドがあります。

数値idフィールドには上記の基本的なアクセサーセットしかありませんが、nameemailフィールドには文字列であるため、いくつかの追加メソッドがあります。文字列への直接ポインタを取得できるmutable_ゲッターと、追加のセッターです。emailがまだ設定されていなくてもmutable_email()を呼び出すことができることに注意してください。自動的に空の文字列で初期化されます。この例で繰り返しメッセージフィールドがあった場合、それもmutable_メソッドを持ちますが、set_メソッドは持ちません。

繰り返しフィールドにもいくつかの特殊なメソッドがあります。繰り返しphonesフィールドのメソッドを見ると、以下のことができることがわかります。

  • 繰り返しフィールドの_sizeをチェックする(つまり、このPersonに関連付けられている電話番号の数)。
  • インデックスを使用して指定された電話番号を取得する。
  • 指定されたインデックスの既存の電話番号を更新する。
  • メッセージに別の電話番号を追加し、それを編集する(繰り返しスカラー型には、新しい値を渡すだけのadd_があります)。

特定のフィールド定義に対してプロトコルコンパイラが正確にどのようなメンバーを生成するかについての詳細は、C++生成コードリファレンスを参照してください。

Enumとネストされたクラス

生成されたコードには、.protoのenumに対応するPhoneType enumが含まれています。この型はPerson::PhoneTypeとして、その値はPerson::PHONE_TYPE_MOBILEPerson::PHONE_TYPE_HOMEPerson::PHONE_TYPE_WORKとして参照できます(実装の詳細は少し複雑ですが、enumを使用する上で理解する必要はありません)。

コンパイラは、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を1人追加し、その新しい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ライブラリによって割り当てられたすべてのグローバルオブジェクトを削除するだけです。ほとんどのプログラムでは、プロセスが終了する際にOSがすべてのメモリを解放するため、これは不要です。ただし、最後のオブジェクトまですべて解放する必要があるメモリリークチェッカーを使用している場合や、単一のプロセスによって複数回ロードおよびアンロードされる可能性のあるライブラリを作成している場合は、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の新しいバージョンでは、

  • 既存のフィールドのフィールド番号を変更してはなりません
  • 必須フィールドを追加したり削除したりしてはなりません
  • オプションまたは繰り返しフィールドを削除することはできます
  • 新しいオプションまたは繰り返しフィールドを追加することはできますが、そのプロトコルバッファでこれまで使用されていないフィールド番号(削除されたフィールドでも使用されていない番号)を使用する必要があります。

(これらのルールにはいくつかの例外がありますが、これらが使用されることは稀です。)

これらのルールに従えば、古いコードは新しいメッセージを問題なく読み取り、新しいフィールドは単純に無視します。古いコードにとって、削除されたオプションフィールドは単純にデフォルト値になり、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み取ります。ただし、古いメッセージには新しいオプションフィールドが存在しないため、has_で明示的に設定されているかどうかを確認するか、フィールド番号の後に[default = value]を指定して.protoファイルで適切なデフォルト値を指定する必要があることに留意してください。オプション要素にデフォルト値が指定されていない場合、型固有のデフォルト値が使用されます。文字列の場合は空の文字列、真偽値の場合はfalse、数値型の場合はゼロです。また、新しい繰り返しフィールドを追加した場合、そのフィールドにはhas_フラグがないため、新しいコードによって空にされたのか(新しいコードの場合)、まったく設定されなかったのか(古いコードの場合)を区別できないことにも注意してください。

最適化のヒント

C++ Protocol Buffersライブラリは非常に高度に最適化されています。しかし、適切な使用法によってパフォーマンスをさらに向上させることができます。ライブラリから最大限の速度を引き出すためのいくつかのヒントを以下に示します。

  • 可能な限りメッセージオブジェクトを再利用してください。メッセージは、クリアされた場合でも、割り当てたメモリを再利用のために保持しようとします。したがって、同じ型と類似した構造を持つ多数のメッセージを連続して処理する場合、メモリ割り当ての負荷を軽減するために、毎回同じメッセージオブジェクトを再利用することをお勧めします。ただし、オブジェクトは時間の経過とともに肥大化する可能性があり、特にメッセージが「形状」で異なる場合や、時折通常よりもはるかに大きなメッセージを構築する場合に顕著です。SpaceUsedメソッドを呼び出してメッセージオブジェクトのサイズを監視し、大きくなりすぎたら削除すべきです。
  • お使いのシステムのメモリアロケータは、複数のスレッドから大量の小さなオブジェクトを割り当てるのに最適化されていない場合があります。代わりにGoogleのTCMallocを使用してみてください。

高度な使用法

Protocol Bufferには、単純なアクセサーとシリアライズを超えた用途があります。他に何ができるかについては、C++ APIリファレンスを必ず確認してください。

プロトコルメッセージクラスが提供する重要な機能の一つはリフレクションです。特定のメッセージ型に対してコードを書くことなく、メッセージのフィールドを反復処理し、その値を操作できます。リフレクションの非常に便利な使い方は、プロトコルメッセージをXMLやJSONなどの他のエンコーディングに変換したり、その逆を行うことです。より高度なリフレクションの使用法としては、同じ型の2つのメッセージ間の違いを見つけたり、特定のメッセージの内容に一致する式を記述できる「プロトコルメッセージ用の正規表現」のようなものを開発したりすることが考えられます。想像力を働かせれば、Protocol Bufferを当初期待するよりもはるかに広範囲の問題に適用することが可能です!

リフレクションはMessage::Reflectionインターフェースによって提供されます。