Protocol Bufferの基本: C++

これは、C++プログラマーがProtocol Buffersを扱うための基本的な入門書です。

このチュートリアルでは、C++プログラマーがProtocol Buffersを扱うための基本的な入門を提供します。簡単なサンプルアプリケーションの作成を通して、以下の方法を説明します。

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

これは、C++でProtocol Buffersを使用するための包括的なガイドではありません。より詳細なリファレンス情報については、Protocol Buffer言語ガイド (proto2)Protocol Buffer言語ガイド (proto3)C++ APIリファレンスC++生成コードガイド、およびエンコーディングリファレンスを参照してください。

問題領域

ここで使用する例は、人々の連絡先の詳細をファイルとの間で読み書きできる、非常にシンプルな「アドレス帳」アプリケーションです。アドレス帳の各個人は、名前、ID、メールアドレス、および連絡先の電話番号を持っています。

このような構造化されたデータをシリアライズおよび取得するにはどうすればよいでしょうか?この問題を解決する方法はいくつかあります。

  • 生のインメモリデータ構造は、バイナリ形式で送信/保存できます。長期的には、これは脆弱なアプローチです。受信/読み取りコードは、完全に同じメモリレイアウト、エンディアンなどでコンパイルする必要があるためです。また、ファイルが生の形式でデータを蓄積し、その形式に対応したソフトウェアのコピーが拡散すると、形式を拡張するのが非常に困難になります。
  • データ項目を単一の文字列にエンコードするアドホックな方法を発明できます。たとえば、4つの整数を「12:3:-23:67」としてエンコードするなどです。これはシンプルで柔軟なアプローチですが、一度限りのエンコードおよびパースコードの記述が必要であり、パースにはわずかなランタイムコストがかかります。これは非常に単純なデータをエンコードするのに最適です。
  • データをXMLにシリアライズします。XMLは(ある程度)人間が読める形式であり、多くの言語用のバインディングライブラリがあるため、このアプローチは非常に魅力的です。他のアプリケーション/プロジェクトとデータを共有したい場合には、良い選択肢となり得ます。しかし、XMLは非常にスペース効率が悪く、エンコード/デコードにはアプリケーションに大きなパフォーマンスペナルティを課す可能性があります。また、XML DOMツリーのナビゲーションは、通常クラス内の単純なフィールドをナビゲートするよりも、かなり複雑になります。

これらのオプションの代わりに、Protocol Buffersを使用できます。Protocol Buffersは、まさにこの問題を解決するための柔軟で効率的な自動化されたソリューションです。Protocol Buffersを使用すると、保存したいデータ構造の.proto記述を記述します。それから、Protocol Bufferコンパイラーは、効率的なバイナリ形式でProtocol Bufferデータの自動エンコードとパースを実装するクラスを作成します。生成されたクラスは、Protocol Bufferを構成するフィールドのゲッターとセッターを提供し、Protocol Bufferをユニットとして読み書きする詳細を処理します。重要なことに、Protocol Buffer形式は、時間の経過とともに形式を拡張するという考え方をサポートしており、コードは古い形式でエンコードされたデータを引き続き読み取ることができます。

サンプルコードの場所

サンプルコードは、ソースコードパッケージの「examples」ディレクトリに含まれています。

プロトコル形式の定義

アドレス帳アプリケーションを作成するには、まず.protoファイルから始める必要があります。.protoファイル内の定義は簡単です。シリアライズするデータ構造ごとにmessageを追加し、メッセージ内の各フィールドの名前と型を指定します。以下は、メッセージを定義する.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++では、生成されたクラスはパッケージ名に一致する名前空間に配置されます。

次に、メッセージ定義があります。メッセージは、型付きフィールドのセットを含む集約体です。boolint32floatdoublestringなど、多くの標準的な単純データ型がフィールド型として利用できます。また、他のメッセージ型をフィールド型として使用することで、メッセージにさらに構造を追加することもできます。上記の例では、PersonメッセージにPhoneNumberメッセージが含まれ、AddressBookメッセージにPersonメッセージが含まれています。他のメッセージ内にネストされたメッセージ型を定義することもできます。ご覧のとおり、PhoneNumber型はPerson内で定義されています。フィールドの1つに事前定義された値のリストのいずれかを持たせたい場合は、enum型を定義することもできます。ここでは、電話番号が次の電話タイプのいずれかであることを指定します:PHONE_TYPE_MOBILEPHONE_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 Buffersのコンパイル

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

  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クラスを詳しく見ると、コンパイラーが各フィールドのアクセサを生成したことがわかります。たとえば、nameidemail、およびphonesフィールドの場合、次のメソッドがあります。

  // 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_メソッドがあります。最後に、各フィールドには、フィールドを空の状態に戻すclear_メソッドがあります。

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

繰り返されるフィールドには、いくつかの特別なメソッドもあります。繰り返されるphonesフィールドのメソッドを見ると、次のことができることがわかります。

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

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

Enumとネストされたクラス

生成されたコードには、.proto enumに対応するPhoneType enumが含まれています。この型をPerson::PhoneTypeとして、その値をPerson::PHONE_TYPE_MOBILEPerson::PHONE_TYPE_HOME、およびPerson::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をファイルに再び書き出すプログラムです。Protocolコンパイラーによって生成されたコードを直接呼び出すか参照する部分は、強調表示されています。

#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の新しいバージョンでは

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

(これらのルールにはいくつかの例外がありますが、めったに使用されません。)

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

最適化のヒント

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

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

高度な使用法

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

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

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