Node.jsフロー制御 Part 2 – FiberとGenerator

この記事は、以前投稿したJavaScript/node.jsでの非同期フローに関する記事の続編です。

今回は以下について取り上げます。

  • Fiber(fibrous.js)
  • Generator(ES6)
  • Generator + co + mz

ここでも私の書いた、Expressフレームワークを使った以下のルート処理(お粗末ですが)を例に見てみましょう。

  • ファイルから読み込む
  • いくつかのプロセスを実行する(ステップの数は3つ)
    プロセスとは、単に拡張データをコールバックする任意の非同期処理を指します
  • ファイルに結果を書き出す
  • リクエストに対して成功またはエラーのメッセージを返す

アプローチ1 – fiberを使う

var fs = require('fs');  
var express = require('express');  
var fibrous = require('fibrous');

var app = express();

app.get('/', function(req, res) {  
  fibrous.run(function() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = fs.sync.readFile(inputFile);
      var processedData1 = process1.sync(inputData);
      var processedData2 = process2.sync(processedData1);
      var result = process3.sync(processedData2);

      fs.sync.writeFile(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  }, function(err) {...});
});

fiberを使用することで、フローは同期的なコードのようになります。非同期関数が値を返すようになるからです。これは、fiberがもたらす”マジック”のような効果の一部です。関数あるいはオブジェクトのメソッドは全て、これを可能にする.syncバージョンを持つことになります。

アプローチ2 – generator(ES6)

generatorはES6で新しく導入された仕組みで、function *()というキーワードを使ってgenerator関数を作り出すことができます。generator関数は(var iter = genFun();)という記述で呼び出され、iteratorを返します。iteratorには、新しく導入されたyieldというキーワードが出現するまでコードの処理を進めることができる特別な機能があります。また、yield式は実行中のアプリケーションを終了させず、戻り値を待つ間だけ一時的に停止させます。

詳細についてはこちらをご覧ください。

# 部分適用を使用した例

var fs = require('fs');  
var express = require('express');  
var run = require('../../lib/runner');

var app = express();

app.get('/', function(req, res) {  
  run(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = yield fs.readFile.bind(fs, inputFile);
      var processedData1 = yield process1.bind(null, inputData);
      var processedData2 = yield process2.bind(null, processedData1);
      var result = yield process3.bind(null, processedData2);

      yield fs.writeFile.bind(fs, outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  });
});

iteratorの実行関数runの部分

function run(fn) {  
  var gen = fn();

  function next(err, res) {
    if (err) return gen.throw(err);
    var ret = gen.next(res);
    if (ret.done) return;
    ret.value(next);
  }

  next();
};

# カリー化を使用した例(lodashで使用した場合)

var fs = require('fs');  
var express = require('express');  
var run = require('../../lib/runner');  
var _ = require('lodash');

var app = express();

app.get('/', function(req, res) {  
  run(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = yield _.curry(fs.readFile.bind(fs))(inputFile);
      var processedData1 = yield _.curry(process1)(inputData);
      var processedData2 = yield _.curry(process2)(processedData1);
      var result = yield _.curry(process3)(processedData2);

      yield _.curry(fs.writeFile.bind(fs))(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  });
});

# thunkを使用した例

var fs = require('fs');  
var express = require('express');  
var run = require('../../lib/runner');  
var thunkify = require('../../lib/thunkify');

var app = express();

app.get('/', function(req, res) {  
  run(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = yield thunkify(fs.readFile.bind(fs))(inputFile);
      var processedData1 = yield thunkify(process1)(inputData);
      var processedData2 = yield thunkify(process2)(processedData1);
      var result = yield thunkify(process3)(processedData2);

      yield thunkify(fs.writeFile.bind(fs))(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  });
});

thunkifyは基本的にカリー化の2つのステップと同等の処理を行う関数なので、最初の呼び出しで入力データを渡し、2回目でコールバックを渡します。

function thunkify(fn) {  
  return function() {
    var args = Array.prototype.slice.call(arguments, 0, fn.length - 1);

    return function(done) {
      fn.apply(null, args.concat(done));
    };
  };
};

# Promiseを使用した例

var Promise = require('bluebird');  
var fs = Promise.promisifyAll(require('fs'));  
var express = require('express');  
var run = require('../../lib/runner');

var app = express();

app.get('/', function(req, res) {  
  run(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = fs.readFileAsync(inputFile);
      var processedData1 = yield Promise.promisify(process1)(inputData);
      var processedData2 = yield Promise.promisify(process2)(processedData1);
      var result = yield Promise.promisify(process3)(processedData2);

      yield fs.writeFileAsync(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  });
});

generator関数にyieldを入れてPromiseを使うには、それをサポートするために、iteratorの実行関数runを修正する必要があります。

function run(fn) {  
  var gen = fn();

  function next(err, res) {
    if (err) return gen.throw(err);
    var ret = gen.next(res);
    if (ret.done) return;

    if (typeof ret.value.then === 'function') {
      try {
        ret.value.then(function(value) {
          next(null, value);
        }, next);
      } catch (e) {
        gen.throw(e);
      }
    } else {
      try {
        ret.value(next);
      } catch (e) {
        gen.throw(e);
      }
    }
  }

  next();
};

アプローチ3 – generator + co + mz

独自のiteratorの実行関数を構築する代わりに、T.J Holowaychuckが作成したcoモジュールを使うことができます。このモジュールは、promiseで実行全体をラップしている間、promise、thunk、配列、オブジェクトなどといったいくつかのオプションをyieldするサポート機能があります。

# coを使用した例

var Promise = require('bluebird');  
var fs = Promise.promisifyAll(require('fs'));  
var express = require('express');  
var co = require('co');

var app = express();

app.get('/', function(req, res) {  
  co(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = fs.readFileAsync(inputFile);
      var processedData1 = yield Promise.promisify(process1)(inputData);
      var processedData2 = yield Promise.promisify(process2)(processedData1);
      var result = yield Promise.promisify(process3)(processedData2);

      yield fs.writeFileAsync(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  }).catch(funtion(err) {...});
});

このコードをさらに簡潔にするには、mzと呼ばれるヘルパーライブラリを使うことができます。mzは、Promiseを戻すように全てのネイティブな非同期APIを修正します。

# co + mzを使用した例

var Promise = require('bluebird');  
var fs = require('mz/fs');  
var express = require('express');  
var co = require('co');

var app = express();

app.get('/', function(req, res) {  
  co(function *() {
    var inputFile = 'input.txt';
    var outputFile = 'output.txt';

    try {
      var inputData = yield fs.readFile(inputFile);
      var processedData1 = yield Promise.promisify(process1)(inputData);
      var processedData2 = yield Promise.promisify(process2)(processedData1);
      var result = yield Promise.promisify(process3)(processedData2);

      yield fs.writeFile(outputFile, result);
      res.status(200).send('success');

    } catch (err) {
      res.status(500).send(err);
    }
  }).catch(onError);
});

このシリーズのPart 1はこちら