本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 使用 Express 與資料庫整合:Handling async/await 與錯誤捕捉


簡介

在使用 Express 撰寫 API 時,幾乎所有的路由都會與資料庫、外部服務或檔案系統等非同步資源互動。
自 Node.js 7 之後,async/await 成為最直觀的非同步寫法,讓程式碼看起來像同步流程,極大提升可讀性與維護性。然而,非同步操作同樣會拋出錯誤,若未妥善捕捉,整個 Express 應用可能會因未處理的例外而崩潰,甚至導致安全漏洞。

本篇文章將 深入探討在 Express + TypeScript 專案中,如何正確使用 async/await、統一錯誤捕捉,並提供實作範例、常見陷阱與最佳實踐,協助你寫出既安全又易於除錯的 API。


核心概念

1. 為什麼要在 Express 中包裝 async handler?

Express 原生的路由處理函式是同步的,若在裡面直接拋出例外,Express 會自動交給錯誤處理中介軟體 (error‑handling middleware)。
async 函式拋出的錯誤會以 rejected Promise 的形式傳遞,如果不把 Promise 交給 Express,錯誤就不會被捕捉,最終會變成「未處理的 Promise 拒絕」警告,甚至讓 Node 直接退出。

結論:所有使用 async/await 的路由必須以 包裝函式(wrapper)或 自訂型別 交給 Express,保證錯誤能傳遞到錯誤中介軟體。


2. 建立通用的 async wrapper

下面的 asyncHandler 是最常見的寫法,只要把原本的 async 函式包起來,回傳一個符合 Express RequestHandler 型別的函式即可。

// utils/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

/**
 * 把 async route handler 包裝成符合 Express 錯誤傳遞機制的函式
 */
export const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>): RequestHandler => {
  return (req, res, next) => {
    fn(req, res, next).catch(next); // 把 rejected Promise 交給 next()
  };
};

使用方式:

import express from 'express';
import { asyncHandler } from './utils/asyncHandler';
import { getUserById } from './services/userService';

const router = express.Router();

router.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id);
  if (!user) {
    // 手動拋出錯誤,會被 asyncHandler 捕捉
    throw new NotFoundError('User not found');
  }
  res.json(user);
}));

小技巧:如果你使用 express-async-errors 套件,會自動幫你把所有 async route 交給 Express,免除自行撰寫 wrapper。但在 TypeScript 專案中,手寫 wrapper 可保留完整型別資訊。


3. 統一錯誤類別與錯誤中介軟體

在大型專案裡,錯誤類別(Error class)可以幫助我們區分 400、404、500 等不同 HTTP 狀態碼,讓錯誤中介軟體只負責格式化回傳。

// errors/HttpError.ts
export class HttpError extends Error {
  public status: number;
  public isOperational: boolean; // 方便未預期錯誤辨識

  constructor(message: string, status: number, isOperational = true) {
    super(message);
    this.status = status;
    this.isOperational = isOperational;
    Object.setPrototypeOf(this, new.target.prototype); // 修正 prototype
    Error.captureStackTrace(this, this.constructor);
  }
}

// 常用子類別
export class BadRequestError extends HttpError {
  constructor(message = 'Bad Request') {
    super(message, 400);
  }
}
export class NotFoundError extends HttpError {
  constructor(message = 'Not Found') {
    super(message, 404);
  }
}
export class InternalServerError extends HttpError {
  constructor(message = 'Internal Server Error') {
    super(message, 500, false); // 非業務錯誤
  }
}

錯誤中介軟體

// middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { HttpError } from '../errors/HttpError';

export const errorHandler = (
  err: Error,
  _req: Request,
  res: Response,
  // Express 會自動把此函式辨識為錯誤中介軟體
  // 只要參數長度為 4 即可
  _next: NextFunction,
) => {
  // 若是自定義的 HttpError,使用其 status;否則回傳 500
  const status = (err as HttpError).status || 500;
  const message = (err as HttpError).isOperational
    ? err.message
    : '系統發生未預期的錯誤,請稍後再試';

  // 在開發環境可以印出完整 stack trace
  if (process.env.NODE_ENV !== 'production') {
    console.error(err);
  }

  res.status(status).json({
    success: false,
    status,
    message,
  });
};

