🎯 什麼情境該想到我

當你「需要把「一個操作」當成資料來傳遞、儲存、排隊、或反轉」的時候——
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。
  • 和事件的區別:命令是「請做這件事」(祈使、可拒絕、通常一個接收者);
    事件是「這件事發生了」(陳述、不可拒絕、多個訂閱者)。
    混用兩者的語義是事件驅動架構最常見的設計錯誤(見 觀察者模式)。

🔗 相關工具