はじめに
Express.jsで非同期処理を扱う際、async/awaitを使うとコードが読みやすくなり、保守性も向上します。しかし、エラーハンドリングを適切に行わないと、エラーが握りつぶされてアプリケーションがハングアップしてしまうことがあります。
この記事では、通常のエラーハンドリングとasync/awaitを使った場合の違いを明確にし、実践的な解決方法を3つ紹介します。特にasync/awaitでハマりやすい「エラーが捕捉されない」という問題について、その仕組みと対処法を詳しく解説していきますね。
Expressの基本的なエラーハンドリング(同期処理)
まずは、Expressの標準的なエラーハンドリングの仕組みを理解しましょう。
Expressでは、ルートハンドラやミドルウェアで発生したエラーを、エラーハンドリングミドルウェアで一元的に処理できます。エラーハンドリングミドルウェアは、4つの引数を持つ特別なミドルウェアです。
const express = require('express');
const app = express();
// 通常のルート(同期処理)
app.get('/sync-error', (req, res, next) => {
throw new Error('同期処理でのエラー');
});
// エラーハンドリングミドルウェア
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: err.message
});
});
app.listen(3000);
同期処理の場合、throwされたエラーはExpressが自動的にキャッチし、エラーハンドリングミドルウェアに渡してくれます。この仕組みがExpressの便利なポイントです。
async/awaitで発生する問題
ところが、async/awaitを使った非同期処理では、この仕組みが機能しません。
app.get('/async-error', async (req, res, next) => {
throw new Error('非同期処理でのエラー');
// このエラーはキャッチされない!
});
このコードを実行すると、エラーハンドリングミドルウェアが呼ばれず、アプリケーションがハングアップしてしまいます。なぜこのような問題が起きるのでしょうか。
Promiseのrejected状態とExpressの関係
async関数は必ずPromiseを返します。async関数内でthrowされたエラーは、Promiseのrejected状態として扱われます。しかし、Expressはこのrejected状態を自動的には処理してくれません。
// async関数は以下のようなPromiseを返す
app.get('/async-error', (req, res, next) => {
return Promise.reject(new Error('エラー'));
// Expressはこのrejectionを捕捉できない
});
Expressが作られた当時、Promiseやasync/awaitはまだ一般的ではありませんでした。そのため、Expressの内部実装は同期的なエラー(throwされたエラー)のみを想定しています。
解決方法1: try-catchを使う方法
最も基本的な解決方法は、async関数内でtry-catchを使い、catchしたエラーをnext()に渡すことです。
app.get('/user/:id', async (req, res, next) => {
try {
const user = await getUserFromDatabase(req.params.id);
res.json(user);
} catch (error) {
next(error); // エラーをExpressに渡す
}
});
この方法のメリットは、追加のライブラリが不要で、動作が明確であることです。各ルートハンドラで何が起きているか一目で分かりますね。
デメリットは、すべてのasync関数でtry-catchを書く必要があり、コードが冗長になることです。ルートが増えると、このボイラープレートコードの管理が負担になります。
解決方法2: ラッパー関数を使う方法
try-catchの繰り返しを避けるため、ラッパー関数を作成して再利用する方法があります。
// asyncHandlerラッパー関数
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// 使用例
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await getUserFromDatabase(req.params.id);
res.json(user);
}));
app.post('/user', asyncHandler(async (req, res) => {
const newUser = await createUser(req.body);
res.status(201).json(newUser);
}));
このasyncHandler関数は、async関数を受け取り、そのPromiseのrejectionを自動的にnext()に渡します。各ルートハンドラをこのラッパーで囲むだけで、エラーハンドリングが機能するようになります。
メリットは、コードの重複が減り、見通しが良くなることです。デメリットは、すべてのルートハンドラをラッパーで囲む必要があり、囲み忘れるとエラーが捕捉されないことです。
解決方法3: express-async-errorsを使う方法
最も簡単な解決方法は、express-async-errorsライブラリを使うことです。
npm install express-async-errors
使い方は非常にシンプルで、アプリケーションの最初にrequireするだけです。
require('express-async-errors');
const express = require('express');
const app = express();
// 特別な処理は不要
app.get('/user/:id', async (req, res) => {
const user = await getUserFromDatabase(req.params.id);
res.json(user);
});
// エラーハンドリングミドルウェア
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: err.message
});
});
app.listen(3000);
このライブラリは、Expressのルーター機能にモンキーパッチを当て、async関数のrejectionを自動的に処理してくれます。開発者は何も意識することなく、通常通りasync/awaitを使えます。
メリットは、導入が簡単で、既存のコードを変更する必要がほとんどないことです。デメリットは、外部ライブラリに依存することと、内部で行われているパッチ処理がブラックボックスになることです。
まとめ
Express.jsでasync/awaitを使う際のエラーハンドリング方法を3つ紹介しました。それぞれの特徴を表にまとめます。
| 方法 | メリット | デメリット | 推奨ケース |
|---|---|---|---|
| try-catch | 動作が明確、依存なし | コードが冗長 | 小規模プロジェクト |
| asyncHandler | 再利用可能、見通し良い | 囲み忘れのリスク | 中規模プロジェクト |
| express-async-errors | 導入が簡単、既存コード変更不要 | 外部依存、ブラックボックス | 大規模プロジェクト |