Express で非同期処理
サーバサイドで Node.js を使うユースケースとしては APIサーバ (BFF、SSR 含む) が考えられるが、
主な処理 (DB との接続、http 通信など) は基本的に非同期処理になっている
非同期処理のライブラリは色々あるが現在は標準に組み込まれた Promise を使うのが主流となっており、これを使えばコールバック地獄は避けられるが、できれば async/await を使って直感的に書きたい
JavaScriptの同期、非同期、コールバック、プロミス辺りを整理してみる
async/await 入門(JavaScript)
しかし結局 await は async の中でしか書けないため、Express を利用する場合は実質トップレベルの RequestHandler
内でどう処理するかという問題が生じる
アンチパターン: そのまま async をつける
const express = require('express');
const router = express.Router();
router.get('/user/:id', async (req, res) => {
const id = req.params.id;
// reject した場合、router が非同期例外を捌けないので NG
const user = await getUser(id);
res.status(200).json(user);
});
深く考えずにこう書いてしまうと、await で reject されたときに処理できなくなってしまうので良くない
パターン1: async 付きの即時関数で囲む
const express = require('express');
const router = express.Router();
router.get('/users', (req, res, next) => {
(async () => {
const id = req.params.id;
if (!id) {
// throw で投げられた error も catch できる
throw new Error('Params not found');
}
// 通常の reject も catch できる
const user = await getUser(id);
res.status(200).json(user);
})().catch(next);
})
module.exports = router;
async 付きの即時関数で囲むことで、非同期例外を catch できるようになる
そこで nextFunction
を渡してやれば、catch された error
が nextFunction
の引数に渡されて実行されるので、 Express のエラーハンドリングで処理することができるのがポイント
副次的な効果として、async 内で普通に例外を投げても catch されるので実質 try/catch の役割も果たしている
個人的にはこの効果が地味にありがたくてよく使っている
パターン2: Wrap 関数を自前で実装する
毎回 async の即時関数を書くのは面倒なので wrap する
非同期例外も普通の例外も wrap 関数 (正確には wrap 関数の戻り値の関数) が catch して nextFunction
に渡すようになるので、パターン1と同様にエラー処理に繋げられる
参考: Asynchronous Error Handling in Express with Promises, Generators and ES7
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);
router.get('/user/:id', wrap(async (req, res) => {
const id = req.params.id;
if (!id) {
// throw で投げられた error も catch できる
throw new Error('UserID is not found');
}
// 通常の reject も catch できる
const user = await getUser(id);
res.status(200).json(user);
}));
ちなみに Wrap 関数自体は、async 付きの RequestHandler
を引数として受け取って、
非同期例外を catch して nextFunction
に渡す処理を加えた RequestHandler
を返すということをやっているだけ
わかりやすく TypeScript で書いた場合↓
import { RequestHandler, Request, Response, NextFunction } from 'express'
interface PromiseRequestHandler {
(req: Request, res: Response, next: NextFunction): Promise<any>
}
function wrap(fn: PromiseRequestHandler): RequestHandler {
return (req, res, next) => fn(req, res, next).catch(next)
}
だいぶ見通しが良くなったが、JavaScript のままだと VSCode で補完が効かなくなるでなんとかしたい
パターン3: Router
側で非同期例外を処理させる
express-promise-router
というライブラリで Promise
を処理できる Router
が使えるようになるので、1番自然な形で記述できる
const router = require('express-promise-router')();
router.get('/user/:id', async (req, res) => {
const id = req.params.id;
if (!id) {
// throw で投げられた error も catch できる
throw new Error('UserID is not found');
}
// 通常の reject も catch できる
const user = await getUser(id);
res.status(200).json(user);
});
ライブラリ側が型定義に対応しているので VSCode でも補完が効くようになった
-> 結局効いていなかったりするのでパターン2 or 3を TypeScript で実装する方が単純かもしれない
import Router from 'express-promise-router'
const router = Router()
router.get('/user/:id', async (req, res) => {
// 略
})
結論
TypeScript はいいぞ
ちなみに Express 5 では Promise
を扱えるようになるらしいので期待
https://github.com/expressjs/express/pull/2237
最初のアンチパターンの形で書けるような仕様になってくれると嬉しい…
【おまけ】 callback ベースの API を Promise
でラップする
Promise
を返す API が用意されていなければ、自分で return new Promise
する関数を作ってあげれば良い
Rx でいう所の Observer.create
のようなイメージ
参考: 今更だけどPromise入門
const stringify = require('csv-stringify');
function stringifyPromise(records, options) {
return new Promise((resolve, reject) => {
stringify(records, options, (err, output) => {
if (err) {
return reject(err);
}
resolve(output);
});
});
}