HTTPで通信するAPIはRESTで設計するのが定石ですが、利用者から見ると不便な場合があります。
RESTは設計に強い制約を与えるため、多人数で開発するときでも設計の一貫性を確保することができるのが利点です。更に一定のパターンに従っている分、既存のRESTクライアントを使って手軽にAPIを利用した機能を実装できるのも魅力的です。
しかし設計がRESTに従う分、例えばいくつかの処理をまとめてトランザクションとして扱いたい、といった場合に、インターフェースを独自に拡張しなくてはいけない状況に立たされることがあります。そもそもRESTだとAPIの単位が細かすぎて、利用者から見て使いにくい、といったケースもあります。
そういった場合はRPC(Remote Procedure Call)でAPIを設計することを検討してみても良いかも知れません。RPCの中でもJSON-RPCという仕様が比較的実装しやすそうだったので、試しにRails4でJSON-RPCの振る舞いを実装してみました。RPCはXMLで通信するのが一般的なようですが、今時はJSONで通信したいですよね。
目次
サンプルコード
https://github.com/mahm/json-rpc-server
JSON-RPCのざっくりした仕様
JSON-RPCでは以下のようなリクエストを送ることができます。
- サーバからレスポンスを返してもらう通常のRPCリクエスト
- サーバにレスポンスの内容を返すことを要求しないNotification RPCリクエスト
- 複数のリクエストを束ねたバッチリクエスト
一つずつ見て行きましょう。リクエストとレスポンスを表現するために、以下の記法を使います。
-->
がクライアントからサーバに送るリクエスト<--
がサーバからクライアントに送るレスポンス
サーバからレスポンスを返してもらう通常のRPCリクエスト
--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1} <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
リクエストにはjsonrpc
、method
、params
、id
というキーを含めて送ります。
jsonrpc
にはJSON-RPCのバージョンを設定します。常につけて送る必要があります。サーバはこの値を見て、JSON-RPC 2.0に従ったリクエストとして処理するかどうかを判断します。method
にはサーバに実行してもらうメソッド名を設定します。常につけて送る必要があります。params
にはメソッドの引数を設定します。メソッドによっては必ずしも送る必要はありません。id
はクライアント側が「このリクエストはこのIDをつけて送った!」と判断するために設定します。サーバはレスポンスの際、クライアントから送られたid
をそのまま送り返してあげる必要があります。
レスポンスにはjsonrpc
、result
、id
を返します。
- リクエスト時に
jsonrpc
に2.0を設定しているなら、サーバはJSON-RPC 2.0に従ったリクエストとして処理するので、そのままレスポンスでも2.0が設定されて返ります。 result
にはメソッドの返り値が入ります。id
にはクライアントから受け取った値をそのまま設定して返します。
これがJSON-RPCの基本パターンです。
サーバにレスポンスの内容を返すことを要求しないNotification RPCリクエスト
一方で別にレスポンスの内容を返さなくても良いよ、という場合もあります。そういう場合はid
キーを設定せずにリクエストすれば、サーバはレスポンスの内容を返さなくて良いです。
(HTTPを使う限りはレスポンス自体は返す必要があるので、204 No Contentあたりを返せば良いかなと思います)
複数のリクエストを束ねたバッチリクエスト
こんな感じにいくつもの処理を一気にまとめて送ることができます。
--> [ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, {"foo": "boo"}, {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, {"jsonrpc": "2.0", "method": "get_data", "id": "9"} ] <-- [ {"jsonrpc": "2.0", "result": 7, "id": "1"}, {"jsonrpc": "2.0", "result": 19, "id": "2"}, {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"}, {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"} ]
バッチの中にNotificationが含まれている場合は、Notificationのレスポンスは抜かしてレスポンスが返ります。バッチの中にエラーのあるリクエストが含まれている場合は、それぞれエラーメッセージを返します。
エラーメッセージのコードはあらかじめ決められているので、それに従って返す必要があります(参考)。
こんな感じに実装した
この仕様をRailsで実装するのですが、以下のポイントをガイドとして実装することにしました。
- メソッド毎にURLを分ける必要はないので、全て一つのアクションでリクエストを処理
- どんどんメソッドを追加できるように、メソッド毎にクラスを分けるようにした
URL設計
全て一つのアクションで処理するので、routes.rbはシンプルですね。
テスト
ざっくりリクエストスペックを書くとこんな感じになります。とはいえ、そこそこ長いですね。。
コントローラ
バッチリクエストかどうかを判定して処理を振り分けています。Notificationリクエストの場合はto_json
したときにnilを返すようにしているので、最終的に何も返す必要がない場合は204 No Contentで返すようにしています。
ディスパッチャ
メソッド名に応じて処理を振り分けます。処理中にエラーになった場合のエラーコードとエラーメッセージはここで設定しています。メソッド名に応じて動的にクラスをロードするので、各メソッドを処理するクラスには名前空間をつけておく、というぐらいの配慮はしています。
Parse Errorの場合だけはRpcControllerに到達する前にActionDispatch::ParamsParserでエラーになってレスポンスされてしまうので、ActionDispatch::ParamsParserの前にこんなmiddlewareを差し込んでエラーを返しています。
各メソッド
initializeでパラメータを取り込んで、executeメソッドでメソッドを実行する、というパターンにしています。こんなインターフェースのクラスをメソッド分実装していく、という感じのイメージです。
雑感
- JSON-RPCの仕様的にはバッチでリクエストした場合に途中でエラーになってもバッチ全体が失敗することはないけど、実際はバッチ丸ごとトランザクションになってくれていた方が良い気もする。
- ActiveRecordのモデルと絡めて利用する場合は、RPC::Hogeがサービスオブジェクト的な役割を果たすのだと思う。
- そもそもはGoだのClojureだのの練習問題的に実装しようと思っていたけど、とりあえず手頃なRailsで実装してみた。