🎯 什麼情境該想到我
當你「需要把「一個操作」當成資料來傳遞、儲存、排隊、或反轉」的時候——
undo/redo、任務佇列、巨集錄製、事務日誌、CQRS 的 Command。
⚙️ 怎麼用
意圖
把一個請求封裝成物件,從而讓你可以用不同的請求對客戶端進行參數化、將請求排隊或記錄請求日誌,並支援可撤銷的操作。
核心洞察:把「動詞」變成「名詞」。
一旦操作變成了資料,你就可以對它做所有能對資料做的事:存、傳、排隊、序列化、重放、反轉。
★ Go 有頭等函式,還需要這個模式嗎?
只要「延遲執行」的話 —— 不需要,func() 就夠了:
queue := []func(){}
queue = append(queue, func() { sendEmail(to, body) })
for _, task := range queue { task() }但只要需要下面任何一項,就需要「真正的命令物件」:
| 需求 | 為什麼 func() 不夠 |
|---|---|
| ★ Undo | 函式無法反轉——需要一個配對的 Undo() |
| ★ 序列化 / 持久化 | ★ Go 的函式無法序列化。要存進資料庫或跨程序傳,必須是資料 |
| 檢視與稽核 | 看不出 func() 要做什麼;命令物件有欄位可以印出來 |
| 冪等鍵 / 去重 | 需要可比較的識別 |
| 權限檢查 | 要能問「這是什麼操作、對什麼資源」 |
★ Go 的長相:可撤銷的命令
type Command interface {
Do(ctx context.Context) error
Undo(ctx context.Context) error
}
type AddItem struct {
Cart *Cart
Item Item
// ★ 為了 Undo 而記錄的狀態
added bool
}
func (c *AddItem) Do(ctx context.Context) error {
c.Cart.items = append(c.Cart.items, c.Item)
c.added = true
return nil
}
func (c *AddItem) Undo(ctx context.Context) error {
if !c.added { return nil }
c.Cart.items = slices.DeleteFunc(c.Cart.items, func(i Item) bool { return i.ID == c.Item.ID })
return nil
}
// ★ Invoker:持有歷史
type History struct{ done []Command }
func (h *History) Execute(ctx context.Context, c Command) error {
if err := c.Do(ctx); err != nil { return err }
h.done = append(h.done, c)
return nil
}
func (h *History) Undo(ctx context.Context) error {
if len(h.done) == 0 { return ErrNothingToUndo }
last := h.done[len(h.done)-1]
h.done = h.done[:len(h.done)-1]
return last.Undo(ctx) // ★ 後進先出
}★ 可序列化的命令(任務佇列的骨架)
type Task struct {
ID string `json:"id"`
Kind string `json:"kind"` // ★ 判別欄位
Payload json.RawMessage `json:"payload"` // ★ 延遲解析
Attempt int `json:"attempt"`
}
var handlers = map[string]func(context.Context, json.RawMessage) error{
"send_email": handleSendEmail,
"resize_image": handleResize,
}
func Dispatch(ctx context.Context, t Task) error {
h, ok := handlers[t.Kind]
if !ok { return fmt.Errorf("unknown task kind %q", t.Kind) }
return h(ctx, t.Payload)
}★ 這就是每一個任務佇列(Asynq、River、Sidekiq、Celery)的本質——
命令模式 + 序列化 + 工廠方法模式 的註冊表。
TypeScript 的長相
type Command =
| { kind: "addItem"; item: Item }
| { kind: "removeItem"; id: string }
| { kind: "setQty"; id: string; qty: number };
// ★ 判別聯集讓「命令」天然可序列化又型別安全
function apply(state: Cart, cmd: Command): Cart {
switch (cmd.kind) {
case "addItem": return { ...state, items: [...state.items, cmd.item] };
case "removeItem": return { ...state, items: state.items.filter(i => i.id !== cmd.id) };
case "setQty": return { ...state, items: state.items.map(i => i.id === cmd.id ? {...i, qty: cmd.qty} : i) };
}
}★ Redux / Zustand 的 action 就是 Command,而 reducer 是 Invoker。
undo 的做法是保留狀態快照而非反轉命令——見 備忘錄模式。
★ 兩種 Undo 策略
| 策略 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| 反向命令 | 每個命令實作 Undo() | 省記憶體 | ★ 有些操作無法完美反轉(刪除後資料沒了、外部副作用已發生) |
| ★ 狀態快照 | 執行前存一份狀態,undo 就還原 | 簡單可靠、一定對 | 記憶體/儲存成本高(見 備忘錄模式) |
實務建議:狀態小 → 快照;狀態大且操作可乾淨反轉 → 反向命令;混合(每 N 步存一次快照 + 中間用反向命令)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 只要延遲執行就用
func()。不要為了「用到模式」而包一個只有Do()的介面。 - ★ Undo 的正確性是最難的部分。有副作用的操作(已發出的 email、已扣的款、已通知的第三方)無法真正 undo。
→ 只能做補償(發更正信、退款),而補償不等於回到原狀(見 工具-分散式事務的取捨)。
設計 undo 前先列出「哪些操作是不可逆的」。 - ★ 序列化的版本相容性:存進佇列的命令,在你部署新版後被消費。
舊格式的 payload 必須還能被新程式碼處理 —— 欄位只加不刪、加version欄位(見 工具-資料編碼與演進)。 - 重試必須冪等。任務佇列多半是「至少一次」投遞,同一個命令可能被執行兩次
→ 帶冪等鍵並在執行端去重(見 工具-MQ消費端防禦三原則)。 - 命令物件持有大量狀態會吃記憶體(尤其歷史很長時)。要設歷史長度上限。
- 不要把命令變成上帝物件。它該只描述「做什麼」,不該包含執行所需的所有相依——
相依用注入的方式給 Invoker。 - 和事件的區別:命令是「請做這件事」(祈使、可拒絕、通常一個接收者);
事件是「這件事發生了」(陳述、不可拒絕、多個訂閱者)。
混用兩者的語義是事件驅動架構最常見的設計錯誤(見 觀察者模式)。
🔗 相關工具
- 備忘錄模式 —— undo 的另一種實作(快照)
- 觀察者模式 —— 命令 vs 事件的語義差別
- 責任鏈模式 —— 命令可以沿著鏈傳遞
- 狀態模式 —— 命令常觸發狀態轉換
- 工具-Transactional-Outbox-Pattern —— 命令的可靠投遞
- 工具-資料編碼與演進 —— 序列化命令的版本相容
- 工具-MQ消費端防禦三原則 —— 冪等與重試
- 設計模式