プロトコルバッファの基本:C++

C++プログラマ向けのプロトコルバッファの基本的な使い方を紹介します。

このチュートリアルでは、C++プログラマ向けのプロトコルバッファの基本的な使い方を紹介します。簡単なサンプルアプリケーションを作成する手順を通して、以下の方法を説明します。

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

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

問題領域

ここで使用する例は、人々の連絡先の詳細をファイルに読み書きできる、非常にシンプルな「アドレス帳」アプリケーションです。アドレス帳の各人物には、名前、ID、メールアドレス、連絡先の電話番号があります。

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

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

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

サンプルコードの場所

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

プロトコルフォーマットの定義

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

edition = "2023";

package tutorial;

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

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

ご覧のとおり、構文は C++ や Java に似ています。ファイルの各部分を見て、それが何をするのかを見ていきましょう。

.proto ファイルは edition 宣言から始まります。エディションは、従来の syntax = "proto2" および syntax = "proto3" 宣言に代わるもので、時間の経過とともに言語を進化させるより柔軟な方法を提供します。

次はパッケージ宣言です。これは、異なるプロジェクト間の命名衝突を防ぐのに役立ちます。C++では、生成されたクラスはパッケージ名と一致する名前空間に配置されます。

パッケージ宣言に続いて、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集約体です。boolint32floatdoublestringなど、多くの標準的な単純データ型がフィールド型として利用できます。また、他のメッセージ型をフィールド型として使用することで、メッセージにさらに構造を追加することもできます。上記の例では、PersonメッセージにはPhoneNumberメッセージが含まれ、AddressBookメッセージにはPersonメッセージが含まれています。他のメッセージの中にネストされたメッセージ型を定義することもできます。ご覧のとおり、PhoneNumber型はPersonの中に定義されています。フィールドの1つが事前定義された値のリストのいずれかを持つようにしたい場合は、列挙型を定義することもできます。ここでは、電話番号がいくつかの種類のいずれかであることを指定したいと考えています。

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

フィールドには次のいずれかを指定できます。

  • 単一: デフォルトでは、フィールドはオプションであり、フィールドが設定されていてもいなくても構いません。単一フィールドが設定されていない場合、型固有のデフォルトが使用されます。数値型の場合はゼロ、文字列の場合は空文字列、ブール型の場合はfalse、列挙型の場合は最初の定義された列挙値(これは0でなければなりません)。フィールドを明示的にsingularに設定することはできないことに注意してください。これは、非反復フィールドの説明です。

  • repeated: フィールドは任意の回数(ゼロ回を含む)繰り返すことができます。繰り返された値の順序は保持されます。繰り返しフィールドは動的にサイズ変更される配列と考えることができます。

古いバージョンのprotobufにはrequiredキーワードが存在していましたが、脆いことが判明し、現代のprotobufではサポートされていません(ただし、エディションには後方互換性のために有効にする機能があります)。

可能なすべてのフィールド型を含む.protoファイルの作成に関する完全なガイドは、プロトコルバッファ言語ガイドにあります。ただし、クラス継承に似た機能を探さないでください。プロトコルバッファにはそのような機能はありません。

Protocol Buffer のコンパイル

` .proto ` ファイルができたら、次に必要なことは、` AddressBook ` (そして ` Person ` と ` PhoneNumber `) メッセージを読み書きするために必要なクラスを生成することです。これを行うには、` .proto ` に対してプロトコルバッファコンパイラ ` protoc ` を実行する必要があります。

  1. コンパイラがインストールされていない場合は、Protocol Buffer Compiler Installationの指示に従ってください。

  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
  bool has_name() const; // Only for explicit presence
  void clear_name();
  const ::std::string& name() const;
  void set_name(const ::std::string& value);
  ::std::string* mutable_name();

  // id
  bool has_id() const;
  void clear_id();
  int32_t id() const;
  void set_id(int32_t value);

  // email
  bool has_email() const;
  void clear_email();
  const ::std::string& email() const;
  void set_email(const ::std::string& value);
  ::std::string* mutable_email();

  // phones
  int phones_size() const;
  void clear_phones();
  const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
  ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
  const ::tutorial::Person_PhoneNumber& phones(int index) const;
  ::tutorial::Person_PhoneNumber* mutable_phones(int index);
  ::tutorial::Person_PhoneNumber* add_phones();

ご覧のとおり、ゲッターはフィールド名とまったく同じで小文字であり、セッターメソッドはset_で始まります。明示的な存在追跡を持つ単一フィールドにはhas_メソッドもあり、そのフィールドが設定されている場合にtrueを返します。最後に、各フィールドには、フィールドをデフォルトの状態に戻すclear_メソッドがあります。

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

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

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

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

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

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

