asterisc

技術ネタ中心にその他雑多なこと

Node.jsでもassertしたい

まえがき

この記事はリクルートエンジニアアドベントカレンダー12日目の記事です。

Node.jsでのAssertion

Assertionとは?

多くのプログラミング言語assert というメカニズムが実装されています。 assert はプログラムを記述する人が、絶対にコードのこの場所ではこの値が入ってくる、という意志を表明するための仕組みです。 例えば、以下の add(a, b) のコードを見てみると、

var assert = require('assert');

function add(a, b) {
  assert(!isNaN(a));
  assert(!isNan(b));
  return a + b;
}

この add(a, b) では、2つの引数、 a b に数値以外の値が入ってきた場合、特に何か特別な処置をしない限り Assertion Error を発生させ、プログラムを終了させます。試しにやってみると

add('string', 2);
assert.js:85
  throw new assert.AssertionError({
  ^
AssertionError: false == true
    at add (/Users/akito/workspace/tmp/assert-test/index.js:5:3)
    at Object.<anonymous> (/Users/akito/workspace/tmp/assert-test/index.js:10:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9) 

このように add に非数の値を与えると、想定通りエラーが発生することがわかります。 この仕組みにより、想定以外の値が入ってきたときに適切にプログラムを終了させることができ、主に開発フェイズでバグの早期発見に役立てることができます。 詳しくは 契約による設計 などで検索してみてください。

Javascript / Node.jsのAssertion

この assert というメカニズムは多くの言語に実装されていますが、実はJavascriptには等価の機能はありません。console.assert() という関数はありますが、これは第1引数の値が false だった場合にconsoleにエラーメッセージを出力するもので、assert 本来の目的を達成するには少し弱いかなと感じています。

一方、Node.jsではcoreのモジュールとして assert が提供されています(上のサンプルコードではそれを利用しています)。この assert は上で見たとおり、表明した値以外の値が入ってきた場合にエラーを発生させ、プログラムを終了させることができます。

じゃあこの assert をプロダクションにガンガン記述し、他言語同様に堅牢なコーディングができるか、というと実はまだ少し足りない点があります。 assert が実装されている多くの言語では、プロダクションビルドもしくは実行時には assert はOFFにするのが一般的です。例えば、以下のJavaの例を見てみると、

public class Main {
    public static void main(String args[]) {
        String name = null;
        assert name != null; // ここでAssertionErrorが起きてほしい
        System.out.println(name);
    }
}
$ javac Main.java
$ java Main

> null

name の中身が null なので、本来は AssertionError が発生してほしいのですが、実際に実行してみるとなにも起きません。実はJavaは実行時にはデフォルトでassertはOFFになるように設計されています。Javaの場合、assertをONにするためには、実行時に -ea オプションを設定します。

$ java -ea Main

Exception in thread "main" java.lang.AssertionError
    at Main.main(Main.java:5)

今度は想定通り AssertionError が発生しました。

いくら堅牢なコードを書くための仕組みといっても、本番で動いているシステムがいきなり AssertionError で死んだら困りますよね。このように、Javaを始め多くの言語ではbuild、もしくは実行時にassertのON / OFFを切り替える機能がついていて、開発時、本番時で挙動を切り替えられるようになっています。

On The FlyでUnassertする

では一方Node.jsではどうかというと、悲しいことにこの機能が実装されていません。ですので、 assert に引っかかったら本番だろうがなんだろうが死んでしまいます。 ようやくこの記事の本題になりますが、今回は assert をNode.jsのプロダクションでも活用するために、動的に assert の ON / OFFを切り替える仕組みを実装していきたいと思います。

unassert

0から作るのはなかなか大変な作業ですが、幸い、id:t-wada さんが作成した、jsのコードベースから assert を除去するnpmモジュール unassert がありますので、こちらをNode.js向けに活用してなんとかしようと思います。

unassertBabelwebpack などのpluginとして動作します。最近のフロントエンド界隈ですとプレーンなjsを記述することは少なくなってきており、babelwebpackを用いてES2016 - 2017もしくはjsxなどをブラウザで動作するjsへとトランスパイルすることが多くなっています。unassert はこのトランスパイル時にコードから assert を取り除くのが主な使い方となってきます。

一方で、サーバサイドJavascriptのNode.jsでは、babelなどを使うケースは、フロントエンドほどは多くはないのではと思っています。1つに、Node.js(とv8)は言語系のキャッチアップの速度が非常に早く、標準になったシンタックスや機能はいち早く取り込んでいます。このサイトを見ても分かる通り、仕様が固まったものに関してはほぼほぼ実装されており、特別なケースが無い限り、 babel を使うメリットはそこまでないのかなと思います。そのため、 Node.jsには unassert がassertを取り除いているトランスパイルのフェイズが存在しないことになります。

requireをhookして実装する

じゃあどうするかというと、Node.jsがモジュールをロードする仕組みであるrequire をhookして、そのタイミングで unassert してしまおうと思います。 Node.jsでは実行時に--requireオプションを指定することができ、そこでモジュールを unassert した上でロードするようにrequireの挙動を書き換えてあげればうまくいきそうです。 コードはこんな感じになります。

var extensions = require.extensions;
var fs = require('fs');
var unassert = require('unassert');
var esprima = require('esprima');
var escodegen = require('escodegen');

function unassertLoader() {
  extensions['.js']= function(localModule, filePath) {
    var originalSource = fs.readFileSync(filePath, 'utf-8')
    var ast = esprima.parse(originalSource);
    var modifiedAst = unassert(ast);
    localModule._compile(escodegen.generate(modifiedAst), filePath);
  };
}

unassertLoader()

実際の複雑なところはすべて unassert がやってくれているのですごくシンプルですね。 流れとしては、requireモジュールの中のjsの拡張子がついてきているファイル用のloaderを書き換え、一旦esprimaでASTになおしてから、unassertし、再度escodegenでASTからコードに戻した上で、module loaderに渡してあげているという形です。

このコードを unassertLoader.js という名前で保存しておきます。

挙動チェックのためにこんなコードを用意しました。

// test.js
var assert = require('assert');
assert(false); // ここで必ず落ちる。
console.log('Hello');

動かしてみます。

$ node test.js
assert.js:85
  throw new assert.AssertionError({
  ^
AssertionError: false == true
    at Object.<anonymous> (/Users/akito/workspace/unassert-loader/benchmark/test.js:3:1)
....

当然ですが落ちますね。 では --require オプションを付けて先程書いた unassertLoader.jsをpreloadしてみたいと思います。

$ node --require ./unassertLoader.js test.js
Hello

今度はassertが削除されて、無事にconsole.log('Hello') が動作していることがわかります。 これで目的どおり、実行時の指定でNode.jsでも動的に、つまり、On The FlyでassertをON/OFFできるようになりました!

簡易ベンチマーク

ちょっと心配なのが、requireの度にASTにしてunassertして更にコードに直して、という処理を入れるのはパフォーマンス的に大丈夫なの?という点です。 実はNode.jsのrequireは一度読み込むとあとはすべてキャッシュから読み込むため、毎度AST変換などの処理は入らないので大きな影響はないというように予想できますが、しかし、推測するな計測せよの精神でとりあえず簡易的にですがベンチマークを取ってみたいと思います。 Benchamrk.jsを使ってこんなテストを書きました。

// bench.js
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;
var decache = require('decache'); // requireのキャッシュをclearするためのモジュール

let time = 0;
suite.add('Require testing', function() { // 1つめのテストケース
  require('./math')
})
.add('Require uncache testing', function() { // 2つめのテストケース
  decache('./math')
  require('./math')
})
.on('cycle', function(event) {
    console.log(String(event.target));
})
.run({ 'async': true });
// math.js
function add (a, b) {
  assert(!isNaN(a));
  assert(!isNaN(b));
  return a + b;
}

module.exports = add;

math.js という冒頭に出てきたモジュールをrequireさせ、ops (operation per sec)を計測します。 1つめのテストケースではrequireのベーシックなパフォーマンスを計測します。Benchmark.jsでは1つのTest Suitを複数回繰り返し、opsを計測するので、想定通りならば2回目以降のrequireはすべてキャッシュから返されるため、通常のケースもunassertLoaderをpreloadした場合もopsに影響を及ばさないと考えられます。 2つめのテストケースではキャッシュから呼び出されるのではなく、1回のrequireの処理のopsを計測します。1度requireしたモジュールを都度decacheして無効化しているので、このケースではunassertLoaderの1回目の処理が通常のケースに比べてどの程度遅延するのかを計測します。想定通りであるならば、通常のケースとunassertLoaderを用いたケースではopsに大きな差があると予測できます。

それでは実際に実行してみます。

$ node ./unaasetLoader.js bench.js
$ node --require ./unassertLoader.js bench.js

結果は以下のようになりました。

Case1 Case2
通常の起動方式 110,789 ops/sec ±1.37% 7,919 ops/sec ±2.34%
unassertLoaderをpreload 108,085 ops/sec ±1.33% 2,421 ops/sec ±2.13%

Case1では想定通り、通常の起動方式もunassertLoaderをpreloadした場合もopsに大きな差は見られませんでした。このことから、requireのキャッシュが十分に働き、パフォーマンスに大きな影響はないことがわかります。 Case2においても、想定通り、opsに3倍以上の差があることがわかります。実際のところ、decacheにかかるオーバーヘッドが計測できていないので正しい数値は出せませんが、やはりAST変換 ~ unassert ~ コード生成の一連の流れは大きな負荷になっていることがわかりました。

ベンチマークの結果から、通常用途であればunassertLoaderは問題なく使用できるが、もし何らかのケースでキャッシュを使えない場合は注意が必要、ということがわかりました(そんなケースあるのか?)。

あとがき

この記事ではNode.jsでも他の言語と同じようにassertを活用した堅牢なプログラミングを行うためにはどのようにすればよいか、ということを考察してきました。 この話を考え始めたきっかけは今年のNode学園祭でt-wadaさんのLTを聞いたことがきっかけでした。たしかにunassert使えばフロントではassert使えるけどサーバだとどうなるんだろう?みたいな感じです。

今回あまり触れることのないNode.jsのrequire関連の挙動について知見を深めることができました。これからは安心して、プロダクションにガンガンassertをいれられそうです。

今回作成したunassertLoaderは近々OSSとして公開する予定です、 公開したらまた報告したいと思います!