app.ts(或 server.ts)最後掛上中介軟體:

import express from 'express';
import { errorHandler } from './middlewares/errorHandler';
import userRouter from './routes/user';

const app = express();

app.use(express.json());
app.use('/api', userRouter);

// 其他路由... (404 handler 可自行實作)

app.use(errorHandler); // <-- 必須放在最底部
export default app;

4. 範例:使用 Prisma 與 async/await

以下示範 Express + TypeScript + Prisma(一個流行的 TypeScript ORM) 的完整資料庫操作流程,包含錯誤捕捉與 transaction。

// services/userService.ts
import { PrismaClient } from '@prisma/client';
import { NotFoundError, BadRequestError } from '../errors/HttpError';

const prisma = new PrismaClient();

/**
 * 取得單一使用者,若不存在拋出 NotFoundError
 */
export const getUserById = async (id: string) => {
  const user = await prisma.user.findUnique({ where: { id } });
  if (!user) throw new NotFoundError(`User with id ${id} not found`);
  return user;
};

/**
 * 建立新使用者,若 email 已存在拋出 BadRequestError
 */
export const createUser = async (data: { name: string; email: string }) => {
  const exists = await prisma.user.findUnique({ where: { email: data.email } });
  if (exists) throw new BadRequestError('Email already in use');
  return prisma.user.create({ data });
};

/**
 * 使用 transaction 同時更新使用者與建立日誌
 */
export const updateUserWithLog = async (
  id: string,
  update: { name?: string; email?: string },
) => {
  return prisma.$transaction(async (tx) => {
    const user = await tx.user.update({
      where: { id },
      data: update,
    });

    await tx.activityLog.create({
      data: {
        userId: id,
        action: 'UPDATE_PROFILE',
        performedAt: new Date(),
      },
    });

    return user;
  });
};

對應的路由:

// routes/user.ts
import { Router } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import {
  getUserById,
  createUser,
  updateUserWithLog,
} from '../services/userService';

const router = Router();

// GET /api/users/:id
router.get(
  '/users/:id',
  asyncHandler(async (req, res) => {
    const user = await getUserById(req.params.id);
    res.json({ success: true, data: user });
  }),
);

// POST /api/users
router.post(
  '/users',
  asyncHandler(async (req, res) => {
    const { name, email } = req.body;
    const newUser = await createUser({ name, email });
    res.status(201).json({ success: true, data: newUser });
  }),
);

// PATCH /api/users/:id
router.patch(
  '/users/:id',
  asyncHandler(async (req, res) => {
    const updated = await updateUserWithLog(req.params.id, req.body);
    res.json({ success: true, data: updated });
  }),
);

export default router;

重點說明

  1. 每個服務層都拋出自訂的 HttpError,讓錯誤中介軟體能回傳正確的 HTTP 狀態碼。
  2. asyncHandler 確保所有 await 失敗都會被 next(err) 捕捉。
  3. 使用 Prisma 的 $transaction 讓多個資料庫操作具備 原子性,若其中任一步驟失敗,整筆交易會自動 rollback。

5. 範例:全域捕捉未處理的 Promise 拒絕與例外

即使在每條路由都使用 asyncHandler,仍有可能在程式的其他角落(如背景工作、第三方套件)產生未捕捉的錯誤。下面示範如何在 Node.js 層面上捕捉這類錯誤,避免應用直接崩潰。

// server.ts
import http from 'http';
import app from './app';

// 監聽未處理的 Promise 拒絕
process.on('unhandledRejection', (reason, promise) => {
  console.error('未處理的 Promise 拒絕:', reason);
  // 可選:寫入日誌、發送告警、Graceful shutdown
});

// 監聽未捕捉的同步例外
process.on('uncaughtException', (err) => {
  console.error('未捕捉的例外:', err);
  // 建議立刻關閉服務,避免不一致狀態
  process.exit(1);
});

const server = http.createServer(app);
const PORT = process.env.PORT || 3000;

server.listen(PORT, () => {
  console.log(`🚀 Server listening on http://localhost:${PORT}`);
});

