第3回MessagePackハッカソン開催報告
4月3日、MessagePackハッカソン第3回を開催しました。
14人のユーザーと開発者が集まり、実際のユースケースを元にしながら多くの問題が解決(への方針が決定)しました。
RPCのバージョニングサポート
背景
ソフトウェアはバージョンアップを繰り返すものですが、分散型のアプリケーションでは、単一のシステムの中で新旧のバージョンが混在して運用されることがあります。昨今のスケーラブルな分散システムでは、システム全体を停止せずに部分的にプログラムをバージョンアップさせていく ローリングアップデート と呼ばれる手法が利用されており、このようなケースでも 新旧のバージョンが互換性を保ったまま相互に通信可能である必要があります。
Thrift では、関数の引数やstructのメンバに optional という修飾子を付けることで、互換性を保ったまま引数やメンバの追加ができるようになっています。
しかし実際には、引数やメンバの追加だけではなく、もっと大きな変更を加えたいケースが多くあります。例えば、バージョン1で A という関数があったが、バージョン2で B と C の2つの関数に分離され、A はまだ呼び出せるが、未来のバージョンで削除される予定なので呼び出すべきではない、というケースがあります。他にも、引数や返り値の型を変更したい場合には、Thriftの方法では対処できません。
- 異なるバージョンで同名の関数を呼び分けたい
- ある機能を実現する一連の関数群があるとき、機能の単位でバージョンを管理したい
- 古いバージョンが呼び出されたときは、警告を表示するようにしたい
プロトコル
MessagePack-RPCのリクエストメッセージは、[REQUEST, msgid, method, args] という4要素の配列です。methodは文字列で、関数名を表しています。
このmethodを、
例: "get:0", "get:1"
互換性の維持のために(また使い勝手のために)、versionは省略可能とします。サーバはversionが省略されていたら、同名の関数の中で最新のバージョンを呼び出すことにします。
IDL
他の要求は IDL で解決します。これは後述します。
RPCの名前空間サポート
背景
1つのサーバプログラムが複数のモジュールから構成されていることは良くあります。それぞれのモジュールを別々の人が設計・実装していることも多いでしょう。それらのモジュールで関数名が重複しないようにするのは、大変な作業です。
RPCの関数名に名前空間を導入することで、このような複雑なアプリケーションをシンプルに実装することができます。
プロトコル
前述のバージョンと同様に、methodを次のように拡張します:
互換性の維持のために、scopeは省略可能とします。サーバはscopeが省略されていたら、デフォルトの名前空間を使うことにします。;
scopeとversionは、どちらも省略可能です。片方だけが省略されていた場合は、次のルールで識別します:
scopeの命名規則:/[a-zA-Z][a-zA-Z_]*/(先頭に数字は使えない)
versionの命名規則:/[0-9]+/(先頭は数字)
RPCのエラー処理
背景
マトモなアプリケーションでは、マトモなエラー処理は必須ですね。返り値でエラーを返すのは、実装もデバッグも大変です。
現在のプロトコルでエラーを返すには、[RESPONSE, msgid, error, result] というメッセージを返します。error と result は任意のオブジェクトです。
このプロトコルでエラーの種類や詳細を返すには、アプリケーション側で対処が必要です。この方法を標準化することで、相互互換性を高めることができます。
考慮すべきことは、エラーの種類はプログラムのバージョンアップ時に変化する可能性があるという点です。
例えば、エラーを細分化(特化)させたいケースがあります。例えば、NotFound というエラーを次のバージョンで KeyNotFound extends NotFound と BucketNotFound extends NotFound に細分化するようなケースです。
プロトコル
エラーを返すプロトコルを [RESPONSE, msgid, error_type, error_object] と拡張します(これもプロトコルの拡張というよりは、既存のプロトコル仕様の上位に新たな標準を加えている)
error_type はエラーの種類を表し、エラーの種類のグループ関係をドットで区切った文字列です。例:"NotFound.KeyNotFound"。
error_object はエラーの詳細を表し、通常は配列です。例:["key not found", "key1"]
クライアントは未知のエラーを受け取ったとき、より親のグループで処理することが可能です。例えば KeyNotFound を知らない古いクライアントが、新しいサーバから "NotFound.KeyNotFound" を受け取った場合でも、NotFound として扱うことができます。
組み込みのエラー
アプリケーション定義のエラーとは別に、MessagePack-RPCのライブラリ側で扱う組み込みのエラーが必要になります。
次のような区分が提案されています:
RPCError | +-- TimeoutError | +-- ClientError | | | +-- TransportError | | | | | +-- NetworkUnreachableError | | | | | +-- ConnectionRefusedError | | | | | +-- ConnectionTimeoutError | | | +-- MessageRefusedError | | | | | +-- MessageTooLargeError | | | +-- CallError | | | +-- NoMethodError | | | +-- ArgumentError | +-- ServerError | | | +-- ServerBusyError | +-- RemoteError | +-- RemoteRuntimeError | +-- (user-defined errors)
アプリケーションに近い型の扱い
Javaで、Dateクラスを扱いたいという提案がありました。
今後も様々な型を扱えるようにしたいという要求がどんどん出てくることが予想されます。アプリケーション層に近い型は、その要求の詳細は今後変化していくでしょう。
しかし、下層(MessagePackの型)に新たな型を追加すると、多言語対応が難しくなる、仕様が複雑になる、JSONと相互変換できなくなるなどの問題が発生します。具体的に言えば、BSONようになってしまうという意味です。"Function" や "MD5" という型がプリミティブとして定義されています。Min key という謎の型*1もあります。SHA1は? 多言語対応はできるのでしょうか?
しかし、"対応している言語で" 合意された標準的な方法があれば、相互互換性が高まることは確かです。
そこで、アプリケーションの型をMessagePackの型と対応付けるガイドラインを用意していくことで対応します。
実装としては、MessagePackの型に新たな型を追加する代わりに、MessagePackの型と言語の型を変換するところに新しいコードを追加します。
日付型
7つの案が出ました:
- 基本:epocからの経過時刻を保存する。タイムゾーンはUTCに固定する
- 案1:経過秒を整数で保存
- 案2:経過ミリ秒を整数で保存
- 案3:経過マイクロ秒を整数で保存
- 案4:経過を浮動小数点数で保存(精度は秒またはミリ秒またはマイクロ秒)
- 案5:(経過秒, マイクロ秒)を2要素の配列で保存
- 案6:(整数, 精度)を2要素の配列で保存
- 案7:実装しない(アプリケーションごとに対応する)
ユースケースとしては、「人間」が入力した時刻を記録するにはミリ秒程度の精度があれば十分だが、プログラムが扱うタイムスタンプにはマイクロ秒以上の精度が欲しくなります。
決定的な案が出なかったので、とりあえずJava版では案2の「ミリ秒を整数で保存する」方法でDateクラスのシリアライズを実装してみることになりました。
decimal型
Java版で、BigDecimalクラスを扱いたいという提案がありました。ユースケースとしては、お金の計算に使います。
- 案1:文字列で保存
- 案2:Binary Coded Decimal (BCD) で保存する
- 案3:Densely Packed Decimal (DPD) で保存する(DPDはIEEE 754-2008で標準化されている)
- 案4:実装しない(アプリケーションごとに対応する)
とりあえずJava版で案1と案2(余裕があれば案3?)を実装してみることになりました。
難しいですねぇ…。
Java版:Templateプリコンパイラ
Java版では、ユーザー定義クラスのシリアライズ/デシリアライズを行う Template を、実行時にコード生成してコンパイル・ロードする機能が実装されています。
しかし、Android(DalvikVM)ではクラスを動的にロードできないため、動かない(リフレクションベースの遅い実装にフォールバックする)という問題がありました。また、初回利用時に少し時間がかかるというデメリットもあります。
現在実装中です。
圧縮
長いテキストを扱う全文検索エンジンや、インターネット越しにメッセージのやりとりを行いたいシステムでは、データを圧縮することで性能を向上させることができます。
- 案1:MessagePackの仕様で対応
- 例:0x04 0x00 DEFLATE_STREAM ...
- メリット:アプリケーションは少ない変更で圧縮の効果を得られる
- デメリット:オブジェクトごとの圧縮になるので、圧縮率が低くなる
- デメリット:圧縮アルゴリズムを追加するたびに互換性は失われ、他の言語への移植が難しくなる
- 案2:RPCのプロトコルで対応
- 案3:RPCのトランスポート層にプラグインする
- 例:圧縮TCPトランスポート
- メリット:ストリーム全体で圧縮するので、圧縮率が高くなる
- メリット:トランスポート層のプラグイン機構を使えるため、既存のライブラリは変更せずに済む
- デメリット:サーバとクライアントで同じ圧縮アルゴリズムを使うように、人間が設定する必要がある
- ただしエラーの表示は可能(TransportError)
- 案4:実装しない(アプリケーションごとに対応する)
案3の方針が有力ですが、トランスポート層の仕様までは決まらず、現状ではプラグインの機構に従ってアプリケーションで対応しつつ、良い実装が現れれば標準仕様に取り込むことになりそうです。
プロジェクト
JIRA(チケット管理)とConfluence(WIki)を導入しました。
JIRAはチケットのネストができます。日付型が欲しい→Java版で日付型を実装 というように、言語をまたいだ共通の提案→各言語の実装 という要求にぴったり合います。
それぞれ以下のURLにあります:
IDL
大いに議論が盛り上がりました。次のエントリで詳しくまとめます。
*1:MongoDBで使うようです