概要

今回はTips的なものです。業務で複数の非同期Webアクセスを逐次実行する必要があったため、その際の試行錯誤を記します。

当エントリでは複数の非同期Webアクセスを逐次実行し、その前後に処理を行いたいと思います。 例として以下の仕様のアプリを実装したいと思います。

  1. 3つのWebアクセスを逐次実行する
  2. 各々のWebアクセス完了後にアクセス先URLをコンソール出力する
  3. すべてのWebアクセスを行う前にstart、すべてのWebアクセスが完了した後にfinishという文字列をコンソール出力する

以上を踏まえると期待する出力は以下となります。

start
http://example.com/1
http://example.com/2
http://example.com/3
finish

実装

前提として以下の定義がしてあるものとします。またAngularJSには$httpという便利なサービスがあるので非同期Webアクセスはこれに任せることにします。

var urls = [
  'http://example.com/1',
  'http://example.com/2',
  'http://example.com/3',
];
var start = function () {
  console.log('start');
};
var finish = function () {
  console.log('finish');
};

1. Array.prototype.forEach

実装

配列をグルグル回せば簡単にできそうです。

start();

urls.forEach(function (url) {
  $http
    .get(url)
    .finally(function () {
      console.log(url);
    })
  ;
});

finish();

結果

start
finish
http://example.com/3
http://example.com/1
http://example.com/2

期待通りの結果になりませんでした。

