(このシリーズのPart 2はこちら)
今回は、JavaScript/node.jsアプリケーションのフロー制御に対するアプローチを、いくつか取り上げて比較してみたいと思います。
- 通常のコールバックを使う
- 平坦化されたコールバックを使う
- Async(@caolan作)を使う
- Highland(こちらも@caolan作)を使う
- Bluebird(@petkaantonov作)を使う
Expressフレームワークを使った以下のルート処理(お粗末ですが)を例に見てみましょう。
- ファイルから読み込む
- いくつかのプロセスを実行する(ステップの数は3つ)
プロセスとは、単に拡張データをコールバックする任意の非同期処理を指します - ファイルに結果を書き出す
- リクエストに対して成功またはエラーのメッセージを返す
アプローチ1 – 継続渡し
var express = require('express'); var fs = require('fs'); var app = express(); app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; fs.readFile(inputFile, function(err, data) { if (err) return res.status(500).send(err); process1(data, function(err, data) { if (err) return res.status(500).send(err); process2(data, function(err, data) { if (err) return res.status(500).send(err); process3(data, function(err, data) { if (err) return res.status(500).send(err); fs.writeFile(outputFile, data, function(err) { if (err) return res.status(500).send(err); res.status(200).send('processed successfully using callback hell'); }); }); }); }); }); });
たったこれだけの(比較的)単純なステップで、すでに読みづらく、かつ論理的に考察しづらくなっています(少なくとも私は)。このルート処理が何をしているのかすぐに把握できません。
実行ステップ数が増えれば増えるほど、ひどいコードになり、アプリケーションの理解や保守、拡張がとても困難になっていくでしょう。
その上、各ステップのエラー処理で同じコードを使いまわすのはDRY原則から外れています。
アプローチ2 – 名前付き継続渡し
この例はpmuellr(@pmuellr)が提供してくれました。
var express = require('express') var fs = require('fs') var app = express() app.post('/process-file', onProcessFile) function onProcessFile(req, res) { var inputFile = 'input.txt' var outputFile = 'output.txt' fs.readFile(inputFile, onReadFile); function onReadFile(err, data) { if (err) return res.status(500).send(err) process1(data, onProcess1) } function onProcess1(err, data) { if (err) return res.status(500).send(err) process2(data, onProcess2) } function onProcess2(err, data) { if (err) return res.status(500).send(err) process3(data, onProcess3) } function onProcess3(err, data) { if (err) return res.status(500).send(err) fs.writeFile(outputFile, data, onWriteFile) } function onWriteFile(err) { if (err) return res.status(500).send(err) res.status(200).send('processed successfully using callback hell') } }
少なくともこのアプローチでは、継続が深くネストされることはありません。しかし、フローの各ステップでエラー処理がDRYの原則に反することで生じる問題があります。また、ステップが増えるほど、フローを追うのが大変になる可能性もあります。
アプローチ3 – async.jsを使う
以下は、asyncライブラリとwaterfallメソッドを使用した同じルート処理です。
var express = require('express'); var async = require('async'); var fs = require('fs'); var app = express(); app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; async.waterfall([ function(callback) { fs.readFile(inputFile, function(err, data) { callback(err, data); }); }, function(data, callback) { process1(data, function(err, data) { callback(err, data); }); }, function(data, callback) { process1(data, function(err, data) { callback(err, data); }); }, function(data, callback) { process1(data, function(err, data) { callback(err, data); }); }, function(data, callback) { fs.writeFile(outputFile, data, function(err) { callback(err, data); }); } ], function(err, result) { if (err) return res.status(500).send(err); res.status(200).send('processed successfully using async lib'); }); });
このアプローチだと、処理の手順として何をしているかが分かりやすくなり、少なくともエラー処理での重複を防ぐことができます。
また、フロー中で処理関数を直接使用することもでき(これは引数がフロー関数のものと同じためです)、最終処理のための名前付きコールバックを作成することもできます。
var express = require('express'); var async = require('async'); var fs = require('fs'); var app = express(); app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; var done = function(err, result) { if (err) return res.status(500).send(err); res.status(200).send('processed successfully with async'); }; async.waterfall([ fs.readFile.bind(fs, inputFile), process1, process2, process3, fs.writeFile.bind(fs, outputFile) ], done); });
“コールバック地獄”に比べれば、格段に良くなりました。でも、まだ十分とは言えません。
アプローチ4 – highland.js(stream)を使う
最近は、@caolanのhighlandライブラリを活用しています。これはネイティブのnode.jsのstreamとの完全な互換性を目指した、高度なstreamのライブラリです。今回はこのライブラリについて触れませんが、以下はhighland streamを使用した同じルート処理です。
var express = require('express'); var _ = require('highland'); var fs = require('fs'); var app = express(); app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; // create a highland stream var data = _([inputFile]); data .flatMap(_.wrapCallback(fs.readFile)) .flatMap(_.wrapCallback(process1)) .flatMap(_.wrapCallback(process2)) .flatMap(_.wrapCallback(process3)) .flatMap(_.wrapCallback(fs.writeFile.bind(fs, outputFile))) .stopOnError(function(err) { res.status(500).send(err); }) .apply(function(data) { res.status(200).send('processed successfully using highland streams') }); });
このアプローチは読みやすく拡張性があると思います。これは関数的アプローチで、アプリケーションはさらに保守しやすく考察しやすいものになります。あと50ステップ追加しても、アプリケーションのフローを簡単に素早く把握できるでしょう。JavaScriptのアプリケーションを作るなら、このライブラリを、また一般に、node.js streamを一読されることを強くお勧めします。
アプローチ5 – Promise(bluebird)
Esailija – @PetkaAntonov(bluebirdの作者)の、promiseを使用した例に賛同し、感謝します。
var express = require('express'); var Promise = require("bluebird"); var fs = Promise.promisifyAll(require('fs')); var app = express(); app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; fs.readFileAsync(inputFile) .then(Promise.promisify(process1)) .then(Promise.promisify(process2)) .then(Promise.promisify(process3)) .then(fs.writeFileAsync.bind(fs, outputFile)) .then(function(data) { res.status(200).send('processed successfully using bluebird promises'); }) .catch(function(err) { res.status(500).send(err); }); });
この例から分かるように、promiseはstreamとよく似ていますが、単一の値だけを出すという点で異なります。promiseの.then()メソッドはnode streamの.pipe()と同等と考えられます。
その結果、私は先週、勉強のためにpromises(A+)を一から実装することに時間を費やしましたが、これがとても面白くて役に立ちました。
おまけ:highlandと.reduce()を使用した例
Lewis Ellis – @LewisJEllis(highlandのcontributorの1人)の巧みな例に感謝します。
var express = require('express'); var _ = require('highland'); var fs = require('fs'); var app = express(); function chain(s, f) { return s.flatMap(_.wrapCallback(f)) } app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; var data = _([inputFile]); _([ fs.readFile, process1, process2, process3, writeToFileName(outputFile) ]).reduce(data, chain).flatten() .stopOnError(function (err) { return res.status(500).send(err); }).apply(function (data) { return res.status(200).send('processed'); }); });
おまけ2:highlandとasync.composeを使用した例
var express = require('express'); var _ = require('highland'); var async = require('async'); var fs = require('fs'); var app = express(); function chain(s, f) { return s.flatMap(_.wrapCallback(f)) } app.post('/process-file', function(req, res) { var inputFile = 'input.txt'; var outputFile = 'output.txt'; var data = _([inputFile]); data .flatMap(_.wrapCallback( async.compose( fs.writeFile.bind(fs, outputFile), process3, process2, process1, fs.readFile ) )) .errors(function(err) { res.status(500).send(err); }) .each(function(data) { res.status(200).send('processed successfully using highland streams and async.compose'); });
この記事のパート2には、さらに、generatorとfiberを使った非同期フローの例があります。