Reactive Extensions で非同期処理を簡潔に記述する


2011年 11月 16日

問題

今時の若者ならばHeroku等を利用して手早く Web アプリを作成・公開することが日常茶飯事です。
バックエンドもフロントエンドも今はフレームワークが充実していますから、
高度な処理を簡潔な記述で行うことができます。

しかし非同期処理となると話は別です。
例えばフロントエンドを作るとなると、まずjQueryを使うことになるでしょう。
jQuery は洗練された API で DOM 操作を簡単に行うことができますし、
非同期通信についても $.ajax を使えば煩雑なことほぼ知らずに済みます。
例えばWikipediaの検索フォームは入力補完が行われるようになっており、
検索フォーム文字が入力されると関連するページのタイトルが候補として表示されます。
このような処理を書くとなると以下のようなコードになるでしょう:

var showCompletionMenu = function (words) {
  ...
};

var completeWords = function (partialWord) {
  $.ajax({
    url: 'http://en.wikipedia.org/w/api.php',
    dataType: 'jsonp',
    data: {
      action: 'opensearch',
      format: 'json',
      limit: 100,
      search: 'foo',
      success: function (data) {
        var words = data.data[1];
        showCompletionMenu(words);
      }
    }
  });
};

var $form = $('#userInput');
$form.keyup(function () {
  completeWords($form.val());
});

このように、イベント処理や非同期通信を行う場合、

  • イベントの監視を始める部分($form.keyup)と発生したイベントを処理する部分(completeWords)
  • リクエストを送信する部分($.ajax)と受信した結果を処理する部分(showCompletionMenu)

を分離し、前者にコールバック関数として後者を渡す形になります。
普通のプログラムならば上から下へ順次処理が行われるのですが、
このように非同期処理が絡むと実行の流れはソースコードの見た目から背離します。
この単純な例ですら非同期処理が2段も積み上がっており、
とても反射的に読んで意味を理解できるソースコードではありません。

ここで本当に行いたいことは補完候補の表示であって、
その表示タイミングや補完候補のデータの出所は重要ではありません。
しかしコールバックという名の中間層を噛まさざるを得ないため、
本質からやや遠ざかったソースコードになっています。
どうにかしてこの状況を打破できないでしょうか。

回答

Reactive Extensions (Rx) を使います。
Rx を使えば非同期なデータ処理を簡単に記述し、また合成することができます。
Rx は .NET Framework 上の1ライブラリなのですが、
JavaScript 版の Rx も提供されています。

試しに先程の問題の例を Rx を使って書くと以下のようなコードになります
(実際に動作するサンプル):

var showCompletionMenu = function (words) {
  ....
};

var completeWords = function (partialWord) {
  return $.ajaxAsObservable({
    url: 'http://en.wikipedia.org/w/api.php',
    dataType: 'jsonp',
    data: {
      action: 'opensearch',
      search: partialWord,
      format: 'json',
      limit: 100
    }
  })
  .Select(function (data) {return data.data[1];});
};

var $form = $('#userInput');
var observableWords = $form
                      .toObservable("keyup")
                      .Select(function (_) {return $form.val();})
                      .Select(function (partialWord) {return completeWords(partialWord);})
                      .Switch();
observableWords.Subscribe(showCompletionMenu);

ここで注目すべきは observableWords の定義です。
Rx では非同期に得られるデータをあたかも普通のデータのシーケンスであるかのように取り扱うことができ、
普通のリスト処理のようにデータの操作処理を積み重ねていくことができます。

まず $form.toObservable("keyup")
では入力フォーム($form)で起きたキー入力イベントを
普通のデータのシーケンスであるかのように扱えるようにしています。
例えば keyup イベントでは「どのキーが入力されたか」等の情報が得られますが、
toObservable
を使うことでこのようなキー入力情報のシーケンスが存在するかのように処理を記述することができます。

一旦 toObservable で変換してしまえば後は任意の処理を積み重ねることができます。

  • .Select(function (_) {return $form.val();})
    では「イベントが発生した時点での入力フォームの内容」のシーケンスに変換しています
    (jQuery で言えば jQuery.map です)。
    今回の例では押下されたキーの情報はどうでもよく、入力フォームの入力内容が重要だからです。
  • .Select(function (partialWord) {return completeWords(partialWord);}) では
    フォームの入力内容から「補完候補の単語の配列(のシーケンス)」のシーケンスに変換しています。
  • .Switch()
    では completeWords の結果を合成して「補完候補の単語の配列」のシーケンスに変換しています。
    例えば入力フォームの内容が「」→「r」→「reactive」と変化したとしましょう。
    この場合は「r」と「reactive」についてそれぞれ補完候補取得APIにリクエストを送ることになります。
    各リクエストに対するレスポンスはどういう順序で返ってくるか不定ですので、
    『先に送信した「r」に対するレスポンスが後から送信した「reactive」に対するレスポンスよりも遅く到着する』
    というケースも十分考えられます。
    このような場合、後から送信したリクエストに対する結果を優先して採用する方が普通です。
    そうでなければ『入力フォームの内容は「reactive」なのに補完候補は「r」に対するもの』
    という一貫性のない状態になってしまいます。
    Switch を使うとこのような場合に対して適切な結果を取捨選択して合成してくれます。

一度シーケンスができあがってしまえば、
後は .Subscribe で各要素に対する処理を記述できます(jQuery で言えば jQuery.each のようなものです)。

ここでは例示のために completeWordsshowCompletionMenu は一度変数に代入していますが、
やろうと思えばこれは全て無名関数にして .Select.Subscribe の引数に渡すこともできます。
実行時の流れとソースコード上の見た目がほぼ一致する形となり、
Rx を使うことで非常に理解し易い記述ができます。
やりましたね。

発展

しかし上記の例だと単純なので面白くありません。
実用性を考えると色々と調整が必要です。
でも Rx を使えば簡潔に記述することができます。

キー入力が落ち着いてから補完候補の取得を行う

最初の例ではキー入力の度に Wikipedia にリクエストを送信してしまいます。
実際のユーザーのキー入力を考えると、
個々のキー入力の発生間隔はかなり短い(知っている単語を入力している時など)か
かなり長い(綴りを思い出せなくなった時など)かのどちらかです。
前者の最中に補完候補を求めても無駄になる確率が高いですから、
実用性を考えると後者のタイミングで補完候補を求める方が良いでしょう。
もう少し厳密に言えば
「500ミリ秒以内に連続してキー入力が発生した場合は最後のキー入力のみを使う」
ということになります。

これはよくあるパターンなので Rx に既に API が用意されています。
具体的には observableWords の定義を1行追加するだけで実装できます:

var observableWords = $form
                      .toObservable("keyup")
                      .Select(function (_) {return $form.val();})
                      .Throttle(500)
                      .Select(function (partialWord) {return completeWords(partialWord);})
                      .Switch();

入力フォームの内容が変化したら補完候補の取得を行う

最初の例では、カーソルキーでのカーソル移動など、
入力フォームの内容が変化しない場合であっても補完候補の取得を行っています。
これも無駄ですから、データの変化があった場合だけ補完候補を取得する方が良いでしょう。

これもよくあるパターンなので Rx に既に API が用意されています。
具体的には observableWords の定義を1行追加するだけで実装できます:

var observableWords = $form
                      .toObservable("keyup")
                      .Select(function (_) {return $form.val();})
                      .Throttle(500)
                      .DistinctUntilChanged()
                      .Select(function (partialWord) {return completeWords(partialWord);})
                      .Switch();