概要
今回はTips的なものです。業務で複数の非同期Webアクセスを逐次実行する必要があったため、その際の試行錯誤を記します。
当エントリでは複数の非同期Webアクセスを逐次実行し、その前後に処理を行いたいと思います。 例として以下の仕様のアプリを実装したいと思います。
- 3つのWebアクセスを逐次実行する
- 各々のWebアクセス完了後にアクセス先URLをコンソール出力する
- すべての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
と記してある通りx
は1
か2
か3
かわからないわけです。
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が提供している$q
はQ
から発想を得た実装です。実装としての$q
とQ
の違いはこちらに詳しいです。
またQ
で逐次処理をする例はこちらにあります。当エントリの内容とは記述方法が若干異なりますが「Promise
をチェーンする」という目的のため「Array.prototype.forEach
を利用」もしくは「Array.prototype.reduce
を利用」、という点は変わりありません。
$q
はもちろんAngularJSフレンドリーではありますが機能も限定的ですので、場合によってはQ
を始めとする他の実装を検討してもよいかもしれませんね。
ちなみに多少Plunker用に改変したものですが当エントリのコードがこちらにありますのでご参考まで。(ブラウザのJavaScriptコンソールを利用して確認してみてください)
http://plnkr.co/edit/sNUD5r