前面在 API Validation 中,我們已經把 Validation 抽成 Middleware,當 Request 不符合 Schema 規則時,不再由 Validation Middleware 自己回傳 Response,而是透過 next(error) 把錯誤往後傳。這樣做的好處是 Validation 可以專心負責資料驗證,至於錯誤最後要怎麼處理,則交給後面的流程。
不過問題來了,next(error) 把錯誤傳出去之後,Express 要怎麼知道接下來該怎麼處理?而且錯誤不一定只會發生在 Validation,Controller、資料庫操作或其他非同步程式也可能發生錯誤。如果每個地方都自己處理錯誤 Response,久了還是會出現大量重複的程式碼。
因此今天要接著處理前面留下來的問題:錯誤被傳出去之後,如何由 Express 統一處理。 我們會使用 Error Handling Middleware,讓 Validation、Controller 和其他地方發生的錯誤,最後都可以集中到同一個地方。
next(error) 把錯誤傳到哪裡?先回頭看前面 Validation Middleware 的寫法:
function validateNote(req, res, next) {
const result = noteSchema.safeParse(req.body);
if (!result.success) {
const issue = result.error.issues[0];
const error = new Error(issue.message);
error.statusCode = 400;
return next(error);
}
req.body = result.data;
next();
}
當 Validation 成功時,使用 next() 讓 Request 繼續往下一個 Middleware 或 Route;如果驗證失敗,則使用 next(error) 告訴 Express:「這裡發生錯誤了。」
這和一般的 next() 不太一樣。next() 是讓流程繼續往下走,而 next(error) 則會讓 Express 進入錯誤處理流程,接下來就會尋找 Error Handling Middleware。
所以前面的 Validation 並不是完全沒有處理錯誤,而是把「發現錯誤」和「處理錯誤」拆開。Validation 負責確認資料有沒有問題,真正的錯誤 Response 則交給後面的 Error Handler。
假設 Controller 發現 Note 不存在,最直接的寫法可能是:
if (!note) {
return res.status(404).json({
message: "找不到這筆 Note"
});
}
這段程式本身沒有問題,但如果之後每支 API 都用同樣的方式處理錯誤,很容易變成每個 Controller 都在判斷 Status Code、建立錯誤訊息和產生 Response。
Validation 可能有自己的 400 Response,Controller 有自己的 404 Response,資料庫發生問題時又有另一套 500 Response。當 API 數量增加之後,不只會有大量重複程式碼,也可能讓不同 API 使用不同的錯誤格式。
因此比較好的方式,是讓發現錯誤的地方只負責把錯誤傳出去,最後由同一個 Error Handler 決定 Status Code 和 Response 格式。
Controller 同樣可以把錯誤交給後面的錯誤處理流程。例如:
if (!note) {
const error = new Error("找不到這筆 Note");
error.statusCode = 404;
throw error;
}
這裡沒有直接使用 res.status() 回傳 Response,而是建立一個 Error,並把 statusCode 一起放進去。這樣錯誤在往後傳遞的過程中,就可以帶著後面處理時需要的資訊。
例如我們可能會把建立 Error 的部分抽成函式:
function createError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
之後 Controller 就可以寫成:
if (!note) {
throw createError("找不到這筆 Note", 404);
}
這裡真正重要的不是 createError() 本身,而是讓錯誤在傳遞時,同時帶著錯誤訊息和 Status Code,讓後面的 Error Handler 可以根據這些資訊建立 Response。
當 API 開始接上資料庫之後,查詢資料、建立資料或更新資料通常都會變成非同步操作,這時候錯誤可能發生在 await 的過程中。
例如:
const getNote = async (req, res, next) => {
try {
const note = await getNoteById(req.params.id);
if (!note) {
throw createError("找不到這筆 Note", 404);
}
res.status(200).json({
data: note
});
} catch (error) {
next(error);
}
};
如果 getNoteById() 查詢失敗,或找不到 Note 時主動 throw Error,程式就會進入 catch,最後透過 next(error) 把錯誤交給 Express。
這裡可以看到 try/catch 的作用不是讓 Controller 自己處理錯誤,而是把非同步流程中的錯誤接住,再交給統一的錯誤處理流程。實際使用 Express 時,也可以依照目前使用的 Express 版本和寫法決定是否需要在每個非同步 Controller 裡手動包 try/catch,不過核心概念是一樣的:錯誤最後應該進入同一個 Error Handler。
當 Validation、Controller 或其他非同步操作把錯誤交給 Express 之後,就需要一個地方統一接收這些錯誤。Express 的 Error Handling Middleware 和一般 Middleware 不同,它會有四個參數:
app.use((err, req, res, next) => {
// Error Handler
});
第一個參數 err 就是前面傳過來的錯誤,因此可以從 err.statusCode 和 err.message 取得錯誤資訊,再統一建立 Response:
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.message || "Internal Server Error"
});
});
例如 Validation 發生錯誤時設定 statusCode = 400,Error Handler 就會回傳 400;Controller 找不到 Note 時設定 statusCode = 404,就會回傳 404。如果收到的是沒有額外設定 statusCode 的一般 Error,就使用預設的 500。
這樣不管錯誤從哪裡發生,最後都可以使用相同的方式建立 Response。
Error Handler 通常會放在所有 Route 和其他 Middleware 的後面:
app.use(express.json());
app.use("/notes", noteRouter);
app.use("/users", userRouter);
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.message || "Internal Server Error"
});
});
因為前面的 Middleware 和 Route 必須先有機會處理 Request,發生錯誤後才會將錯誤交給 Error Handler,所以它通常會放在整個 Application 的後面。
整個流程就可以整理成:
Request
↓
Validation Middleware
↓
Router
↓
Controller
↓
資料庫 / 其他操作
↓
發生錯誤
↓
next(error)
↓
Error Handler
↓
Response
這樣錯誤就不需要散落在每一個 API 裡處理,而是有一個統一的錯誤入口。
錯誤集中之後,還需要注意一件事情:Server 發生的錯誤,不代表所有資訊都應該直接回傳給 Client。
例如「找不到這筆 Note」是 API 使用者可以理解的錯誤,因此可以直接回傳;但像資料庫連線失敗、程式執行例外或內部錯誤,完整訊息通常比較適合留給開發者 Debug,而不是直接交給 Client。
因此 Error Handler 除了產生 Response,也可以負責把錯誤記錄下來:
app.use((err, req, res, next) => {
console.error(err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.statusCode
? err.message
: "Internal Server Error"
});
});
這樣可以預期的錯誤,例如 400 或 404,可以回傳原本的錯誤訊息;沒有特別設定 Status Code 的錯誤,則只回傳一般性的 500 訊息,同時在 Server 端保留完整錯誤。實際專案中通常會進一步使用 Logging 工具,讓錯誤可以被搜尋、追蹤與監控。
在 Development 環境中,通常希望看到完整的錯誤資訊,方便找到錯誤發生的位置;到了 Production,則應該避免把 Stack Trace 或資料庫錯誤等內部資訊直接暴露給 Client。
例如可以根據 NODE_ENV 做不同處理:
app.use((err, req, res, next) => {
console.error(err);
if (process.env.NODE_ENV === "development") {
return res.status(err.statusCode || 500).json({
message: err.message,
stack: err.stack
});
}
res.status(err.statusCode || 500).json({
message: err.statusCode
? err.message
: "Internal Server Error"
});
});
這樣 Development 可以保留比較完整的資訊來協助 Debug,而 Production 則只回傳 Client 真正需要知道的內容,完整錯誤則留在 Server 端的 Log 中。
前面的 Validation 解決的是「Request 進來之後,資料是否符合規則」;今天則接著處理「如果任何地方發生錯誤,這個錯誤要怎麼被傳遞和處理」。Validation Middleware 可以發現資料錯誤,Controller 可以發現資源不存在,資料庫操作也可能發生例外,但這些錯誤都不需要各自建立一套 Response,而是可以交給同一個 Error Handler。
整個流程可以整理成:
Request
↓
Validation
↓
Router
↓
Controller
↓
資料庫 / 其他操作
↓
發生錯誤
↓
next(error)
↓
Error Handler
├── Response → Client
└── Log → Developer
這樣做之後,Validation 可以專心驗證資料,Controller 可以專心處理 API 邏輯,而 Error Handler 則負責統一處理錯誤 Response。錯誤可以在不同地方發生,但不需要在不同地方各自處理。