實戰範例 005:剝除裝飾:簡化過度設計的日誌與異常處理鏈
簡介與核心精神
在 TypeScript 或 Python 等支援裝飾器 (Decorator Pattern) 的語言中,開發者往往會被這種看似優雅的語法糖所吸引。裝飾器最初的目的是為了解決橫切關注點 (Cross-Cutting Concerns) 的問題,例如日誌記錄 (Logging)、權限校驗 (Authorization) 或是效能監控 (Performance Monitoring)。
然而,當這種模式被濫用時,災難就此發生。在 Ponytail 的極簡主義視角中,「黑魔法語法往往伴隨著極高的認知負擔」。當一個簡單的方法上方疊加了五、六個裝飾器時,開發者已經無法直觀地看出這個方法到底發生了什麼事。異常是在哪裡被捕獲的?日誌的層級是什麼?如果其中一個裝飾器發生了非預期的錯誤,整個呼叫堆疊 (Stack Trace) 會變得如同迷宮一般難以解讀。
本實戰範例探討了一個常見的過度設計場景:利用層層疊疊的裝飾器來自動處理日誌記錄與異常重試。我們將示範如何剝除這些華而不實的「裝飾」,回歸最基礎、最直白的 try...catch 區塊,讓程式碼重新變得清晰且易於維護。
實際場景描述
在一個金融數據分析的後台系統中,有一個負責從外部 API 抓取匯率資料的 ExchangeRateService。前輩工程師為追求「程式碼極致的乾淨」,決定將所有的非業務邏輯(如日誌記錄、效能計時、錯誤重試)全部抽離成裝飾器。
於是,原本應該清晰明瞭的抓取函式,變成了一個只剩下一行程式碼的空殼,上面卻頂著一座由裝飾器堆砌而成的高塔。當外部 API 偶爾回傳 500 錯誤時,團隊成員發現他們根本無法從凌亂的日誌中找到真正的錯誤原因,因為裝飾器內部吞噬 (Swallow) 並重新包裝了異常。
原始的過度設計代碼
讓我們來看看這段充滿「設計感」但難以除錯的原始程式碼。
// 檔案:src/utils/decorators.ts
export function LogExecutionTime() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = Date.now();
const result = await originalMethod.apply(this, args);
const finish = Date.now();
console.log(`[Timer] ${propertyKey} executed in ${finish - start} milliseconds`);
return result;
};
return descriptor;
};
}
export function LogParametersAndResult() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
console.log(`[Input] ${propertyKey} called with:`, JSON.stringify(args));
const result = await originalMethod.apply(this, args);
console.log(`[Output] ${propertyKey} returned:`, JSON.stringify(result));
return result;
};
return descriptor;
};
}
export function RetryOnFailure(retries: number = 3) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
let attempt = 0;
while (attempt < retries) {
try {
return await originalMethod.apply(this, args);
} catch (error: any) {
attempt++;
console.warn(`[Retry] ${propertyKey} failed on attempt ${attempt}. Retrying...`);
if (attempt >= retries) {
console.error(`[Error] ${propertyKey} failed after ${retries} attempts.`);
// 將原始異常吞噬,拋出一個模糊的新異常
throw new Error(`Execution of ${propertyKey} failed.`);
}
}
}
};
return descriptor;
};
}
// 檔案:src/services/ExchangeRateService.ts
import { LogExecutionTime, LogParametersAndResult, RetryOnFailure } from '../utils/decorators';
export class ExchangeRateService {
// 疊床架屋的裝飾器塔
@LogExecutionTime()
@LogParametersAndResult()
@RetryOnFailure(3)
public async fetchDailyRate(currencyCode: string): Promise<number> {
// 真正的業務邏輯只有寥寥幾行
console.log(`Fetching rate for ${currencyCode} from external API...`);
// 模擬外部 API 呼叫,隨機失敗
if (Math.random() < 0.5) {
throw new Error("Network timeout or 500 Internal Server Error");
}
return 30.5; // 模擬回傳固定匯率
}
}
在這段程式碼中,裝飾器的執行順序非常難以一眼看穿(在 TypeScript 中,工廠函式由上往下評估,但實際執行是由下往上)。更嚴重的是,RetryOnFailure 裝飾器在達到最大重試次數後,拋出了一個全新的 Error 物件,導致原始的 Network timeout 錯誤堆疊遺失。開發者在排查線上問題時,只會看到一句毫無幫助的 Execution of fetchDailyRate failed.。
開發者與 AI 的對話記錄
面對這個難以除錯的系統,開發者求助於崇尚極簡主義的 AI 代理 (Ponytail)。
開發者:
「AI,我們的匯率抓取服務最近一直報錯,但我看 CloudWatch 上的日誌,只寫著
Execution of fetchDailyRate failed.,我完全不知道是網路超時、API 金鑰失效還是對方伺服器掛了。我打算寫一個新的@DetailedErrorLog裝飾器疊加上去,幫我把原始錯誤印出來,你覺得呢?」
AI (Ponytail 模式):
「停。這正是典型的『用更多魔法來修補魔法造成的破洞』。你現在的問題不是缺少日誌,而是過度依賴裝飾器導致控制流 (Control Flow) 變得極度不透明。
為什麼你需要把最基本的錯誤處理和重試邏輯藏在一個
descriptor.value.apply()的黑盒子裡面?裝飾器把程式碼的執行順序隱藏了起來,還順便吃掉了你最重要的例外呼叫堆疊 (Stack Trace)。」
開發者:
「可是前輩說這樣可以保持
fetchDailyRate的『純粹性』啊!你看,這個方法裡面只有純粹的業務邏輯,多乾淨!」
AI (Ponytail 模式):
「『純粹性』不應該建立在犧牲『可讀性』與『可除錯性』之上。在 Ponytail 的哲學裡,明示勝於暗示 (Explicit is better than implicit)。
重試機制、錯誤捕獲和計時,對於網路請求來說,本來就是其『業務邏輯』不可或缺的一部分。把它們寫清楚,用簡單的迴圈和
try...catch,讓下一個看這段程式碼的人能夠一眼看懂資料是怎麼流動的,發生錯誤時是怎麼處理的。我們不需要@LogExecutionTime,我們需要的是樸實無華的console.log和標準的異常拋出。」
開發者:
「好…那如果不使用裝飾器,這段程式碼不會變得又臭又長嗎?」
AI (Ponytail 模式):
「我們來動手重構。把所有的裝飾器砍掉,我們將會發現,直白的程式碼不僅沒有比較長,反而邏輯更加緊湊,且具備完美的型別安全與錯誤追蹤能力。」
重構與優化:回歸直白的流程控制
我們將廢棄所有的自訂裝飾器,將日誌、計時與重試邏輯以最清晰的指令式 (Imperative) 語法直接寫在方法內部。同時,我們確保完整的保留了 Agent Reach 強調的安全防護與錯誤細節。
// 檔案:src/services/ExchangeRateService.ts
export class ExchangeRateService {
public async fetchDailyRate(currencyCode: string): Promise<number> {
// 邊界檢查:確保輸入合法
if (!currencyCode || currencyCode.trim() === '') {
throw new Error("Currency code must be provided.");
}
const maxRetries = 3;
const startTime = Date.now();
console.log(`[Input] fetchDailyRate called with currency: ${currencyCode}`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[Attempt ${attempt}] Fetching rate for ${currencyCode}...`);
// 模擬外部 API 呼叫,可能發生錯誤
const rate = await this.performNetworkRequest(currencyCode);
const executionTime = Date.now() - startTime;
console.log(`[Output] fetchDailyRate returned: ${rate} (took ${executionTime}ms)`);
return rate;
} catch (error: any) {
console.warn(`[Retry] fetchDailyRate failed on attempt ${attempt}: ${error.message}`);
if (attempt === maxRetries) {
console.error(`[Error] fetchDailyRate completely failed after ${maxRetries} attempts.`);
// 關鍵改進:保留原始錯誤,並可選擇附加額外上下文
// 這裡直接拋出原始錯誤,保留完整的 Stack Trace
throw error;
}
// 簡單的退避 (Backoff) 機制,防止過度頻繁請求
await new Promise(res => setTimeout(res, 500 * attempt));
}
}
// 理論上不會執行到這裡,但為了 TypeScript 的完整性回傳預設錯誤
throw new Error("Unexpected end of retry loop.");
}
// 將真正的網路請求隔離成私有方法,保持職責單一
private async performNetworkRequest(currencyCode: string): Promise<number> {
if (Math.random() < 0.5) {
throw new Error("Network timeout or 500 Internal Server Error");
}
return 30.5;
}
}
// 檔案:src/index.ts
const service = new ExchangeRateService();
// 調用者能清楚捕獲並看到真正的錯誤原因
service.fetchDailyRate('USD')
.then(rate => console.log(`Success: ${rate}`))
.catch(err => console.error("Final catch in main:", err.stack));
重構亮點分析
- 所見即所得的執行順序:不再需要猜測裝飾器的執行順序。由上往下讀,先驗證參數、記錄開始時間、進入重試迴圈、捕獲異常,一目了然。
- 完整保留 Stack Trace:在最後一次失敗時,我們選擇直接
throw error;,而不是拋出一個模糊的新字串。這使得除錯時可以直接定位到performNetworkRequest內部發生問題的程式碼行數。 - 靈活的細節控制:在直白的程式碼中,我們能輕易加入「指數退避 (Exponential Backoff)」等待機制 (
setTimeout),這在死板的裝飾器中往往難以優雅地實作。 - 降低新人學習曲線:任何懂基礎 JavaScript/TypeScript 的開發者都能瞬間看懂這段程式碼,不需要去研究
PropertyDescriptor的運作原理或apply(this, args)的語法。
效益分析表格與解讀
下表量化了此次重構帶來的長期效益:
| 評估維度 | 裝飾器層疊模式 (過度設計) | 直白控制流 (Ponytail) | 改善程度 / 解讀 |
|---|---|---|---|
| 可讀性 | 差 (邏輯散落於多個檔案與函式中) | 極優 (邏輯集中,線性閱讀) | 消除認知切換。開發者不需在多個裝飾器定義檔之間來回跳轉。 |
| 錯誤追蹤 | 困難 (原始異常易被吞噬) | 簡單 (保留完整堆疊資訊) | 顯著降低修復線上 Bug 所花費的時間 (MTTR)。 |
| 型別推斷 | 易流失 (any[] 滿天飛) |
嚴謹 (完美保留 TypeScript 推斷) | 裝飾器中常見的 ...args: any[] 破壞了型別安全,重構後全面恢復。 |
| 擴充彈性 | 低 (需修改通用裝飾器,易影響其他模組) | 高 (可隨意針對單一方法調整重試或日誌邏輯) | 增加如「針對特定錯誤碼不重試」等條件變得極為容易。 |
結論總結
技術的價值在於解決問題,而不是炫耀語法。裝飾器 (Decorators) 在某些特定的框架(如 Angular 或 NestJS 的路由標測)中有其存在價值,但絕不應該被當作隱藏基礎控制流的遮羞布。
Ponytail 的極簡主義告訴我們:好的程式碼就像一篇敘事清晰的文章。它不需要花俏的修辭(黑魔法語法),只需要直白、誠實地陳述事實(指令式邏輯)。透過剝除那些只為「看起來很酷」而存在的裝飾器,我們贏回了程式碼的透明度、可維護性,以及工程師在深夜除錯時的心理健康。