最近のjs非同期処理 PromiseとGeneratorの共存

  • 471
    Like
  • 0
    Comment
More than 1 year has passed since last update.

(2015/10/21追記)
記事を書いてから半年経ちましたが、最近はRxの良さを理解したりES7のasync/awaitがbabelによって実用的になりつつあったり等、またもやベストプラクティスとはなんだったのか状態です。とはいえ、いまのところPromiseは非同期処理の土台であり続けそうですし、Generatorもasync/awaitへの足がかりとして知っておくことのメリットは大きいかと思いますので、引き続き公開させたままとさせて頂きます。
(追記ここまで)

数ある非同期処理のプラクティスを試してみて、だいたいこれが良いんじゃないかというパターンが固まったので書きます。効用はコールバック地獄からの脱出と結局非同期どれが良いの感の払拭。ES6寄りです。

前提知識

JavaScript Promiseの本
http://azu.github.io/promises-book/

ジェネレータの解説と非同期への適用 - Block Rockin’ Codes
http://jxck.hatenablog.com/entry/2014-01-12/generator-screencaset

結論

1.基本は全てPromiseでくるむ。
2.並列処理はPromise.all
3.直列処理は1をさらにGeneratorで包む。

(追記)2,3はあくまでおすすめ。詳しくはまとめ追記をどうぞ。

Example

ある非同期処理

function p(str) {
  setTimeout(function() {
    resolve(str);
  }, 1000);
}

まずはPromiseでくるむ。

function p(str) {
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve(str);
    }, 1000);
  });
}

準備ok。

基本呼び出し

promise.js
p('Async 1').then(function(result) {
  console.log(result);
});

console.log('Sync 1');

実行結果

$ time node --harmony promise.js
Sync 1
Async 1
# node --harmony promise.js  0.05s user 0.05s system 8% cpu 1.179 total

並列処理

Promise.allを使う。

promise.js
var tasks = [
  p('Async 1'),
  p('Async 2'),
  p('Async 3')
];
Promise.all(tasks).then(function(results) {
  console.log(results);
});

console.log('Sync 1');

実行結果

$ time node --harmony promise.js
Sync 1
[ 'Async 1', 'Async 2', 'Async 3' ]
# node --harmony promise.js  0.07s user 0.03s system 8% cpu 1.079 total

並列処理なので実行時間は約1秒(1000ms)。

直列処理(不採用)

並列処理ならPromise.allのような直感的なAPIがあるが、直列処理はあまり美味しくない(下記)。
まずはよく見かける「あくまでPromiseで工夫する方法」を見てみる。

Array.prototype.reduceを使う

promise.js
var tasks = [p, p, p];
var serial = tasks.reduce(function(promise, task, i) {
  return promise.then(function(_) {
    return task('Async '+ (i+1));
  })
}, Promise.resolve());

serial.then(function(result) {
  console.log(result);
});

console.log('Sync 1');

実行結果

$ time node --harmony promise.js
Sync 1
Async 3
# node --harmony promise.js  0.07s user 0.03s system 3% cpu 3.099 total

直列なので実行時間は3秒。
当然だがserial.then()に渡されてくるデータは「最後の直列処理でresolve()されたもの」なので、Async 3しか出力されていない。

もちろんそれ自体はreduceループ側にconsole.logを仕込めば解決するが、各処理結果に異なる処理を施したい時はループの中で条件分岐させる必要がある。

ネスト増えるし、かゆいところに手が届かない感じ。

直列処理(採用)

ということで、Generatorとcoを使って直感的に処理する。
coはpromiseのインターフェイスにも対応しているのでそのままyieldに渡すことが出来る。

generator.js
var co = require('co');
co(function *() {
  var res1 = yield p('Async 1');
  console.log(res1);

  var res2 = yield p('Async 2');
  console.log(res2);

  var res3 = yield p('Async 3');
  console.log(res3);
});

console.log('Sync 1');

実行結果

$ time node --harmony generator.js
Sync 1
Async 1
Async 2
Async 3
# node --harmony generator.js  0.06s user 0.02s system 2% cpu 3.081 total

実行時間は3秒、ちゃんと直列に処理されている。
また見ての通り同期的に縦に書けるため、各処理結果に対し個別に追加処理するのも簡単。

generator.js
var co = require('co');
co(function *() {
  var res1 = yield p('aSyNc');
  console.log(res1);

  var res2 = yield p(res1.toUpperCase());
  console.log(res2);

  var res3 = yield p(res2.toLowerCase());
  console.log(res3);
});

console.log('Sync');

実行結果

$ time node --harmony generator.js
Sync
aSyNc
ASYNC
async
# node --harmony generator.js  0.07s user 0.02s system 2% cpu 3.074 total

Error handling

Promiseのインターフェイスでreject&catchできる。

function p(str) {
  return new Promise(function(_, reject) {
    setTimeout(function() {
      reject(new Error('err!'));
    }, 1000);
  });
}
generator.js
var co = require('co');
co(function *() {
  var res1 = yield p('aSyNc');
  console.log(res1);

}).catch(function(err) {
  console.log(err.message);
});

console.log('Sync');

実行結果

$ time node --harmony generator.js
Sync
err!
# node --harmony generator.js  0.07s user 0.03s system 8% cpu 1.076 total

generatorっぽくtry~catchでも良いけど、ネスト一段減らせるこちらの方が好み。

まとめ

PromiseとGeneratorの使い分けに厳密なこだわりはなく「俺は並列処理もGenerator」でも良いと思います。
単純にPromise.allは使いやすいので、ならそれで良いだろうという程度。

この記事の肝としては

  • thenrable(Promiseに使えるインターフェイス)はyieldable(Generatorに使えるインターフェイス)である

つまり「共存できる」ということ。

当初は「PromiseかGenerator(か生collbackかasync.jsかetc)から一つどれを選ぶか」という観点で
Generator使うなら全く新しいエコシステムを作らなければならない、と考えていました。

しばらくはモリモリthunkifyする日々を続けたのですが、これはなかなかキツイなと(既存資産との乖離が大きい&プロジェクト全体がGeneratorでlockinされてしまう感)。

それがthenrableはyieldableに気づいてから「まずはPromise」で良いと分かってだいぶ気楽になった&今後はこの方向かな、と腑に落ちた経緯があります。

coもv4からthunkをdepricatedにしているので、割とこんな流れになるんじゃないかなと思っています。

(追記)then()チェインは?

そもそも直列処理はthen()チェインでどう?というコメントを頂いたので追記。

promise.js
p('Async 1').then(function(result) {
  console.log(result);
  return p('Async 2')
}).then(function(result) {
  console.log(result);
  return p('Async 3')
}).then(function(result) {
  console.log(result);
});

実行結果

 $ time node --harmony promise.js
Async 1
Async 2
Async 3
node --harmony promise.js  0.06s user 0.06s system 3% cpu 3.218 total

完全に見た目の問題ですが、

  • ノイズが膨らみやすい(一つ繋げるたびに.then(function() {})
  • 処理(p('Async'))が左右に散らばる

ところがすっきりせず、個人的にはGeneratorが好きです。

好き嫌いの話かよというとまさにその通りで、まとめにも書いた通り大事なのは「共存できる」こと、つまり気持よく書ける方へ行き来できる柔軟さがあることだと思います。とりあえずはPromiseで書いておき、ちょっとchain増えてカオスになってきたからGeneratorいくか〜、も全然アリです。もう「非同期どれが良いの」で悩まなくてよくなる安心感付き。

このゆるふわさの許容がthenrableはyieldableのよいところであり、Generatorの敷居を大きく下げる要因になるのかもしれません。