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;
重點說明:
- 每個服務層都拋出自訂的
HttpError,讓錯誤中介軟體能回傳正確的 HTTP 狀態碼。 asyncHandler確保所有await失敗都會被next(err)捕捉。- 使用 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 中直接拋錯即可。 |
最佳實踐清單
- 統一錯誤類別:自訂
HttpError,讓所有服務層都拋出帶有status的錯誤。 - 全域 async wrapper:使用
asyncHandler或express-async-errors,保證每條路由都被捕捉。 - 錯誤中介軟體最後掛載:確保所有路由、404 handler 之後才掛上
errorHandler。 - 日誌與告警:在
errorHandler或process.on內寫入結構化日誌(如 Winston、pino),便於追蹤。 - 測試錯誤路徑:使用 Jest / SuperTest 撰寫單元測試,確認 400、404、500 等回應正確。
- Graceful shutdown:在
uncaughtException或unhandledRejection後,先關閉 server 再 exit,避免半完成的請求。
實際應用場景
1. 電子商務平台 – 訂單建立
- 需要同時寫入
orders、order_items、更新庫存、產生交易紀錄。 - 使用 Prisma
$transaction包裹所有寫入,若任一步驟失敗(例如庫存不足),整筆訂單自動 rollback。 - 若庫存不足,服務層拋出
BadRequestError('Insufficient stock'),最終回傳 400 給前端。
2. 多租戶 SaaS 應用 – 權限驗證
- 每個 API 前置驗證需要查詢
tenants、users、roles,且可能因租戶不存在拋出NotFoundError。 - 透過
asyncHandler包裝的驗證中介軟體,保證任何非同步 DB 查詢失敗都會被捕捉,並以 404 回傳。
3. 實時聊天服務 – 背景任務
- 使用
bull(Redis queue)處理訊息推送。工作者裡的await失敗會產生未處理的 Promise。 - 在工作者入口加入
process.on('unhandledRejection')監聽,寫入失敗訊息至日誌,並在必要時重試。
總結
在 Express + TypeScript 的開發環境中,async/await 為非同步程式碼帶來極佳的可讀性,但若不配合 統一的錯誤捕捉機制,很容易留下未處理的例外,進而影響服務穩定性。本文重點如下:
- 使用
asyncHandler(或express-async-errors),確保每個 async route 的 rejected Promise 交給 Express。 - 自訂
HttpError類別,讓服務層能清晰地表達錯誤類型與 HTTP 狀態碼。 - 實作全域錯誤中介軟體,在此統一格式化回應、記錄日誌、隱藏內部錯誤細節。
- 在資料庫操作時使用 transaction(如 Prisma
$transaction),確保複雜寫入的原子性。 - 捕捉 Node.js 層面的未處理例外,避免應用因背景錯誤直接 Crash。
掌握以上技巧後,你的 Express API 不僅能寫得更簡潔、易讀,亦能在面對各種非同步失敗時保持一致、可預測的行為,為前端與使用者提供更可靠的服務體驗。祝開發順利,持續寫出高品質的 TypeScript 後端程式!