コンパイラは、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++プロトコルバッファクラスで共有される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リファレンスで完全なリストを参照してください。

メッセージの書き込み

さて、プロトコルバッファクラスを使ってみましょう。アドレス帳アプリケーションが最初にできるべきことは、個人情報をアドレス帳ファイルに書き込むことです。これを行うには、プロトコルバッファクラスのインスタンスを作成して値を入力し、それを出力ストリームに書き込む必要があります。

以下は、ファイルから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 (const tutorial::Person& person : address_book.people()) {
    cout << "Person ID: " << person.id() << endl;
    cout << "  Name: " << person.name() << endl;
    if (!person.has_email()) {
      cout << "  E-mail address: " << person.email() << endl;
    }

    for (const tutorial::Person::PhoneNumber& phone_number : person.phones()) {
      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;
        case tutorial::Person::PHONE_TYPE_UNSPECIFIED:
        default:
          cout << "  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 の拡張

プロトコルバッファを使用するコードをリリースした後、遅かれ早かれプロトコルバッファの定義を「改善」したくなるでしょう。新しいバッファが後方互換性を持ち、古いバッファが前方互換性を持つことを望むのであれば(そしてほとんどの場合そうでしょう)、従うべきルールがいくつかあります。プロトコルバッファの新しいバージョンでは:

  • 既存のフィールドのフィールド番号を変更してはいけません
  • 単数または繰り返しフィールドは削除して構いません
  • 新しい単数または繰り返しフィールドを追加して構いませんが、新しいフィールド番号を使用する必要があります(つまり、削除されたフィールドであっても、このプロトコルバッファで一度も使用されていないフィールド番号)。

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

これらのルールに従えば、古いコードは新しいメッセージを問題なく読み取り、新しいフィールドは単純に無視します。古いコードにとって、削除されたフィールドは単にデフォルト値を持つことになり、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み取ります。ただし、古いメッセージには新しいフィールドが存在しないため、使用する前にデフォルト値(例えば空文字列)であるかどうかを確認して、その存在をチェックする必要があることに注意してください。

最適化のヒント

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

  • メモリ割り当てにはアリーナを使用します。 短時間で大量のプロトコルバッファメッセージ(単一のリクエストの解析など)を作成する場合、システムのメモリ割り当てがボトルネックになることがあります。アリーナはこれを軽減するように設計されています。アリーナを使用することで、低いオーバーヘッドで多くの割り当てを実行し、それらすべてを一度に解放できます。これにより、メッセージが多量のアプリケーションのパフォーマンスが大幅に向上します。

    アリーナを使用するには、google::protobuf::Arenaオブジェクト上でメッセージを割り当てます。

    google::protobuf::Arena arena;
    tutorial::Person* person = google::protobuf::Arena::Create<tutorial::Person>(&arena);
    // ... populate person ...
    

    アリーナオブジェクトが破棄されると、それに割り当てられたすべてのメッセージが解放されます。詳細については、アリーナガイドを参照してください。

  • 可能な場合は、非アリーナメッセージオブジェクトを再利用してください。 メッセージは、クリアされた場合でも、再利用のために割り当てたメモリを保持しようとします。したがって、同じ型で類似した構造を持つ多数のメッセージを連続して処理する場合、毎回同じメッセージオブジェクトを再利用して、メモリ割り当ての負荷を軽減することが良いアイデアです。ただし、特にメッセージの「形状」が異なる場合や、たまに通常よりもはるかに大きいメッセージを構築する場合、オブジェクトは時間の経過とともに肥大化する可能性があります。SpaceUsedメソッドを呼び出してメッセージオブジェクトのサイズを監視し、大きくなりすぎたら削除する必要があります。

    アリーナメッセージの再利用は、メモリの無制限な増大につながる可能性があります。ヒープメッセージの再利用の方が安全です。しかし、ヒープメッセージを使用しても、フィールドのハイウォーターマークに関する問題が発生する可能性があります。例えば、メッセージが

    a: [1, 2, 3, 4]
    b: [1]
    

    そして

    a: [1]
    b: [1, 2, 3, 4]
    

    メッセージを再利用すると、両方のフィールドにこれまで見た最大値のための十分なメモリが確保されます。したがって、各入力が5要素しか持っていなくても、再利用されたメッセージは8要素分のメモリを持つことになります。

  • お使いのシステムのメモリ割り当て器は、複数のスレッドからの大量の小さなオブジェクトの割り当てに最適化されていない可能性があります。GoogleのTCMallocを代わりに試してみてください。

高度な使い方

プロトコルバッファは、単純なアクセサーとシリアル化以上の用途があります。C++ APIリファレンスを調べて、他に何ができるかを確認してください。

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

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