Protocol Buffer の基本:Dart

Protocol Buffer を使用するための Dart プログラマー向けの基本的な紹介。

このチュートリアルでは、プロトコルバッファ言語の proto3 バージョンを使用して、プロトコルバッファを扱うための基本的な Dart プログラマー向け入門を提供します。簡単なサンプルアプリケーションの作成を通して、以下の方法を示します。

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

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

問題領域

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

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

  • データ項目を1つの文字列にエンコードするアドホックな方法を考案できます。たとえば、4つの整数を「12:3:-23:67」のようにエンコードします。これはシンプルで柔軟なアプローチですが、一度限りのエンコードおよびパースコードの記述が必要であり、パースにはわずかな実行時コストがかかります。これは非常にシンプルなデータをエンコードする場合に最適です。
  • データを XML にシリアル化します。このアプローチは、XML が(ある程度)人間が読める形式であり、多くの言語に対応するバインディングライブラリがあるため、非常に魅力的です。他のアプリケーションやプロジェクトとデータを共有したい場合には良い選択肢となります。ただし、XML は空間を大量に消費することで知られており、そのエンコード/デコードはアプリケーションに大きなパフォーマンス上のペナルティを課す可能性があります。また、XML DOM ツリーをナビゲートすることは、クラス内の単純なフィールドを通常ナビゲートするよりもかなり複雑です。

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

サンプルコードの場所

私たちの例は、プロトコルバッファを使用してエンコードされたアドレス帳データファイルを管理するためのコマンドラインアプリケーションのセットです。コマンド dart add_person.dart はデータファイルに新しいエントリを追加します。コマンド dart list_people.dart はデータファイルを解析し、データをコンソールに出力します。

完全な例は、GitHub リポジトリの examples ディレクトリで見つけることができます。

プロトコル形式の定義

アドレス帳アプリケーションを作成するには、まず .proto ファイルから始める必要があります。.proto ファイルの定義はシンプルです。シリアル化したい各データ構造に対して メッセージ を追加し、次にメッセージ内の各フィールドの名前と型を指定します。私たちの例では、メッセージを定義する .proto ファイルは addressbook.proto です。

.proto ファイルは、異なるプロジェクト間の名前の競合を防ぐのに役立つパッケージ宣言から始まります。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む集合体です。boolint32floatdoublestring など、多くの標準的な単純なデータ型がフィールド型として利用できます。また、他のメッセージ型をフィールド型として使用することで、メッセージにさらなる構造を追加することもできます。

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

上記の例では、Person メッセージには PhoneNumber メッセージが含まれ、AddressBook メッセージには Person メッセージが含まれています。他のメッセージ内にネストされたメッセージ型を定義することもできます。ご覧のとおり、PhoneNumber 型は Person の内部で定義されています。フィールドが事前に定義された値のリストのいずれかを持つようにしたい場合は、enum 型を定義することもできます。ここでは、電話番号が PHONE_TYPE_MOBILEPHONE_TYPE_HOME、または PHONE_TYPE_WORK のいずれかであることを指定したいとします。

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

フィールド値が設定されていない場合、デフォルト値が使用されます。数値型の場合はゼロ、文字列の場合は空の文字列、ブール型の場合は false です。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは何も設定されていません。明示的に設定されていないフィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。

フィールドが repeated の場合、そのフィールドは任意の回数(ゼロ回を含む)繰り返すことができます。繰り返し値の順序はプロトコルバッファ内で保持されます。繰り返しフィールドは、動的にサイズ変更可能な配列と考えることができます。

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

Protocol Buffer のコンパイル

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

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

  2. Dart プロトコルバッファプラグインを その README に記載されているとおりにインストールしてください。プロトコルバッファの protoc が見つけられるように、実行可能ファイル bin/protoc-gen-dartPATH に含まれている必要があります。

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

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

    Dart コードが必要なので、--dart_out オプションを使用します。他のサポートされている言語にも同様のオプションが提供されています。

