35
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Expressでのエラーハンドリング ベストプラクティス

Last updated at Posted at 2020-04-29

Express公式サイトのベストプラクティスには、パフォーマンスと信頼性についてのベストプラクティスが解説されています。

その中で、適切なエラーハンドリングのベストプラクティスについて解説されています。

Express(Node.js)では発生したエラーがキャッチされないと、プロセスが異常終了したりハングしてしまいます。そうなると、Epxressアプリケーションの信頼性(可用性)が地に落ちてしまいます。このようにエラーハンドリングの適切さは信頼性に大きく影響するため、エラーハンドリングはとっても重要なのです。

今回の投稿では、Express公式サイト(と、そこからリンクされる参考ページ)で紹介されているエラーハンドリングのベストプラクティスを、できるだけ分かりやすく説明させていだこうと思います。

まずはアンチパターンについて説明します。

Expressにおけるエラーハンドリング【アンチパターン】

その①:コールバック関数で、手動でnextを呼び出す。

非同期関数(以下の例ではqueryDb(), makeCsv())を呼び出した後のエラーハンドリングでは、コールバック関数でnext関数にエラーを渡して実行する必要があります。

app.get('/', function (req, res, next) {
  queryDb(function (err, data) {
    if (err) return next(err)
    // handle data

    makeCsv(data, function (err, csv) {
      if (err) return next(err)
      // handle csv

    })
  })
})
app.use(function (err, req, res, next) {
  // handle error
})

しかし、それは以下の点で問題があります。

  • 非同期関数でエラーが発生した場合、nextの呼び出しをうっかり忘れてしまう恐れがある。
  • 非同期関数が正常に処理されたとしても、コールバック中にエラーが起きることはある。その場合に、nextの呼び出しをうっかり忘れてしまう恐れがある。

その②:Promiseを利用する。

そこで、非同期関数を呼び出してPromiseを受け取り、Promiseのcatch()にnext関数を登録すれば良い、ということになります。

app.get('/', function (req, res, next) {
  // do some sync stuff
  queryDb()
    .then(function (data) {
      // handle data
      return makeCsv(data)
    })
    .then(function (csv) {
      // handle csv
    })
    .catch(next)
})
app.use(function (err, req, res, next) {
  // handle error
})

ただ、この書き方は冗長で読みづらいです。そこで、async awaitを使って読みやすくしてみましょう。

その③:async awaitを利用する。

app.get('/', (req, res, next) => {
  (async () => {
    let data = await queryDb()
    // handle data
    let csv = await makeCsv(data)
    // handle csv
  })().catch(next)
}))

async関数は、暗黙的にPromiseを返します。このため、

  • queryDb()makeCsv()でエラーがthrowされる
  • async関数内の他の箇所でエラーがthrowされる

といった場合は、throwされたエラーでPromiseがrejectされます。すると、catchに登録された関数nextにそのエラーが渡されてnextが実行されます。(これは直前のパターンでも同じことです。)

これで、Promiseのチェーンで書かれたコードよりは、少し読みやすくなりました。

ただ、このように「async即時関数でcatch」というコードが散在するのはNGでしょう。ということで、冒頭のパターンがベストプラクティスということなのです。

ベストプラクティスに進む前に、念のため以下のアンチパターンも紹介しておきます。

その④:ハンドラ関数をasyncにするだけ。

以下のように、ハンドラ関数をasyncにしただけではNGです。

app.get('/', async (req, res) => {
  let data = await queryDb()
  // handle data
  let csv = await makeCsv(data)
  // handle csv
}))

この場合、queryDb()makeCsv()でエラーがthrowされると、async関数が返すPromiseがrejectされます。しかし、ExpressはrejectされたPromiseをハンドリングしません。

すると、サーバー側の処理は終了となります(UnhandledPromiseRejectionWarningが発生するはずです)。クライアント側(GETメソッドの呼び出し元)には何も返されません。

