Protocol Buffer の基本:Java

プロトコルバッファを使用するための基本的な Java プログラマ向けの入門。

このチュートリアルでは、プロトコルバッファの基本的な Java プログラマ向けの入門を提供します。簡単なサンプルアプリケーションの作成を通して、次のことを示します。

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

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

問題領域

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

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

  • Java シリアライゼーションを使用します。これは言語に組み込まれているためデフォルトのアプローチですが、多くの既知の問題があり (Josh Bloch 著「Effective Java」213 ページを参照)、C++ や Python で書かれたアプリケーションとデータを共有する必要がある場合にもあまりうまく機能しません。
  • データ項目を単一の文字列にエンコードするアドホックな方法を考案することもできます。たとえば、「12:3:-23:67」として4つの整数をエンコードするような方法です。これはシンプルで柔軟なアプローチですが、一度限りのエンコードおよび解析コードの記述が必要であり、解析にはわずかな実行時コストがかかります。これは、非常に単純なデータをエンコードする場合に最適です。
  • データをXMLにシリアル化します。このアプローチは、XMLが(ある程度)人間が読める形式であり、多くの言語向けにバインディングライブラリがあるため、非常に魅力的です。他のアプリケーション/プロジェクトとデータを共有したい場合には良い選択肢となります。しかし、XMLはサイズが大きくなることで悪名高く、エンコード/デコードにはアプリケーションに多大なパフォーマンスペナルティを課す可能性があります。また、XML DOMツリーをナビゲートすることは、クラス内の単純なフィールドを通常ナビゲートするよりもかなり複雑です。

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

サンプルコードの場所

サンプルコードは、ソースコードパッケージの「examples」ディレクトリに含まれています。ここからダウンロードしてください。

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

アドレス帳アプリケーションを作成するには、.proto ファイルから始める必要があります。.proto ファイル内の定義は単純です。シリアライズしたい各データ構造に対してメッセージを追加し、メッセージ内の各フィールドに名前と型を指定します。以下は、メッセージ addressbook.proto を定義する .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 内に定義されています。フィールドのいずれかに定義済みの値のリストのいずれかを持たせたい場合は、enum 型を定義することもできます。ここでは、電話番号が次の電話タイプのうちのいずれかであることを指定したいとします。PHONE_TYPE_MOBILEPHONE_TYPE_HOME、または PHONE_TYPE_WORK

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

各フィールドには、次の修飾子のいずれかをアノテーションする必要があります。

  • optional: フィールドは設定されてもされなくてもかまいません。オプションのフィールド値が設定されていない場合、デフォルト値が使用されます。単純な型の場合、例の電話番号 type のように、独自のデフォルト値を指定できます。それ以外の場合、システムデフォルトが使用されます。数値型の場合はゼロ、文字列の場合は空の文字列、ブール型の場合は false です。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは設定されていません。明示的に設定されていないオプション (または必須) フィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。
  • repeated: フィールドは何度でも (ゼロ回を含む) 繰り返すことができます。繰り返された値の順序は、プロトコルバッファで維持されます。繰り返しフィールドは、動的にサイズ変更可能な配列と考えてください。
  • required: フィールドの値が指定されている必要があります。そうでない場合、メッセージは「初期化されていない」と見なされます。初期化されていないメッセージを構築しようとすると、RuntimeException がスローされます。初期化されていないメッセージを解析しようとすると、IOException がスローされます。これ以外では、必須フィールドはオプションフィールドとまったく同じように動作します。

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

Protocol Buffer のコンパイル

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

  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 ファイルではアンダースコア付きの小文字が使用されています。この変換はプロトコルバッファコンパイラによって自動的に行われ、生成されたクラスが標準的な Java スタイルの慣例に一致するようにします。.proto ファイルのフィールド名には常にアンダースコア付きの小文字を使用する必要があります。これにより、すべての生成言語で適切な命名規則が保証されます。適切な .proto スタイルに関する詳細については、スタイルガイドを参照してください。

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

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

生成されたコードには、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 内のネストされたクラスとして生成されます。

ビルダーとメッセージ

プロトコルバッファコンパイラによって生成されるメッセージクラスはすべて不変です。メッセージオブジェクトが構築されると、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 リファレンスを参照してください。

メッセージの書き込み

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

以下は、ファイルから AddressBook を読み込み、ユーザー入力に基づいて新しい Person を追加し、新しい 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 の拡張

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

  • 既存のフィールドのタグ番号を変更しては*いけません*。
  • required フィールドを追加または削除しては*いけません*。
  • optional または repeated フィールドを削除することは*できます*。
  • 新しい optional または repeated フィールドを追加することは可能ですが、新しいタグ番号 (つまり、このプロトコルバッファで、削除されたフィールドによってでさえも使用されなかったタグ番号) を使用する必要があります。

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

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

高度な使い方

プロトコルバッファには、単純なアクセサーやシリアライゼーションを超えた用途があります。他に何ができるかについては、Java API リファレンスを必ず調べてください。

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

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