🎯 什麼情境該想到我

當你「一個東西改變時,要通知一群不特定的對象」的時候——UI 更新、事件匯流排、快取失效、稽核日誌。
★ 它最大的實務風險是「忘記取消訂閱造成記憶體洩漏」,這頁會把它講透。

⚙️ 怎麼用

意圖

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

Subject ──notify()──▶ Observer A
   │                  Observer B
   └─ observers []    Observer C
★ Subject 不知道 Observer 是誰、有幾個

★ 做法一:callback 清單(經典版)

type Event struct{ Key string; Val any }
type Handler func(Event)
 
type Bus struct {
    mu   sync.RWMutex
    subs map[int]Handler
    next int
}
 
// ★ 回傳一個 unsubscribe 函式 —— 這是最重要的設計
func (b *Bus) Subscribe(h Handler) (cancel func()) {
    b.mu.Lock()
    defer b.mu.Unlock()
    id := b.next
    b.next++
    b.subs[id] = h
    return func() {
        b.mu.Lock()
        defer b.mu.Unlock()
        delete(b.subs, id)
    }
}
 
func (b *Bus) Publish(e Event) {
    b.mu.RLock()
    hs := slices.Collect(maps.Values(b.subs))   // ★ 先複製再解鎖
    b.mu.RUnlock()
    for _, h := range hs {
        h(e)                                     // ★ 不持鎖呼叫,避免死鎖
    }
}

★ Subscribe 回傳 cancel func() 而不是 Unsubscribe(h):
Go 的函式不可比較,無法用 h 當 key 刪除。
回傳 closure 是慣用做法,而且呼叫端可以直接 defer cancel()——不容易忘。

兩個必須處理的細節(上面程式碼已含):

  1. ★ 不要持著鎖呼叫 handler —— handler 若反過來 Subscribe/Publish 就死鎖
  2. 先複製訂閱者清單再迭代 —— handler 在執行中取消訂閱會造成 map 並行修改

★ 做法二:channel(Go 更自然的形態)

type Bus struct {
    mu   sync.RWMutex
    subs map[chan Event]struct{}
}
 
func (b *Bus) Subscribe(buf int) (<-chan Event, func()) {
    ch := make(chan Event, buf)
    b.mu.Lock(); b.subs[ch] = struct{}{}; b.mu.Unlock()
    return ch, func() {
        b.mu.Lock(); delete(b.subs, ch); close(ch); b.mu.Unlock()
    }
}
 
func (b *Bus) Publish(e Event) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for ch := range b.subs {
        select {
        case ch <- e:
        default:                    // ★ 訂閱者太慢就丟棄,不要阻塞發布者
        }
    }
}

★ select + default 的取捨必須明講:

  • 有 default(丟棄):發布者永不阻塞,但訂閱者會漏事件
  • 沒有 default(阻塞):不漏事件,但一個慢的訂閱者會拖垮所有人

沒有第三種選擇——這是背壓(backpressure)的根本取捨。
需要「不漏且不阻塞」就得引入持久化佇列(見 工具-MQ消費端防禦三原則)。

TypeScript:EventEmitter 與 AbortSignal

import { EventEmitter } from "node:events";
const bus = new EventEmitter();
 
const onUpdate = (e: Event) => { ... };
bus.on("update", onUpdate);
bus.off("update", onUpdate);          // ★ 必須是「同一個函式參考」才移除得掉
 
// ★ 現代做法:用 AbortSignal 統一取消
const ac = new AbortController();
bus.on("update", onUpdate, { signal: ac.signal });
target.addEventListener("click", h, { signal: ac.signal });
ac.abort();                           // ★ 一次取消全部訂閱

★ AbortSignal 是現代 JS 管理訂閱生命週期的正解——
它把「一堆分散的 off/removeEventListener」收攏成一個取消點。

⚠️ bus.off 必須傳入同一個函式參考。寫成 bus.off("update", (e) => {...}) 是移除不掉的
——這是 JS 最常見的洩漏來源。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

★ 記憶體洩漏(最常見也最嚴重的問題)

Subject 持有 Observer 的參考 → Observer 永遠不會被 GC 回收。

一個短命的物件訂閱了一個長命的 bus,卻沒有取消訂閱 →
它連同它引用的所有東西都被留在記憶體裡。 這在長時間執行的服務裡會緩慢累積成 OOM。

三個對策:

  1. ★ 訂閱一定要配對取消:cancel := bus.Subscribe(h); defer cancel()
  2. 綁定生命週期:用 context.Context 或 AbortSignal,父層結束時自動全部取消
  3. 監控訂閱者數量——它只增不減就是有洩漏(見 工具-遙測與監控)

其他

  • ★ 通知順序不保證。依賴「A 一定比 B 先收到」的設計是錯的。需要順序就不要用事件。
  • ★ 連鎖反應難以追蹤。A 的事件觸發 B,B 又觸發 C……
    堆疊追蹤裡看不到因果鏈,這是事件驅動架構最大的除錯痛點。
    → 對策:事件裡帶 correlation/trace id,並在 log 裡串起來。
  • 循環通知:A 通知 B,B 改了東西又通知 A → 無限迴圈。要加防護(深度上限或「正在通知中」旗標)。
  • ★ Handler 裡的 panic 會炸掉發布者。一定要 recover:
    func safe(h Handler) Handler {
        return func(e Event) {
            defer func() { if r := recover(); r != nil { slog.Error("handler panic", "r", r) } }()
            h(e)
        }
    }
  • 同步 vs 非同步要想清楚:同步通知會讓發布者等待所有 handler;非同步(go h(e))不會,
    但失去了錯誤處理與順序,而且可能開出無限多個 goroutine。
  • ★ 不要用觀察者取代明確的呼叫。只有一個訂閱者、而且永遠只有一個時,直接呼叫更清楚。
    事件的價值在「發布者不需要知道訂閱者」,沒有這個需求就別付這個代價。
  • 跨程序要用真的訊息佇列。行程內的 bus 沒有持久化、沒有重試、沒有至少一次保證。

🔗 相關工具