taiyoh's memorandum

@ttaiyoh が、技術ネタで気づいたことを書き溜めておきます。

node.jsのvmとsynchronizeはライフチェンジング

[追記 5/29]
 synchronizeというかfiberには以下の問題点があるのでそちらを参照の上、このエントリをご覧ください
 → node-fiberでライフチェンジングとか煽ったことを若干後悔してる - taiyoh's memorandum
[追記 終わり]

 node.jsをお使いのみなみなさま、コールバック地獄の中をいかがお過ごしでしょうか。
 最近になって僕はvmモジュールとsynchronizeモジュールを使い出しまして、これがちょっと尋常じゃないくらい自分の実装方法に影響を与えております。
 百聞は一見にしかずということで、実際どんな感じで使っているか、最近作ったユーティリティファイルの一部を載せます。

// util.js
var vm   = require('vm')
  , fs   = require("fs")
  , path = require("path")
  , sync = require("synchronize")
  , root = path.resolve(__dirname + "/../");
// rootは、実行するアプリケーションのルートディレクトリが指定できればいい

// fs.readFileメソッドを"同期"処理にする!!!
sync(fs, "readFile");

module.exports = {
  runScript : (function() {
    var cache = {};
    return function(p, context) {
      if (!cache[p]) {
        cache[p] = fs.readFile(root + "/" + p, "utf-8");
      }
      vm.runInContext(cache[p], this.createContext(context), p);
    };
  })(),
  createContext : function(context) {
    context = context || {};
    context.require = require;
    context.module  = module;
    context.console = console;
    context.global  = global;
    context.myutil  = this;
    context.sync    = sync;
    return vm.createContext(context);
  }
}

これを実行ファイルか何かで

var myutil = require("./lib/util");

という感じで受け取っておき、アプリケーションに必要なコードは全てmyutil.runScript(ファイル名, コンテキストになるオブジェクト)でロードするルールにしてます。
vm.runInContextの一番の使いドコロは、やっぱり中で実行する処理のコンテキストを自分で指定できるのがよくて、globalを汚さずに済むし、時々DSLというか黒魔術っぽいこともできるあたりで、ファイル毎に色を持たせてシンプルに記述することができます。あと、vm.runInContextの第一引数には実際の処理を文字列として入れなくちゃいけなくてすげーめんどいので、runScript関数としてファイルのロード+コードのキャッシュまで全部面倒みるようにしてるのもポイント。
 そしてsynchronizeは、非同期処理のはずなのに同期処理っぽく書けるようになる夢のようなモジュールです。コアにはFiberというモジュールを使っていて、このFiberというのは、Fiberのブロック内で特殊なフィールドを作り出し、イベントループは続いてるはずなのに、実際の処理はFiber.yield()で値を返さないと続きが実行されない、というよくわかんない状態になります。
laverdet/node-fibers · GitHub
FiberのReadMeにサンプルがいくつもあるので、動かしてみてくださいな。
とにかく、要はsynchronizeというのは、Fiberをもっと気軽に使えるようにするラッパーということです。
 例えば、さっきのutil.jsで

sync(fs, "readFile");

とありますが、内部的にはおおよそこんな感じのことをしています。
ここでは概念的に書くだけなので、実際のコードはもう少し複雑です。

var original_readFile = fs.readFile;
fs.readFile = function(filename, encode, callback) {
  if (typeof callback == 'function') {
    original_readFile.apply(fs, arguments);
  }
  else {
    encode = encode || "utf8";
    return sync.await(original_readFile.call(fs, filename, encode, sync.defer()));
  }
};

さてここで新たにawaitとdeferというメソッドが出て来ました。sync.deferメソッドは、大雑把に書くとこんなことをしてます。

sync.defer = function() {
  var fib = Fiber.current;
  return function(err, result) {
    if (err) {
      throw err;
    }
    fib.run(result);
  };
};

これを、sync.await(= Fiber.yield)で待って結果を返す、というのが、synchronizeで同期っぽく見せるカラクリです。
 ここで注意なのが、sync.defer()で返ってくるコールバック関数は、必ず第一引数にエラー、第二引数に結果オブジェクト、というルールになっているので、適当に第一引数に結果を渡すような実装にsync.defer()を混ぜてしまうと、かなり泣きを見ることになります。
 これを応用して、DB周りの実装でsynchronizeを使うと、ライフチェンジング度がもっとアップします。今Sequelizeを使っているのですが、モデル定義の際、classMethodsに以下の様な処理を入れてみました。

opts.classMethods = {
  findAwait: function(attrs) {
    var cb = sync.defer();
    return sync.await(
        this.find(attrs)
            .success(function(obj) { cb(null, obj); })
            .error(function(err) { cb(err, null); })
    );
  },
  findAllAwait: function(attrs) {
    var cb = sync.defer();
    return sync.await(
        this.findAll(attrs)
            .success(function(results) { cb(null, results); })
            .error(function(err) { cb(err, null); })
    );
  }
};

そうすると、今までは

User.find({where:{foo:"bar"}}).success(function(user) {
  // コールバックで続きの処理を書かなくてはいけない
});

だったのが、

var user = User.findAwait({where:{foo:"bar"}});

と書けるようになるので、これなら同期処理の書き方と殆ど変わらない!!!ほんの2ヶ月くらい前だったらjQuery.Deferredを使ってましたが、このsynchronize(=Fiber)の旨味を知っちゃったら、もう昔には戻れないっすね。。。
 もちろん、こうした書き方はsync.fiber()(というかFiber(function() {}).run())の中でしか動かないという制約はあるので、実装は多少変更にはなるのですが、その変更部分をなるべく最小限に抑えられれば、この余りあるメリットを存分に享受できるのではないでしょうか。
 あと欲を言えば、Fiberかsynchronizeには、concurrencyもサポートしてもらえたら最高ですね。そうすると、RubyEM-Synchronyっぽいことができるようになるなぁ、と。