Theme / v4.9.0

Ponytail

讓 AI 編碼代理像最懶的資深工程師

實戰範例

實戰範例 005:剝除裝飾:簡化過度設計的日誌與異常處理鏈

分析過度使用裝飾器 (Decorator) 模式造成的程式碼閱讀困難,並示範如何以直白、易懂的方式處理日誌與異常。

實戰範例 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));

重構亮點分析

  1. 所見即所得的執行順序:不再需要猜測裝飾器的執行順序。由上往下讀,先驗證參數、記錄開始時間、進入重試迴圈、捕獲異常,一目了然。
  2. 完整保留 Stack Trace:在最後一次失敗時,我們選擇直接 throw error;,而不是拋出一個模糊的新字串。這使得除錯時可以直接定位到 performNetworkRequest 內部發生問題的程式碼行數。
  3. 靈活的細節控制:在直白的程式碼中,我們能輕易加入「指數退避 (Exponential Backoff)」等待機制 (setTimeout),這在死板的裝飾器中往往難以優雅地實作。
  4. 降低新人學習曲線:任何懂基礎 JavaScript/TypeScript 的開發者都能瞬間看懂這段程式碼,不需要去研究 PropertyDescriptor 的運作原理或 apply(this, args) 的語法。

效益分析表格與解讀

下表量化了此次重構帶來的長期效益:

評估維度 裝飾器層疊模式 (過度設計) 直白控制流 (Ponytail) 改善程度 / 解讀
可讀性 差 (邏輯散落於多個檔案與函式中) 極優 (邏輯集中,線性閱讀) 消除認知切換。開發者不需在多個裝飾器定義檔之間來回跳轉。
錯誤追蹤 困難 (原始異常易被吞噬) 簡單 (保留完整堆疊資訊) 顯著降低修復線上 Bug 所花費的時間 (MTTR)。
型別推斷 易流失 (any[] 滿天飛) 嚴謹 (完美保留 TypeScript 推斷) 裝飾器中常見的 ...args: any[] 破壞了型別安全,重構後全面恢復。
擴充彈性 低 (需修改通用裝飾器,易影響其他模組) 高 (可隨意針對單一方法調整重試或日誌邏輯) 增加如「針對特定錯誤碼不重試」等條件變得極為容易。

結論總結

技術的價值在於解決問題,而不是炫耀語法。裝飾器 (Decorators) 在某些特定的框架(如 Angular 或 NestJS 的路由標測)中有其存在價值,但絕不應該被當作隱藏基礎控制流的遮羞布。

Ponytail 的極簡主義告訴我們:好的程式碼就像一篇敘事清晰的文章。它不需要花俏的修辭(黑魔法語法),只需要直白、誠實地陳述事實(指令式邏輯)。透過剝除那些只為「看起來很酷」而存在的裝飾器,我們贏回了程式碼的透明度、可維護性,以及工程師在深夜除錯時的心理健康。