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 さんです.

★Happy Birthday★

今日 10/03 は蛯原友里ちゃんの ♥誕生日♥ です.
おめでとう!! 友里ちゃん!!!!
[]\(^o^)/\(^o^)/\(^o^)/[]

写真集「EBI01」も買ったよ!!

Teeda 1.0.13-sp11 リリース

しました.
なんと一年ぶりのリリースです.


■ 変更内容

Teeda 1.0.13-sp10 からの変更点は以下のとおりです.

  • Bug
    • [TEEDA-520] - 非 Windows 環境の Web コンテナにファイルアップロードを行うと,UploadedFile#getName() がファイル名ではなくパスを返してしまう問題を修正しました.[Seasar-user:20199]
    • [TEEDA-521] - レンダリング時に入力系コンポーネントの type および name 属性が重複して出力される問題を修正しました.[Seasar-user:20996]
    • [TEEDA-522] - テンプレート HTML に checked="checked" が指定されると,checked 属性が重複して出力される問題を修正しました.[Seasar-user:21007]
    • [TEEDA-523] - テンプレート HTML に記述した <script> 要素に baseViewId 属性が出力されてしまう問題を修正しました.[Seasar-user:21011]


■移行の注意

今回はありません.
過去の移行の注意はこちらからどうぞ.


■ ダウンロードはこちらからどうぞ.


Maven2 からのご利用はこちらを参照ください.

Seasar2.4.44 リリース

しました.


■変更点

Seasar2.4.43 からの変更点は次のとおりです.

  • Bug
    • [CONTAINER-429] - [S2Tx] WebSphere を使用している場合,RuntimeException のサブクラスを addCommitRule() でコミットするように指定してもトランザクションロールバックされてしまう問題を修正しました.
    • [CONTAINER-430] - [S2JDBC] 同じエンティティに対する最初の問い合わせが複数のスレッドから同時に行われると NullPointerException が発生する問題を修正しました。
    • [CONTAINER-433] - [S2Tx] WAS でトランザクション処理中に例外が発生してロールバックされると IllegalStateException が発生して元の例外が失われる問題を修正しました。[Seasar-user:20311]
    • [CONTAINER-435] - [S2JDBC] 実行した SQL のログ出力において、SQL 中のリテラルに含まれた ? がバインド変数として扱われてしまい、パラメータが足りないと例外がスローされる問題を修正しました。

■移行の注意点


■ダウンロードはこちらからどうぞ.


Maven2からのご利用はこちらを参照ください.

Node.js の非同期 I/O におけるデータ受信の別バリエーション

(Twitterで言及を読んで追記) 普通にアプリケーション側のコードをコールバックベースで記述できるなら、その方が自然です。そうした方がいいと思います。ここで扱う例は「アプリケーション側のコードを変更できない事情がある」「アプリケーション側のコードが巨大になることが予想されるためコールバックベースの記述を積み上げることを容認できない」などの状況の場合に、アプリケーション側のコードを非コールバックベースのものにしつつnode.jsでどうにかするための方法です。

Twitter での言及というのはたぶん自分のこれ.

「node.jsの非同期I/Oにおけるデータ受信のパターン」 http://ow.ly/4srsP 超微妙。素直に考えると read(bytes, callback) な API を用意すれば足りるはず。

こうつぶやいた時点では上で引用した注記はまだなかったので,「Node.js におけるパターン」としては超微妙だなと思ったのです.でまぁ,いろいろ事情があるらしいのですが,その制約が一般的なのかはやっぱり超微妙.Node を使うのに,Node らしい非同期プログラミングが容認されない? うーん...


よくわかりませんが,そういう制約がなかった場合のバリエーションということで read(bytes, callback) を実装してみました.というか,setEncoding() されていることを前提に文字列を扱う read(length, callback) ということにしました.

exports.createReader = function(stream) {
  return new Reader(stream);
};

function Reader(stream) {
  this.stream = stream;
  this.buf = '';
  this.loength = 0;
  this.callback = null;
  stream.on('data', Reader.prototype.onData.bind(this));
  stream.on('end', Reader.prototype.onEnd.bind(this));
};

Reader.prototype.read = function read(length, callback) {
  if (!this.stream) {
    process.nextTick(callback.bind(null, new Error('no data')));
    return;
  }
  this.length = length;
  this.callback = callback;
  if (length <= this.buf.length) {
    process.nextTick(this.notify.bind(this));
  }
};

Reader.prototype.onData = function onData(data) {
  this.buf += data;
  if (this.length <= this.buf.length) {
    this.notify();
  }
};

Reader.prototype.onEnd = function onEnd() {
  if (this.length) {
    this.callback(new Error('no data'), this.buf);
  }
  this.stream = null;
};

Reader.prototype.notify = function notify() {
  var frame = this.buf.slice(0, this.length);
  this.buf = this.buf.slice(this.length);
  var cb = this.callback;
  this.length = 0;
  this.callback = null;
  cb(null, frame);
};

いろいろ手抜きだけど気にしない.


こんな感じで使います.

var createReader = require('./reader').createReader;

var stream = ...;
stream.setEncoding('utf8');
var reader = createReader(stream);
...
reader.read(4, function(err, data) { // 4 文字読み込む
    if (err) return console.log(err);
    // ここで data を処理
    ...
});

現状だと際限なくストリームから読み出してしまうので,実用的なものにするにはバッファがある程度大きくなったらストリームの pause() を呼び出してあげるとかしないといけないけど,イメージとしてはこんな感じということで.

node-handlersocket 0.0.3 リリース

しました.

0.0.2 からの変更点は次のとおりです.

  • NULL 値を正しく扱っていなかったのを再度修正しました.
  • エラーの扱いを改善しました.

npm install node-handlersocket でインストール,または npm update node-handlersocket でアップデートできます.


NULL 値に関しては HandlerSocket 本体の実装がまだらしいです.

node-handlersocket 0.0.2 リリース

しました.

0.0.1 からの変更点は次のとおりです.

  • Bug
    • NULL 値を正しく扱っていなかったのを修正しました.
    • INSERT/UPDATE/DELETE でレスポンスの更新行数を正しく扱っていなかったのを修正しました.

npm install node-handlersocket でインストール,または npm update node-handlersocket でアップデートできます.