Protocol Bufferの基本: 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_files
、java_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
ファイルを生成し、ラッパークラスを外部クラスとして使用し、他のすべてのクラスをラッパークラス内にネストする従来の動作の代わりに)。
次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集合体です。bool
、int32
、float
、double
、string
など、多くの標準的な単純データ型がフィールド型として利用可能です。他のメッセージ型をフィールド型として使用することで、メッセージにさらに構造を追加することもできます。上記の例では、Person
メッセージにはPhoneNumber
メッセージが含まれ、AddressBook
メッセージにはPerson
メッセージが含まれています。他のメッセージ内にネストされたメッセージ型を定義することもできます。ご覧のとおり、PhoneNumber
型はPerson
内に定義されています。フィールドの1つを事前定義された値のリストのいずれかにしたい場合は、enum
型を定義することもできます。ここでは、電話番号が以下の電話タイプ(PHONE_TYPE_MOBILE
、PHONE_TYPE_HOME
、またはPHONE_TYPE_WORK
)のいずれかであることを指定したいとします。
各要素の「 = 1」、「 = 2」というマーカーは、そのフィールドがバイナリエンコーディングで使用する一意の「タグ」を識別します。タグ番号1~15は、それより高い番号よりもエンコードに1バイト少なくて済むため、最適化として、これらを頻繁に使用される要素や繰り返し要素に使い、16以上のタグはあまり使用されないオプション要素のために残しておくことができます。繰り返しフィールドの各要素はタグ番号を再エンコードする必要があるため、繰り返しフィールドはこの最適化の特に良い候補です。
各フィールドは以下のいずれかの修飾子でアノテーション付けされる必要があります。
optional
: フィールドは設定されてもされなくてもよい。オプションフィールドの値が設定されていない場合、デフォルト値が使用されます。単純な型の場合、例の電話番号のtype
のように、独自のデフォルト値を指定できます。そうでない場合、システムデフォルトが使用されます: 数値型はゼロ、文字列は空の文字列、ブール型はfalse。組み込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは何も設定されていません。明示的に設定されていないオプション(または必須)フィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。repeated
: フィールドは任意の回数(ゼロ回を含む)繰り返される可能性があります。繰り返し値の順序はProtocol Buffer内で保持されます。繰り返しフィールドは動的にサイズ変更される配列と考えることができます。required
: フィールドには値が提供されなければなりません。そうでない場合、メッセージは「未初期化」と見なされます。未初期化メッセージをビルドしようとするとRuntimeException
がスローされます。未初期化メッセージをパースするとIOException
がスローされます。これ以外では、必須フィールドはオプションフィールドとまったく同じように動作します。
重要
Requiredは永遠に フィールドをrequired
としてマークすることには非常に注意する必要があります。ある時点で必須フィールドの書き込みや送信を停止したい場合、そのフィールドをオプションフィールドに変更することは問題となります。古いリーダーは、このフィールドがないメッセージを不完全と見なし、意図せずに拒否または破棄する可能性があります。代わりに、バッファに対するアプリケーション固有のカスタム検証ルーチンを作成することを検討すべきです。Google内では、required
フィールドは強く推奨されていません。proto2構文で定義されたほとんどのメッセージは、optional
とrepeated
のみを使用しています。(Proto3はrequired
フィールドをまったくサポートしていません。).proto
ファイルの書き方(利用可能なすべてのフィールド型を含む)の完全なガイドは、Protocol Buffer言語ガイドにあります。ただし、クラス継承に似た機能を探さないでください。Protocol Buffersにはそのような機能はありません。
Protocol Buffersのコンパイル
.proto
ファイルができたので、次に必要なのは、AddressBook
(ひいてはPerson
およびPhoneNumber
)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、Protocol Bufferコンパイラprotoc
を.proto
ファイルに対して実行する必要があります。
コンパイラをインストールしていない場合は、パッケージをダウンロードし、READMEの指示に従ってください。
次にコンパイラを実行し、ソースディレクトリ(アプリケーションのソースコードがある場所。値を指定しない場合は現在のディレクトリが使用されます)、出力先ディレクトリ(生成されたコードの出力先。多くの場合
$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 Buffersとオブジェクト指向設計 Protocol Bufferクラスは基本的に、追加機能を提供しないデータホルダー(C言語の構造体など)です。オブジェクトモデルにおいて、優れた第一級市民とはなりません。生成されたクラスに豊富な振る舞いを追加したい場合、最善の方法は、生成されたProtocol Bufferクラスをアプリケーション固有のクラスでラップすることです。.proto
ファイルの設計を制御できない場合(例えば、他のプロジェクトのものを再利用している場合など)も、Protocol Buffersをラップするのは良い考えです。その場合、ラッパークラスを使用して、アプリケーションの固有の環境により適したインターフェースを作成できます。一部のデータとメソッドを隠蔽したり、便利な関数を公開したりするなどです。生成されたクラスから継承して振る舞いを追加してはなりません。これは内部メカニズムを破壊し、いずれにせよ良いオブジェクト指向の実践ではありません。メッセージの書き込み
では、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
インターフェースの一部として提供されます。