Protocol Buffer の基本: Java

これは、Protocol Buffer を使用するJavaプログラマー向けの基本的な入門書です。

このチュートリアルでは、Protocol Buffer を使用するJavaプログラマー向けの基本的な入門書を提供します。簡単なサンプルアプリケーションの作成を通して、以下の方法を説明します。

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

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

問題領域

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

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

  • Java シリアライゼーションを使用する。これは言語に組み込まれているためデフォルトのアプローチですが、多くの周知の問題(Josh Bloch 著「Effective Java」213ページを参照)があり、C++ または Python で記述されたアプリケーションとデータを共有する必要がある場合にはあまりうまく機能しません。
  • データ項目を単一の文字列にエンコードするアドホックな方法を考案できます。たとえば、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;

option java_multiple_files = true;
option java_package = "com.example.tutorial.protos";
option java_outer_classname = "AddressBookProtos";

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 ファイルは、パッケージ宣言で始まります。これは、異なるプロジェクト間の名前の衝突を防ぐのに役立ちます。Java では、ここで示すように明示的に java_package を指定しない限り、パッケージ名が Java パッケージとして使用されます。java_package を指定した場合でも、Protocol Buffers の名前空間だけでなく、Java 以外の言語でも名前の衝突を避けるために、通常の package も定義する必要があります。

パッケージ宣言の後には、Java 固有の 3 つのオプション java_multiple_filesjava_package、および java_outer_classname があります。java_package は、生成されたクラスが存在する Java パッケージ名を指定します。これを明示的に指定しない場合、package 宣言で指定されたパッケージ名と一致しますが、これらの名前は通常、適切な Java パッケージ名ではありません(通常、ドメイン名で始まらないため)。java_outer_classname オプションは、このファイルを表すラッパークラスのクラス名を定義します。java_outer_classname を明示的に指定しない場合、ファイル名をアッパーキャメルケースに変換することによって生成されます。たとえば、「my_proto.proto」は、デフォルトで「MyProto」をラッパークラス名として使用します。java_multiple_files = true オプションを使用すると、生成されたクラスごとに個別の .java ファイルを生成できます(ラッパークラス用に単一の .java ファイルを生成し、ラッパークラスを外部クラスとして使用し、他のすべてのクラスをラッパークラス内にネストするというレガシーな動作の代わりに)。

次に、メッセージ定義があります。メッセージは、型付きフィールドのセットを含む集約です。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: フィールドの値を指定する必要があります。そうしないと、メッセージは「初期化されていない」と見なされます。初期化されていないメッセージをビルドしようとすると、RuntimeException がスローされます。初期化されていないメッセージをパースすると、IOException がスローされます。これ以外は、必須フィールドはオプションフィールドとまったく同じように動作します。

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

Protocol Buffers のコンパイル

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

  1. コンパイラをインストールしていない場合は、パッケージをダウンロード し、README の指示に従ってください。

  2. 次に、コンパイラを実行して、ソースディレクトリ(アプリケーションのソースコードが存在する場所。値を指定しない場合は現在のディレクトリが使用されます)、宛先ディレクトリ(生成されたコードを配置する場所。多くの場合、$SRC_DIR と同じ)、および .proto へのパスを指定します。この場合、あなたは…

    protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    Java クラスが必要なため、--java_out オプションを使用します。他のサポートされている言語にも同様のオプションが用意されています。

これにより、指定された宛先ディレクトリに com/example/tutorial/protos/ サブディレクトリが生成され、いくつかの生成された .java ファイルが含まれます。

Protocol Buffer API

生成されたコードの一部を見て、コンパイラが作成したクラスとメソッドを確認してみましょう。com/example/tutorial/protos/ を見ると、addressbook.proto で指定した各メッセージのクラスを定義する .java ファイルが含まれていることがわかります。各クラスには、そのクラスのインスタンスを作成するために使用する独自の Builder クラスがあります。Builder の詳細については、以下の Builder と Message セクションを参照してください。

メッセージと Builder の両方に、メッセージの各フィールドに対して自動生成されたアクセサメソッドがあります。メッセージにはゲッターのみがありますが、Builder にはゲッターとセッターの両方があります。以下は、Person クラスのアクセサの一部です(実装は簡潔にするために省略されています)。

// required string name = 1;
public boolean hasName();
public String getName();

// required int32 id = 2;
public boolean hasId();
public int getId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();

// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);

一方、Person.Builder には、同じゲッターに加えてセッターがあります。

