async/awaitを使ったコードのエラーハンドリングのもやもや
es6で導入されたasync/await、皆さん使われていますか?
かつてのコールバック地獄から始まり、Promiseを経てこのasync/awaitが使えるようになったことで、非同期処理はとてもシンプルに書けるようになりました。
しかしこのasync/awaitですが、特にexpressなどを使ったサーバーサイドで書いている時にエラーハンドリングどうしたら良いか困ったりした経験はないでしょうか?
内部的にはPromiseが使われているので、要はPromiseのエラーハンドリングと同じなのですが、僕は当初もやもやしていました。
もやもやその1: catchした後も実行が止まらない…
例えば下記みたいなコードを書いた経験がある方もいるのでは?
私も最初書きましたw
const getItem = async(req, res) => {
const { itemId } = req.params;
const item = await db.getItem(itemId).catch(e => {
res.status(500).send("error!")
return
})
res.json(item)
}
このコードの期待する動作としては、db.getItem
で例外が発生したら、500のステータスコードでレスポンスを返して、そのあとの処理をしたくないのです。
ところが、getItemで例外が発生した後catchが実行され500でレスポンスするところまでは良いのですが、その後も実行は止まらずにその後のコードまで実行してしまいます。
今回のスニペットでは、itemを送っているだけなので、nodeから「一度レスポンスを送ったらもう送れないよ」的なwarningをもらうだけですが、ここに更新処理などがあったらちょっと痛い目にあってしまいます。
もやもやその2: 非同期な関数がネストしている時に途中でcatch。そこからまたthrowしなきゃ上に伝わらない?
例えばこんなコード
async function top() {
const result = await sub1().catch(e => {
console.log("error at top")
})
console.log(result)
}
async function sub1() {
const result = await sub2().catch(e => {
console.log("error at sub1")
})
return result
}
async function sub2() {
const str = 'sub2str';
await sleep(1)
makeException()
return str
}
top()
// 実行結果
// error at sub1
sub2のmakeException()
で例外が発生する想定なわけですが、このコードの実行結果はerror at sub1
しかconsoleには表示されず、top関数で定義されているcatchにはひっかかりません。topでは例外など無かったかのようにresultにundefinedが普通に入って処理が続きます。
sub1のcatchで再度throw new Error(e)
のように例外を投げ直せば上に伝わりますが、非同期な関数が出てくる度に毎回こんなん書いてるのはちょっと冗長でキツイですよね。
async/awaitの例外処理の特徴
もやもやを実際のコードを元に解決していきます。
もやもやその1の解:try-catchで囲むと例外が発生した時点で処理を止められる
awaitが含まれるコードをtry-catchで囲むと、例外が発生した時点で処理を止められます。
下記のコードでは、sub1で処理が止まりますので、leave top
はconsoleに出力されません。
async function top() {
try {
console.log("enter top")
const result = await sub1() // ここで例外を発生させる
console.log("leave top")
} catch(e) {
console.log("catch!")
}
}
top()
// 実行結果
// enter top
// catch!
.catch
のほうを使ってしまうと止まらないのですが、この方式だと止まってくれるようです。インデントが深くなってしまうのがちょっと気にはなりますが、まあしょうがないですね。
もやもやその2の解:ネストされた関数で発生した例外は、catchするまでpropagateされる
下記のように、top -> sub1 -> sub2とawaitでネストした関数の一番深いsub2で例外が発生した場合、catchされるまで例外がpropagateされます。sub2で発生したエラーオブジェクトがtopまで上ってきているのが分かります。
const sleep = require("sleep-promise")
async function top() {
try {
const result = await sub1()
console.log(result)
} catch(e) {
console.log(`catch at top: ${e.message}`)
}
}
async function sub1() {
const result = await sub2()
return result
}
async function sub2() {
const str = 'sub2str';
str.hoge.fuga = 1; // ここで例外発生
await sleep(1)
return str
}
top()
// 実行結果
// catch at top: Cannot set property 'fuga' of undefined
nodejsでの例外処理の実装ポリシー案
以上の特徴を踏まえて、僕が書いているnode-expressのプロジェクトではこういうポリシーにしました。
- エラーハンドリングは基本的に全てトップ階層で行う
- expressの場合はコントローラー
- バッチの場合は起点になる関数
- トップ階層以外の関数(modelなど)ではcatchは基本書かない
- 例外が発生した際に個別の処理が必要な場合は、再度Exceptionを上に投げる
ちなみに、try-catchではない.catch
の方では、catchの中でreturnした内容をawaitした関数の戻り値になります。
下記の例だとresultに例外発生時の値を入れて、そのまま処理を継続できます。
これを積極的に使う方針も考えられますが、コントロールされた別ケースなのか予期せぬ例外のハンドリングなのかコードを読む側が分かりにくくなってしまうと思ったので、この書き方は推奨していません(ここらへんは好みが分かれるかもしません)
async function sub1() {
const result = await sub2().catch(e => {
return "exception!!"
})
console.log(result)
}
async function sub2() {
throw new Error("dummy error")
}
sub1()
// 実行結果
// exception!!
まとめ
promiseってエラーハンドラを書かないと例外が発生した時に「エラーハンドラ書かんかい!」って怒られるんで、await書いたらペアでcatchも書くもの?とか最初は思ってしまいますが、むしろこまめに書きすぎると、伝えたい階層に伝わりにくくなったり、余計な処理が実行されてむしろ不幸になるケースもあるってことですね