iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 15

Day 15|Decorator(裝飾器模式) 解開扣款後續任務的組合難題

  • 分享至 

  • xImage
  •  

創建型五天王告一段落,今天我們正式進入結構型 Pattern(Structural)

第一個要來聊的是 Decorator 裝飾器模式

原文的定義,一樣來自 Design Patterns: Elements of Reusable Object-Oriented Software

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

用中文理解可以是

動態地為一個物件附加額外的職責,裝飾器提供了比繼承更有彈性的方式,來擴充物件的功能

核心結構與觀念

  1. 不改動原始物件:想加功能,不用回頭改原本的類別,也不用開一個子類別去繼承
  2. 一層包一層:裝飾器把原本的物件包起來,自己也實作跟原物件相同的介面,呼叫端分不出來現在拿到的是本尊還是被包過的版本
  3. 依需求自由疊加:呼叫時「由外而內」傳遞,回傳時「由內而外」加工,包裝的先後順序會直接決定邏輯的執行時序

舉個生活化的例子,包禮物

一份禮物本身就能送人,但想讓它更體面,可以先包上包裝紙,再綁上緞帶,包裝紙、緞帶都不會動到禮物本身,只是在外面多包一層,而且想加幾層都行

// 呼叫端在乎的介面:能描述自己、能算出價錢
public interface IGift
{
    string Describe();
    int Price { get; }
}

// 本體:一份最單純的禮物
public class BasicGift : IGift
{
    public string Describe() => "一份禮物";
    public int Price => 200;
}

// 不能 new 的抽象骨架(abstract class),嚴格遵守禮物的合約(實作 IGift)
public abstract class GiftDecorator : IGift
{
    protected readonly IGift _gift;

    protected GiftDecorator(IGift gift)
    {
        _gift = gift;
    }

    // 預設行為:直接轉交給被包裝的對象
    public virtual string Describe() => _gift.Describe();
    public virtual int Price => _gift.Price;
}

// 具體裝飾器:各自只負責「加一件事」,其他都轉呼叫被包住的物件
public class WrappingPaperDecorator : GiftDecorator
{
    public WrappingPaperDecorator(IGift gift) : base(gift) { }

    public override string Describe() => $"{_gift.Describe()},包上包裝紙";
    public override int Price => _gift.Price + 30;
}

public class RibbonDecorator : GiftDecorator
{
    public RibbonDecorator(IGift gift) : base(gift) { }

    public override string Describe() => $"{_gift.Describe()},綁上緞帶";
    public override int Price => _gift.Price + 20;
}

呼叫端這樣用

// 這裡說順序很重要,因為沒辦法先綁緞帶再包上包裝紙
IGift gift = new RibbonDecorator(new WrappingPaperDecorator(new BasicGift()));

Console.WriteLine($"{gift.Describe()},{gift.Price} 元");
// 一份禮物,包上包裝紙,綁上緞帶,250 元

BasicGift 從頭到尾沒被改過,WrappingPaperDecoratorRibbonDecorator 也各自只知道自己要加什麼

包了幾層、包的順序,都是呼叫端當下決定的

這種感覺有點像是俄羅斯娃娃的概念

  • 最內層核心:BasicGift(實作了 IGift 型別也是)
  • 中間層外殼:WrappingPaperDecorator(型別也是 IGift)
  • 最外層外殼:RibbonDecorator(型別也是 IGift)
呼叫端 (Client)
  │ 1. 呼叫 Describe()
  ▼
[ RibbonDecorator ]                   -----> 最外層娃娃
      │
      │ 2. 向內問: _gift.Describe()
      ▼
  [ WrappingPaperDecorator ]          -----> 中間娃娃
        │
        │ 3. 向內問: _gift.Describe()
        ▼
    [ BasicGift ]                     -----> 最內層娃娃
          │
          │ 4. 回傳 "一份禮物"
          ▼
  [ WrappingPaperDecorator ]
      │
      │ 5. 加工並回傳: "一份禮物,包上包裝紙"
      ▼
[ RibbonDecorator ]
    │
    │ 6. 加工並回傳
    ▼
string result = gift.Describe();  //一份禮物,包上包裝紙,綁上緞帶"

我也花了好久才慢慢領悟其中的道理,回到 Decorator 的本質

  1. 不改動原始物件: 沒有動到原始的 BasicGift ✅
  2. 一層包一層:包裝紙、緞帶都各自包住前一層,而且都實作同一個 IGift 介面,呼叫端分不出現在拿到的是本尊還是被包過的版本 ✅
  3. 依需求自由疊加:新增了包裝紙、新增了綁緞帶,客人可以決定要包裝紙還是緞帶,順序、層數都能自由組合 ✅

