創建型五天王告一段落,今天我們正式進入結構型 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.
用中文理解可以是
動態地為一個物件附加額外的職責,裝飾器提供了比繼承更有彈性的方式,來擴充物件的功能
核心結構與觀念
舉個生活化的例子,包禮物
一份禮物本身就能送人,但想讓它更體面,可以先包上包裝紙,再綁上緞帶,包裝紙、緞帶都不會動到禮物本身,只是在外面多包一層,而且想加幾層都行
// 呼叫端在乎的介面:能描述自己、能算出價錢
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 從頭到尾沒被改過,WrappingPaperDecorator、RibbonDecorator 也各自只知道自己要加什麼
包了幾層、包的順序,都是呼叫端當下決定的
這種感覺有點像是俄羅斯娃娃的概念
呼叫端 (Client)
│ 1. 呼叫 Describe()
▼
[ RibbonDecorator ] -----> 最外層娃娃
│
│ 2. 向內問: _gift.Describe()
▼
[ WrappingPaperDecorator ] -----> 中間娃娃
│
│ 3. 向內問: _gift.Describe()
▼
[ BasicGift ] -----> 最內層娃娃
│
│ 4. 回傳 "一份禮物"
▼
[ WrappingPaperDecorator ]
│
│ 5. 加工並回傳: "一份禮物,包上包裝紙"
▼
[ RibbonDecorator ]
│
│ 6. 加工並回傳
▼
string result = gift.Describe(); //一份禮物,包上包裝紙,綁上緞帶"
我也花了好久才慢慢領悟其中的道理,回到 Decorator 的本質
小黑最近開始在跟 Qber Eats、foodDog 談合作,對方業務窗口丟來一份上架檢查清單,其中一條寫著:「商家須具備開立電子發票能力」
馬上找到了阿柴(畢竟阿柴使命必達): 「 柴哥,幫我新增一個建立電子發票的功能,還有我也希望扣款成功後系統能自動把這筆訂單同步進雲端 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
阿柴先確認呼叫端在乎的介面,只有付款本身:傳訂單資訊,拿到 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 一起測
阿柴一開始以為兩個裝飾器誰先誰後 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 轉接器上場
[Day15] Design Pattern - Decorator裝飾者模式