[JavaScript] 非同期処理のコールバック地獄から抜け出す方法

  • 375
    いいね
  • 5
    コメント

JavaScript (Node.js) で開発する上で避けては通れない
非同期処理、コールバックについて考えてみたい。
自分なりのお勧めの方式を書いてみた。
いろいろなものを試した結果である。

※この記事でのお勧めの方法は ES6 Harmony で実装された generators (yield)
 の技術を使用しています。
 実はまだ Babel(6to5) 等を利用するか Node.js v4~v6 でしか
 実質的に使用できない技術だと思います。悪しからず。
 (早くブラウザにも広く普及する事を祈っています)
 まだブラウザでは独自ライブラリか Promise (Deferred) 等を使っています。
※2015/10/15: 記事の内容を npm aa (async-await) に対応させました。
※2015/04/19: 記事の内容を npm co@4 に対応させました。
※2015/11/03: 追加記事を書きました。
ES7 async/await + Promise で解決できる事、とES6 generators (yield) + Promise + npm aa (async-await) で解決できる事 - Qiita

Agenda

  • 非同期処理とコールバック
  • コールバック地獄
  • これからの非同期 aa
  • aa.Channel を使った場合
  • 今までの JavaScript
  • 今でも超人気の async.js
  • 必ず理解すべき Promise (Deferred)

非同期処理とコールバック

JavaScript はシングルスレッドで動くので、同時に複数の処理がしたい場合、
非同期処理を行うのだが、それには結果や完了をお知らせしてもらうための
コールバックが必要だ。

つまり非同期処理と言えば、コールバックだ。
JavaScript をやる以上、コールバックを避けて通る事はできない。
習得しなければいけない技術の1つだ。

コールバックにも同期型と非同期型がある。

コールバックには一度だけ必ず呼ばれるという前提のコールバックと、
呼ばれないかもしれないし、何度も呼ばれるかもしれない
イベント的なものがある。

それを踏まえて先に進もう。

コールバック地獄

非同期な処理を実行するとコールバックが出てくる。
簡単な例を通して、どの様に実装できるのか考えてみる。

非同期な get という処理

get という関数を用意しておく。
サンプルは Node.js で書いてみた。

get.js
'use strict';

function get(file, callback) {
  console.log('file: %s...', file);
  setTimeout(function () {
    console.log('file: %s complete', file);
    callback(null, '(' + file + ')');
  }, 200 + Math.random() * 100);
}

module.exports = get;

これはランダムに 200~300 msec くらい時間をおいて
答えを返す非同期処理の例である。
引数はファイル名やURLの文字列を想定しており、
返す値はその文字列を元にしている。

get a, b, c をシーケンシャルに処理したい

a.txt, b.txt, c.txt の3つのファイルを順番に取得して、
結果を表示するサンプルを考えてみる。

それぞれ非同期処理なのでコールバックが呼ばれてから次の処理を開始している。

callback-hell1.js
'use strict';

var get = require('./get');

get('a.txt', function (err, a) {
  get('b.txt', function (err, b) {
    get('c.txt', function (err, c) {
      console.log(a + b + c);
    });
  });
});
$ node callback-hell1.js

あぁ、まさにこれがコールバック地獄である。
3つの処理をシーケンシャルに実行するために、
関数の呼び出しがどんどん深くなってしまっている。
エラー処理なんて考えたくも無い。

ちょっと処理が複雑になったらどうするのだろうか。
ある条件の時、get b を呼び出さない、などというロジックを考えてみて欲しい。
あっちゅーまに複雑になる。

get a, b, c をパラレルに処理したい

では、並行処理ができる様にしてみる。

callback-hell2.js
'use strict';

var get = require('./get');

var n = 0;
var res = {};

++n;
get('a.txt', function (err, a) {
  res.a = a;
  if (--n === 0) callback(null, res);
});

++n;
get('b.txt', function (err, b) {
  res.b = b;
  if (--n === 0) callback(null, res);
});

++n;
get('c.txt', function (err, c) {
  res.c = c;
  if (--n === 0) callback(null, res);
});

function callback(err, res) {
  console.log(res.a + res.b + res.c);
}
$ node callback-hell2.js

生の JavaScript だとこれくらいのロジックで勘弁して欲しい。
この方式だと、ある条件の時 get b を呼ばない、とか簡単だ。

get a, b を並列に、その後 get c を

では、get a と get b を並列にやって最後に get c をやってみる。

callback-hell3.js
'use strict';

var get = require('./get');

var n = 0;
var res = {};

++n;
get('a.txt', function (err, a) {
  res.a = a;
  if (--n === 0) callback(null, res);
});

++n;
get('b.txt', function (err, b) {
  res.b = b;
  if (--n === 0) callback(null, res);
});

function callback(err, res) {
  get('c.txt', function (err, c) {
    res.c = c;
    console.log(res.a + res.b + res.c);
  });
}
$ node callback-hell3.js

