2008-12-20

近況

WEB+DB PRESS Vol.48 に記事を書かせてもらいました. デバッグをねたに, という話だったのだけれど, デバッグのような辛い記憶はすぐに忘れてしまうので無理です...とごねて その手前, エラー処理の話を書いてみました. 他の方はちゃんとデバッグの話を書いていてえらい...

おまえはいつから Web とか DB をやるようになったんだという指摘は甘受いたします.

なぜ RPC はいまいちか (今更編)

WEB+DB Press には REST の連載があり, 今号は RPC の話だった. その記事を読んでいるうちに, 以前書いた 少し関係のある話 が途中だったのを思いだした. 元の記事は REST vs. RPC の議論だったけれど, REST はさておき RPC はどんな場合にいまいちか, すこし書いてみたい.

多くの RPC は, だいたい次のような作りになっている(と思う.)

このように, RPC は リクエストとレスポンスからなる 1 対 1 の通信 を 関数呼び出しという形で抽象化してくれる. ただ, アプリケーションに登場するメッセージングのパターンは他にも色々ある. たとえば近隣のノードに対して ブロードキャスト をしたり, 特定の条件をみたすノードに マルチキャスト をしたいかもしれない. しかも 順序保証 まで求められることもある. サーバによっては, 特定のメッセージを 別のサーバに 転送 または 中継 したいかもしれない. 負荷分散のために送信先を ラウンドロビン したいかもしれない.

1 対 1 のメッセージングにもバリエーションがある. 送信に失敗したメッセージを 後から 再送 したいかもしれない. 前のレスポンスを受けとる前に次のメッセージを パイプライニング したいかもしれない. リクエストの順序とは違う順番でレスポンスを返すために 追い越し をしたいかもしれない. 副作用のない呼び出しは結果を キャッシュ したいかもしれない.

RPC ライブラリの中には, こうしたバリエーションの一部をサポートするものもある. しかし自分のやりたいメッセージングのパターンがサポートされているとは限らない. それに, たとえば N ノードに対して RPC をマルチキャストしたら結果は N 個ある. このように, メッセージングのパターンによっては関数呼び出しの抽象が合わないものもある. (ただし, 関数呼び出しの抽象に限界があること自体は RPC の問題ではない. 何にでも限界はある.)

RPC がそのまま使えないとき, 開発者はその上で自身の使うパターンを実装する. どんなパターンであれデータを直列化してネットワークに送り出す必要があるから, その部分には RPC を使う.

上と下

が, ここに問題がある. たとえばチャットルームを作る場合を考えてみる. 誰かが発言したら, それをチャットルームの参加者にマルチキャストするケースを考えてみよう. きっとこんなコードになるだろう.

for (int i=0; i<m_members.size(); ++i) {
  m_members[i].notifySaid(senerName, message); // ここが RPC
}

これを RPC の上 での通信パターンの実装と呼ぼう.

チャットルームでマルチキャストをしたい場面は, 発言以外にも色々ある. (IRC でいう)ルームのトピックを変更したい時, 入室, 退室時 ... 一般に, 一つのメッセージングパターンで交換するメッセージの種類は複数あることが多い. 素朴に考えると, 各メッセージの実装ごとに上のループを書き直さなければいけない. ただのループで済むなら毎度書いてもいいけれど, 実際には面倒な条件が色々ある. 発言者毎に持っているブラックリストを参照して送信先をマスクしたい, 参加者が多い時は何度かに分割して送信したい... 条件が複雑になるほど, コピペのダメージは大きくなる.

"複雑なループはイテレータで隠せばいい" という人もいるだろう. それは正しいが, 一方でメッセージのパターンはマルチキャスト以外にも色々ある. たとえば再送なんてのはイテレータで隠しにくい.

ここでは RPC が適切な抽象ではない場面で RPC を使っているために面倒を招いている. 適切ではないはずの RPC を使いたくなるのは, RPC がデータの直列化をしてくれるから, という面が大きい.

本来, データの直列化は RPC から独立して利用できるべきだ. そして, RPC を初めとするメッセージングのパターンは, 送受信するデータの中身と独立に実装できれば嬉しい. イメージとしては, だいたいこんな風に書きたい:

member_multicastor(senderName) // member_multicastor() は通信パターンをカプセル化したオブジェクトを返す
  .send(NOTIFY_SAID, SaidMessage(message).serialized_bytes());

あるいは member_multicastor が RPC のインターフェイスを実装しても良い.

member_multicastor(senderName).notifySaid(message);

これを RPC の下 での通信パターンの実装と呼ぼう.

RPC の実装にもよるが, 送受信とは独立にオブジェクトを直列化できるものはある. また通信のレイヤがインターフェイスで拡張可能になっていることもある. たとえば Facebook の Thrift が生成した オブジェクト (struct) には Write() メソッドがあり, これを使えば送受信と無関係にオブジェクトを直列化できる. Write() の引数は TProtocol インターフェイスで, これは通信層(の上の直列化層)を仮想化している. だから適切に設計された RPC ライブラリを使えば上のようなつくりは可能だと言える.

