JSDeferredで、面倒な非同期処理とサヨナラ

第2回JSDeferredを用いたアプリケーション開発(その1)

JSDeferredの基本的な読みかた

JSDeferredを使ったコードを読むときには、next() という関数がキーになっています。

リスト1
// next() のチェイン

Deferred.next(function () { // 最初の next は Deferred.next
  alert(1);
}).
next(function () { // これは Deferred.prototype.next
  alert(2);
}).
next(function () {
  alert(3);
});

前回すこしばかり例を出しましたが、このように next を繋げていくことで処理の流れを表現します。

Deferred.next() と Deferred オブジェクトのメソッドとしての next() があることに注意してください。Deferred オブジェクトのメソッドとしての next() のほうは直前に . があります。

このような書きかたを普段しない方のために、等価のコードも示します。単純に見た目の違いです。個人的には . のあとで改行するのが好きですが、慣れていないと解りにくい気もしますので、好きに記述していただいて構いません。

リスト2
// next() のチェイン

Deferred.next(function () {
  alert(1);
}).next(function () {
  alert(2);
}).next(function () {
  alert(3);
});

読みかたさえ気をつければ、各 next() にFunctionオブジェクトを渡しているだけの何の変哲もないコードです。実行される内容的にも、単に渡した Function オブジェクトを実行しているだけだろうということが想像できるかと思います。

簡単なアプリケーションを作ってみる

やはり実例がないと理解しずらいと思います。そこで、サンプルを作りながら解説していきます。今回は、以下に示すような単純なものを作ってみます。

  1. Wikipediaから緯度経度情報をJSONPで取得して
  2. Google Mapにプロットする

JavaScriptのみで動きますし、Wikipediaから取得するので、JavaScriptで単純に扱うにはすこし多い情報を扱うサンプルになります。

実際のサンプルを見てもらうのが早いかと思うので、以下のリンクを参照してください。

ライブラリ読みこみ

このサンプルを作成する準備として、いくつかライブラリを読みこみます。今回はDOM関係の煩雑な処理を簡略化するためにjQueryを使ってみます。もちろんJSDeferredも使いますが、ここではjQuery bindingバージョンを使うことにします。

リスト3 以下は抜粋したもの全部見る場合はこちら
<script type="text/javascript" src="http://jqueryjs.googlecode.com/files/jquery-1.3.2.min.js"></script>
<script type="text/javascript" src="http://github.com/cho45/jsdeferred/raw/master/jsdeferred.jquery.js"></script>
<script type="text/javascript" src="deferred-sample1.js"></script>