これにより、指定したデスティネーションディレクトリに addressbook.pb.dart が生成されます。

Protocol Buffer API

addressbook.pb.dart を生成すると、以下の便利な型が提供されます。

  • List<Person> get people ゲッターを持つ AddressBook クラス。
  • nameidemailphones のアクセサメソッドを持つ Person クラス。
  • numbertype のアクセサメソッドを持つ Person_PhoneNumber クラス。
  • Person.PhoneType 列挙型内の各値に対する静的フィールドを持つ Person_PhoneType クラス。

何が正確に生成されるかの詳細については、Dart 生成コードガイドで詳しく読むことができます。

メッセージの書き込み

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

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

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';

// This function fills in a Person message based on user input.
Person promptForAddress() {
  Person person = Person();

  print('Enter person ID: ');
  String input = stdin.readLineSync();
  person.id = int.parse(input);

  print('Enter name');
  person.name = stdin.readLineSync();

  print('Enter email address (blank for none) : ');
  String email = stdin.readLineSync();
  if (email.isNotEmpty) {
    person.email = email;
  }

  while (true) {
    print('Enter a phone number (or leave blank to finish): ');
    String number = stdin.readLineSync();
    if (number.isEmpty) break;

    Person_PhoneNumber phoneNumber = Person_PhoneNumber();

    phoneNumber.number = number;
    print('Is this a mobile, home, or work phone? ');

    String type = stdin.readLineSync();
    switch (type) {
      case 'mobile':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_MOBILE;
        break;
      case 'home':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_HOME;
        break;
      case 'work':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_WORK;
        break;
      default:
        print('Unknown phone type.  Using default.');
    }
    person.phones.add(phoneNumber);
  }

  return person;
}

// Reads the entire address book from a file, adds one person based
// on user input, then writes it back out to the same file.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: add_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  File file = File(arguments.first);
  AddressBook addressBook;
  if (!file.existsSync()) {
    print('File not found. Creating new file.');
    addressBook = AddressBook();
  } else {
    addressBook = AddressBook.fromBuffer(file.readAsBytesSync());
  }
  addressBook.people.add(promptForAddress());
  file.writeAsBytes(addressBook.writeToBuffer());
}

メッセージの読み込み

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

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';
import 'dart_tutorial/addressbook.pbenum.dart';

// Iterates though all people in the AddressBook and prints info about them.
void printAddressBook(AddressBook addressBook) {
  for (Person person in addressBook.people) {
    print('Person ID: ${ person.id}');
    print('  Name: ${ person.name}');
    if (person.hasEmail()) {
      print('  E-mail address:${ person.email}');
    }

    for (Person_PhoneNumber phoneNumber in person.phones) {
      switch (phoneNumber.type) {
        case Person_PhoneType.PHONE_TYPE_MOBILE:
          print('   Mobile phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_HOME:
          print('   Home phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_WORK:
          print('   Work phone #: ');
          break;
        default:
          print('   Unknown phone #: ');
          break;
      }
      print(phoneNumber.number);
    }
  }
}

// Reads the entire address book from a file and prints all
// the information inside.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: list_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  // Read the existing address book.
  File file = new File(arguments.first);
 AddressBook addressBook = new AddressBook.fromBuffer(file.readAsBytesSync());
  printAddressBook(addressBook);
}

Protocol Buffer の拡張

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

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

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

これらのルールに従うと、古いコードは新しいメッセージを問題なく読み取り、新しいフィールドは単純に無視します。古いコードにとっては、削除された単一のフィールドはデフォルト値を持つことになり、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み取ることができます。

ただし、新しいフィールドは古いメッセージには存在しないため、デフォルト値に対して何か合理的な処理を行う必要があることに留意してください。型固有のデフォルト値が使用されます。文字列の場合、デフォルト値は空の文字列です。ブール型の場合、デフォルト値は false です。数値型の場合、デフォルト値はゼロです。