読者です 読者をやめる 読者になる 読者になる

MessagePack IDL 仕様案

先日MessagePackハッカソンで議論した、MessagePack IDL の仕様についてまとめます。
実際のユースケースを元に、大規模な分散アプリケーションまでカバーできる実践的な仕様を目指しました。

基本的な文法

IDLは、大きく分けて 型の定義サービスの定義 に分かれます。

型の定義では、RPCでやりとりしたりログに保存したりするメッセージの構造を定義します。この構造の定義から、各言語のクラス定義や、シリアライズ・デシリアライズを行うコードを生成したりするのが、IDL処理系の役割の一つです。

サービスの定義では、RPCのインタフェースを定義します。この定義からクライアントやサーバのコードを生成します。

コメント
# これはコメント
// これもコメント
/* これもコメント /* ネストしても */ 良いではないか */
名前空間

名前空間は、Javaではパッケージ名、C++ではnamespace、Rubyではmoduleになります。
言語ごとに名前空間に関する文化が異なるので、言語ごとに定義を上書きできるようにしています。

namespace com.example    # 全言語向けの定義 区切りは.
namespace ruby Example   # 特定の言語で定義を上書き

型の定義

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"
enum
enum EnumExample {
    0: RED
    1: GREEN
    2: BLUE
}

enumは、Javaではクラス名の中で定義される(EnumClass.FIELDとアクセスする)一方、Cでは外側の名前空間に定義されます(FIELDとアクセスする)。

MessagePack IDLではJava風の方が良さそうです。C++では以下のように生成することになりそうです:

struct EnumExample {
    enum type {
        RED   = 0,
        GREEN = 1,
        BLUE  = 2,
    };
};

拡張仕様

採用されるかもしれないけど、とりあえず最初の実装では見送る仕様です。

デフォルト値

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

Scalaだと非常にきれいに実装できそうですが、C++OCamlでも何とかなりそう、です。

クライアント側のコード生成

クライアント側では、バージョンの選択に加えて、どの名前空間(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="")
}