HackerNews翻訳してみた

HackerNewsを中心に、人気の英語記事を翻訳してお届けします。記事は元記事の著者に許可をとった上で翻訳・掲載をしています。

Node.jsのコールバック地獄をPromiseやGeneratorを使って解消する

「HackerNews翻訳してみた」が POSTD (ポスト・ディー) としてリニューアルしました! この記事はここでも公開されています。


Original article: Managing Node.js Callback Hell with Promises, Generators and Other Approaches by Marc Harter


下のようなコードが、親しみをこめて「コールバック地獄」とか「死のピラミッド」とか呼ばれているのはご存じですよね。

doAsync1(function () {
  doAsync2(function () {
    doAsync3(function () {
      doAsync4(function () {
    })
  })
})

この状態がコールバック地獄かどうかは、意見の分かれるところでしょう。ネストがいくら深くても全く問題がないコードもあるからです。非同期のコードでフロー管理ができないほど複雑になってしまった場合は悪夢ですが。自分のコードがどの程度"ひどい"状態に陥っているかを確認するには、こう自問してみてください。「doAsync1の前にdoAsync2が実行された場合、リファクタリングにどこまで労力がかけられるか」と。ここでのゴールはネストの階層を減らすことではなく、モジュール化された(もちろんテスト可能な)、論理的で耐障害性の高いコードを書くことです。

この記事では、複数ツールやライブラリを使ってモジュールを作成し、どのようにフロー制御が動作するのかを検証してみようと思います。さらにN ode.jpの次バージョンで導入される予定のソリューションについても触れるつもりです。


問題

それでは、特定のディレクトリ内で最もサイズの大きいファイルを探すモジュールを実装することにしましょう。

var findLargest = require('./findLargest')
findLargest('./path/to/dir', function (er, filename) {
  if (er) return console.error(er)
  console.log('largest file was:', filename)
})

コードを書く前に手順をリストアップしてみます。

  • 対象ディレクトリにあるファイルを読み込む
  • ディレクトリ内の各ファイルのstatsを取得する
  • サイズの一番大きいファイルを判定する(同じサイズのファイルが複数あれば1つ選ぶ)
  • 最大のファイルの名前を返す

どこかでエラーが発生した場合は、ファイル名ではなくエラー値を返します。また、コールバック処理の呼び出しは一度だけとします。

ネストを使ったアプローチ

最初にネストを使ったアプローチを試してみましょう。もちろん"ひどい"ネストではありませんよ。ロジックを内向きに書いていくのです。

var fs = require('fs')
var path = require('path')
 
module.exports = function (dir, cb) {
  fs.readdir(dir, function (er, files) { // [1]
    if (er) return cb(er)
    var counter = files.length
    var errored = false
    var stats = []
 
    files.forEach(function (file, index) {
      fs.stat(path.join(dir,file), function (er, stat) { // [2]
        if (errored) return
        if (er) {
          errored = true
          return cb(er)
        }
        stats[index] = stat // [3]
 
        if (--counter == 0) { // [4]
          var largest = stats
            .filter(function (stat) { return stat.isFile() }) // [5]
            .reduce(function (prev, next) { // [6]
              if (prev.size > next.size) return prev
              return next
            })
          cb(null, files[stats.indexOf(largest)]) // [7]
        }
      })
    })
  })
}
  • ディレクトリ内のすべてのファイルを読み込みます。
  • 各ファイルのstatsを取得します。ここは並列処理されるので、I/O がすべて終了したかどうかを確認するためにcounter変数を用います。また、エラーが起こった場合にコールバック関数(cb)が2度以上呼ばれないよう、ブール値erroredを使用します。
  • 各ファイルのstatsを収集します。並列処理で配列にstatの値をセットしているところに注目してください(filesからstats配列への変換)。
  • すべての並列処理が完了したかどうかをチェックします。
  • 通常のファイルだけを取得します(リンクやディレクトリなどは除く)。
  • リスト内で最大のファイルを特定するため、リストを絞り込みます。
  • 特定したstatとコールバック関数を使ってファイル名を返します。

このアプローチで十分問題を解決できそうですが、並列処理の扱いと、コールバックが一度しか呼ばれないようにする処理は、注意が必要そうです。これらの注意点を扱う方法は後で触れることにして、まずは同じ処理を小さいモジュールに分けて考えてみましょう。

モジュールを使ったアプローチ

先ほどのネストを使ったアプローチは、以下の3つのモジュールに分けることができます。

  • ディレクトリからファイルを取得する
  • それらのファイルからstatsを取得する
  • statsとfilesを処理して最大のファイルを決定する

最初のタスクは基本的にfs.readdir()で事足りますから、わざわざ関数を書く必要はありませんね。まずは、順序を保持しつつ対象となるパスのstatsを返す関数を書いてみましょう。

function getStats (paths, cb) {
  var counter = paths.length
  var errored = false
  var stats = []
  paths.forEach(function (path, index) {
    fs.stat(path, function (er, stat) {
      if (errored) return
      if (er) {
        errored = true
        return cb(er)
      }
      stats[index] = stat
      if (--counter == 0) cb(null, stats)
    })
  })
}

さて次に必要なのは、statsとfilesを使って最大のファイルの名前を返す関数です。

function getLargestFile (files, stats) {
  var largest = stats
    .filter(function (stat) { return stat.isFile() })
    .reduce(function (prev, next) {
      if (prev.size > next.size) return prev
      return next
    })
    return files[stats.indexOf(largest)]
}

では、これらを統合してみましょう。

var fs = require('fs')
var path = require('path')
 
module.exports = function (dir, cb) {
  fs.readdir(dir, function (er, files) {
    if (er) return cb(er)
    var paths = files.map(function (file) { // [1]
      return path.join(dir,file)
    })
 
    getStats(paths, function (er, stats) {
      if (er) return cb(er)
      var largestFile = getLargestFile(files, stats)
      cb(null, largestFile)
    })
  })
}
  • ファイルとディレクトリからパスのリストを生成しています。

モジュールを使ったアプローチでは、コードが再利用がしやすく、テストも容易になります。メインのエクスポートが分かりやすいというメリットもあります。しかし、statを取得する並列処理の管理は自前で実装していましたね。これをフロー制御モジュールを使った処理に変更してみましょう。

asyncモジュールを使ったアプローチ

asyncモジュールは幅広く使われていて、Nodeコアにも親和性のある方法です。では早速、今回のプログラムをasyncモジュールを使って実装してみましょう。

var fs = require('fs')
var async = require('async')
var path = require('path')
 
module.exports = function (dir, cb) {
  async.waterfall([ // [1]
    function (next) {
      fs.readdir(dir, next)
    },
    function (files, next) {
      var paths =
       files.map(function (file) { return path.join(dir,file) })
      async.map(paths, fs.stat, function (er, stats) { // [2]
        next(er, files, stats)
      })
    },
    function (files, stats, next) {
      var largest = stats
        .filter(function (stat) { return stat.isFile() })
        .reduce(function (prev, next) {
        if (prev.size > next.size) return prev
          return next
        })
        next(null, files[stats.indexOf(largest)])
    }
  ], cb) // [3]
}
  • async.waterfallは、一連のフローを実行します。nextというコールバック関数を用いることで、1つの処理で得たデータを一連のフロー内の次の関数に渡すことができます。
  • async.mapは、取得したパスを使って非同期でfs.statを実行し、結果の配列を返します(順序は保持されます)。
  • cb関数は最後のステップが完了した後、または途中でエラーが起こった場合に一度だけ呼ばれます。

asyncモジュールを使えば、コールバック関数は間違いなく一度しか呼ばれません。また、私たちの代わりにエラーを伝播し、並列処理を管理してくれます。

promiseを使ったアプローチ

promiseは、エラー処理と関数型プログラミングを得意とします。promiseを使って、今回の問題にアプローチしてみましょう。ここではQモジュールを使います(もちろんpromiseを使った他のライブラリを採用してもかまいません)。

var fs = require('fs')
var path = require('path')
var Q = require('q')
var fs_readdir = Q.denodeify(fs.readdir) // [1]
var fs_stat = Q.denodeify(fs.stat)
 
module.exports = function (dir) {
  return fs_readdir(dir)
    .then(function (files) {
      var promises = files.map(function (file) {
        return fs_stat(path.join(dir,file))
      })
      return Q.all(promises).then(function (stats) { // [2]
        return [files, stats] // [3]
      })
    })
    .then(function (data) { // [4]
      var files = data[0]
      var stats = data[1]
      var largest = stats
        .filter(function (stat) { return stat.isFile() })
        .reduce(function (prev, next) {
        if (prev.size > next.size) return prev
          return next
        })
      return files[stats.indexOf(largest)]
    })
}
  • Nodeコア機能はpromiseを意識しないので、認識できるようにします。
  • Q.allはすべてのstatの取得処理を並列で実行します。その際、結果の配列の順序は保持されます。
  • 次のthen関数に引き渡すため、最後にfilesとstatsを返します。

これまでの例と違い、promiseチェーン(つまりthen)内で投げられた例外はキャッチされて処理されます。では、クライアントAPIもpromise仕様に変更しましょう。

var findLargest = require('./findLargest')
findLargest('./path/to/dir')
  .then(function (er, filename) {
    console.log('largest file was:', filename)
  })
  .catch(console.error)

設計は上記のようになりますが、インターフェイスにpromiseを用いる必要はありません。多くのpromiseライブラリには、nodebackスタイルのコールバックも扱えるようにメソッドが用意されています。Qモジュールでは、nodeify関数がそれに当たります。

promiseのスコープについては、ここではこれ以上掘り下げません。詳細については、こちらをお読みになることをお勧めします。

generatorを使ったアプローチ

記事の冒頭で触れたように、Node v0.11.2から新機能が仲間入りします。そう、generatorです。

generatorはJavaS cript向けの軽量版のコルーチンです。yieldキーワードを使って関数を一時停止したり、再開したりできます。generator関数はfunction* ()という特殊な構文を使います。その威力をもってすれば、prom iseや「サンク」を使った非同期処理を一時停止したり、再開したりすることもできるのです。 つまり"同期風"の非同期コードが書けるというわけですね。

「サンク」はコールバックを呼ぶのではなく、"返す"関数です。コールバック自体は一般的なnodeback関数と同じシグネチャを持ちます(つまり、第一引数はエラーです)。詳しくはこちらをお読みください。

それでは非同期処理を制御するフローに、generatorを活用した例を見てみましょう。今回はTJHolowaychuk氏のcoモジュールを紹介します。このcoモジュールを使って、今回の最大のファイルを返すプログラムを実装しました。

var co = require('co')
var thunkify = require('thunkify')
var fs = require('fs')
var path = require('path')
var readdir = thunkify(fs.readdir) <strong>[1]</strong>
var stat = thunkify(fs.stat)
 
module.exports = co(function* (dir) { // [2]
  var files = yield readdir(dir) // [3]
  var stats = yield files.map(function (file) { // [4]
    return stat(path.join(dir,file))
  })
  var largest = stats
    .filter(function (stat) { return stat.isFile() })
    .reduce(function (prev, next) {
      if (prev.size > next.size) return prev
      return next
    })
  return files[stats.indexOf(largest)] // [5]
})
  • Nodeコア機能は「サンク」を意識しないため、認識できるようにします。
  • coはgenerator関数を引数に取ります。関数はyieldキーワードを使っていつでも一時停止できます。
  • generator関数はreaddirが返るまで停止しています。結果の値はfiles変数に割り当てられます。
  • coは配列を扱う並列処理にも対応できます。結果の配列は順序を保持しつつstats変数に割り当てられます。
  • 最終結果を返します。

このgeneratorを使った関数は、記事の冒頭で紹介したコールバックAPIでも利用できます。coモジュールには優れたエラー処理能力があり、すべてのエラー(例外発生を含む)をコールバック関数に引き渡してくれるのです。またgeneratorでは、yieldステートメントをtry/catchブロックで囲むことができ、coモジュールではこの特性を利用しています。

try {
  var files = yield readdir(dir)
} catch (er) {
  console.error('something happened whilst reading the directory')
}

coモジュールは、配列、オブジェクト、ネストされたgenerator、promiseなどをサポートするたくさんの機能があります。

他にも新しいgeneratorモジュールが次々と登場しています。例えばQモジュールにはQ.asyncメソッドがあり、generatorを使うcoモジュールと同じような働きをします。

まとめ

今回は、「コールバック地獄」を解消するため、つまりアプリケーションのフローを制御するために、さまざまなアプローチを検証しました。個人的にはgeneratorを使ったアプローチがお勧めです。generatorが今後koaのような新しいフレームワークでどのような展開を見せるのか、楽しみですね。

サードパーティのモジュールを検討した箇所では触れませんでしたが、モジュールを使ったアプローチは、どんなフロー制御ライブラリ(async、promise、generator)にも適用することができます。今回のプログラムをよりモジュール形式に修正するは、どうすればいいでしょうか。使えそうなライブラリやテクニックを知っている方がいたら、ぜひコメント欄に書き込んでください。

この記事で紹介したコードのサンプル、その他のgeneratorの使用例をチェックしたい方は、GitHub repoへどうぞ。



StrongOpsを使ってNodeアプリを監視する

イベントループの監視、Nodeクラスタの管理、メモリリークの追跡に関心があるあなたに朗報です。ローカル環境でもお好みのクラウド上でも、すぐにStrongOpsを始められますよ。npmで簡単にインストールできます。

"Screen Shot 2014-02-03 at 3.25.40 AM"

その他のおすすめ記事