プロトコル バッファの基本: Kotlin

Kotlin プログラマー向けのプロトコル バッファの基本的な入門。

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

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

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

問題領域

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

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

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

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

サンプルコードの場所

このサンプルは、プロトコル バッファを使用してエンコードされたアドレス帳データファイルを管理するためのコマンドライン アプリケーションのセットです。コマンド add_person_kotlin は、データファイルに新しいエントリを追加します。コマンド list_people_kotlin は、データファイルをパースし、データをコンソールに出力します。

完全なサンプルは、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 の場合、そのフィールドは任意の回数 (0 回を含む) 繰り返すことができます。繰り返し値の順序はプロトコル バッファで保持されます。繰り返しフィールドは、動的なサイズの配列と考えるとよいでしょう。

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

プロトコル バッファのコンパイル

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

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

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

    protoc -I=$SRC_DIR --java_out=$DST_DIR --kotlin_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    Kotlin コードを生成したいので、--kotlin_out オプションを使用します。他のサポートされている言語にも同様のオプションが用意されています。

Kotlin コードを生成する場合、--java_out--kotlin_out の両方を使用する必要があることに注意してください。これにより、指定した Java の出力ディレクトリにいくつかの生成された .java ファイルを含む com/example/tutorial/protos/ サブディレクトリが生成され、指定した Kotlin の出力ディレクトリにいくつかの生成された .kt ファイルを含む com/example/tutorial/protos/ サブディレクトリが生成されます。

プロトコル バッファ API

Kotlin 用のプロトコル バッファ コンパイラは、Java 用のプロトコル バッファに対して生成された既存の API に追加される Kotlin API を生成します。これにより、Java と Kotlin が混在するコードベースが、特別な処理や変換なしに同じプロトコル バッファ メッセージ オブジェクトとやり取りできるようになります。

JavaScript やネイティブなど、他の Kotlin コンパイルターゲット向けのプロトコル バッファは現在サポートされていません。

addressbook.proto をコンパイルすると、Java で以下の API が提供されます。

  • AddressBook クラス
    • Kotlin からは、peopleList : List<Person> プロパティを持ちます
  • Person クラス
    • Kotlin からは、nameidemail、および phonesList プロパティを持ちます
    • number および type プロパティを持つ Person.PhoneNumber ネストされたクラス
    • Person.PhoneType ネストされた Enum

しかし、以下の Kotlin API も生成します。

  • addressBook { ... } および person { ... } ファクトリ メソッド
  • phoneNumber { ... } ファクトリ メソッドを持つ PersonKt オブジェクト

何が生成されるかに関する詳細については、Kotlin 生成コードガイドで確認できます。

メッセージの書き込み

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

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

import com.example.tutorial.Person
import com.example.tutorial.AddressBook
import com.example.tutorial.person
import com.example.tutorial.addressBook
import com.example.tutorial.PersonKt.phoneNumber
import java.util.Scanner

// This function fills in a Person message based on user input.
fun promptPerson(): Person = person {
  print("Enter person ID: ")
  id = readLine().toInt()

  print("Enter name: ")
  name = readLine()

  print("Enter email address (blank for none): ")
  val email = readLine()
  if (email.isNotEmpty()) {
    this.email = email
  }

  while (true) {
    print("Enter a phone number (or leave blank to finish): ")
    val number = readLine()
    if (number.isEmpty()) break

    print("Is this a mobile, home, or work phone? ")
    val type = when (readLine()) {
      "mobile" -> Person.PhoneType.PHONE_TYPE_MOBILE
      "home" -> Person.PhoneType.PHONE_TYPE_HOME
      "work" -> Person.PhoneType.PHONE_TYPE_WORK
      else -> {
        println("Unknown phone type.  Using home.")
        Person.PhoneType.PHONE_TYPE_HOME
      }
    }
    phones += phoneNumber {
      this.number = number
      this.type = type
    }
  }
}

// Reads the entire address book from a file, adds one person based
// on user input, then writes it back out to the same file.
fun main(args: List) {
  if (arguments.size != 1) {
    println("Usage: add_person ADDRESS_BOOK_FILE")
    exitProcess(-1)
  }
  val path = Path(arguments.single())
  val initialAddressBook = if (!path.exists()) {
    println("File not found. Creating new file.")
    addressBook {}
  } else {
    path.inputStream().use {
      AddressBook.newBuilder().mergeFrom(it).build()
    }
  }
  path.outputStream().use {
    initialAddressBook.copy { peopleList += promptPerson() }.writeTo(it)
  }
}

メッセージの読み取り

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

import com.example.tutorial.Person
import com.example.tutorial.AddressBook

// Iterates though all people in the AddressBook and prints info about them.
fun print(addressBook: AddressBook) {
  for (person in addressBook.peopleList) {
    println("Person ID: ${person.id}")
    println("  Name: ${person.name}")
    if (person.hasEmail()) {
      println("  Email address: ${person.email}")
    }
    for (phoneNumber in person.phonesList) {
      val modifier = when (phoneNumber.type) {
        Person.PhoneType.PHONE_TYPE_MOBILE -> "Mobile"
        Person.PhoneType.PHONE_TYPE_HOME -> "Home"
        Person.PhoneType.PHONE_TYPE_WORK -> "Work"
        else -> "Unknown"
      }
      println("  $modifier phone #: ${phoneNumber.number}")
    }
  }
}

fun main(args: List) {
  if (arguments.size != 1) {
    println("Usage: list_person ADDRESS_BOOK_FILE")
    exitProcess(-1)
  }
  Path(arguments.single()).inputStream().use {
    print(AddressBook.newBuilder().mergeFrom(it).build())
  }
}

プロトコル バッファの拡張

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

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

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

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

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