Node.js Expressのエラーハンドリング&next()の知見

主にExpress v4でのはなし。

Expressでのエラー処理全然わからなくて困っていたけど、ちょっとわかってきた気がするのでメモついでに書く。

くらいの人向けを想定して書いている。

便利なモジュール

boomはエラーオブジェクトユーティリティライブラリ。

github.com

hapijsのモジュールだけどExpressでも使えるので使っていく。

boomはようは『適切なエラーオブジェクト生成君』でエラーを出したいときに何のエラーかを区別する必要があったりするけど、boomを通すとラクになる。

例えば

Boom.forbidden('try again some time'); という感じにboomのforbiddenメソッドを呼ぶと

{
    "statusCode": 403,
    "error": "Forbidden",
    "message": "try again some time"
}

こういうエラーオブジェクトを作ってくれる。こっちでステータスコードをどうこう考えなくてよくなって便利。

エラー処理用ミドルウェアを作る

というわけでboomを用いたエラー処理ミドルウェア君を作っておく。

./middleware/error.js のようなファイルを作って

const errorHandler = (err, req, res, next) => {
  console.log('エラーハンドリング')
  if (res.headersSent) return next(err)
  if (!err.statusCode) err = boom.boomify(err)

  if (err.isServer) {
    // boom通した500番台のエラーはisServerでtrueが返るので
    // 500番台のみsentryみたいなエラー監視saasに送信したりできる
  }
  return err.isBoom
    ? res.status(err.output.statusCode).json(err.output.payload)
    : res.status(err.statusCode).json(err)
}

export default errorHandler

こういう感じのエラーハンドラーを作る。

boomを通したエラーオブジェクトはisServerisBoomなどのキーを持つので、これらによって「boomを介したエラーか」「500番台エラーか」とかの切り分けができる。

三項演算子でfalse側を用意しているのはboomを介していないエラーを投げたい時もあるはずなので、そういうときの処理のために書いている。 あとここではブログ的にエラーハンドラーの処理がなされたことがわかるようにconsole.logしているけど実際はいらない。

index.js 的なexpressの設定を書いているこういうファイルで、作ったエラーハンドラーを食わせるようにしておく

import express from 'express'
import errorHandler from '../middlewares/error'

const app = express()

// エラー処理ミドルウェア
app.use(errorHandler)

これで最低限のエラー処理機構ができた。エラーが想定されるところでエラーを投げると、エラー処理ミドルウェアがキャッチして処理してくれるようになった。

それではエラーを投げてみましょう。

そもそもどう書けばいいのか

エラーハンドラーのミドルウェアを書いたはいいけど、じゃあこのミドルウェアへ行くようにどう書けばいいのか。

エラーハンドリングのガイドは特に読みにくく、つらい。

『Expressでエラーを投げてエラー処理に行くには??』

それは

next(error)

です。

…と日本語の公式ガイドには"明確に"書かれていない…。

nextメソッドの第一引数になにかを入れると、エラー処理のミドルウェアにいきます…。

なお

if (err) {
    next(err); // Pass errors to Express.
}

なお英語版の公式ガイドにはこう書いてあるのでひと目で察しがつく

next(), next(error), next('route')について

公式ガイドに

next() 関数に (ストリング 'route' を除く) 何らかを渡すと、Express は、現在の要求でエラーが発生したと想定して、エラーが発生していない残りのすべての処理のルーティングとミドルウェア関数をスキップします。

next() および next(err) の呼び出しは、現在のハンドラーが完了したことと、その状態を示します。next(err) は、上記のようにエラーを処理するようにセットアップされたハンドラーを除き、チェーン内の残りのハンドラーをすべてスキップします。

こう書いてあるけど「?」ってなる

ようは

ということなのだけどわかりが悪い。

引数なしのnext()はわかるけど、引数ありはどういうことなのか。

next(error)

next(error)は

だった。

例としてこういうルーティングとミドルウェアを想定してみる。例なので isSuccess は強制的にfalseにしている。

returnなしパターン
router.get('/hello', (req, res, next) => {
    let isSuccess = false
    if (!isSuccess) next(Boom.badRequest('invalid query'))

    // returnしてないので表示される
    console.log('Logging: A')
  },
  // 後続のミドルウェア
  (req, res, next) => {
    // next(error)したので表示されない
    console.log('Logging: B')
  }
)

