錯誤不是你的敵人,忽略它才是。
程式碼在實際情況裡運行,就一定會出錯。
網路會斷、硬碟會滿、使用者會輸入亂七八糟的東西。
錯誤處理的目的,就是讓系統在混亂中依然能健壯且可預測地運行。
一個函式的合約只有兩個結果:成功完成它的工作,或者清楚地報告它為什麼失敗。
沒有第三個選項。
// 🔴 掩蓋問題的臭味道
try {
doSomethingCritical();
} catch (e) {
// 這個空白,是留給未來維護者的定時炸彈
}
這就像車子的引擎警示燈亮了,卻用黑色膠帶把它貼起來。
問題還在,只是從「已知」變成「未知」,隨時可能引爆。
捕捉到一個錯誤,只有兩種選擇。
所謂「處理」,是指你能從錯誤中完全恢復,讓函式繼續成功完成任務。
重試:網路請求失敗?也許再試一次就好。
使用備案:讀取設定檔失敗?改用一個安全的預設值。
// 🟡 這才叫「處理」:我們從錯誤中恢復了。
function getConfig() {
try {
return JSON.parse(fs.readFileSync('/path/to/config.json'));
} catch (e) {
// 讀取失敗,沒關係,我們有備案,函式依然能成功。
log.warn('Config file failed to load, using default values.');
return { timeout: 5000, retries: 3 }; // 回傳預設值
}
}
如果無法完全恢復,那就不叫「處理」,那叫「掩蓋錯誤」。
當你的函式沒有足夠的資訊來處理錯誤時,你的責任就是立即將問題報告給更高層的呼叫者。
一個好的拋出,不只是 throw e;
,而是會為錯誤增添價值:
記錄它 (Log):留下詳細的堆疊追蹤和相關變數,這是未來除錯的唯一線索。
包裝它 (Wrap):將低階、模糊的錯誤包裝成有業務意義的高階錯誤( PaymentGatewayError
)。
這能建立清晰的抽象邊界,避免底層實作細節洩漏。
// 🟢 好味道:我處理不了,但我會清楚地報告一個有意義的問題
// 定義一個自訂的、有業務意義的錯誤類型
class PaymentGatewayError extends Error {
constructor(message, cause) {
super(message);
this.name = 'PaymentGatewayError';
this.cause = cause; // 保留原始錯誤,方便追蹤
}
}
async function chargeCustomer(customerId, amount) {
try {
// 在 async 函式中,若 Promise 沒有後續處理,可直接回傳
return paymentGateway.charge(amount);
} catch (e) {
// 1. 記錄詳細資訊,給維護者看
log.error(`Payment gateway failed for customer ${customerId}`, { error: e });
// 2. 拋出一個新的、有意義的錯誤,給呼叫者處理
throw new PaymentGatewayError('Failed to charge customer.', e);
}
}
finally
的角色無論 try
區塊是成功還是失敗,有些事必定要執行,常用在「資源的釋放」,這就是 finally
的用途。
// 🟢 好味道的收尾工作
const connection = database.connect();
try {
// 使用 connection 進行資料庫操作
runQuery(connection);
} catch (e) {
log.error("Database query failed", e);
throw e; // 繼續向上拋出
} finally {
// 無論成功或失敗,這個區塊都會執行
connection.close(); // 確保資料庫連線被關閉,避免資源洩漏
}
使用 finally
是確保清理工作的經典做法。
不過,針對「資源生命週期管理」這個特定問題,還有更優雅、更專門的模式,我們將在下一篇文章深入探討。
「處理或拋出」的決策,往往取決於你程式碼所在的架構層次。
非預期的異常 (Exceptions):系統故障,資料庫斷線、網路中斷,使用 throw
來處理,並讓系統快速失敗。
可預期的失敗 (Return Values):業務規則的失敗,使用者輸入驗證錯誤。將「失敗」作為函式正常回傳的一部分。
// 🟢 好味道:函式清楚地承諾,它的回傳值會告訴你成功或失敗。
function validateUsername(name) {
if (!name || name.length < 3) {
// 這不是系統異常,是業務規則的正常分支
return { ok: false, error: 'username_too_short' };
}
return { ok: true, value: name };
}
當整個團隊都遵循類似的 { ok, error, value }
格式時,你們就建立了一套一致的錯誤處理協議,大大降低了溝通成本。
對於某些致命錯誤,例如應用程式啟動時無法連上資料庫、或是讀不到必要的設定檔,最好的策略不是「硬撐」,而是「快速失敗」。
讓程式立即崩潰聽起來很可怕,但它通常是最安全的選擇。
因為在這種核心元件失效的情況下,系統的狀態已經變得不可靠。
如果讓它繼續「跛行」,它可能會在錯誤的狀態下處理請求,進而導致資料汙染、計算錯誤或更嚴重的連鎖反應。
一個乾淨的、有著清晰錯誤日誌的崩潰,遠比一個在默默汙染數據的「殭屍」系統要好得多。
絕不隱藏錯誤:空的 catch
是魔鬼。
處理或拋出:一個函式的責任只有兩個:完成工作,或者清楚說明為什麼它做不到。
使用 finally
清理資源:確保連線、檔案等資源在任何情況下都能被釋放。
區分情境:用回傳值處理可預期的失敗(業務規則),用例外處理非預期的異常(系統故障)。
擁抱快速失敗:對於讓系統無法正常運作的致命錯誤,讓它立即、大聲地崩潰是最負責任的作法,這能防止更嚴重的後果。
我的程式碼裡是否有空的 catch
區塊?
我拋出的錯誤是否包含了足夠的上下文?是低階細節還是高階的業務錯誤?
我是否為團隊建立了一致的錯誤回傳格式?
當捕捉到錯誤時,我是否有必要的觀測手段(Log/Metrics/Trace)?
對於致命錯誤(如資料庫連線失敗),我的系統是會跛行還是快速失敗?
把錯誤處理做對,讓系統更可預期,這就是在消除你程式碼的臭味。