Node.js のエラーハンドリング

JavaScript Advent Calendar 2011 (Node.js/WebSocketsコース) の 13 日目の記事です.


Node といえば非同期プログラミングですが,そのスタイルは大雑把にわけて 2 種類あります.一つ目は fs モジュールなどで使われているコールバック関数のスタイル.

fs.readFile(path, function(err, content) {
  if (err) {
    // エラー時の処理
    return;
  }
  // 成功時の処理
});

このスタイルは,何らかの要求に対する結果を一発で受け取る (要求と結果が 1 対 1) 場合に使われます.そして,コールバック関数の第1引数でエラーの有無が通知されます.エラーがなければ null,エラーがあった場合は Error オブジェクトというのが原則のような気がしますが,undefined が渡されたりすることも.


コールバックスタイルの場合は非同期 API の呼び出しがネストしやすく,それに伴ってエラーハンドリングも煩雑化しやすいのですが,それについてはエラーのルーティングをしてくれるフロー制御モジュールを使うとすっきりするはず.フロー制御については東京 Node 学園 1 時限目で発表した資料も参考にどぞー.

この Advent Calendar 16 日目で Async (たぶん caolan/async だよね) が紹介されるようなので,そちらも参考になるかもしれません.



さて,本エントリの主題はもう一つのスタイル,EventEmitter を使った場合のエラーハンドリングについてです.EventEmitter は Node.js の非同期 API における中核とも言えるコンポーネントです.おなじみのサンプル:

var http = require('http');
http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

この中に出てくる reqres (これらは Advent Calendar 4 日目 で紹介されたストリームでもあります) に加えて,http.createServer() の戻り値も EventEmitter です.


EventEmitteremit(name, [args...]) メソッドを呼び出すことで,イベントを生成することができます.第 1 引数がイベント名になります.そして on(name, listener) または once(name, listener) でイベントが発生した時に呼び出される「リスナ」関数 listener を登録することができます.once() で登録したリスナは一度呼び出されると EventEmitter から取り除かれます.on() で登録したリスナは removeListener(name, listener) で明示的に取り除くまで有効です (メモリリークに注意!).
ということで,いろいろな種類のイベントが繰り返し (0 回も含む) 発生するようなケースでは EventEmitter が使われます.


イベント名には任意の文字列を使うことができます.ストリームでおなじみなのは 'data''end' ですね.
ここからが本題の本題.EventEmitter には一つだけ,特別に扱われるイベント名があります.それが 'error' です.もちろん,エラーが発生したことを通知するためのイベント名です.


まずは,簡単なサンプルで 'error' イベントを発生させてみましょう.

var events = require('events');

var emitter = new events.EventEmitter();
emitter.emit('error');

console.log('しゅ〜りょ〜');

実行してみます.

$ node a.js

node.js:201
        throw e; // process.nextTick error, or 'error' event on first tick
              ^
Error: Uncaught, unspecified 'error' event.
    at EventEmitter.emit (events.js:50:15)
    at Object.<anonymous> (/tmp/a.js:4:9)
    at Module._compile (module.js:432:26)
    at Object..js (module.js:450:10)
    at Module.load (module.js:351:31)
    at Function._load (module.js:310:12)
    at Array.0 (module.js:470:10)
    at EventEmitter._tickCallback (node.js:192:40)
$ 

スタックトレースが出力されてしまいました (Advent Calendar 2 日目 も参考にどぞー).そして最後の 「しゅ〜りょ〜」が表示されるていません.つまり,'error' イベントが発生すると,Node はクラッシュしてしまうのです!!


なーんてことはありません.スタックトレースにちゃんと出てますね,「Uncaught」って.'error' イベントが発生したことが原因ではなく,'error' イベントを捕まえていないことが原因でプロセスは終了しただけなのです.
それなら捕まえればいいじゃない.

var events = require('events');

var emitter = new events.EventEmitter();
emitter.on('error', function() {
  console.log('えら〜');
});
emitter.emit('error');

console.log('しゅ〜りょ〜');

実行しましょう.

$ node b.js
えら〜
しゅ〜りょ〜
$ 

無事「しゅ〜りょ〜」まで表示されました.'error' イベントをちゃんと捕まえることもできています.
つ・ま・り

  • 'error' イベントが発生した時,
    • リスナがあればそれが呼び出される.
    • リスナがないとプロセスは終了する.

ということです.'error' 以外のイベントでは,リスナが無くても何もおきません.これが,EventEmitter'error' イベントを特別に扱っている点です.


さて.'error' イベントが発生した時,リスナがないとプロセスは終了すると書いたばかりですが,これは不正確です.実際に何が起きるかというと,例外がスローされます.上のスタックトレースでも「Error: Uncaught, unspecified 'error' event.」と表示されていますね.これは例外 (Error オブジェクト) のメッセージです.
例外なら捕まえればいいじゃない.

var events = require('events');

var emitter = new events.EventEmitter();
try {
  emitter.emit('error');
} catch (err) {
  console.log('えら〜', err);
}

console.log('しゅ〜りょ〜');

実行しましょう.

$ node c.js
えら〜 [Error: Uncaught, unspecified 'error' event.]
しゅ〜りょ〜
$ 

