結構型 Pattern 我們到 Day21 的 Flyweight 告一段落
接下來我們繼續進入行為型 Pattern(Behavioral)(灑花)
柴咖啡的故事,也陸續走到連鎖店規模化的階段,那我們第一個要來聊的是 Strategy 策略模式
原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
用中文理解可以是
定義一系列演算法,將每一個演算法封裝起來,並使它們可以互相替換。策略模式讓演算法的變動獨立於使用它的客戶端
核心角色
我們來看看 UML 圖
舉個生活化的例子,我是路痴,出門吃個飯幾乎都要導航,所以最常用導航 App
從我家當起點到餐廳終點,App 可以幫你規劃開車路線、大眾運輸路線、或步行路線,這三種規劃邏輯完全不一樣
但你使用 App 的方式(輸入起點終點、按下規劃)從頭到尾沒變過
// 1. Strategy(共同介面)
public interface IRouteStrategy
{
void BuildRoute(string from, string to);
}
// 2. ConcreteStrategy:各自是一種路線規劃的演算法
public class DrivingStrategy : IRouteStrategy // 開車演算法
{
public void BuildRoute(string from, string to) => Console.WriteLine($"規劃開車路線:{from} -> {to},預估 20 分鐘");
}
public class TransitStrategy : IRouteStrategy // 大眾運輸演算法
{
public void BuildRoute(string from, string to) => Console.WriteLine($"規劃大眾運輸路線:{from} -> {to},預估 35 分鐘(含轉乘)");
}
public class WalkingStrategy : IRouteStrategy // 步行演算法
{
public void BuildRoute(string from, string to) => Console.WriteLine($"規劃步行路線:{from} -> {to},預估 50 分鐘");
}
// 3. Context:只認識 IRouteStrategy,不管背後實際是哪一種演算法
public class NavigatorContext
{
private IRouteStrategy _strategy;
public NavigatorContext(IRouteStrategy strategy)
{
_strategy = strategy;
}
public void SetStrategy(IRouteStrategy strategy) => _strategy = strategy; // 使用者隨時可以切換交通方式
public void Navigate(string from, string to) => _strategy.BuildRoute(from, to);
}
var navigator = new NavigatorContext(new DrivingStrategy());
navigator.Navigate("台北車站", "松山機場");
// 規劃開車路線:台北車站 -> 松山機場,預估 20 分鐘
navigator.SetStrategy(new TransitStrategy()); // 使用者改變主意,切成大眾運輸
navigator.Navigate("台北車站", "松山機場");
// 規劃大眾運輸路線:台北車站 -> 松山機場,預估 35 分鐘(含轉乘)
NavigatorContext 沒有使用 if-else 去判斷「現在是哪一種交通方式」,它只管呼叫手上這個 IRouteStrategy,實際要開車還是搭車,是外部決定、隨時可以換的
柴咖啡展店到第二家分店(台北、台中),小黑開始玩不同的促銷手法搶市佔
台北店主打折扣戰(結帳打九折),台中店主打買一送一
問題是,這兩家店共用同一套結帳系統,同一筆訂單、同一套流程,卻要能吃得下兩種不同的促銷算法
離展店時間很近,阿柴為了搶時間,直接在結帳服務裡,用 storeCode 判斷這張訂單屬於哪家店,再各自算一次
public class StoreCheckoutService
{
public int CalculateFinalPrice(Order order, string storeCode)
{
if (storeCode == "TPE01") // 台北店:折扣戰,打九折
{
return (int)(order.TotalPrice * 0.9);
}
else if (storeCode == "RMQ01") // 台中店:買一送一
{
int totalQuantity = order.Items.Sum(i => i.Quantity);
int freeCount = totalQuantity / 2;
int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
return order.TotalPrice - freeCount * avgPrice;
}
else
{
throw new Exception("未知的分店代碼");
}
}
}
上線一陣子後,小黑決定開第三家店,高雄店,這次想玩集點折抵,阿柴得回頭補一個 else if
而且集點折抵需要知道「訂單用掉幾點」,所以方法簽章都得跟著多加一個參數,即使台北、台中兩家店用不到這個參數
public class StoreCheckoutService
{
public int CalculateFinalPrice(Order order, string storeCode, int usedPoints) // 多了只有高雄店會用到的參數
{
if (storeCode == "TPE01") // 台北店:折扣戰,打九折
{
return (int)(order.TotalPrice * 0.9);
}
else if (storeCode == "RMQ01") // 台中店:買一送一
{
int totalQuantity = order.Items.Sum(i => i.Quantity);
int freeCount = totalQuantity / 2;
int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
return order.TotalPrice - freeCount * avgPrice;
}
else if (storeCode == "KHH01") // 高雄店:集點折抵,usedPoints 是這筆訂單客人選擇折抵的點數
{
return Math.Max(0, order.TotalPrice - usedPoints / 10);
}
else
{
throw new Exception("未知的分店代碼");
}
}
}
而小黑想抬高台中店的業績,推出上午時段買一送一、下午時段打折扣戰(台北店維持原本的折扣戰不變),但這支方法把促銷算法整組寫死綁在 storeCode 上,沒辦法在系統跑著的當下,臨時幫同一家店切換成另一種折扣算法
阿柴發現,促銷算法本身要用哪一種,跟結帳流程要怎麼跑,是兩件事,卻被這支方法綁在一起
阿柴把三種促銷算法(折扣戰、買一送一、集點折抵),各自抽成一個 IPromotionStrategy 的具體實作,結帳流程改成只認識這個介面
public interface IPromotionStrategy
{
int Apply(Order order);
}
public class PercentageOffStrategy : IPromotionStrategy
{
private readonly double _percentage;
public PercentageOffStrategy(double percentage)
{
_percentage = percentage;
}
public int Apply(Order order) => (int)(order.TotalPrice * _percentage);
}
public class BuyOneGetOneStrategy : IPromotionStrategy
{
public int Apply(Order order)
{
int totalQuantity = order.Items.Sum(i => i.Quantity);
int freeCount = totalQuantity / 2;
int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
return order.TotalPrice - freeCount * avgPrice;
}
}
public class PointsRedemptionStrategy : IPromotionStrategy
{
private readonly int _points;
public PointsRedemptionStrategy(int points)
{
_points = points;
}
public int Apply(Order order) => Math.Max(0, order.TotalPrice - _points / 10);
}
結帳的 Context 只認識 IPromotionStrategy,不知道背後實際跑的是哪一種算法
public class PromotionContext
{
private IPromotionStrategy _strategy;
public PromotionContext(IPromotionStrategy strategy)
{
_strategy = strategy;
}
public void SetStrategy(IPromotionStrategy strategy) => _strategy = strategy;
public int CalculateFinalPrice(Order order) => _strategy.Apply(order);
}
// 台北店:維持折扣戰不變
var taipeiCheckout = new PromotionContext(new PercentageOffStrategy(0.9));
Console.WriteLine(taipeiCheckout.CalculateFinalPrice(order));
// 台中店:上午時段先套用買一送一
var taichungCheckout = new PromotionContext(new BuyOneGetOneStrategy());
Console.WriteLine(taichungCheckout.CalculateFinalPrice(order));
// 到了下午,小黑想試試看改用折扣戰,把台中店切換成折扣戰
taichungCheckout.SetStrategy(new PercentageOffStrategy(0.9));
Console.WriteLine(taichungCheckout.CalculateFinalPrice(order));
// 高雄店開幕,主打集點折抵,直接注入對應的策略
var kaohsiungCheckout = new PromotionContext(new PointsRedemptionStrategy(500));
Console.WriteLine(kaohsiungCheckout.CalculateFinalPrice(order));
以後小黑想主打新的促銷手法,阿柴只要新增一個 ConcreteStrategy 類別,就可以在 SetStrategy()做抽換使用
我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
朋友如果你第一次看 Mermaid 架構圖,這是小指南
方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / + 代表 private / public)
下層:對外提供的方法 (- / + 代表 private / public)
箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考
演算法不多、也不常變動時,不一定划算:如果柴咖啡永遠只有「打九折」這一種促銷,方式固定不會再變,一整組介面加上一個具體實作,會比原本一行 order.TotalPrice * 0.9 更繞路
Strategy 要解決的問題:
一個行為存在多種可互相替換的演算法變體,且未來可能新增變體、或需要在執行期動態切換,若把這些演算法寫死在呼叫端內部(if-else/switch),會讓呼叫端難以維護,也無法在執行期彈性替換
優點:
缺點:
明天,柴咖啡的庫存跟訂單狀態,開始需要在變化發生的當下,同步通知好幾個不同的對象——庫存不足要通知進貨窗口,訂單完成要通知客人跟外送平台,這種「一個事件發生,多個對象都要知道」的情境,換 Observer 觀察者模式上場
Design Pattern | 從復仇者看策略模式( Strategy Pattern ) feat. TypeScript