JavaScript の Promise を返す関数を直列で実行したいので Pinscher というライブラリを作ってみた。 - (define -ayalog '())について。 例えば、非同期に実行されるPromise p1, p2, p3があったとしてcallbackをp1, p2, p3の順番に実行したい場合、reduceが使える。
例1は、p1, p2, p3を並列に実行した上で、callbackの順序を保証したい場合。
まずテスト用に、一定時間まってからvalueでresolveする関数resolveLaterを定義しておく。
function resolveLater(value, ms) { return new Promise(function (resolve) { setTimeout(function () { resolve(value); }, ms); }); }
var promises = [ resolveLater('a', 1000), resolveLater('b', 1000), resolveLater('c', 2000), resolveLater('d', 0), ]; var callback = console.log.bind(console); promises.reduce(function (current, next) { var p = current.then(function (v) { return next; }); p.then(callback); return p; }, Promise.resolve()); // a, b, c, d
このようになる。タスクそのものは並列に実行されるが、callbackは順番に実行される。
逆に、p1, p2, p3自体を順番に実行したい場合、つまり、あるタイミングで同時に動いてるpromiseが1つである必要がある場合。(ネットワークアクセスなどで、負荷をかけたくない場合などはこっちのパターンに近い実装をすることになる)
var argList = [ ['a', 1000], ['b', 1000], ['c', 2000], ['d', 0] ]; var callback = console.log.bind(console); argList.reduce(function (current, args) { return current.then(function () { return resolveLater.apply(null, args).then(function (v) { callback(v); }); }); }, Promise.resolve());
非同期コードのテストについてだが、Jest | Painless JavaScript Unit Testingがおすすめで、jestを使うと、setTimeout系の関数自体を乗っ取って(実行順序を保証しながら)同期的に実行しながらsetTimeout.mock.callsに呼び出された時のmock関数の引数を記録していってくれるので、実際の非同期で不確実な動作に依存せずにテストしやすいコードを書ける。
ただ非同期のテストはどちらにしろ複雑になりがちで、ある程度のところで諦めてテストケースは書くがチェックは手動、という風にするのが現実的なのかもしれない。
追記
コメントでライブラリで解決していることを実現できていないと指摘されたので、その他の例について触れる。
まず、上記コードにはなかった、順番に実行したいpromiseがコード中で動的に追加される場合。 個々のタスク自体は並列に実行するが出力を順番にしたい場合
var callback = console.log.bind(console) var task = Promise.resolve(); var identity = Object; // この場合function (a) { return a; } task = task.then(identity.bind(null, resolveLater('a', 1000))).then(callback); task = task.then(identity.bind(null, resolveLater('b', 1000))).then(callback); task = task.then(identity.bind(null, resolveLater('c', 2000))).then(callback); task = task.then(identity.bind(null, resolveLater('d', 0))).then(callback);
このようにreduceを手動で展開することで動的に追加できるようになる。
もう一つの例として、個々のタスクの実行も順番に(同時に一つしか動かさないように)したい場合
var callback = console.log.bind(console) var task = Promise.resolve(); task = task.then(resolveLater.bind(null, 'a', 1000)).then(callback); task = task.then(resolveLater.bind(null, 'b', 1000)).then(callback); task = task.then(resolveLater.bind(null, 'c', 2000)).then(callback); task = task.then(resolveLater.bind(null, 'c', 0)).then(callback);
このようになる。 Promiseの上にコントロールフローを実装するライブラリを使うより、このように直接promiseを組み合わせる手法を使ったほうがいいと思う理由は複数あって、まず、自由度の点。 例えばここに載せたコードでは、全体の状態がPromiseとして表現されていて取り出すことができる(reduceを直接使った例では、reduceが返すPromiseがそれにあたる)ので、複数のパターンを組み合わせることができる。 具体的には、最初のpromise数個をarrayとして入れておき、動的に実行したいものを後から追加したい場合。
var identity = Object; // この場合function (a) { return a; } var callback = console.log.bind(console); var task = [ resolveLater('a', 1000), resolveLater('b', 1000), resolveLater('c', 2000), resolveLater('d', 0), ].reduce(function (current, next) { var p = current.then(function (v) { return next; }); p.then(callback); return p; }, Promise.resolve()); task = task.then(identity.bind(null, resolveLater('e', 1000))).then(callback);
このようにできる。
reduce相当の処理をしていないコードのもう一つの問題は、エラー処理。動的にタスクを追加する例では全体のエラーという概念は薄いかもしれないが、例えば、記事中のコントロールフローライブラリでは、すべての実行が終わった状態というものをPromiseとして取り出せない。よって、全体のどこかでエラーがあった場合にそれらをcatchする関数、というものが書けない。
全体の処理がPromiseとして表現されている場合は
task.then(null, function (err) { console.error(err); });
などを最後に追加することで、エラー処理が握りつぶされるわかりづらいエラーが防げる。