過去三天談的都是「程式碼正常運作時,該怎麼被組織」。今天要換一個角度:當程式碼「不正常運作」、發生錯誤的時候,系統該怎麼被設計來應對——這同樣需要架構層級的規劃,而不是每個人各自在自己負責的函式裡隨手寫 try-catch。
定義:在系統的最外層設置統一的錯誤攔截點,而不是在每一個函式裡各自決定「這個錯誤要怎麼回應」。例如在 Express.js 這類後端框架裡,可以在所有路由的最後面掛上一個專門處理錯誤的 middleware,任何路由裡沒有被攔截的錯誤,最終都會落到這裡統一處理。
好處:錯誤處理的邏輯不會散落在整個程式碼裡,回應格式、記錄 log 的方式都能保持一致,日後要調整錯誤回應的規則時,也只需要改一個地方。
定義:除了 JavaScript 內建的 Error 物件,可以自己定義繼承自 Error 的子類別,讓錯誤本身帶有更多語意(例如是「庫存不足」還是「驗證失敗」),而不是只有一句籠統的錯誤訊息字串。
好處:上層攔截到錯誤時,可以根據錯誤的「類型」決定不同的處理方式與對應的 HTTP 狀態碼,而不是用字串比對錯誤訊息內容這種脆弱、容易失效的做法。
定義:當系統的某一部分功能發生錯誤或暫時無法使用時,不讓整個系統因此癱瘓,而是退回到一個仍然可以運作、只是功能較少的狀態,讓使用者至少還能完成核心操作。
常見做法:搭配 Feature Flag,在偵測到某個服務異常時,自動關閉依賴該服務的次要功能,但保留核心功能持續運作,等該服務恢復後再重新開啟。
延續 Day 18 的訂單儲存範例,加上通知服務失敗時的處理。情境:買家送出訂單後,系統先把訂單寫入資料庫(核心功能),再嘗試發送 LINE 通知(次要功能)。如果 LINE API 當下故障,不應該讓整筆訂單建立的請求因此失敗。
// errors/AppError.js —— 自訂錯誤類別,帶有語意與狀態碼
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = this.constructor.name;
}
}
class OutOfStockError extends AppError {
constructor(itemId) {
super(`商品 ${itemId} 庫存不足`, 409);
}
}
// services/orderService.js —— 核心功能與次要功能分開處理
async function createOrder(input, stock) {
if (input.quantity > stock) {
throw new OutOfStockError(input.itemId); // 核心驗證失敗,直接拋出讓上層攔截
}
const order = await orderRepository.save(input); // 核心功能
try {
await notifier.notify(order.buyerId, '訂單已成立'); // 次要功能
} catch (err) {
console.error('通知發送失敗,但訂單已成立', err); // 優雅降級:記錄下來,但不中斷主流程
}
return order;
}
// app.js —— 全域異常攔截,統一決定怎麼回應
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error(err);
res.status(500).json({ error: '系統發生未知錯誤' });
});
在這個例子裡,OutOfStockError 是核心流程的錯誤,直接往外拋、交給全域攔截統一處理;而通知發送失敗屬於次要功能,用區域的 try-catch 接住、記錄下來,讓訂單建立這件核心任務照常完成——這就是優雅降級。
全域攔截讓「怎麼回應錯誤」這件事集中管理,自訂 Error 類別讓錯誤本身「帶有意義」,優雅降級則確保次要功能的故障不會拖垮核心流程——三者合起來,才是一套面對真實世界裡各種故障時,不會一次性癱瘓的錯誤處理架構。