常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記使用 asyncHandler async route 拋出的錯誤不會走到錯誤中介軟體,導致 UnhandledPromiseRejectionWarning 為所有 async route 包裝 asyncHandler,或使用 express-async-errors
在 catch 區塊自行回傳 response 可能會在同一 request 中回傳兩次(一次在 catch、一次在全域錯誤中介軟體)。 在 catch 中只 next(err),讓錯誤中介軟體統一處理。
直接拋出原始 Error 無法得知要回傳的 HTTP status,前端只能得到 500。 使用自訂 HttpError(或 createError)並設定 status
在錯誤中介軟體裡呼叫 next(err) 會形成無限遞迴,最終 Stack Overflow。 最後的錯誤中介軟體只回傳 response,不再呼叫 next.
忘記在開發環境印出 stack trace 調試困難,錯誤來源不明。 errorHandler 中根據 NODE_ENV 決定是否打印 err.stack
在 transaction 內沒有正確回傳 Prisma 會認為 transaction 成功,即使內部拋錯也不會 rollback。 把所有 DB 操作放在 await prisma.$transaction(async (tx) => { … }),在 async function 中直接拋錯即可。

最佳實踐清單

  1. 統一錯誤類別:自訂 HttpError,讓所有服務層都拋出帶有 status 的錯誤。
  2. 全域 async wrapper:使用 asyncHandlerexpress-async-errors,保證每條路由都被捕捉。
  3. 錯誤中介軟體最後掛載:確保所有路由、404 handler 之後才掛上 errorHandler
  4. 日誌與告警:在 errorHandlerprocess.on 內寫入結構化日誌(如 Winston、pino),便於追蹤。
  5. 測試錯誤路徑:使用 Jest / SuperTest 撰寫單元測試,確認 400、404、500 等回應正確。
  6. Graceful shutdown:在 uncaughtExceptionunhandledRejection 後,先關閉 server 再 exit,避免半完成的請求。

實際應用場景

1. 電子商務平台 – 訂單建立

  • 需要同時寫入 ordersorder_items、更新庫存、產生交易紀錄。
  • 使用 Prisma $transaction 包裹所有寫入,若任一步驟失敗(例如庫存不足),整筆訂單自動 rollback。
  • 若庫存不足,服務層拋出 BadRequestError('Insufficient stock'),最終回傳 400 給前端。

2. 多租戶 SaaS 應用 – 權限驗證

  • 每個 API 前置驗證需要查詢 tenantsusersroles,且可能因租戶不存在拋出 NotFoundError
  • 透過 asyncHandler 包裝的驗證中介軟體,保證任何非同步 DB 查詢失敗都會被捕捉,並以 404 回傳。

3. 實時聊天服務 – 背景任務

  • 使用 bull(Redis queue)處理訊息推送。工作者裡的 await 失敗會產生未處理的 Promise。
  • 在工作者入口加入 process.on('unhandledRejection') 監聽,寫入失敗訊息至日誌,並在必要時重試。

總結

Express + TypeScript 的開發環境中,async/await 為非同步程式碼帶來極佳的可讀性,但若不配合 統一的錯誤捕捉機制,很容易留下未處理的例外,進而影響服務穩定性。本文重點如下:

  1. 使用 asyncHandler(或 express-async-errors,確保每個 async route 的 rejected Promise 交給 Express。
  2. 自訂 HttpError 類別,讓服務層能清晰地表達錯誤類型與 HTTP 狀態碼。
  3. 實作全域錯誤中介軟體,在此統一格式化回應、記錄日誌、隱藏內部錯誤細節。
  4. 在資料庫操作時使用 transaction(如 Prisma $transaction),確保複雜寫入的原子性。
  5. 捕捉 Node.js 層面的未處理例外,避免應用因背景錯誤直接 Crash。

掌握以上技巧後,你的 Express API 不僅能寫得更簡潔、易讀,亦能在面對各種非同步失敗時保持一致、可預測的行為,為前端與使用者提供更可靠的服務體驗。祝開發順利,持續寫出高品質的 TypeScript 後端程式!