// required string name = 1;
public boolean hasName();
public String getName();
public Builder setName(String value);
public Builder clearName();

// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();

// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
public Builder setPhones(int index, PhoneNumber value);
public Builder addPhones(PhoneNumber value);
public Builder addAllPhones(Iterable<PhoneNumber> value);
public Builder clearPhones();

ご覧のとおり、各フィールドにはシンプルな JavaBeans スタイルのゲッターとセッターがあります。また、各単数フィールドには、そのフィールドが設定されている場合に true を返す has ゲッターもあります。最後に、各フィールドには、フィールドを空の状態に戻す clear メソッドがあります。

繰り返されるフィールドには、いくつかの追加メソッドがあります。Count メソッド(リストのサイズの省略形)、インデックスでリストの特定の要素を取得または設定するゲッターとセッター、リストに新しい要素を追加する add メソッド、および要素のコンテナ全体をリストに追加する addAll メソッドです。

これらのアクセサメソッドが、.proto ファイルが小文字とアンダースコアを使用しているにもかかわらず、キャメルケースの命名を使用していることに注目してください。この変換は、生成されたクラスが標準の Java スタイル規則に一致するように、Protocol Buffer コンパイラによって自動的に行われます。.proto ファイルのフィールド名には常に小文字とアンダースコアを使用する必要があります。これにより、生成されたすべての言語で適切な命名規則が保証されます。適切な .proto スタイルの詳細については、スタイルガイド を参照してください。

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

Enum とネストされたクラス

生成されたコードには、Person 内にネストされた PhoneType Java 5 enum が含まれています。

public static enum PhoneType {
  PHONE_TYPE_UNSPECIFIED(0, 0),
  PHONE_TYPE_MOBILE(1, 1),
  PHONE_TYPE_HOME(2, 2),
  PHONE_TYPE_WORK(3, 3),
  ;
  ...
}

ネストされた型 Person.PhoneNumber は、予想どおり、Person 内のネストされたクラスとして生成されます。

Builder と Message

Protocol Buffer コンパイラによって生成されたメッセージクラスはすべて immutable です。メッセージオブジェクトが構築されると、Java String のように変更できません。メッセージを構築するには、まず Builder を構築し、設定するフィールドを選択した値に設定してから、Builder の build() メソッドを呼び出す必要があります。

メッセージを変更する Builder の各メソッドが別の Builder を返すことに気付いたかもしれません。返されるオブジェクトは、実際にはメソッドを呼び出したのと同じ Builder です。これは、複数のセッターを 1 行のコードに連結できるように、便宜上返されます。

Person のインスタンスを作成する方法の例を次に示します。

Person john =
  Person.newBuilder()
    .setId(1234)
    .setName("John Doe")
    .setEmail("jdoe@example.com")
    .addPhones(
      Person.PhoneNumber.newBuilder()
        .setNumber("555-4321")
        .setType(Person.PhoneType.PHONE_TYPE_HOME)
        .build());
    .build();

標準 Message メソッド

各メッセージクラスと Builder クラスには、メッセージ全体を確認または操作できる多数の他のメソッドも含まれています。以下を含みます。

  • isInitialized(): すべての必須フィールドが設定されているかどうかを確認します。
  • toString(): メッセージの人間が読める形式の表現を返します。特にデバッグに役立ちます。
  • mergeFrom(Message other): (Builder のみ) other の内容をこのメッセージにマージし、単数スカラーフィールドを上書きし、複合フィールドをマージし、繰り返されるフィールドを連結します。
  • clear(): (Builder のみ) すべてのフィールドを空の状態に戻します。

これらのメソッドは、すべての Java メッセージと Builder で共有される Message および Message.Builder インターフェイスを実装します。詳細については、Message の完全な API ドキュメント を参照してください。

パースとシリアライゼーション

最後に、各 Protocol Buffer クラスには、Protocol Buffer バイナリ形式 を使用して、選択した型のメッセージを書き込みおよび読み取るためのメソッドがあります。以下が含まれます。

  • byte[] toByteArray();: メッセージをシリアライズし、その生のバイトを含むバイト配列を返します。
  • static Person parseFrom(byte[] data);: 指定されたバイト配列からメッセージをパースします。
  • void writeTo(OutputStream output);: メッセージをシリアライズし、OutputStream に書き込みます。
  • static Person parseFrom(InputStream input);: InputStream からメッセージを読み取り、パースします。