startの後すぐにfinishが表示されてしまいます。そもそも$httpはHTTPリクエストを生成してpromiseオブジェクトを返してくれます。そしてpromiseオブジェクトが解決される処理は非同期なので、同期処理完了後に動作します。(参考:[JavaScript Promiseの本 2.3. コラム: Promiseは常に非同期?](http://azu.github.io/promises-book/#promise-is-always-async))このことが原因ですね。

また、この実装を何度か実行すると表示されるURLの順序が1 > 3 > 2だったり3 > 2 > 1だったりと一貫性がない上に期待する順序どおりにならないことがわかります。各々のリクエストにおいてその時々で通信状況やリクエストの内容によってレスポンスが異なるため、必ずしもリクエストの生成順序どおりにレスポンスを受け取るとは限らないわけです。

以上より処理のフローを表すと以下の感じですね。ここでURLがhttp://example.com/xと記してある通りx123かわからないわけです。

1. `start`を出力
2. http://example.com/1 へのHTTPリクエストを生成
3. http://example.com/2 へのHTTPリクエストを生成
4. http://example.com/3 へのHTTPリクエストを生成
5. `finish`を出力

ここまで↑が同期処理

6. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。
7. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。
8. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。

これら↑はお互いに非同期

2. $q.all

URLの順序修正はひとまず後回しにするとして、まずはfinishの出力タイミングを修正したいと思います。 AngularJSには$qという便利なサービスがあり、$q.allというメソッド持っています。これは、複数の非同期処理が完了した時点で事後処理を行いたいときに使えます。今回の狙いに合いそうですね。

実装

start();

$q
  .all(urls.map(function (url) {
    return $http
      .get(url)
      .finally(function () {
        console.log(url);
      })
    ;
  }))
  .finally(finish)
;

結果

start
http://example.com/3
finish
http://example.com/1
http://example.com/2

期待通りの結果になりませんでした。すべてのレスポンスではなくひとつ目のレスポンス後にURLを出力してしまっています。

$q.allのドキュメントにはCombines multiple promises into a single promise that is resolved when all of the input promises are resolved.とあります。これによるとすべてのpromiseが解決された場合に$q.allが返却していたpromiseが解決状態になるんですね。翻って、扱っている複数のpromiseのうちひとつでも棄却状態なものができた時点で$q.allが返却していたpromiseは棄却状態として決定するということが言えます。

今回は http://example.com/3 へのリクエストがエラーとなっていました。よってこの時点で$q.allが返すpromiseの状態が決定したためfinishが出力されたわけです。

処理のフローは以下のようになりました。

1. `start`を出力
2. http://example.com/1 へのHTTPリクエストを生成
3. http://example.com/2 へのHTTPリクエストを生成
4. http://example.com/3 へのHTTPリクエストを生成

ここまで↑が同期処理

5. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。
6. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。
7. http://example.com/x からHTTPレスポンスを受け取る。URLを出力。

これら↑はお互いに非同期

8. 5.6.7.の結果が決定した時点で`finish`を出力

3. $q.all改

上の例では、リクエストが一つでも失敗すると$http.getの返すpromiseが棄却状態となり、それにより$q.allの返すpromiseも棄却状態として決定してしまうという点が問題でした。 そこで、各リクエストが「完了」さえすれば(成功・失敗に関係なく)それに対するpromiseは解決状態となるように手を加えてみます。

実装

start();

var requests = urls.map(function (url) {
    var deferred = $q.defer();

    $http
      .get(url)
      .finally(function () {
        console.log(url);
        deferred.resolve();
      })
    ;

    return deferred.promise;
});

$q
  .all(requests)
  .finally(finish)
;

結果

start
http://example.com/3
http://example.com/1
http://example.com/2
finish

仕様通りの結果にはなっていませんがすべてのWebアクセス完了後にfinishが表示されるようになった点は改善点です。

次に表示URLを期待する順序どおりにするように修正しましょう。

4. Promiseのメソッドチェーン

まずは期待するフローを洗い出してみます。

1. `start`を出力
2. http://example.com/1 へのHTTPリクエストを生成
5. http://example.com/1 からHTTPレスポンスを受け取る。URLを出力。
3. http://example.com/2 へのHTTPリクエストを生成
6. http://example.com/2 からHTTPレスポンスを受け取る。URLを出力。
4. http://example.com/3 へのHTTPリクエストを生成
7. http://example.com/3 からHTTPレスポンスを受け取る。URLを出力。
8. `finish`を出力

現状では最初に3つのリクエストを立て続けに生成するため、レスポンスを待っている複数のリクエストが並存している状態が存在します。そこでリクエストの生成を前のリクエストがレスポンスを受け取ったことをトリガにして行うようにすることとします。そのためにpromiseをチェーンします。

実装

start();

var deferred = $q.defer();
var promise = deferred.promise;

urls.forEach(function (url) {
  promise = promise.finally(function () {
    return $http
      .get(url)
      .finally(function () {
        console.log(url);
      })
    ;
  });
});

promise = promise.finally(function () {
  finish();
});

deferred.resolve();

結果

start
http://example.com/1
http://example.com/2
http://example.com/3
finish

実現できました。

5. Promiseのメソッドチェーンを Array.prototype.reduce で

一時変数としてのpromiseを使用しないようにするにはArray.prototype.reduceを利用することで実現できます。結果は4.の場合と同等です。

実装

start();

var tasks = urls.map(function(url) {
  return function() {
    return $http
      .get(url)
      .finally(function() {
        console.log(url);
      });
  };
});
tasks.push(finish);

var deferred = $q.defer();

tasks.reduce(function(promise, task) {
  return promise.finally(task);
}, deferred.promise);

deferred.resolve();

最後に

今回は簡単なTipsでした。

AngularJSが提供している$qQから発想を得た実装です。実装としての$qQの違いはこちらに詳しいです。

またQで逐次処理をする例はこちらにあります。当エントリの内容とは記述方法が若干異なりますが「Promiseをチェーンする」という目的のため「Array.prototype.forEachを利用」もしくは「Array.prototype.reduceを利用」、という点は変わりありません。

$qはもちろんAngularJSフレンドリーではありますが機能も限定的ですので、場合によってはQを始めとする他の実装を検討してもよいかもしれませんね。

ちなみに多少Plunker用に改変したものですが当エントリのコードがこちらにありますのでご参考まで。(ブラウザのJavaScriptコンソールを利用して確認してみてください)

http://plnkr.co/edit/sNUD5r