ここでは直接ホスト元のを読みこんでいますが、実際に書く場合には自分のサイトでホストしましょう (googlecodeはまだしもgithubはときどきとても重いので⁠⁠。

ここからのコードはdeferred-sample1.jsに記述していくものとします。

WikipediaからJSONPでデータを読みこむ

「WikipediaからJSONPでデータを読みこむ」処理の流れは、以下のとおりです。

  1. カテゴリ名からそのカテゴリに属する項目を取得(JSONP)
  2. 各項目の内容をそれぞれループで取得し、緯度・経度情報を抜きだす(JSONP)

結構な回数をWikipediaにリクエストするので、うまくリクエストをキャッシュする仕組みも入れたいですね。

とはいえ、まずは全ての情報を適切に取得できなければならないため、そこから作っていきます。

カテゴリ名からそのカテゴリに属する項目を取得

Wikipediaは実はJSONPのAPIを持っているため、サーバサイドプログラムなしで情報を再利用できるようになっています。例えば、カテゴリ名からそのカテゴリに属する項目を取得には以下のようなURLを叩くことになります。

リスト4 カテゴリ名からそのカテゴリに所属する記事を取得する
http://ja.wikipedia.org/w/api.php
  ?format=json
  &callback=foobar
  &action=query
  &list=categorymembers
  &cmlimit=500
  &cmtitle=Category:%E4%BA%AC%E9%83%BD%E5%B8%82%E3%81%AE%E7%A5%9E%E7%A4%BE

この場合のデータ構造は以下の通りです。

リスト5
{
  "query": {
    "categorymembers": [
      {
        "title": "愛宕神社 (京都市)",
        "ns": 0,
        "pageid": 450088
      },
      {
        "title": "綾戸国中神社",
        "ns": 0,
        "pageid": 606892
      },
      ...

まずはここまでをコードにしてみます。

リスト7
var url = "http://ja.wikipedia.org/w/api.php?format=json&callback=?&action=query&list=categorymembers&cmlimit=500&cmtitle=" + encodeURIComponent(category);
$.getJSON(url).
next(function (data) {
    alert(data.query.categorymembers[0].title);
}).
error(function (e) {
    alert(e);
});

JSDeferredのjQuery bindingバージョンを読みこんだ場合、jQueryの $.ajax 系の関数( $.get, $.post, $.getJSON など)は Deferred を返すようになり、コールバックを利用した形ではなく、next() を呼べるようになります。

ここではまだ、単純にJSONPをして情報を取得しているだけです。最後の .error() というのがついていますが、これは非同期処理中に発生したエラーを全て表示するためです。Firefoxなどのブラウザでは非同期処理でエラーが発生しても一切どこにもメッセージがでずに黙殺されて困るので、とりあえず常につけておくと便利です。

各項目の内容をそれぞれループで取得し、緯度・経度情報を抜きだす

さて、ここからさらに、各記事のタイトルから緯度経度情報を抜きだすわけですが、その際使うAPIは以下のようになります。

リスト8
http://ja.wikipedia.org/w/api.php
  ?format=json
  &callback=foobar
  &action=query
  &prop=revisions
  &rvprop=content
  &titles=松尾大社|賀茂別雷神社

この場合のデータ構造は以下の通りです。

リスト9
{
  "query": {
    "pages": {
      "49366": {
        "title": "賀茂別雷神社",
        "ns": 0,
        "revisions": [
          {
            "*": " .... 内容 .... "
          }
        ],
        "pageid": 49366
      },
      "458561": {
        "title": "松尾大社",
        "ns": 0,
        "revisions": [
          {
            "*": " .... 内容 .... "
          }
        ],
        "pageid": 458561
      }
  }
}

若干かえってくる構造がわかりにくいです。それはともかく、このAPI、複数のページタイトルをパイプで渡せるのはいいのですが、あまりたくさん渡しても全てキッチリは返してくれない[1]ため、適当な数で分割してアクセスしていきます。このようなとき、JSDeferredだと大変簡単にアクセスしていけます。

リスト10
var url = "http://ja.wikipedia.org/w/api.php?format=json&callback=?&action=query&list=categorymembers&cmlimit=500&cmtitle=" + encodeURIComponent(category);
$.getJSON(url).
next(function (data) {
  var members = data.query.categorymembers;
  return Deferred.loop({ end : members.length - 1, step : 35 }, function (n, o) {
    var q = [];
    for (var i = 0; i< o.step; i++) {
      q.push(encodeURIComponent(members[n + i].title));
    }
    return $.getJSON("http://ja.wikipedia.org/w/api.php?format=json&callback=?&action=query&prop=revisions&rvprop=content&titles=" + q.join("|")).
    next(function (res) {
      for (var key in res.query.pages) if (res.query.pages.hasOwnProperty(key)) {
        var page = res.query.pages[key];
        page.revisions[0]["*"].replace(/\{\{ウィキ座標2段度分秒\|(\d+)\|(\d+)\|(\d+)\|N\|(\d+)\|(\d+)\|(\d+)\|E/g, function (_, lat1, lat2, lat3, lng1, lng2, lng3) {
          alert([
            page.title,
            +lat1 + (+lat2 / 60) + (+lat3 / 60 / 60),
            +lng1 + (+lng2 / 60) + (+lng3 / 60 / 60)
          ]);
        });
      }
    });
  });
}).
error(function (e) {
  alert(e);
});

Deferred.loop という関数は前回もでてきましたが、これによってJSONPの非同期処理をループさせています。Deferred.loop はいろいろな引数のとりかたをできますが、例のようにすると end まで step 個ずつ要素をループさせるような処理を書くことができます。ここでは35個ずつに分割して記事をそれぞれ一括でとってくるために、このようなループ構成になっています。

ループ内部では実際にAPIに投げるクエリを構築し、JSONPリクエストを出し、$.getJSON の返すもの、すなわち Deferred オブジェクトを return しています。Deferred.loop() 関数はループ関数が Deferred オブジェクトを返した場合、その実行を待つという特徴があるため、これにより、

  1. 35ずつ
  2. 順番に
  3. 任意の個数の記事を全て

取得することができています。直接緯度経度を取得する方法は著者が確認した限りなかったため、本文中のテンプレートから正規表現で抜きだしてきます。ちなみに1つのページに複数の項目が混ざっていることもあるため、複数対応するために、置換するわけではないですが replace() を使っています。

Google Mapにプロットする

データはひとまず用意できたのでGoogle Mapにプロットしてみます。と、その前に、今まで書いた内容を関数にまとめておきます。

リスト11
function CollectLatLongFromCategory (category, callback) {
  var url = "http://ja.wikipedia.org/w/api.php?format=json&callback=?&action=query&list=categorymembers&cmlimit=500&cmtitle=" + encodeURIComponent(category);
  return $.getJSON(url).
  next(function (data) {
    var members = data.query.categorymembers;
    return loop({ end : members.length - 1, step : 15 }, function (n, o) {
      var q = [];
      for (var i = 0; i< o.step; i++) {
        q.push(encodeURIComponent(members[n + i].title));
      }
      return $.getJSON("http://ja.wikipedia.org/w/api.php?format=json&callback=?&action=query&prop=revisions&rvprop=content&titles=" + q.join("|")).
      next(function (res) {
        for (var key in res.query.pages) if (res.query.pages.hasOwnProperty(key)) {
          var page = res.query.pages[key];
          page.revisions[0]["*"].replace(/\{\{ウィキ座標2段度分秒\|(\d+)\|(\d+)\|(\d+)\|N\|(\d+)\|(\d+)\|(\d+)\|E/g, function (_, lat1, lat2, lat3, lng1, lng2, lng3) {
            callback({
              title: page.title,
              lat : +lat1 + (+lat2 / 60) + (+lat3 / 60 / 60),
              lng : +lng1 + (+lng2 / 60) + (+lng3 / 60 / 60)
            });
          });
        }
      });
    });
  }).
  error(function (e) {
    alert(e);
  });
}

リクエストが返ってくる度にマップを更新したいため、callback をとるようにしてみました。ついでに全てが終わったときからの処理を継続したいため、Deferred を返しています。

リスト12
$(function () {
  var map = ... Google Map 初期化 ...
  CollectLatLongFromCategory(category, function (obj) {
    var point  = new GLatLng(obj.lat, obj.lng);
    var marker = new GMarker(point, icon);
    map.addOverlay(marker);
  }).
  next(function () {
    alert('done');
  }).
  error(function (e) {
    alert(e);
  });
});

Google Mapの初期化などの解説は省略しましたが、特に説明する部分もなく、単にマーカーを立てているだけです。CollectLatLongFromCategory が Deferred を返すため、そのまま繋げて終わったときに done と alert させています。

さて、一通りできましたね。次回はキャッシュの仕組みを入れるのと、geolocation を例に既存APIとの繋ぎこみなどを説明したいと思います。

おすすめ記事

記事・ニュース一覧