Node.js
Express
promise
TypeScript
AsyncAwait

【Express.js】非同期処理の個人的ベストプラクティス (async/await)

Express で非同期処理

サーバサイドで Node.js を使うユースケースとしては APIサーバ (BFF、SSR 含む) が考えられるが、
主な処理 (DB との接続、http 通信など) は基本的に非同期処理になっている

非同期処理のライブラリは色々あるが現在は標準に組み込まれた Promise を使うのが主流となっており、これを使えばコールバック地獄は避けられるが、できれば async/await を使って直感的に書きたい

JavaScriptの同期、非同期、コールバック、プロミス辺りを整理してみる
async/await 入門(JavaScript)

しかし結局 await は async の中でしか書けないため、Express を利用する場合は実質トップレベルの RequestHandler 内でどう処理するかという問題が生じる

アンチパターン: そのまま async をつける

user.js
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 付きの即時関数で囲む

user.js
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 された errornextFunction の引数に渡されて実行されるので、 Express のエラーハンドリングで処理することができるのがポイント

副次的な効果として、async 内で普通に例外を投げても catch されるので実質 try/catch の役割も果たしている
個人的にはこの効果が地味にありがたくてよく使っている

パターン2: Wrap 関数を自前で実装する

毎回 async の即時関数を書くのは面倒なので wrap する
非同期例外も普通の例外も wrap 関数 (正確には wrap 関数の戻り値の関数) が catch して nextFunction に渡すようになるので、パターン1と同様にエラー処理に繋げられる

参考: Asynchronous Error Handling in Express with Promises, Generators and ES7

user.js
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 で書いた場合↓

wrap.ts
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番自然な形で記述できる

参考: Express with async/await

user.js
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 で実装する方が単純かもしれない

user.ts
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);
    });
  });
}