これらは、パースとシリアライゼーションに提供されるオプションのほんの一部です。繰り返しますが、完全なリストについては、Message API リファレンス を参照してください。

Message の書き込み

Protocol Buffer クラスを使用してみましょう。アドレス帳アプリケーションで最初にできるようにしたいのは、個人情報をアドレス帳ファイルに書き込むことです。これを行うには、Protocol Buffer クラスのインスタンスを作成してデータを設定し、それらを出力ストリームに書き込む必要があります。

これは、ファイルから AddressBook を読み取り、ユーザー入力に基づいて新しい Person を 1 人追加し、新しい AddressBook をファイルに書き戻すプログラムです。Protocol コンパイラによって生成されたコードを直接呼び出すか参照する部分は強調表示されています。

import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;

class AddPerson {
  // This function fills in a Person message based on user input.
  static Person PromptForAddress(BufferedReader stdin,
                                 PrintStream stdout) throws IOException {
    Person.Builder person = Person.newBuilder();

    stdout.print("Enter person ID: ");
    person.setId(Integer.valueOf(stdin.readLine()));

    stdout.print("Enter name: ");
    person.setName(stdin.readLine());

    stdout.print("Enter email address (blank for none): ");
    String email = stdin.readLine();
    if (email.length() > 0) {
      person.setEmail(email);
    }

    while (true) {
      stdout.print("Enter a phone number (or leave blank to finish): ");
      String number = stdin.readLine();
      if (number.length() == 0) {
        break;
      }

      Person.PhoneNumber.Builder phoneNumber =
        Person.PhoneNumber.newBuilder().setNumber(number);

      stdout.print("Is this a mobile, home, or work phone? ");
      String type = stdin.readLine();
      if (type.equals("mobile")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_MOBILE);
      } else if (type.equals("home")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_HOME);
      } else if (type.equals("work")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_WORK);
      } else {
        stdout.println("Unknown phone type.  Using default.");
      }

      person.addPhones(phoneNumber);
    }

    return person.build();
  }

  // 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.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  AddPerson ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    AddressBook.Builder addressBook = AddressBook.newBuilder();

    // Read the existing address book.
    try {
      addressBook.mergeFrom(new FileInputStream(args[0]));
    } catch (FileNotFoundException e) {
      System.out.println(args[0] + ": File not found.  Creating a new file.");
    }

    // Add an address.
    addressBook.addPerson(
      PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
                       System.out));

    // Write the new address book back to disk.
    FileOutputStream output = new FileOutputStream(args[0]);
    addressBook.build().writeTo(output);
    output.close();
  }
}

Message の読み取り

もちろん、アドレス帳から情報を取得できなければ、あまり役に立ちません。この例では、上記の例で作成されたファイルを読み取り、その中のすべての情報を印刷します。

import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintStream;

class ListPeople {
  // Iterates though all people in the AddressBook and prints info about them.
  static void Print(AddressBook addressBook) {
    for (Person person: addressBook.getPeopleList()) {
      System.out.println("Person ID: " + person.getId());
      System.out.println("  Name: " + person.getName());
      if (person.hasEmail()) {
        System.out.println("  E-mail address: " + person.getEmail());
      }

      for (Person.PhoneNumber phoneNumber : person.getPhonesList()) {
        switch (phoneNumber.getType()) {
          case PHONE_TYPE_MOBILE:
            System.out.print("  Mobile phone #: ");
            break;
          case PHONE_TYPE_HOME:
            System.out.print("  Home phone #: ");
            break;
          case PHONE_TYPE_WORK:
            System.out.print("  Work phone #: ");
            break;
        }
        System.out.println(phoneNumber.getNumber());
      }
    }
  }

  // Main function:  Reads the entire address book from a file and prints all
  //   the information inside.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  ListPeople ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    // Read the existing address book.
    AddressBook addressBook =
      AddressBook.parseFrom(new FileInputStream(args[0]));

    Print(addressBook);
  }
}

Protocol Buffer の拡張

Protocol Buffer を使用するコードをリリースした後、遅かれ早かれ、Protocol Buffer の定義を「改善」したくなるでしょう。新しいバッファに後方互換性を持たせ、古いバッファに前方互換性を持たせたい場合(そしてほぼ確実にそうしたいでしょう)、従う必要のあるルールがいくつかあります。Protocol Buffer の新しいバージョンでは

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

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

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

高度な使用法

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

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

リフレクションは、Message および Message.Builder インターフェイスの一部として提供されます。