Protocol Bufferの基本: Java

Protocol Buffersを扱うためのJavaプログラマー向けの基本的な紹介です。

このチュートリアルでは、Protocol Buffersを扱うためのJavaプログラマー向けの基本的な紹介を提供します。簡単なサンプルアプリケーションを作成する手順を通して、以下の方法を説明します。

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

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

問題領域

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

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

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

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_packagejava_outer_classnamejava_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 Buffersにはそのような機能はありません。

Protocol Buffersのコンパイル

.protoファイルができたので、次に必要なのは、AddressBook(ひいてはPersonおよびPhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、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クラスがあります。ビルダーに関する詳細は、以下のビルダーとメッセージセクションで確認できます。

メッセージとビルダーの両方に、メッセージの各フィールドの自動生成されたアクセサーメソッドがあります。メッセージにはゲッターのみがあり、ビルダーにはゲッターとセッターの両方があります。以下は、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ファイルでアンダースコア付きの小文字を使用しているにもかかわらず、キャメルケースの命名を使用していることに注目してください。この変換はProtocol Bufferコンパイラによって自動的に行われ、生成されたクラスが標準のJavaスタイル規約に一致するようにします。.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内のネストされたクラスとして生成されます。

ビルダーとメッセージ

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

メッセージを変更するビルダーの各メソッドが別のビルダーを返すことに気づいたかもしれません。返されるオブジェクトは、実際にはメソッドを呼び出したのと同じビルダーです。これは、複数のセッターを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();

標準メッセージメソッド

各メッセージおよびビルダークラスには、メッセージ全体をチェックまたは操作できる他のメソッドも多数含まれています。以下はその例です。

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

これらのメソッドは、すべてのJavaメッセージおよびビルダーで共有される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リファレンスを参照してください。

メッセージの書き込み

では、Protocol Bufferクラスを使ってみましょう。アドレス帳アプリケーションにまずさせたいことは、個人情報をアドレス帳ファイルに書き込むことです。これを行うには、Protocol Bufferクラスのインスタンスを作成してデータを格納し、それらをOutputStreamに書き込む必要があります。

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

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

メッセージの読み込み

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

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_で明示的に設定されているかどうかを確認するか、タグ番号の後に.protoファイルで[default = value]を使用して妥当なデフォルト値を指定する必要があります。オプション要素にデフォルト値が指定されていない場合、型固有のデフォルト値が代わりに使用されます。文字列の場合は空の文字列、ブール値の場合はfalse、数値型の場合はゼロです。また、新しい繰り返しフィールドを追加した場合、そのフィールドにはhas_フラグがないため、新しいコードによって空にされたのか(新しいコードによる)、あるいは全く設定されなかったのか(古いコードによる)を判断できないことに注意してください。

高度な使用法

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

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

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