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 ファイルの定義は簡単です。シリアライズしたい各データ構造に対して「メッセージ」を追加し、次にメッセージ内の各フィールドの名前と型を指定します。以下がメッセージを定義する .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: フィールドは設定されていても、されていなくてもかまいません。optional なフィールド値が設定されていない場合、デフォルト値が使用されます。単純な型の場合、例の電話番号の type のように、独自のデフォルト値を指定できます。それ以外の場合、システムのデフォルトが使用されます。数値型の場合はゼロ、文字列の場合は空文字列、ブール値の場合は false です。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは何も設定されていません。明示的に設定されていない optional (または required) フィールドの値を取得するためにアクセサを呼び出すと、常にそのフィールドのデフォルト値が返されます。
  • repeated: フィールドは何度でも(ゼロ回を含む)繰り返すことができます。繰り返された値の順序は protocol buffer 内で保持されます。repeated フィールドは動的サイズの配列と考えることができます。
  • required: フィールドには値を指定する必要があります。さもないと、メッセージは「初期化されていない」と見なされます。libprotobuf がデバッグモードでコンパイルされている場合、初期化されていないメッセージをシリアライズするとアサーションエラーが発生します。最適化ビルドでは、チェックはスキップされ、メッセージはとにかく書き込まれます。ただし、初期化されていないメッセージのパースは常に失敗します(パースメソッドから false が返されます)。これ以外では、required フィールドは optional フィールドとまったく同じように動作します。

.proto ファイルの書き方に関する完全なガイド(すべての可能なフィールドタイプを含む)は、Protocol Buffer 言語ガイド にあります。クラス継承に似た機能を探さないでください。protocol buffers はそれをサポートしていません。

Protocol Buffer のコンパイル

.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 クラスを詳しく見ると、コンパイラが各フィールドのアクセサを生成していることがわかります。例えば、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_ で始まります。また、各単数形(required または optional)フィールドには、そのフィールドが設定されている場合に true を返す has_ メソッドがあります。最後に、各フィールドには、フィールドを空の状態に戻す clear_ メソッドがあります。

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

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

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

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

列挙型とネストされたクラス

生成されたコードには、.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;: すべての required フィールドが設定されているか確認します。
  • 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 では、

  • 既存のフィールドのフィールド番号を変更しては*いけません*。
  • required フィールドを追加または削除しては*いけません*。
  • optional または repeated フィールドを削除することは*できます*。
  • 新しい optional または repeated フィールドを追加することは*できます*が、新しいフィールド番号(つまり、この protocol buffer で一度も使用されたことのないフィールド番号で、削除されたフィールドによって使用されたものでもない)を使用しなければなりません。

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

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

最適化のヒント

C++ Protocol Buffers ライブラリは非常に高度に最適化されています。しかし、適切な使用法によってパフォーマンスはさらに向上します。ライブラリから最後の一滴の速度を絞り出すためのヒントをいくつか紹介します。

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

高度な使い方

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

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

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