Scribe

ただ, 可能だからといって実際の利用者がそうやってコードを書くとは限らない. 通信のツールとして RPC が与えられたら, それを素朴に("上から")使う利用者の方が多いだろう. 10 月に Facebook が公開した Scribe 分散ロギングシステムからも, 利用者のそうした兆候を伺うことができる.

Scribe は Thrift の上に構築されたログ収集システム. スケーラビリティを確保するために, ログを吐きだすノードと吐きだされたログを収集するノードを分離している. ノードの分離までは syslog なども対応しているが, Scribe ではある種のルーティングを行い, ログの柔軟な 配送 を実現している. あるノードで発生したログは, Scribe の設定に従って複数のノード間を転送され, 最終的な集積ノードに辿りつく. 配送にはいくつものバリエーションがある. 負荷分散のためのラウンドロビン, 冗長化のためのマルチキャスト, 一時的な故障に備えた再送, あるノードから別のノードへの中継... こうしたバリエーションは設定ファイルに記述され, 各ノードはその指示に従ってログを転送, 収集する. なかなか凝ったメッセージングを実装している.

Scribe は RPC の "上" で実装されている. Scribe クライアントは自身のルーティング指定に応じ, scribe インターフェイス の メソッドを呼び出す. 個々のログは category フィールドと message フィールドを文字列で持つ LogEntry struct で表現されている. この他の書式はサポートされていない.

もし Scribe が RPC の "下" で実装されていたら, 書式はより柔軟に選ぶことができたかもしれない. Scribe のリリースノート は ログの書式を単純なものに留めたという設計上の決定に言及しているが, その決断は RPC の圧力によるものだったとは考えられないだろうか. ログにテキストだけを使うのは人間にとっての読みやすさへの配慮もあるだろうから, 一概に柔軟なら良いとは言えないけれども, 少し残念な気持になる. 実際, RPC の下でロギングを実装している人も いる. Scribe が RPC の下で実装されていたら, このようなファイルベースのログ機構を分散化する際にも話は早かっただろう. (いま Base64 や JSON が頭をよぎった人はバッドノウハウ漬けの生活習慣を改めた方がいいですよ...)

RPC 誤用のにおい

ここまで, RPC とデータ直列化の癒着が RPC 以外のメッセージングパターンを再利用する妨げになっているかもしれない, という主張をしてきた. 先のループのようなコードの重複は, 見逃されたコード共有の機会を暗ににおわせている. RPC の誤用を示すもう一つのにおいに, オブジェクトのオーバーラップがある. RPC で直列化する一つのオブジェクトに複数の意味を持たせていないだろうか. フラグ isSaid が有効な時はフィールド message はチャットのメッセージ, 無効な時はルームのトピック, isSaid が無効な時のみ意味のある isAppend フラグは, トピックへの "追記" をあらわしている... これは極端に酷い例だけれど, RPC で引き渡すオブジェクトには複数の意味を持たせられる傾向がある. こうするとメッセージング部分のコード重複を避けることができ, 表面的には DRY の体裁を保つことができるからだ. しかし実際にはまずいコードであることに変わりはない.

ログのテキストから正規表現で数値を抜き出すなんてのも, このオーバーラップのバリエーションと見ることができる. 失われた構造を復元するために, プログラマは(そう自明でもない)データ解釈のコードを書いているわけだから.

まとめ

というわけでまとめ: RPC は限られたメッセージングパターンのみをサポートしている. そのパターンにあてはまらないメッセージングを PRC で実現しようとすると設計が歪む. そんな場面では, RPC の下にコードを書けないか考えてみると良い. また RPC 自体を実装する際には, メッセージングのパターンに関する部分とオブジェクトの直列化に関する部分ははっきり分離し, 直列化だけを単独で利用できるよう配慮すべきである.

さて, メッセージキューのシステムをはじめ, 世の中の RPC 以外のメッセージング技術は 大半が通信に使うデータ (envelope) と中身のメッセージ自身 (payload) を区別している. なぜ RPC だけはこの区別が曖昧になってしまったのかと考えていた. おそらく他のメッセージング技術がメッセージングのパターンを切り離そうとしているのに対し, RPC は関数呼び出しをネットワーク越しにやるというアイデアがまずあって, そのアイデアから自然と行き着くパターンとして 1:1 のリクエスト-レスポンス対に至ったんだろうね. 実際, このパターンを標準化したメッセージング機構(=HTTP)はそんな混同をしないわけだから. そう考えると, 上で書いた話も "RPC はクールなアイデアだけれど適用場面は限られますよ" という当たり前の主張のバリエーションに過ぎないのだなあ. それにしては長々と書きすぎた...すんません.