Protocol Bufferの基本:Kotlin

Protocol Buffersを扱うための基本的なKotlinプログラマー向け入門。

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

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

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

問題領域

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

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

  • kotlinx.serializationを使用する。これは、C++やPythonで書かれたアプリケーションとデータを共有する必要がある場合にはあまりうまく機能しません。kotlinx.serializationにはprotobufモードがありますが、これはprotocol buffersの全機能を提供しません。
  • データを単一の文字列にエンコードするアドホックな方法を考案できます。例えば、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内に定義されています。フィールドの1つが定義済みの値のリストから1つを持つようにしたい場合は、enum型を定義することもできます。ここでは、電話番号がPHONE_TYPE_MOBILEPHONE_TYPE_HOME、またはPHONE_TYPE_WORKのいずれかであることを指定したいとします。

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

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

フィールドが repeated の場合、そのフィールドは任意の回数(ゼロを含む)繰り返すことができます。繰り返される値の順序は Protocol Buffer に保存されます。repeated フィールドは動的にサイズ変更される配列と考えてください。

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

Protocol Buffer のコンパイル

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

  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出力先ディレクトリ内にcom/example/tutorial/protos/サブディレクトリが生成され、いくつかの生成された.javaファイルが含まれます。また、指定されたKotlin出力先ディレクトリ内にcom/example/tutorial/protos/サブディレクトリが生成され、いくつかの生成された.ktファイルが含まれます。

Protocol Buffer API

Kotlin用のProtocol Bufferコンパイラは、Java用のProtocol Bufferで生成された既存のAPIにKotlin APIを追加します。これにより、JavaとKotlinを混在させて書かれたコードベースが、特別な処理や変換なしに同じProtocol Bufferメッセージオブジェクトと相互作用できるようになります。

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

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

  • AddressBookクラス
    • これはKotlinから見ると、peopleList : List<Person>プロパティを持ちます
  • Personクラス
    • これはKotlinから見ると、nameidemail、およびphonesListプロパティを持ちます
    • numbertypeプロパティを持つPerson.PhoneNumberネストクラス
    • Person.PhoneTypeネストされた列挙型

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

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

生成される内容の詳細については、Kotlin生成コードガイドで詳しく読むことができます。

メッセージの書き込み

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

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

Protocol Buffer の拡張

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

  • 既存のフィールドのタグ番号を変更しては*いけません*。
  • フィールドを削除しても*かまいません*。
  • 新しいフィールドを追加しても*かまいません*が、新しいタグ番号(つまり、この Protocol Buffer で一度も使用されていないタグ番号。削除されたフィールドが使用していたものも含む)を使用しなければなりません。

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

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

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