ちゃんと捕まえることができました.
'error' イベントが発生しても,そのリスナが無くても,プロセスが終了してしまうとは限らないのです.
つ・ま・り

  • 'error' イベントが発生した時,
    • リスナがあればそれが呼び出される.
    • リスナがないと例外がスローされる.
  • 例外がスローされた時,
    • キャッチされないとプロセスは終了する.

ということですね.
もうちょっと詳しく書くと,例外がキャッチされることなくイベントループに到達すると,プロセスは終了しちゃいます.
ちなみに,スローされる例外は 'error' イベントを生成する際に指定することもできます.emit(name, [args...]) の第 2 引数に Error オブジェクトを指定すると,それがスローされます.

var events = require('events');

var emitter = new events.EventEmitter();
try {
  emitter.emit('error', new Error('ほげ'));
} catch (err) {
  console.log('えら〜', err);
}

console.log('しゅ〜りょ〜');

実行すると

$ node d.js
えら〜 [Error: ほげ]
しゅ〜りょ〜
$ 


さてさて,「例外がスローされた時,キャッチされないとプロセスは終了する」と書いたばかりですが,これは不正確です.実際に何が起きるかというと,process というオブジェクトで 'uncaughtException' というイベントが生成されます.
イベントなら捕まえればいいじゃない.

var events = require('events');

process.on('uncaughtException', function(err) {
  console.log('えら〜', err);
});

var emitter = new events.EventEmitter();
emitter.emit('error');

console.log('しゅ〜りょ〜');

実行すると

$ node e.js
えら〜 [Error: Uncaught, unspecified 'error' event.]
$ 

スタックトレースが出力されなくなりました.でも「しゅ〜りょ〜」も出てないので,やっぱりプロセスは終了しちゃったように見えますね.emit('error') で例外がスローされているので,その後のログ出力は実行されていないのですが,それでプロセスが終了したわけではありません.'uncaughtException' イベントのリスナが実行されて,他にすることがないので終了しただけです.
もうちょっと分かりやすくしてみましょう.

var events = require('events');

process.on('uncaughtException', function(err) {
  console.log('えら〜', err);
});
process.nextTick(function() {
  console.log('しゅ〜りょ〜');
});

var emitter = new events.EventEmitter();
emitter.emit('error');

実行

$ node f.js
えら〜 [Error: Uncaught, unspecified 'error' event.]
しゅ〜りょ〜
$ 

例外がスローされて 'uncaughtException' リスナが呼び出された後,ちゃんと process.nextTick() で登録した関数が実行されました.これがサーバアプリであれば,監視している接続などがある限り実行を継続することになります.


そんなわけで (どんなわけで?),'error' イベントが発生しても,そのリスナが無くても,例外がスローされても,それがキャッチされなくても,プロセスが終了してしまうとは限らないのです.
つ・ま・り

  • 'error' イベントが発生した時,
    • リスナがあればそれが呼び出される.
    • リスナがないと例外がスローされる.
  • 例外がスローされた時,
    • キャッチされないと process オブジェクトで 'uncaughtException' イベントが発生する.
  • process オブジェクトで 'uncaughtException' イベントが発生した時,
    • リスナがあればそれが呼び出される.
    • リスナがないと例外のスタックトレースを出力してプロセスは終了する.

ということです.
実際の所,'error' イベントに対するリスナが無かった場合,例外をキャッチするのは現実的ではありません.'error' イベントは主にネットワークやファイルに対する I/O が原因で発生するわけで,要するに 'error' イベントを 生成 (emit()) するのは Node 本体.これじゃアプリではキャッチのしようがありません.
なので,

  • 'error' イベントが発生した時,
    • リスナがないと process オブジェクトで 'uncaughtException' イベントが発生する.
  • process オブジェクトで 'uncaughtException' イベントが発生した時,

と簡素化して覚えておいても問題はないでしょう.


そんなわけで (どんなわけで?),何らかのエラーが発生しても動作を続けなければならないサーバアプリケーションの場合,'uncaughtException' イベントのハンドリングは必須です.最悪,

process.on('uncaughtException', console.debug);

の一行を入れておくだけで,エラーが発生してもプロセスは終了しなくなるので,ちょっとしたデモアプリとか作る場合はこれでも入れておくといいでしょう.プロダクションレベルだとフレームワーク的なところで登録して,ロギングとかちゃんとすることになるでしょうか.


さて.
'uncaughtException' イベントのリスナを登録すれば,'error' イベントが発生してもプロセスは終了しなくなるわけですが,言ってしまえばそれだけです.Web アプリなんかだと,リクエストの処理中にエラーが発生した場合,それなりの画面を返すとかすべきことはいろいろあるはずですが,それをするには 'uncaughtException' イベントは大雑把すぎます.となると,結局は個々の EventEmitter'error' イベントのリスナを登録して対処するしかありません.'uncaughtException' イベントのリスナは,万が一個々の EventEmitter'error' イベントのリスナが漏れていた場合のガードに過ぎないのですね.


細粒度の 'error' イベント (個々の EventEmitter) と粗粒度の 'uncaughtException' イベント (process オブジェクト).
それはちょっと両極端だよねー,ということで Node v0.8 で導入される予定なのが「Domains」です.これについては明日の「東京 Node 学園 3 時限目」で簡単に紹介する予定です.簡単に,ね.


まとめると,エラーが発生するとすぐにクラッシュするから Node は普及しそうもないとか言っていいのは小学 3 年生までだよねキャハハーということでよろしくです.


明日は nori0428 さんです.