Node.jsにおけるcallback地獄からの脱却について

※この記事はNode.jsのv4以降を前提として記述しています。
 (記事中のコードはv4.4.3で動作確認しました)

callback地獄

Node.jsに限らず、JavaScriptで非同期処理を書いているとcallback地獄と呼ばれる状態に陥りやすい。

callback地獄とは何か?言葉で説明するよりも実際にコードで示した方が早いであろう。

callback地獄とは以下のような状態である。

var fs = require('fs');

fs.readdir('.', function(err, files){

  if (err) throw err;

  var fileList = [];

  files.forEach(function (file){

    if (file.indexOf('.js') == -1) return;

    fs.readFile('./' + file, function(err, text){

      if (err) throw err;

      fs.writeFile('./output/' + file, text, function(err){

        if (err) throw err;
      });
    });
  });

  console.log('done!');
});

これはプログラム実行ディレクトリのjsファイルをその配下にあるoutputフォルダにコピーするという単純な目的を持つコードである。

しかし非同期で書くとforeachループのfunctionを除いても三つものfunctionイコールcallback関数がネストされてしまっていることが分かる。

callback関数は、冒頭に

if (err) throw err;

といったエラー処理を記述せねばならず、このようなコードは可読性を著しく低下させる。

もちろん例の場合、fsには同期用の関数であるreadFileSyncやwriteFileSyncが用意されているので、それらを利用することでcallback地獄を回避することができる。

しかしこれがたとえばデータベースへの問い合わせ処理の場合、非同期で記述する必要がある。
それでもcallback地獄は避けたいので悩ましい状態に陥る。

Promiseによる書き換え

多重ネストとインデント増加による読みづらさを回避するだけであればPromiseを利用することで解決できる。

上記をPromiseで書き換えてみよう。

Promiseを利用するには非同期関数をPromiseでラップしてやる必要がある。
たとえばreaddirの場合は以下のようにする。

function readdir(dir){
  return new Promise(function(resolve, reject){
    fs.readdir(dir, function(err, files){
      if (err){
        reject(err);
      }
      resolve(files);
    });
  });
}

しかしPromiseによるラップを手作業で行うのは面倒なので、ここではbluebirdを利用する。

bluebirdのサイト

bluebirdのGithub

bluebirdのインストール

npm install bluebird

以下のようにbluebirdを利用して最初のコードをPromise利用で書き直す。

var Promise = require('bluebird');
var fs = require('fs');

Promise.promisifyAll(fs);

fs.readdirAsync('.').then(function(files){

  files.forEach(function (file){

    if (file.indexOf('.js') == -1) return;

    fs.readFileAsync('./' + file).then(function(text){
      return fs.writeFileAsync('./output/' + file, text);
    }).catch(function(err){
      throw err;
    });
  });

  console.log('done!');

}).catch(function(err){
  throw err;
});

bluebirdにはPromisificationという機能があり、callbackを必要とする関数を元にPromiseを返す関数を自動で定義してくれる。

この場合、4行目の

Promise.promisifyAll(fs);

でreadFileAsyncや、writeFileAsyncといった関数をfsモジュールに対して拡張してくれている。

Promiseを利用した例は、最初の例と比べて関数のネストとインデントを減らすことができている。

またcallback関数の第1引数を利用したエラー処理ではなく、catch関数にエラー処理をまとめられる(エラー処理を一か所に記述できる)という利点もある。

しかしPromiseを利用してネストを減らすやり方の場合、

fs.readFileAsync('./' + file).then(function(text){
  return fs.writeFileAsync('./output/' + file, text);
}).catch(function(err){
  throw err;
});

のようにPromiseを返す関数をreturnで記述してthenでつないでいくのだが、これがやはり直観的にはわかりにくい。

またthenに対して処理用の関数を与えねばならず煩雑さを免れない。

bluebirdのcoroutineとGenerator関数を利用した解決

そこでbluebirdのcoroutine関数を利用することでさらなる改善を試みる。

coroutine関数は、Generator関数を引数に取る。

coroutine関数のAPI Reference

GeneratorとはJavascript1.7で追加された機能であり、関数呼び出し時にキーワードyieldの位置まで実行するというものである。

両者を組み合わせることで、直観的に理解しやすい非同期処理を記述することが可能だ。

var Promise = require('bluebird');
var fs = require('fs');

Promise.promisifyAll(fs);

var exec = function *(){

  var files = yield fs.readdirAsync('.');

  for(var i = 0; i < files.length; i++){

    var file = files[i];

    if (file.indexOf('.js') == -1) continue;

    var text = yield fs.readFileAsync('./' + file);

    yield fs.writeFileAsync('./output/' + file, text);
  }

  console.log('done!');
};

Promise.coroutine(exec)().catch(showError);

var showError = function (err){
  console.error(err);
};

6行目のexecがGenerator関数である。

execの内部でyieldキーワードを利用することにより、Promiseの戻り値を見かけ上同期処理のように受け取ることができる。

一番最初の例と比べてみれば明らかに可読性が向上しているはずである。

もちろんGenerator関数に名前を付けずに以下のように記述することもできる。

var Promise = require('bluebird');
var fs = require('fs');

Promise.promisifyAll(fs);

Promise.coroutine(function *(){

  var files = yield fs.readdirAsync('.');

  for(var i = 0; i < files.length; i++){

    var file = files[i];

    if (file.indexOf('.js') == -1) continue;

    var text = yield fs.readFileAsync('./' + file);

    yield fs.writeFileAsync('./output/' + file, text);
  }

  console.log('done!');
})().catch(showError);

var showError = function (err){
  console.error(err);
};

またcoモジュールを利用することでも同じような記述が可能だ。

var Promise = require('bluebird');
var fs = require('fs');
var co = require('co');

Promise.promisifyAll(fs);

co(function *(){

  var files = yield fs.readdirAsync('.');

  for (var i = 0; i < files.length; i++){

    var file = files[i];

    if (file.indexOf('.js') == -1) continue;

    var text = yield fs.readFileAsync('./' + file);

    yield fs.writeFileAsync('./output/' + file, text);
  }

  console.log('done!');

}).catch(showError);

function showError(err){
  console.error(err.stack);
}

まとめるとbluebirdやcoといったモジュールとGenerator関数を併用することでNode.jsで陥りやすいcallback地獄を脱却することが可能だ。

長期的なプロダクトメンテナビリティ向上のために、リファクタリングの機会をとらえ、積極的にcallbackによる記述を置き換えていくのが良いだろう。

Node.jsにおけるcallback地獄からの脱却について」への1件のフィードバック

  1. ピンバック: ES6のPromiseはBluebirdの4倍遅いらしい | Solutionware開発ブログ

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

7 + 3 =