この広告は、90日以上更新していないブログに表示しています。
「HackerNews翻訳してみた」がPOSTD (ポスト・ディー) としてリニューアルしました!この記事はここでも公開されています。
Original article:Managing Node.js Callback Hell with Promises, Generators and Other Approaches byMarc 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)})
コードを書く前に手順をリストアップしてみます。
どこかでエラーが発生した場合は、ファイル名ではなくエラー値を返します。また、コールバック処理の呼び出しは一度だけとします。
最初にネストを使ったアプローチを試してみましょう。もちろん"ひどい"ネストではありませんよ。ロジックを内向きに書いていくのです。
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.lengthvar errored =falsevar stats =[] files.forEach(function (file, index){ fs.stat(path.join(dir,file),function (er, stat){// [2]if (errored)returnif (er){ errored =truereturn 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 prevreturn next}) cb(null, files[stats.indexOf(largest)])// [7]}})})})}
counter変数を用います。また、エラーが起こった場合にコールバック関数(cb)が2度以上呼ばれないよう、ブール値erroredを使用します。このアプローチで十分問題を解決できそうですが、並列処理の扱いと、コールバックが一度しか呼ばれないようにする処理は、注意が必要そうです。これらの注意点を扱う方法は後で触れることにして、まずは同じ処理を小さいモジュールに分けて考えてみましょう。
先ほどのネストを使ったアプローチは、以下の3つのモジュールに分けることができます。
最初のタスクは基本的にfs.readdir()で事足りますから、わざわざ関数を書く必要はありませんね。まずは、順序を保持しつつ対象となるパスのstatsを返す関数を書いてみましょう。
function getStats (paths, cb){var counter = paths.lengthvar errored =falsevar stats =[] paths.forEach(function (path, index){ fs.stat(path,function (er, stat){if (errored)returnif (er){ errored =truereturn cb(er)} stats[index] = statif (--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 prevreturn 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モジュールは幅広く使われていて、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 prevreturn next}) next(null, files[stats.indexOf(largest)])}], cb)// [3]}
nextというコールバック関数を用いることで、1つの処理で得たデータを一連のフロー内の次の関数に渡すことができます。cb関数は最後のステップが完了した後、または途中でエラーが起こった場合に一度だけ呼ばれます。asyncモジュールを使えば、コールバック関数は間違いなく一度しか呼ばれません。また、私たちの代わりにエラーを伝播し、並列処理を管理してくれます。
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 prevreturn next})return files[stats.indexOf(largest)]})}
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のスコープについては、ここではこれ以上掘り下げません。詳細については、こちらをお読みになることをお勧めします。
記事の冒頭で触れたように、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 prevreturn next})return files[stats.indexOf(largest)]// [5]})
yieldキーワードを使っていつでも一時停止できます。readdirが返るまで停止しています。結果の値はfiles変数に割り当てられます。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へどうぞ。
イベントループの監視、Nodeクラスタの管理、メモリリークの追跡に関心があるあなたに朗報です。ローカル環境でもお好みのクラウド上でも、すぐにStrongOpsを始められますよ。npmで簡単にインストールできます。

引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。