Node.js における Promise を使った例外処理

さて、 Node.js のエラーハンドリングは難しいと言われてますが、 2016年現在、つまりNodeの v4 とか v6 が主流になり、 Promise が基本的な処理として採用されている状況ではどうでしょうか。ちょっと考えてみます。 一応これの補足です。

qiita.com

TL;DR

未だに難しい。ただし、 Promise で改善されている。async-await や zone まで来たらかなり楽になる。 あと、 unhandledRejection が uncaughtException よりも酷いことにならないので、大分マシになっている。

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

まず JavaScript には同期と非同期のエラーハンドリングのやり方があります。前者は所謂 try-catch による方法、後者は callback を使って第一引数で実現する方法や emit('error') - on('error') でイベントとして渡す方法等が存在します。

難しいとされるのはいくつかあって、同期と非同期が混じった時に画一的にハンドリングする方法が無いので、特に問題が起きます。

また JavaScriptJava とかと違って try-catch を書かなくてもコンパイルエラーにならない言語なので、 catch の書き漏れが起きやすい言語なわけです。catch を書き漏らすと Node.js のさらに上のレイヤで補足され、誰も catch しない場合に 最終的に processuncaughtException としてcatchされる事となり、そこではログに書いて落とす以外の選択肢が取れません(後述)。

非同期は非同期で on('error') って書くスタイルや callback のスタイルで混じります。前者は Stream というか EventEmitter を使った時の標準的な方法です、後者は最もベーシックなやり方で callback 関数の第一引数を必ず error に当てるというものです。

つまりは画一的なエラーハンドリング処理が存在しないというのが JavaScript における難しいところの1つです。

Promise

Promise を使うと上の状況を少しだけ回避できます。Promise が提供してくれるのはこの画一的なエラーハンドリングであり、 Promise の中で起きた例外は同期の throw であろうと reject であろうと全て catch されて、Promise の .catch の関数に来てくれます。よく callback-hell の解決策として登場する Promise ですが、Promiseが解決するのはエラーハンドリングのやり方でもあります。

.catch を書き忘れた場合には unhandledRejection という形で uncaughtException のように最終的に process の所でキャッチされます。ただし、これは uncaughtException ほど絶望的な状況じゃありません。

ただ別に Promise も例外処理を雑に扱える銀の弾丸じゃありません。 Stream / EventEmitter のように 連続でイベントが発生するようなものは Promise で表現できないので結局 .on('error') でのハンドリングは残ると思います。

uncaughtException と unhandledRejection について

uncaughtExceptionunhandledRejection もどちらも同じようにキャッチされなかった例外が process まで来てしまった時の例外ですが、認識しておいてもらいたいのは unhandledRejectionuncaughtException では処理の方法が全く異なる、という事です。

そもそもなんで uncaughtException ではprocessを落とさないといけないのか

uncaughtException で落ちないといけないのは、簡単にいえば、例外を v8(C++) の層でキャッチしているから、です。

github.com

github.com

Node.js はイベントループモデルで作られていますが、イベントループの状態や実行中のタスクがどうなるかとか無関係に uncaughtException が起きると v8 の C++ レイヤまで一気に突き抜けてキャッチされます。こうなってしまうともう継続不能です。イベントループがおかしくなって、結果エラーが出続ける可能性もあるし、EventEmitter で積んだはずのイベントがちゃんと処理されない可能性もあります。なので、継続するのは推奨されておらず、ログに書いて、プロセスを落としてから再起動させる以外の手段がありません。

ただ、もう少し深ぼると、 Node.js のJSレイヤで全体を try-catch で括れば C++ レイヤまで来ないようにすることも可能です。こうすると、少なくとも イベントループ がコントロールできない状況は防げる可能性はあります。これをしなかったのは、 v8 の最適化が try-catch のブロックが書かれた関数をJITで最適化しないという制約が存在するためです。性能を優先して、Node.js 内部ではこの方式は取られませんでした。

unhandledRejection ではなんでprocessは落ちなくていいのか

uncaughtExceptionC++ のレイヤでキャッチしているという話をしましたが、 unhandledRejection はJSレイヤで処理されています。

v8 の中のコードを見ると分かりますが、 Promise はほぼすべてのコードが JavaScript で記述されています。

github.com

