🎯 什麼情境該想到我
當你「一個東西改變時,要通知一群不特定的對象」的時候——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()——不容易忘。
兩個必須處理的細節(上面程式碼已含):
- ★ 不要持著鎖呼叫 handler —— handler 若反過來
Subscribe/Publish就死鎖 - 先複製訂閱者清單再迭代 —— 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。
三個對策:
- ★ 訂閱一定要配對取消:
cancel := bus.Subscribe(h); defer cancel() - 綁定生命週期:用
context.Context或AbortSignal,父層結束時自動全部取消 - 監控訂閱者數量——它只增不減就是有洩漏(見 工具-遙測與監控)
其他
- ★ 通知順序不保證。依賴「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 沒有持久化、沒有重試、沒有至少一次保證。
🔗 相關工具
- 中介者模式 —— 也是解耦,但中介者知道所有同儕;Observer 的 Subject 不認識訂閱者
- 命令模式 —— 把事件物件化以便排隊、重放、undo
- 狀態模式 —— 狀態轉換常搭配發出事件
- 工具-靠通訊共享記憶體 —— Go 的 channel 哲學
- 工具-MQ消費端防禦三原則 —— 跨程序事件的正確做法
- 工具-訊息傳遞整合模式 —— 企業級的事件整合
- 工具-服務容錯設計 —— 慢訂閱者的背壓處理
- 設計模式