うむ。複雑だが、がんばればできるね。
でも、やってる事に比べてソースの行数が多くないか?
コピペが多いし。
これでいいわけはない。

これからの非同期 aa

いろんなライブラリを試してやっとたどり着いたのが aa だ。

申し訳ないが ES6 generators (yield) が有効になっていないといけない。

最近のブラウザでは Firefox, Chrome, Edge は常に有効の様だ。
IE はあきらめろ。babel 使ってください。

aa の準備

aa の準備をしておこう。

npm-install-aa.sh
$ npm i aa -S

または

npm-install-aa-save.sh
$ npm install aa --save

aa 経由で get を使うために非同期関数 get を
aa.thunkify または aa.promisify で wrap する。
Promise スタイルにしたい時は、promisify を使うと良い。
※ややこしい fs.exists や child_process.exec にも対応している。

aa: get a, b, c をシーケンシャルに処理する

aa を使ってシーケンシャル処理を書いてみる。

aa-get1.js
'use strict';

var aa = require('aa');
var get = aa.thunkify(require('./get'));

aa(function*() {
  var a = yield get('a.txt');
  var b = yield get('b.txt');
  var c = yield get('c.txt');
  console.log(a + b + c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });

実行するには以下のコマンドを使う。

$ node aa-get1.js

おぉ、メインロジックはすっきりだね。
シーケンシャルそのままだ。

実はエラー処理も try catch で良い。これはいい。

aa: get a, b, c をパラレルに処理する

aa を使ってパラレル処理を書いてみる。

aa-get2.js
'use strict';

var aa = require('aa');
var get = aa.thunkify(require('./get'));

aa(function*() {
  var res = yield {a: get('a.txt'),
                   b: get('b.txt'),
                   c: get('c.txt')};
  console.log(res.a + res.b + res.c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-get2.js

おぉ、やっぱりすっきり。
パラレル感がはんぱねぇ。

配列もオブジェクトも yield できるので
並列に処理する数が不明の場合も対応できるね。
エラー処理も try catch できるし。

aa: get a, b を並列に、その後 get c を

aa を使って get a, b を並列に、その後 get c を。

aa-get3.js
'use strict';

var aa = require('aa');
var get = aa.thunkify(require('./get'));

aa(function*() {
  var res = yield {a: get('a.txt'),
                   b: get('b.txt')};
  res.c = yield get('c.txt');
  console.log(res.a + res.b + res.c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-get3.js

おぉ、これも、なんか、いい感じじゃない?
これなら、いろいろなロジックが書けそうな気がする。
aa で JavaScript の世界が変わると思う。
aa 最高!

aa.Channel を使った場合

もうこれで解決した気になったかもしれないが、
非同期処理やコールバックには、実は他のパターンもある。

イベントやストリームだ。
aa と aa.thunkify/aa.promisify だけでは解決できない。
aa.Channel というのもあるので、別パターンという事で実験的に書いてみる。

aa.Channel を使ったシーケンシャル処理

aa.Channel を使ってシーケンシャル処理を書いてみる。

コールバックとしてチャネルを渡し、結果を得るためには yield すれば良い。
チャネルはキューやバッファの様なものだと思えばいい。

aa-chan1.js
'use strict';

var aa = require('aa');
var Channel = aa.Channel;
var get = require('./get');

aa(function*() {
  var ch = Channel();

  get('a.txt', ch);
  var a = yield ch;

  get('b.txt', ch);
  var b = yield ch;

  get('c.txt', ch);
  var c = yield ch;

  console.log(a + b + c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-chan1.js

あまり aa + Promise や aa + thunkify パターンと変わらないが get 関数は wrap されていない。
Node スタイルの非同期関数のままだ。

aa.CHannel を使った並列処理

aa.Channel を使って並列処理を書いてみる。

aa-chan2.js
'use strict';

var aa = require('aa');
var Channel = aa.Channel;
var get = require('./get');

aa(function*() {
  var ch_a = Channel();
  var ch_b = Channel();
  var ch_c = Channel();

  get('a.txt', ch_a);
  get('b.txt', ch_b);
  get('c.txt', ch_c);
  var a = yield ch_a;
  var b = yield ch_b;
  var c = yield ch_c;

  console.log(a + b + c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-chan2.js

get を続けて呼べば、並列処理だし、yield すればそこで同期できるのだ。
get が完了する順番が変わっても、この場合は正しく同期できる。

aa.Channel: get a, b を並列に、最後に c を

aa.Channel で a, b を並列に、最後に c の処理を。

aa-chan3.js
'use strict';

var aa = require('aa');
var Channel = aa.Channel;
var get = require('./get');

aa(function*() {
  var ch_a = Channel();
  var ch_b = Channel();
  var ch_c = Channel();

  get('a.txt', ch_a);
  get('b.txt', ch_b);
  var a = yield ch_a;
  var b = yield ch_b;

  get('c.txt', ch_c);
  var c = yield ch_c;

  console.log(a + b + c);
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-chan3.js

並列も直列も思いのままだ。
コールバックとしてチャネルを渡し、同期したい時に yield するだけだ。

aa.Channel は get などを Promise や thunkify 等で wrap しなくて良いという利点があるが、
見映えはあまり良くないので、こういうパターンでは aa + Promise または aa + thunkify をお勧めする。
記述量が少なく簡単な方がいいからね。

aa.Channel でスレッド間通信の様な事

aa.Channel ではスレッド間通信の様な事ができる。

2つのスレッド間で、データをやりとりする例を書いてみる。

aa-chan4.js
'use strict';

var aa = require('aa');
var Channel = aa.Channel;

var ch1 = Channel();
var ch2 = Channel();

// *** FIRST THREAD ***
aa(function*() {
  // send
  ch2('(a)');

  // receive
  var b = yield ch1;
  console.log(b);

  // send
  ch2('(c)');

  console.log('end of thread 1');
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });

// *** SECOND THREAD ***
aa(function*() {
  // receive
  var a = yield ch2;

  // send
  ch1('(b)');

  // receive
  var c = yield ch2;

  console.log(a + c);
  console.log('end of thread 2');
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });
$ node aa-chan4.js

ちなみにスレッドの事を説明せねばなるまい。
最初に書いたが JavaScript はマルチスレッドではない。
シングルスレッドなのだ。
ここでスレッドと書いたが本当はスレッドもどきだ。
Non-preemptive Multitasking かな。
Windows 3.1 (16bit) のメッセージ処理と同じだ。
自分がここで処理を開放しても良いと思った時にだけ
他の処理に制御が移せる。
co-routine だ。

イベント処理やストリーム処理

aa.Channel が得意なパターンとして、イベント処理やストリーム処理がある。
aa と thunkify/promisify だけでは、コールバックが1回だけ呼び出されるという前提があり、
その上で yield が可能なのであったが、
aa.Channel ではコールバックが複数回呼び出されることが前提となっている。

今は面倒なので詳しく書かないが socket などのストリームからのイベント処理を以下の様に記述できる。

var ch = Channel();
ch.stream(socket);
// socket.on('end', ch.end);
// socket.on('error', ch);
// socket.on('readable', ch.readable);

var buff;
while (buff = yield ch) {
  console.log(String(buff));
}

今までの JavaScript

もう、今までの JavaScript では書けない。
コールバック地獄から早く抜け出そう。

今でも超人気の async.js

async.js は恐らく最初に発見する非同期処理のライブラリだ。
いっぱい使われている様でダウンロードされまくっている。
名前が良いんだね。
map とか filter とか、いろいろあって非常に便利だと思う。
でも function キーワードがコード中にたくさん出てくるので、
私はもう使っていない。

必ず理解すべき Promise (Deferred)

Promise (Deferred) は絶対おぼえておかなきゃならない。

Node.js ではサーバなので、開発側というかサービスを提供する側が、
バージョンや起動オプションを決めることができるので、
yield ベースの aa や co なんかを使う事ができる。

だが、ブラウザ(クライアント)側では、そうはいかない。
yield が使える様になるまでは、npm bluebird や jQuery などのライブラリが提供している
Promise (Deferred) を勉強しておこう。

ちなみに aa や co でも Promise (thenable) が yieldable なので、いいよね。
意味不明。

co ベースの koa(koajs) に進もう

express の開発者達が co ベースの koa を作っている。
これはもう使わない手は無い。

サーバ側なのだから ES6 generators/yield はもう使える技術なのだ。

※記事書いてます → Node.js + co + koa の勧め - Qiita

aa や co の暗黒面

夢の様な力を持つ aa や co だが、実はダークサイドもあるのだ。

非同期処理のコールバックは非常にうまく使えるのだが、
同期的なコールバックと yield を組み合わせると、崩壊するのだ。ははは。

'use strict';

var aa = require('aa');

aa(function*() {
  var ary = [1, 2, 3];

  ary.forEach(function (val) {
    console.log(val);
  });
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });

配列の forEach の引数の関数の中には yield が書けないのだ。
こういう場合は for 文で ary[i] を回せ。ダサいけど。

同期的なコールバックの例としては Array の forEach, map, filter や
String の replace など、探せばたくさんある。

map と arrow function を組み合わせれば、うまく対応できるよ。
以下のコードを見て欲しい。
map の中は非同期な処理が複数含まれていれば並行処理となる。

'use strict';

var aa = require('aa');

aa(function*() {
  var ary = [1, 2, 3];

  return yield ary.map(val => function* () {
    console.log(val);
    yield Promise.resolve(val);
  });
}).then(
  function (val) { console.info('ok: ' + val); },
  function (err) { console.error('ng: ' + err); });

推奨URL / 参考文献

以下のリンクを是非読んで欲しい。

コールバック……駆逐してやる…この世から…一匹…残らず!!

JavaScriptの非同期処理には何を使うべきか