uncaughtException の時と違って C++ のレイヤまで突き抜けることはありません。 Promise が中で try-catch しているので、unhandledRejection が起きた所でそれは JS の中のレイヤで起きている『try-catchで例外をcatchしたけど無視されている状況』です。少なくともイベントループの状態はおかしくなったりしません

また、 Promise としては unhandledRejection になった状態を別に禁止していません。その時点で unhandled な状態であったとしても別な時に catch される可能性があるためです。

結局のところ unhandledRejection が起きた所でそれをどうするかはユーザーのアプリケーションに依存する訳です。

process を継続したくない時のエラー

エラーの中には process が動き続けることでより深刻な状況になるエラーも存在します。『あり得ない状況』になっているのに無理に動き続けた結果、より深刻な問題になってしまう事もあります。

『この状況になったらアプリケーションが落ちる以外に打つ手がない』という時、これまでは例外を投げて uncaughtException として落とすことができましたが、 Promise で括ってしまうと、『プロセスを継続したくない時のエラー』だろうとなんでも catch されてしまいます。

結局のところ、 JavaScript には Java で言うところの非検査例外(RuntimeException)も無ければ、 golang で言うところの panic に相当するような処理もありません。

少し前に node symposiums という有識者だけで行われたイベントでこの辺りの『検査例外(checked exception)と非検査例外(unchecked exception)』を Promise でどう扱うかの話があったのですが、結局 JS の try-catchシンタックスを拡張するしかない上に、今のところこの手の話が TC39 で話されてるのも僕は知らないので、throw側 か catch側で工夫するしか無さそうです。

github.com

デフォルトの unhandledRejection の動き

今のところ、 Node.js は unhandledRejection が起きたとしてもデフォルトでは何も言いません。これは議論の最中です。

github.com

issue opener の意見としては、『unhandledRejectionが起きた時に何も言わないのはさすがにどうなのか、各種ブラウザの動きとも異なる』という話です。今のところ決定策は出てません。

ちなみに 各種ブラウザ動きが若干異なります。

  • Chrome は unhandledRejection が起きた時点にエラーとして出力して終わりです
  • FirefoxGC が発生した時にその段階で Promise が回収され、 rejection がハンドリングされなかったらエラーとして出力します
  • IE Edge は unhandledRejection で hook はできるけど、何も言わない??(未検証)

今後どうなりそうかで言うと

  • 単純に標準エラー出力に warn を吐くだけ (chrome 案)
  • GC で Promise に該当するオブジェクトが 回収された時にprocessを落とす (Firefox & uncaughtException の動きに合わせる案)
  • 何もしない (現状維持)

のあたりの案がありますが、個人的には現状維持かやっても chrome 案くらいかなと思っています。GCの時にデフォルトで落とすのは Promise.reject で最初からunhandledな例外オブジェクトを作れる以上、やり過ぎ感があるかなと。。

何も言わないのは一見不親切に見えるかもしれませんが、 unhandledRejection の動きをどうするべきかは、ユーザーのアプリケーションでどうするべきかを決めることであって、 Node.js が積極的に決めることではないので、デフォルトをどうするべきか難しいのです。こういう話は『機構と方針の分離』という話もあり、機構は用意するが、方針としては何も意見を出さないという姿勢を持つような処理系の哲学でもあります。

ちなみに Domain

Domain は中で uncaughtException を使って処理しているだけなので、もう使わないでください。 結局domainの領域でエラーがcatchできた所で uncaughtException が起きていると、再起動する以外に策はありません。

今後としては Zone に期待しましょう。

docs.google.com

まだ目下のところ stage 0 ですが、 domain 的に発生した exception を領域ごとにまとめて掴んで処理することができるようになるので、 unhandledRejection よりも細やかなエラーハンドリングが可能になります。Zone は中の実装がどうなるかまだ決まりきっていませんが、おそらく Promise のような機構を使って、 JSの内部で処理をすると考えられるので、 domain よりは使えるものになる可能性が高いです。

実際の所どうするべきか

ここから先は個人的な意見で、『自分がウェブアプリケーションサーバを Node.js で作るとしたらどういう風にエラーハンドリングをするべきか』を記述しておきます。

  • 基本的に Promise を使う
  • Web Application Framework のミドルウェアのレイヤできちんと例外を補足する
  • もしミドルウェアの外でエラーが起きても unhandledRejection を使ってエラーを補足する