その結果、多くの場合、呼び出し元はタイムアウトとなります。

Epxressのエラーハンドリング用ミドルウェアでハンドリングするためには、先ほどの例のように、nextが呼び出されるようにする必要があるのです。

Expressにおけるエラーハンドリング【ベストプラクティス】

ベストプラクティスは以下の通りです。

const wrap = fn => (...args) => fn(...args).catch(args[2])

app.get('/', wrap(async (req, res, next) => {
  const company = await getCompanyById(req.query.id)
  const stream = getLogoStreamById(company.id)
  stream.on('error', next).pipe(res)
}))

パーツごとに説明していきます。

まずは以下の部分です。

const wrap = fn => (...args) => fn(...args).catch(args[2])

wrap関数は、fnを受け取り、(...args) => fn(...args).catch(args[2])という関数を返します。私がはじめにこれを見たときは「カリー化なのかな・・」と思ったのですが、そうではありませんでした。

今回では、fnにはPromiseを返す関数が指定されます。wrap関数の引数に指定されているasync (req, res, next) => …という関数が、正にこれに当たります。async関数は暗黙的にPromiseを返すのでした。

次に、

(...args) => fn(...args).catch(args[2])

という関数では何をやっているのでしょうか?

それは以下のとおりです。

  • 今回の場合、fnにはasync関数が指定されます。async (req, res, next) => …のことです。
  • 引数...args(今回の場合は、expressから渡されるreq, res, next)をfnに渡してfnを実行します。
  • fnを実行するとPromiseが返ります。そのPromiseのcatchにargs[2]、つまりexpressから渡される引数の3番目であるnextを指定しています。nextは次のExpressミドルウェアを呼び出す関数です。Promiseでrejectとなった場合のコールバックとして、nextを登録しているのです。

fn(つまりasync (req, res, next) => …)の実行中にエラーが発生すると、Promiseがrejectされます。すると、catchに登録したコールバックであるnextが実行されます。発生したエラーは、nextに引数として渡されます。

その結果、Expressのエラーハンドリング用のミドルウェアが実行され、無事にエラーが捕捉されるというわけです。

この技法を使うことで、「async即時関数でcatch」というコードが散在してしまう、という事態を避けることができます。

また

stream.on('error', next).pipe(res)

についても説明します。

これはストリームなどを使う場合のみ該当する話なのですが、async関数の中であっても、イベント・エミッター (ストリームなど) により、例外がキャッチされないことがあります。そこでストリームのerrorイベントが発生したときに、確実にnextが呼び出されるようにしています。

注意点

上記のベストプラクティスが通用するのは、async関数内で呼び出す非同期関数がPromiseを返す場合だけです。awaitはあくまでもPromiseがresloveされるかrejectされるのを待つ機能だからです。

ですので、上記のasync関数内でPromiseを返さない非同期関数を呼び出す場合、はじめに説明したアンチパターンと同じことになってしまいます。つまり、コールバック関数でnext(err)の実装を忘れてしまう、というリスクがあります。

今どき、Promiseを返さない非同期関数を使わなきゃいけない、というケースは少ないかなと思いますが、もしそういったケースに当てはまる場合は、以下のうちいずれかを選択しましょう。

  • Promiseを返さない非同期関数から、Promiseを返す関数を自動生成し、後者を呼び出すようにする。Node.js標準のutil.promisify()を使うことで、自動生成を実現できます。こちらが参考になります。(以前は、Bluebird等のライブラリで実現していましたが、Node.js 8から標準で組み込まれました。)
  • 「Promiseを返さない非同期関数」をラップした関数を定義して、Promiseを返すようにする。こちらが参考になります。
  • 1つ目のアンチパターンの難点を受け入れ、コールバック関数でnext(err)を忘れずに実装する、と堅く誓う(ソースコードレビューが大変…)。

参考

以上です。Expressのハンドラ関数で発生するエラーを、確実にハンドリングしていきましょう!

35
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?