iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

Day 23|Observer (觀察者模式 )解耦狀態與反應:如何讓各部門同時接收事件

  • 分享至 

  • xImage
  •  

昨天用 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.

用中文理解可以是

定義物件之間一對多的依賴關係,當一個物件的狀態改變時,所有依賴它的物件都會自動收到通知並更新

核心角色

  1. Subject(被觀察者):維護一份觀察者清單,提供訂閱/取消訂閱的機制,狀態改變時逐一通知清單裡的每一個觀察者
  2. Observer(觀察者):定義收到通知時要執行的操作介面
  3. ConcreteSubject(具體被觀察者):實際持有狀態,狀態改變時觸發通知
  4. ConcreteObserver(具體觀察者):實作 Observer 介面,收到通知後各自執行自己的邏輯

我們來看看 UML 圖
https://ithelp.ithome.com.tw/upload/images/20260919/20183470nTMGedTU4g.jpg

Observer 主要是要解決什麼問題?

  1. 為了知道狀態變化而引發的無效輪詢
  2. 狀態來源與接收者之間的強耦合

我們舉個生活化的例子,氣象局發布豪雨特報這件事

同一個「豪雨特報」事件,火車、學校、超商收到之後要做的事截然不同,火車要減速慢行、學校要發停課通知、超商要調度雨具等等
https://ithelp.ithome.com.tw/upload/images/20260919/20183470NclP7S3qsn.jpg

但氣象局不需要知道這些單位收到警報後實際上會做什麼,他只負責通報

// 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 模式,他主要是想要解決什麼問題?

  1. 解除強耦合:避免發布事件的物件依賴於多個接收者。主題只認識 IObserver 介面,不需要知道具體有哪些類別訂閱了它

  2. 避免輪詢:不需要觀察者每隔幾秒詢問主題「狀態變了嗎?」,改由主題主動推送狀態通知

  3. 支援動態廣播:可在執行時期隨時新增或移除觀察者,而不影響主題的原有程式碼(符合開閉原則 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,才能決定該叫哪幾個通知方法

改一家店的通知需求,就要回頭改這支所有分店共用的方法

阿柴發現,庫存本身的狀態變化,跟「誰要在狀態變化時收到通知、收到後要做什麼」,是兩件事,卻被這支方法綁在一起

Observer 上場

阿柴把「收到通知後要做什麼」抽成一個 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

總部想看的是全連鎖店彙總的告急狀況,不是每家店各自一份,所以 HeadquartersDashboardNotifiernew 一次,讓同一個實例同時訂閱台北、高雄兩間店——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 的取捨

通知順序跟依賴不容易掌控:Observer 之間如果彼此有先後順序要求(例如一定要先同步總部儀表板,才能通知店長),這套模式本身不保證通知順序,這種隱藏依賴容易在觀察者變多之後互相踩到

容易觸發難以追蹤的連鎖反應:如果某個觀察者收到通知後,自己又觸發了另一個事件(例如店長 App 收到通知後又寫回庫存系統),一路串下去很難一眼看出「這次狀態變化最後到底觸發了哪些後續動作」

今天學到的事

Observer 要解決的問題:

一個物件的狀態改變時,需要讓數量不定、種類不定的物件同步得知並各自反應,若把這些通知邏輯寫死在狀態變化的物件內部,會讓兩者緊密耦合,每次新增或修改觀察者都要回頭改動狀態物件本身

優點

  • Subject 與 Observer 互相解耦,Subject 不需要知道訂閱者的具體型別與數量
  • 可以在執行期動態增加或移除觀察者,不需要修改 Subject 的程式碼,符合開放封閉原則 OCP
  • 同一個事件來源,可以讓任意數量的觀察者各自獨立反應,彼此互不影響

缺點

  • 通知順序不易掌控,觀察者之間若存在隱藏的執行順序依賴,很容易出包
  • 觀察者內部如果又觸發新的事件,容易形成難以追蹤的連鎖反應(更新風暴)
  • 觀察者一多,除錯與追蹤「這次狀態變化到底觸發了哪些後續動作」會變得困難

C# 實務補充:

在 C# 裡,其實語言原生的 event 與委派(Action / Func)本質上就是一種輕量級的 Observer 模式(+= 代表訂閱、-= 代表取消訂閱)

而複雜的非同步串流則會使用 Reactive Extensions (Rx.NET) 或 IObservable


明天,柴咖啡要讓「點餐」這個動作本身變成一個可以被記錄、取消、重做的物件,顧客點錯飲料想取消、店員想重做上一筆操作,這種「把一個請求包裝成物件」的情境,換 Command 命令模式上場

可以看看延伸的資料,希望可以幫助到你

Refactoring Guru - Observer

Design Pattern | 只要你想知道,我就告訴你 - 觀察者模式( Observer Pattern ) feat. TypeScript

深入淺出設計模式(Design Pattern)-觀察者模式(2-Observer Pattern)

[GoF] 觀察者模式 Observer


上一篇
Day 22|Strategy (策略模式) 把演算法當外掛隨插即用
下一篇
Day 24|Command (命令模式) 按錯能反悔?把操作封裝成物件,打造可撤銷重做的後台
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言