昨天用 Strategy 把促銷算法跟結帳流程拆開,今天我們繼續介紹行為型 Pattern
柴咖啡的故事,也陸續走到連鎖店規模化的階段,庫存跟訂單的一舉一動,開始牽動越來越多不同的人
原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
用中文理解可以是
定義物件之間一對多的依賴關係,當一個物件的狀態改變時,所有依賴它的物件都會自動收到通知並更新
核心角色
我們來看看 UML 圖
Observer 主要是要解決什麼問題?
我們舉個生活化的例子,氣象局發布豪雨特報這件事
同一個「豪雨特報」事件,火車、學校、超商收到之後要做的事截然不同,火車要減速慢行、學校要發停課通知、超商要調度雨具等等
但氣象局不需要知道這些單位收到警報後實際上會做什麼,他只負責通報
// 1. Observer(觀察者共同介面)
public interface IWeatherObserver
{
void OnHeavyRainAlert(string area); // 大雨通報
}
// 2. ConcreteObserver(具體觀察者):收到警報後,各自做不同的事
public class TrainOperator : IWeatherObserver
{
public void OnHeavyRainAlert(string area) => Console.WriteLine($"[火車] {area} 豪雨特報,地上路段減速慢行");
}
public class School : IWeatherObserver
{
public void OnHeavyRainAlert(string area) => Console.WriteLine($"[{area}學校] 發送停課通知簡訊給家長");
}
public class ConvenienceStore : IWeatherObserver
{
public void OnHeavyRainAlert(string area) => Console.WriteLine($"[超商] 緊急調度雨傘與雨衣到{area}門市");
}
// 3. Subject(被觀察者):只認識 IWeatherObserver,不知道實際訂閱的是誰
public class WeatherBureau // 氣象署
{
private readonly List<IWeatherObserver> _observers = new();
public void Subscribe(IWeatherObserver observer) => _observers.Add(observer);
public void Unsubscribe(IWeatherObserver observer) => _observers.Remove(observer);
public void IssueHeavyRainAlert(string area)
{
foreach (var observer in _observers)
{
observer.OnHeavyRainAlert(area);
}
}
}
var bureau = new WeatherBureau();
bureau.Subscribe(new TrainOperator());
bureau.Subscribe(new School());
bureau.Subscribe(new ConvenienceStore());
bureau.IssueHeavyRainAlert("台北市");
// [火車] 台北市 豪雨特報,地上路段減速慢行
// [台北市學校] 發送停課通知簡訊給家長
// [超商] 緊急調度雨傘與雨衣到台北市門市
WeatherBureau 只負責「發布警報」,不知道訂閱者是誰、收到之後要做什麼
哪天水利局也想加入,調度抽水站,只是再多訂閱一個 IWeatherObserver,不用改 WeatherBureau
回到 Observer 模式,他主要是想要解決什麼問題?
解除強耦合:避免發布事件的物件依賴於多個接收者。主題只認識 IObserver 介面,不需要知道具體有哪些類別訂閱了它
避免輪詢:不需要觀察者每隔幾秒詢問主題「狀態變了嗎?」,改由主題主動推送狀態通知
支援動態廣播:可在執行時期隨時新增或移除觀察者,而不影響主題的原有程式碼(符合開閉原則 OCP)
柴咖啡展店到三間分店規模,庫存不足這件事,牽動的人也越來越多,除了要通知進貨窗口叫貨,店長想第一時間知道,總部更想要一個即時的庫存儀表板,掌握全連鎖店的狀況
一開始只有「通知進貨窗口」這一件事,阿柴直接寫在 InventoryService.CheckStock() 裡
public class InventoryService
{
private readonly Dictionary<string, int> _stock = new();
private const int LowStockThreshold = 10;
public void CheckStock(string itemName, int quantity)
{
_stock[itemName] = quantity;
if (quantity < LowStockThreshold)
{
NotifyProcurement(itemName); // 通知進貨窗口
}
}
private void NotifyProcurement(string itemName) => Console.WriteLine($"[LINE通知進貨窗口] {itemName} 庫存不足,請叫貨");
}
小黑後來想要店長也同步收到通知,阿柴在同一支方法裡再加一行呼叫
public void CheckStock(string itemName, int quantity)
{
_stock[itemName] = quantity;
if (quantity < LowStockThreshold)
{
NotifyProcurement(itemName);
NotifyStoreManager(itemName); // 新增:推播通知店長 App
}
}
private void NotifyStoreManager(string itemName) => Console.WriteLine($"[App推播店長] {itemName} 庫存告急");
private void NotifyProcurement(string itemName) => Console.WriteLine($"[LINE通知進貨窗口] {itemName} 庫存不足,請叫貨");
接著總部也要接進來,阿柴又加一行
public void CheckStock(string itemName, int quantity)
{
_stock[itemName] = quantity;
if (quantity < LowStockThreshold)
{
NotifyProcurement(itemName);
NotifyStoreManager(itemName);
NotifySyncToHeadquartersDashboard(itemName); // 新增:同步總部庫存告急儀表板
}
}
private void NotifySyncToHeadquartersDashboard(string itemName) => Console.WriteLine($"[同步總部儀表板] {itemName} 庫存告急");
private void NotifyStoreManager(string itemName) => Console.WriteLine($"[App推播店長] {itemName} 庫存告急");
private void NotifyProcurement(string itemName) => Console.WriteLine($"[LINE通知進貨窗口] {itemName} 庫存不足,請叫貨");
問題接著來了,三家分店想要的通知組合不一樣
台北店還想額外通知配合的鮮乳廠商提前備貨,高雄店嫌 LINE 通知常常被員工滑掉,想換成簡訊
阿柴這下得在 CheckStock 裡塞進一堆判斷分店代碼的 if-else,才能決定該叫哪幾個通知方法
改一家店的通知需求,就要回頭改這支所有分店共用的方法
阿柴發現,庫存本身的狀態變化,跟「誰要在狀態變化時收到通知、收到後要做什麼」,是兩件事,卻被這支方法綁在一起
阿柴把「收到通知後要做什麼」抽成一個 IStockObserver 介面,InventoryService 只負責偵測庫存狀態、通知所有訂閱者,不管訂閱者實際上是誰
public interface IStockObserver
{
void OnStockLow(string itemName, int quantity);
}
public class ProcurementNotifier : IStockObserver
{
public void OnStockLow(string itemName, int quantity) => Console.WriteLine($"[LINE通知進貨窗口] {itemName} 庫存不足(剩 {quantity}),請叫貨");
}
public class StoreManagerNotifier : IStockObserver
{
public void OnStockLow(string itemName, int quantity) => Console.WriteLine($"[App推播店長] {itemName} 庫存告急(剩 {quantity})");
}
public class HeadquartersDashboardNotifier : IStockObserver
{
public void OnStockLow(string itemName, int quantity) => Console.WriteLine($"[同步總部儀表板] {itemName} 庫存告急(剩 {quantity})");
}
public class SmsProcurementNotifier : IStockObserver // 高雄店想要的簡訊版進貨通知
{
public void OnStockLow(string itemName, int quantity) => Console.WriteLine($"[簡訊通知進貨窗口] {itemName} 庫存不足(剩 {quantity}),請叫貨");
}
public class InventoryService
{
private readonly Dictionary<string, int> _stock = new();
private readonly List<IStockObserver> _observers = new();
private const int LowStockThreshold = 10;
public void Subscribe(IStockObserver observer) => _observers.Add(observer);
public void Unsubscribe(IStockObserver observer) => _observers.Remove(observer);
public void CheckStock(string itemName, int quantity)
{
_stock[itemName] = quantity;
if (quantity < LowStockThreshold)
{
foreach (var observer in _observers)
{
observer.OnStockLow(itemName, quantity);
}
}
}
}
分店要哪一組通知組合,變成在啟動時自由訂閱,不用改 InventoryService
總部想看的是全連鎖店彙總的告急狀況,不是每家店各自一份,所以 HeadquartersDashboardNotifier 只 new 一次,讓同一個實例同時訂閱台北、高雄兩間店——Observer 不是只能一對多,同一個 Observer 訂閱多個 Subject 一樣成立
var headquartersDashboard = new HeadquartersDashboardNotifier(); // 只 new 一次,全連鎖店共用同一個總部儀表板
// 台北店:進貨窗口 + 店長 + 總部儀表板
var taipeiInventory = new InventoryService();
taipeiInventory.Subscribe(new ProcurementNotifier());
taipeiInventory.Subscribe(new StoreManagerNotifier());
taipeiInventory.Subscribe(headquartersDashboard);
// 高雄店:進貨窗口改用簡訊版,但總部儀表板訂閱同一個實例
var kaohsiungInventory = new InventoryService();
kaohsiungInventory.Subscribe(new SmsProcurementNotifier());
kaohsiungInventory.Subscribe(headquartersDashboard);
taipeiInventory.CheckStock("美式咖啡豆", 8);
// [LINE通知進貨窗口] 美式咖啡豆 庫存不足(剩 8),請叫貨
// [App推播店長] 美式咖啡豆 庫存告急(剩 8)
// [同步總部儀表板] 美式咖啡豆 庫存告急(剩 8)
kaohsiungInventory.CheckStock("拿鐵奶泡", 5);
// [簡訊通知進貨窗口] 拿鐵奶泡 庫存不足(剩 5),請叫貨
// [同步總部儀表板] 拿鐵奶泡 庫存告急(剩 5)
不管是台北還是高雄觸發庫存告急,收到通知的都是同一個 headquartersDashboard,總部才能彙總全連鎖店的狀況
這套 IStockObserver 拿去套用在別的事件上一樣成立
訂單完成時要通知客人、也要同步外送平台狀態,阿柴比照這套思路另外拉出 IOrderObserver
public interface IOrderObserver
{
void OnOrderCompleted(Order order);
}
public class CustomerNotifier : IOrderObserver
{
public void OnOrderCompleted(Order order) => Console.WriteLine($"[簡訊通知客人] 訂單 {order.OrderId} 已完成,請取餐");
}
public class DeliveryPlatform : IOrderObserver
{
public void OnOrderCompleted(Order order) => Console.WriteLine($"[同步外送平台] 訂單 {order.OrderId} 狀態更新為已完成");
}
OrderService 只要在訂單完成時通知已訂閱的 IOrderObserver,不需要知道客人是用簡訊還是 App 推播、外送平台的 API 長什麼樣子,跟庫存事件是同樣思路,只是換了一個 Subject
我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
朋友如果你第一次看 Mermaid 架構圖,這是小指南
方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / + 代表 private / public)
下層:對外提供的方法 (- / + 代表 private / public)
箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考
通知順序跟依賴不容易掌控:Observer 之間如果彼此有先後順序要求(例如一定要先同步總部儀表板,才能通知店長),這套模式本身不保證通知順序,這種隱藏依賴容易在觀察者變多之後互相踩到
容易觸發難以追蹤的連鎖反應:如果某個觀察者收到通知後,自己又觸發了另一個事件(例如店長 App 收到通知後又寫回庫存系統),一路串下去很難一眼看出「這次狀態變化最後到底觸發了哪些後續動作」
Observer 要解決的問題:
一個物件的狀態改變時,需要讓數量不定、種類不定的物件同步得知並各自反應,若把這些通知邏輯寫死在狀態變化的物件內部,會讓兩者緊密耦合,每次新增或修改觀察者都要回頭改動狀態物件本身
優點:
缺點:
C# 實務補充:
在 C# 裡,其實語言原生的 event 與委派(Action / Func)本質上就是一種輕量級的 Observer 模式(+= 代表訂閱、-= 代表取消訂閱)
而複雜的非同步串流則會使用 Reactive Extensions (Rx.NET) 或 IObservable
明天,柴咖啡要讓「點餐」這個動作本身變成一個可以被記錄、取消、重做的物件,顧客點錯飲料想取消、店員想重做上一筆操作,這種「把一個請求包裝成物件」的情境,換 Command 命令模式上場
Design Pattern | 只要你想知道,我就告訴你 - 觀察者模式( Observer Pattern ) feat. TypeScript
深入淺出設計模式(Design Pattern)-觀察者模式(2-Observer Pattern)