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_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 Buffer はそれを行いません。
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
クラスがあります。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 リファレンス を参照してください。
重要
Protocol Buffers とオブジェクト指向設計 Protocol Buffer クラスは、基本的に追加機能を提供しないデータホルダー(C の構造体のようなもの)です。オブジェクトモデルで優れた第一級市民になるわけではありません。生成されたクラスに豊富な動作を追加したい場合、これを行う最良の方法は、生成された Protocol Buffer クラスをアプリケーション固有のクラスでラップすることです。Protocol Buffer のラッピングは、.proto
ファイルの設計を制御できない場合(たとえば、別のプロジェクトから再利用している場合など)にも良い考えです。その場合、ラッパークラスを使用して、アプリケーションの独自の環境に適したインターフェイスを作成できます。一部のデータとメソッドを非表示にし、便利な関数を公開するなどです。生成されたクラスに継承によって動作を追加しないでください。これは内部メカニズムを破壊し、いずれにせよ適切なオブジェクト指向の実践ではありません。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
インターフェイスの一部として提供されます。