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向けに活用してなんとかしようと思います。
unassert
は Babel や webpack などのpluginとして動作します。最近のフロントエンド界隈ですとプレーンなjsを記述することは少なくなってきており、babel
やwebpack
を用いて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として公開する予定です、 公開したらまた報告したいと思います!