MessagePack IDL 仕様案
先日のMessagePackハッカソンで議論した、MessagePack IDL の仕様についてまとめます。
実際のユースケースを元に、大規模な分散アプリケーションまでカバーできる実践的な仕様を目指しました。
基本的な文法
IDLは、大きく分けて 型の定義 と サービスの定義 に分かれます。
型の定義では、RPCでやりとりしたりログに保存したりするメッセージの構造を定義します。この構造の定義から、各言語のクラス定義や、シリアライズ・デシリアライズを行うコードを生成したりするのが、IDL処理系の役割の一つです。
サービスの定義では、RPCのインタフェースを定義します。この定義からクライアントやサーバのコードを生成します。
コメント
# これはコメント // これもコメント /* これもコメント /* ネストしても */ 良いではないか */
型の定義
message
メッセージ型は↓このように定義します:
# 組み込み型 message BasicTypeExample { # <id>: <type> <name> 1: byte f1 2: short f2 3: int f3 4: long f4 5: ubyte f5 6: ushort f6 7: uint f7 8: ulong f8 9: float f9 10: double f10 11: bool f11 12: raw f12 13: string f13 } # 総称型 message ContainerTypeExample { 1: list<string> f1 2: map<string,string> f2 3: map<string, list<string,string>> f3 # 総称型はネスト可能 } # optionalとrequired message OptionalExample { 1: string f1 # required non-nullable # デフォルトはrequired 2: required string f2 # required non-nullable 3: optional string f3 # optional non-nullable } # ? を付けるとnullableになる message NullableExample { 1: string? f1 # required nullable # デフォルトはrequired 2: required string? f2 # required nullable 3: optional string? f3 # optional nullable }
exception
exceptionは例外を定義します。messageとほぼ同じですが、生成されるコードが変わってきます:
exception NotFound { 1: string message } # この例外の文字列表現は "NotFound" # 継承ができる exception KeyNotFound < NotFound { # 基底クラスの最大のID(この場合は1: string message)以下のIDは使えない 2: raw key } # この例外の文字列表現は "NotFound.KeyNotFound"
拡張仕様
採用されるかもしれないけど、とりあえず最初の実装では見送る仕様です。
デフォルト値
messageのフィールドや関数の引数にデフォルト値を設定できると便利です。
パーサの実装は難しくないのですが、右辺値と左辺値の型があっているかどうか検査する実装が大変そうな気がします(そうでもないかも)。コンテナ型にデフォルト値を設定できるようにすると、mapやlistのリテラルが存在しない言語でコード生成が大変になります。
実装する場合でも、コンテナ型のデフォルト値は設定できないようにする案が有力です。
message DefaultValueExample { 1: optional ulong flag = 1 # デフォルト値の指定 2: optional raw? value # nullableなフィールドのデフォルト値はnull 3: optional string message # nullableでないフィールドのデフォルト値は0,空文字列,falseなど }
typedef
型に別名を付ける構文です。typedefはIDL処理系の中で完結し、実際のコードの中には現れないという案と、現れた方がいいという案があります(Javaではどうすれば良いのでしょう…)
typedef ulong NodeId typedef map<string,string> PropertyMap
typespec
言語やアプリケーションごとに型をカスタマイズする構文です。
よく使う型には便利なメソッドが実装されているクラスを使いたいし、クラスを詰め直す処理も書きたくない場合など、欲しくなる機能です。
typespec cpp PropertyMap std::tr1::unordered_map<std::string,std::string> # typedefに対するtypespec typespec MyApp1 DefaultValueExample.value myapp::Value # フィールドのtypespec typespec MyApp2 DefaultValueExample myqpp::MyClass # 型に対するtypespec
サービスの定義
RPCインタフェースは service で定義し、複数のserviceをまとめて application を宣言します。
service間では名前空間が分かれており、serviceが異なれば同名の関数を宣言できます。
serviceは複数のバージョンを宣言することができ、互換性を保ったまま新しいバージョンを追加ができます:
service StorageService:0 { # <name>:<version> raw? get(1: raw key) void add(1: raw key, 2: raw value) throws DiskFullError ulong getDiskFreeSize() } service StorageService:1 { # 前のバージョンのメソッド一覧も全部書く raw? get(1: raw key) void add(1: raw key, 2: raw value) # 無くなったメソッドは書かない # 追加したメソッドも普通に書く map<raw,raw>? getAttributes(1: raw key) void setAttributes(1: raw key, 2: map<raw,raw> attrs) throws DiskFullError } service StatusService:0 { ulong getDiskFreeSize() } application MyApp { # <service>:<version> <scope> default? StorageService:1 storage default StatusService:0 status }
サーバ側のコード生成
新しいバージョンの関数は、デフォルトでは古いバージョンの関数を呼んで欲しいが、古いバージョンと挙動が異なることもあります。
オブジェクト指向言語ではなかなか実現が難しそうですが、関数を関数オブジェクトとして扱えばスマートに解決できそうです:
// serviceは、関数の一覧をメンバ変数としてコード生成 class StorageService_0 { Function<raw? (raw)> get Function<void (raw, raw)> add Function<ulong ()> getDiskFreeSize } class StorageService_1 { Function<raw? (raw)> get Function<void (raw, raw)> add Function<map<raw,raw>? (raw)> getAttributes Function<void (raw, map<raw,raw>)> setAttributes } class StatusService_0 { Function<ulong ()> getDiskFreeSize } // applicationは、シングルトンのインスタンス object MyApp { // 宣言されたserviceを、過去のバージョンを含めて保持 StorageService_0 storage_0; StorageService_1 storage_1; StatusService status_0; // serviceの関数には初期値が設定されている: // 古いバージョンの関数が存在すれば、古いバージョンの関数を呼び出す関数 storage_1.get = (raw key) => storage_0.get(key) storage_1.add = (raw key, raw value) => storage_0.add(key, value) // そうでなければ、例外を投げる関数 storage_0.get = (raw key) => throw new org.msgpack.rpc.NotImpelmentedError storage_0.add = (raw key, raw value) => throw new org.msgpack.rpc.NotImpelmentedError storage_0.getDiskFreeSize = () => throw new org.msgpack.rpc.NotImpelmentedError storage_1.getAttributes = (raw key) => throw new org.msgpack.rpc.NotImpelmentedError storage_1.setAttributes = (raw key, map<raw,raw>) => throw new org.msgpack.rpc.NotImpelmentedError status_0.getDiskFreeSize = (raw key, raw value) => throw new org.msgpack.rpc.NotImpelmentedError }
関数を実装する場合は、このように関数オブジェクトを代入します:
// 関数を代入して処理を実装 MyApp.storage_0.get = (raw key) => { return ... } // 新しいバージョンで挙動が変わった場合でも対応可能 MyApp.storage_1.get = (raw key) => { return ... } // 互換性を維持するためには、古い関数も実装しておく(実装を残しておく) MyApp.storage_0.getDiskFreeSize = () => { return ... } MyApp.status_0.getDiskFreeSize = MyApp.storage_0.getDiskFreeSize
クライアント側のコード生成
クライアント側では、バージョンの選択に加えて、どの名前空間(scope)の関数を呼ぶか? という点が問題になってきます。
まず、アプリケーションではこのように使います:
// アプリケーションからはこのように使う: MyApp remote = new MyApp(host, port) StorageService_1 storage = remote.storage().version1() storage.add("key", "value") // add:storage:1 を呼ぶ storage.get("key") // get:storage:1 を呼ぶ StatusService_0 stat = remote.status().version0() stat.getDiskFreeSize() // getDiskFreeSize:stat:0 を呼ぶ
これは↓このようなコードを生成すると実現できます:
// serviceは、RPCを行う関数を実装したクラス(直接インスタンス化はできない) abstract class StorageService_0 { raw? get(raw key) void add(raw key, raw value) throws DiskFullError ulong getDiskFreeSize() } abstract class StorageService_1 { raw? get(raw key) void add(raw key, raw value) map<raw,raw> getAttributes(raw key) void setAttributes(raw key, map<raw,raw> attrs) throws DiskFullError } interface StorageService { StorageService_0 version0() // バージョン0のRPCインタフェースを返す StorageService_1 version1() // バージョン1のRPCインタフェースを返す } abstract class StatusService_0 { ulong getDiskFreeSize() } interface StatusService { StatusService_0 version0() // バージョン0のRPCインタフェースを返す } // applicationはファクトリークラス class MyApp { MyApp(host, port); StorageService storage() StatusService status() }
拡張仕様
採用されるかもしれないけど、とりあえず最初の実装では見送る仕様です。
メタサービス
サービスの情報を取得するサービスがあると非常に便利です。例えば、サーバ側のバージョンを取得して処理を分岐したりできます。
applicationには暗黙的に組み込みのメタサービスが存在することにすると、きれいにまとまりそうです:
# メタ関数を提供する組み込みのサービスが定義されていることにする service BuiltInService:0 { # 指定されたscopeの最新バージョンを返す int getVersion(1: string scope) } # applicationの定義では... application MyApp { StorageService:1 storage default StatusService:0 status BuiltInService:0 _ # このscopeが暗黙的に存在することにする }
サーバ側では、BuiltInServiceの実装はコード生成器が自動的に生成します。
クライアント側では、このような関数を提供します:
MyApp remote = new MyApp(host, port) int v = remote.storage().getVersion() // メタ関数の呼び出し // この呼び出しと同じ: int v = remote._().version0().getVersion("storage") // 接続先のバージョンによって処理を分岐するクライアントを書ける switch(v) { case 0: return remote.storage().version0() defaut: /* 将来もversion 1がサポートされ続けることを期待 */ return remote.storage().version1() }
同様にして、serviceに定義されている関数の一覧を取得する機能も実現できそうです。Rubyで使いたくなりそうですね。
interface
RPCのクライアント側で、ある特定の関数群だけを使うメソッドを書くことは良くあります。
すべての関数を1つのserviceに記述していると、serviceはバージョンアップ時にクラス名が変わってしまうため、そのserviceを使っているすべてのコードを書き換える必要があります(静的型付け言語の場合)。
interfaceを導入すると、この問題を回避できます:
typedef map<raw,raw> Row interface TableInterface:0 { void insert(1: Row row) Row? selectMatch(1: raw column, 2: raw data) Row? selectPrefixMatch(1: raw column, 2: raw prefix) } interface KeyValueInterface:1 { void set(1: raw key, 2: raw value) raw? get(1: raw key) } service StorageService:2 { implements TableInterface:0 implements KeyValueInterface:1 uint getNumEntries() } application MyApp { StorageService storage default }
interfaceにもバージョン番号を付けられますが、実際にこのバージョン番号がネットワーク上を流れることはありません。コード生成されるクラス名だけに使われます。
サーバ側のコード生成は、単にserviceの中にinterfaceの関数群を取り込んだものを生成します。
# この定義と同じコードを生成:
service StorageService:2 {
void insert(1: Row row)
Row? selectMatch(1: raw column, 2: raw data)
Row? selectPrefixMatch(1: raw column, 2: raw prefix)
void set(1: raw key, 2: raw value)
raw? get(1: raw key)
uint getNumEntries()
}
クライアント側では、serviceをinterfaceにアップキャストして利用できるようにコード生成します。
interface TableInterface_0 { void insert(Row row) Row? selectMatch(raw column, raw data) Row? selectPrefixMatch(raw column, raw prefix) } interface KeyValueInterface_1 { void set(raw key, raw value) raw? get(raw key) } abstract class StorageService_2 implements TableInterface_0, KeyValueInterface_1 { void insert(Row row) Row? selectMatch(raw column, raw data) Row? selectPrefixMatch(raw column, raw prefix) void set(raw key, raw value) raw? get(raw key) uint getNumEntries() }
scopeなしで使う
1つのapplicationを複数のserviceに分割して定義する機能は、複数のモジュールで構成される大規模なプログラムには必要になりますが、ちょっとしたプログラムを書きたいときには不便です。
scopeなしでも使えると、カジュアルなユースケースにも対応できそうです。
サーバ側では、serviceを定義と共に暗黙的に同名のapplicationが定義されると考えれば、シンプルに対応できそうです。
object StorageService { StorageService_0 _0 // scopeが無名 StorageService_1 _1 // scopeが無名 BuiltInService __0 // メタ関数サービス _1.get = (raw key) => _0.get(key) _1.add = (raw key, raw value) => _0.add(key, value) _0.get = (raw key) => throw new org.msgpack.rpc.NotImpelmentedError _0.add = (raw key, raw value) => throw new org.msgpack.rpc.NotImpelmentedError _0.getDiskFreeSize = () => throw new org.msgpack.rpc.NotImpelmentedError _1.getAttributes = (raw key) => throw new org.msgpack.rpc.NotImpelmentedError _1.setAttributes = (raw key, map<raw,raw>) => throw new org.msgpack.rpc.NotImpelmentedError } StorageService._0.get = (raw key) => { return ... } StorageService._1.get = (raw key) => { return ... } StorageService._0.getDiskFreeSize = () => { return ... }
クライアント側では、serviceごとに生成するクラスにクラスメソッドを追加するだけで良さそうです。
abstract class StorageService_0 { static StorageService_0 open(host, port, scope="") } abstract class StorageService_1 { static StorageService_1 open(host, port, scope="") }