基本的に Promise を使う

速度が問題になるのであれば、 これまでのスタイル(callback etc)をそのまま使ったほうがいいでしょう。ただ、所謂普通の web application のような後ろに DB があって、それをコールするような処理をするのであれば、 JIT 最適化で稼げる速度よりも IO の時間のほうが問題になるので、これまで callback で書いていたような所は Promise で置き換えても問題ないと思います。

Web Application Framework のミドルウェアのレイヤできちんと例外を補足する

koa v2 からは async-await ベースでミドルウェアが書けるようになり、 async 関数は Promise でラップされる、という事なので、 koa v2 を使えば微妙な try-catch の書き忘れで全体の process が落ちなきゃいけなくなるというようなことは避けることが可能です。

一方で express とか hapi だと、ミドルウェアは Promise ベースじゃないので try-catch の書き忘れにより、上記に上げたような uncaughtException が起きる可能性はまだまだ高いと言えるでしょう。

ただ express に関しては StrongLoop が出している記事で、babel + async-awaitを含めた今後のエラーハンドリングの方法が書いてあるので紹介します。

StrongLoop | Asynchronous Error Handling in Express with Promises, Generators and ES7

// promiseのエラーをキャッチして next に渡す関数を用意しておく
let wrap = fn => (...args) => fn(...args).catch(args[2])

// async 関数で middleware を書く、つまり middleware の中は Promise になる
app.get('/', wrap(async function (req, res) {
  let data = await queryDb()
  // handle data
  let csv = await makeCsv(data)
  // handle csv
}))

// もしもエラーが発生して、 try-catch を書き忘れたりしたとしても、 エラーハンドラにエラーが渡るので express のエラーになり、プロセスが落ちなくて済む
// エラーハンドラ
app.use(function(err, req, res, next) {
  res.status(500);
  res.send(err);
});

実際の所 try-catch でmiddleware内部を囲んでいるのとそこまで変わりません。本来であれば、try-catch を入れてエラーをその単位でちゃんとハンドリングするのが良いですが、万が一忘れたとしてもwrap関数でerrorがunhandledRejectionにならないようになってちゃんと 500 エラーになるように担保してくれます。(ほぼ koa に近づくような話ですね。)

ちなみに Stream と一緒に使うときは下記のようにします。

app.get('/', wrap(async (req, res, next) => {
  let company = await getCompanyById(req.query.id)
  let stream = getLogoStreamById(company.id)
  stream.on('error', next).pipe(res) // on error でフックしたエラーを next に渡す
}))

もしミドルウェアの外でエラーが起きても unhandledRejection を使ってエラーを補足する

さらに unhandledRejection を使って ミドルウェアの外で Promise のエラーが発生しても気がつけるようにしましょう。

callback スタイルの時は callback の第一引数に渡したエラーがハンドリングされるかどうかを ESLint なりの Linter でチェックできましたが、 Promise になると、エラーを catch しているかどうかを見るのは静的解析だけでは厳しいので、 unhandledRejection を入れてチェックするのをオススメします。特に開発期間中は変なtypoだったり、Nullpointer exception 的な問題で気づかないのは問題なので、 開発中は気付きやすいようにエラーログに入れたり、敢えて落とすようにしてチェックしやすくするのをオススメします。本番になったら unhandledRejection レイヤーでは基本的には何もしなくても問題のないケースがほとんどかと、強いてやるなら一応ログに書き出すくらいでしょうか。

if (process.env.NODE_ENV === 'development') {
  process.on('unhandledRejection', (err, p) => {
    // 開発中はログに出力する
    console.error('Error : ', err);
    console.error('Promise : ', p);
    // もしも気づくのを早めたかったら落とすとか
    // throw err;
  });
}

process.on('unhandledRejection', (err, p) => {
  // 本番では何もしない
  // もしも何かしたければ、せめてログに書く等
  // logger.error(err, p);
});

process.on('uncaughtException', (err) => {
  console.error(err);
  process.abort(); // uncaughtException の時は落ちる
});

まとめ

  • Promise を使うとエラーを画一的に処理できる
  • unhandledRejection と uncaughtException の違い
  • checked exception と unchecked exception
  • Node.js では Promise の unhandledRejection が起きても何も言わない
  • domain はもう使わない
  • 現時点で Web アプリを作る場合はどうするべきかという私見