// 後続のルーティング処理
router.get('/hello', (req, res, next) => {
  // next(error)したので表示されない
  console.log('後続のルーティング処理だよ〜')
  next()
})

コンソールの表示はこう

Logging: A
エラーハンドリング

最初に紹介したBoomモジュールを使ってnextの第一引数にエラーを渡している。のでnext(error)したかたち。

エラー処理ミドルウェアの処理に移ったので「エラーハンドリング」が表示されている。

気をつけたほうがいいのはnext(error)した箇所でreturnしないとミドルウェア内の後続の処理は走ってしまう。だからLogging: Aが表示されている。

next(error)すると後続のミドルウェア・ルーティング処理はスキップされるので、てっきりthrow new Error()的な挙動をするのかと思うけど実際はそうではない。

なので、後続の処理をガードしたいならこう。

returnありパターン
router.get('/hello', (req, res, next) => {
  let isSuccess = false
  if (!isSuccess) return next(Boom.badRequest('invalid query')) // ここでreturn

  // returnしたので表示されない
  console.log('Logging: A')
  },
  // 後続のミドルウェア
  (req, res, next) => {
    // next(error)したので表示されない
    console.log('Logging: B')
  }
)

// 後続のルーティング処理
router.get('/hello', (req, res, next) => {
  // next(error)したので表示されない
  console.log('後続のルーティング処理だよ〜')
  next()
})

コンソールの表示はこう

エラーハンドリング

「Logging: A」が消えた。ミドルウェア内の後続処理をガードしたいならこのように適切にreturnしてあげないといけない。

next('route')

next('route')は

だった。なので以下のようになる

router.get('/hello', (req, res, next) => {
  let isSuccess = false
  if (!isSuccess) return next('route')

  // returnしたので表示されない
  console.log('Logging: A')
  },
  // 後続のミドルウェア
  (req, res, next) => {
    // next(error)したので表示されない
    console.log('Logging: B')
  }
)

// 後続のルーティング処理
router.get('/hello', (req, res, next) => {
  // next(error)はしたけどnext('route')なのでこれは表示される
  console.log('後続のルーティング処理だよ〜')
  next()
})

コンソールの表示はこう

後続のルーティング処理だよ〜

nextの第一引数になにかを渡しているのでnext(error)形式ではあるので後続のミドルウェアは実行されない。なので「Logging: B」は出ていない。

またnext(error)形式ではあるものの例外的にエラー処理には移らないので「エラーハンドリング」も表示されていない。後続のルーティング処理に移るので「後続のルーティング処理だよ〜」は実行されている。


next(), next(error), next('route')の挙動は難しい。


実際の処理の様子

そういうわけで実際には基本的には上記をふまえてこのように書いていくと思う。あくまでパターンの一例ですが例として書いておきます。

Expressアプリケーション側

import boom from 'boom'

router.use('/v1/hello', (req, res, next)=> {
  try {
    // なんかサーバー処理実行する
    next()
  } catch(error) {
    next(boom.clientTimeout('timed out'))

    //// boom通したくないならエラーオブジェクトをそのまま放り込む     
    // next(error)
  }
})

クライアント側

import axios from 'axios'

const awesomeFetch = async() => {
  try {
    const {data} = await axios(`/v1/hello`, {
          method: 'get',
          responseType: 'json'
        })
  } catch (error) {
    console.log(error.response)
  }
}

awesomeFetch()

axiosでフェッチ処理してエラーが発生(Express側でエラー処理がなされたら)、catch節にてこういう感じにエラーオブジェクトをcatchできます。

ここではboom.clientTimeout('timed out')タイムアウトエラーを発行しているので、

{
    "statusCode": 408,
    "error": "Request Time-out",
    "message": "timed out"
}

てな感じにエラー内容が取得できます。


とりあえず長いので今回はこの辺までで。

async/awaitを使ったPromiseベースのエラーハンドリングについてはもうちょいあるのでまたこんど書くかもしれない。(下記参考URLに載っていることがほとんどではあるけれど)

なんか間違ってるところがあるかもしれないのでなんかあったら逆に教えてほしいです。


【参考URL】