回到柴咖啡

小黑最近開始在跟 Qber Eats、foodDog 談合作,對方業務窗口丟來一份上架檢查清單,其中一條寫著:「商家須具備開立電子發票能力」

馬上找到了阿柴(畢竟阿柴使命必達): 「 柴哥,幫我新增一個建立電子發票的功能,還有我也希望扣款成功後系統能自動把這筆訂單同步進雲端 Excel 報表,我開手機就看得到」

我們來看一下需求:

  1. 開立電子發票
  2. 把訂單同步寫進雲端 Excel 報表

阿柴翻開付款程式碼,裡面只有扣款邏輯,沒有開發票、也沒有同步報表的程式碼

public class PaymentResult
{
    public bool Success { get; set; }
    public string TransactionId { get; set; }
}

public class PaymentService
{
    public PaymentResult Charge(string orderId, int amount)
    {
        // 呼叫金流 API 進行扣款...
        Console.WriteLine($"訂單 {orderId} 扣款 {amount} 元成功");
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

Decorator 上場

阿柴學習了 Decorator ,看了這兩個需求,決定各自包成一層 Decorator

阿柴先確認呼叫端在乎的介面,只有付款本身:傳訂單資訊,拿到 PaymentResult,接著讓原本的 PaymentService 掛上這個介面,內部邏輯不用動

public interface IPaymentProcessor
{
    PaymentResult Charge(string orderId, int amount);
}

public class PaymentService : IPaymentProcessor
{
    public PaymentResult Charge(string orderId, int amount)
    {
        Console.WriteLine($"訂單 {orderId} 扣款 {amount} 元成功");
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

PaymentResult 多留一個欄位,讓「開發票」這個動作可以把發票號碼記回去,之後其他裝飾器才拿得到

public class PaymentResult
{
    public bool Success { get; set; }
    public string TransactionId { get; set; }
    public string InvoiceNumber { get; set; } // 開立發票後才會有值
}

接著加一層裝飾器基底,長得跟 IPaymentProcessor 一樣,但內部包著另一個 IPaymentProcessor

public abstract class PaymentDecorator : IPaymentProcessor
{
    protected readonly IPaymentProcessor _processor;

    protected PaymentDecorator(IPaymentProcessor processor)
    {
        _processor = processor;
    }

    public virtual PaymentResult Charge(string orderId, int amount) => _processor.Charge(orderId, amount);
}

「開發票」「同步雲端報表」各自寫一個裝飾器,只管自己的事,其他一律轉呼叫被包住的付款流程

public class InvoiceDecorator : PaymentDecorator
{
    public InvoiceDecorator(IPaymentProcessor processor) : base(processor) { }

    public override PaymentResult Charge(string orderId, int amount)

    {
        // 1. 去程(由外向內):先呼叫內層,直達核心扣款
        var result = _processor.Charge(orderId, amount);

        // 2. 回程(由內向外):拿到扣款結果後,開始加工
        if (result.Success)
        {
            result.InvoiceNumber = $"INV-{DateTime.Now:yyyyMMdd}-{orderId}";
            Console.WriteLine($"已開立發票:{result.InvoiceNumber}");
        }
        return result;
    }
}

public class SalesReportDecorator : PaymentDecorator
{
    public SalesReportDecorator(IPaymentProcessor processor) : base(processor) { }

    public override PaymentResult Charge(string orderId, int amount)
    {
        // 1. 去程(由外向內):先呼叫內層
        var result = _processor.Charge(orderId, amount);

        // 2. 回程(由內向外):拿到扣款結果後,同步報表
        if (result.Success)
        {
            // 假設這裡會呼叫 Google Sheets API
            Console.WriteLine($"[雲端報表] 訂單 {orderId},金額 {amount} 元,發票號碼:{result.InvoiceNumber ?? "(無)"}");
        }
        return result;
    }
}

而在實務情況下不會手動 new 出這條鏈,是在啟動時用 DI 容器註冊,所以阿柴加裝了 Scrutor 這個套件來補上這塊

// Program.cs
builder.Services.AddScoped<IPaymentProcessor, PaymentService>();

// 呼叫順序(由外到內,呼叫端觸發的方向):SalesReportDecorator → InvoiceDecorator → PaymentService
// 處理順序(由內到外,回傳時才輪到):PaymentService → InvoiceDecorator → SalesReportDecorator

// 1. 先包 InvoiceDecorator(會放在內層):離核心最近,扣款一結束、回傳時最先輪到它處理,填上發票號碼
builder.Services.Decorate<IPaymentProcessor, InvoiceDecorator>();

// 2. 後包 SalesReportDecorator(在外層):呼叫端會先呼叫到它,但要等內層都處理完、發票開好了,回傳到它手上才輪到它印報表
builder.Services.Decorate<IPaymentProcessor, SalesReportDecorator>();

呼叫端(例如結帳用的 CheckoutController)只透過建構子拿 IPaymentProcessor,也不需要知道背後包了幾層

public class CheckoutController
{
    private readonly IPaymentProcessor _paymentProcessor;

    public CheckoutController(IPaymentProcessor paymentProcessor)
    {
        _paymentProcessor = paymentProcessor; // DI 容器注入進來的,已經是包好兩層的完整鏈
    }

    public void Checkout(string orderId, int amount)
    {
        _paymentProcessor.Charge(orderId, amount);
        // 已開立發票:INV-20260903-A001
        // [雲端報表] 訂單 A001,金額 150 元,發票號碼:INV-20260903-A001
    }
}

而且現在 InvoiceDecorator 自己就是一個獨立的類別,想單獨驗證「發票號碼格式對不對」,包一個假的 IPaymentProcessor 進去測試就好,不用每次都連著扣款、呼叫雲端 API 一起測

Decorator 的取捨:疊加順序,會影響結果

阿柴一開始以為兩個裝飾器誰先誰後 Decorate 都沒差,直到他把 Program.cs 裡的註冊順序寫反

// 順序顛倒:SalesReportDecorator 包在內層,InvoiceDecorator 包在外層

builder.Services.AddScoped<IPaymentProcessor, PaymentService>();

// 呼叫順序(由外到內):InvoiceDecorator → SalesReportDecorator → PaymentService
// 處理順序(由內到外,回傳時才輪到):PaymentService → SalesReportDecorator → InvoiceDecorator

builder.Services.Decorate<IPaymentProcessor, SalesReportDecorator>(); // 內層:離核心最近,扣款完第一個輪到它處理
builder.Services.Decorate<IPaymentProcessor, InvoiceDecorator>();     // 外層:呼叫端最先呼叫到它,但處理順序上最後才輪到它
// CheckoutController 沒有改動,但這次 Charge() 印出來的結果變成:

// 執行結果:
// [雲端報表] 訂單 A002,金額 150 元,發票號碼:(無)  ← 太早跑了!
// 已開立發票:INV-20260903-A002  ← 這時候才開出來,太遲了

呼叫端呼叫 .Charge() 時,最先進入方法本體的是外層的 InvoiceDecorator,它會先把呼叫轉給內層的 SalesReportDecorator,最後才到 PaymentService 完成扣款——這是呼叫順序,由外到內

但實際處理、印訊息的順序是反過來的:PaymentService 扣款完成後,往回傳的路上先輪到內層的 SalesReportDecorator 處理(這時候發票號碼還沒被寫進 PaymentResult),最後才輪到外層的 InvoiceDecorator 處理並補上發票號碼——這是處理順序,由內到外

同步報表那一刻,發票號碼根本還沒被寫進 PaymentResult,因為 InvoiceDecorator 這時候都還沒輪到它處理

這是 Decorator 疊加時容易忽略的一點:每個裝飾器只管自己要做的事,但疊的順序決定了「誰先誰後」,如果裝飾器之間有先後依賴,順序排錯,結果就錯了

用組合,不是繼承:裝飾器是在建構子把原本的物件包進來(組合),不是去繼承 PaymentService 再開一個 PaymentWithInvoiceAndReport 子類別(繼承)

今天學到的事

Decorator 要解決的問題:

在不修改原始類別程式碼的前提下,於執行時期動態地為物件彈性擴充新功能的問題

優點

  • 不用改動原始物件,新增一個職責只要多寫一個裝飾器類別,符合開放封閉原則
  • 職責可以任意組合、任意順序疊加,甚至同一個裝飾器可以重複疊好幾層
  • 每個裝飾器只做一件事,彼此獨立,可以單獨測試、單獨維護

缺點

  • 裝飾器間如果存在先後依賴,這個依賴只存在於「組裝順序」裡,型別系統不會檢查、編譯期和執行期都不會報錯,排除較為困難
  • 疊的層數一多,一次呼叫實際會經過哪些裝飾器、依序做了什麼,會比看一個扁平的方法更難一眼看穿

明天,小黑正式要跟 Qber Eats、foodDog 談的外送合作要上線了

柴咖啡內部的訂單格式跟這些平台傳來的資料完全對不上,這種「介面不相容,卻需要合作」的情境,就換 Adapter 轉接器上場

參考資料

Refactoring Guru - Decorator

[Day15] Design Pattern - Decorator裝飾者模式


上一篇
Day 14|創建型 Pattern 總結對比
下一篇
Day 16|Adapter (轉接器模式) 用萬國轉接頭收服規格打架
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言