Codetopia 的市政調度中心,氣氛比昨晚還緊張。
「總設計師,我們有麻煩了!」派工調度員 Liam(Dispatcher)的聲音從通訊器傳來,背景音滿是此起彼落的電話鈴聲。
昨夜,Tess 與 Kaito 才剛用責任鏈模式(Chain of Responsibility)漂亮地將棘手陳情案分流完畢,讓權責歸屬變得清晰。但,真正的風暴現在才開始。
「Rhea!Rhea!聽到請回答!」Liam 對著麥克風喊道。
「在路上!怎麼了?」道路維修隊領班 Rhea(Crew Lead)的聲音有些雜訊。
「計畫變更!剛剛都更署下了臨時路權變更,你們得改道去 B 區!不對...等等,市長室說要優先處理海港音樂祭的場佈......」
Rhea 的小隊剛出發,命令就得改;派出去的工程車,可能下一秒就得撤回;更別提那些必須在清晨六點準時、自動執行的批次任務。如果每個動作都是一個寫死的 API 呼叫,光是處理「撤銷」、「重做」跟「排程」這三件鳥事,就足以讓整個調度系統徹底崩潰。
(旁白:「是的,你沒看錯。當流程的執行方式本身變得複雜時,問題就不再是『誰來做』,而是『怎麼記下這堆該死的指令』。」)
看來,我們需要的不只是一個分流系統,而是一個能將「動作」本身打包、管理、甚至可以反悔的機制。
GoF|Command:將一個「請求」或「操作」封裝成一個獨立的物件。這讓你可以參數化客戶端(Client),將請求排入佇列、記錄請求日誌,以及實現可撤銷的操作。
EIP|Command Bus / Work Queue:一種訊息傳遞模式,命令被發送到一個中央通道(Bus)或佇列(Queue),由一個或多個工作者(Worker)非同步地拉取並執行。
MAS|任務代理 + DF:在多代理系統中,具備不同能力的代理人(Agent)會在黃頁(Directory Facilitator)上註冊。調度中心只需將任務(命令)發布,黃頁會媒合給能執行該任務的代理。
在引入 Command 模式之前,調度中心的早期原型系統是怎麼處理這一切的呢?讓我們把時間倒帶,看看工程師 Andy 寫下的第一版災難性程式碼。
他把所有的派工邏輯,全都硬生生地塞在 UI 按鈕的點擊事件裡。
# 反例:沒有 Command,直接在服務層寫死邏輯
def on_dispatch_clicked(case, need_revoke, schedule_time):
# 直接呼叫 API,一個動作一槍
api.send_car("crew-a", case.loc)
api.notify_residents(case.block)
# 想要撤銷?行,寫一組「反向操作」的 if
if need_revoke:
print("🚨 夭壽!派錯了,趕快摳回來!")
api.recall_car("crew-a")
api.revert_notify(case.block)
# 需要排程?更精彩了,手動拼接 crontab 字串...
if schedule_time == "06:00":
print("⏰ 早上六點的鬧鐘,我手動設定...")
cron_job_string = f"0 6 * * * api.send_car crew-a {case.loc}"
# ...然後把這串字手動貼到某個神秘的排程腳本裡
# (Andy 祈禱自己沒有拼錯字)
這段程式碼簡直是災難現場。每次新增一種派工、一種撤銷邏輯,Liam 的操作介面後方就長出一片新的 if/else
熱帶雨林。至於排程?那更是土法煉鋼,充滿了拼錯字的風險與半夜被 call 爆的惡夢。(旁白:「真正的問題是責任錯置:排程的責任應該交給 Scheduler 或 Bus,且直接內插字串更有被注入攻擊的風險!」)
「夠了!」總設計師終於看不下去。「我們不是在寫腳本,我們在建城市!我們需要的是命令模式(Command Pattern)!」
一句話解釋:把每一個「派工動作」都變成一個標準化的「命令物件(Command Object)」。
這個物件包含了執行該動作所需的一切資訊。調度員 Liam(Invoker)的工作不再是親自打電話或呼叫 API,而是「提交」這些命令物件。這些命令被交給命令台或佇列,由它們在適當的時機執行。而前線的 Rhea 和她的維修隊(Receiver)則專注於如何完成命令中交辦的任務。
重要語意區分:對於已成功執行的命令,我們用 Undo
(撤銷);對於尚未執行的排程命令,我們應該用 Cancel
(取消)。Undo
的前提是命令已被記錄於歷史中。
這樣做的好處是:
解耦:提交命令的人(Liam)和執行命令的人(Rhea)徹底分開。
可管理:命令物件可以被儲存、排隊、傳遞。
可擴展:要新增功能?寫一個新的命令類別就好,不動舊程式。
需要撤銷/重做 (Undo/Redo):當每個命令物件都帶有 undo()
方法時,實現撤銷就易如反掌。
需要排程/佇列 (Scheduling/Queueing):命令物件可以被放入佇列,等待稍後執行或依序執行。
需要巨集命令 (Macro Commands):將一連串的命令組合成一個單一的複合命令,一鍵執行。
需要日誌與審計 (Logging & Auditing):可以輕鬆記錄所有被執行的命令,方便追蹤、除錯,甚至災難重演(Replay)。
操作極簡:如果你的應用程式只有幾個簡單、不需要回溯或排程的動作,引入 Command 模式反而會過度設計。
固定流程骨架:如果流程的骨架是固定的,只是其中幾個步驟的具體實現不同,那可能更適合策略模式(Strategy)或樣板方法模式(Template Method)。
導播,鏡頭拉一下!讓我們從三個不同尺度,看看這個「王牌命令」在 Codetopia 是如何運作的。
視角 | 觀念/模式 | 在 Codetopia 的說法 |
---|---|---|
微觀 (GoF) | Command | 派工單物件:每個動作(出隊、封路)都是可執行、可撤銷的標準工單。 |
中觀 (EIP/EDA) | Command Bus / Work Queue | 派工命令總線:Liam 將工單丟進佇列,由 Worker 非同步拉取執行,失敗則進入「死信區」。 |
宏觀 (MAS) | 任務代理 + DF | 黃頁派工:Liam 只需下達任務,黃頁系統會自動媒合給具備相應能力的現場代理(道路、照明、警導)。 |
這張圖展示了命令模式的核心結構:Invoker
(調度台)持有並觸發 Command
,而具體的 Command
實現(如 DispatchCrewCmd
)則呼叫 Receiver
(現場作業系統 FieldOps
)來完成工作。History
則負責記錄,以支援撤銷與重做。
當中觀尺度看,Worker
扮演「命令執行器」的角色,負責從佇列拉取命令並觸發。真正的 Receiver
則是後端的 FieldOpsService
。只有當命令執行成功後,才會被寫入 History
,此刻起才支援 Undo
。
Talk is cheap, show me the code! 讓我們看看用 Python 風格的偽代碼,如何實現這個優雅且工程正確的系統。
# 現場作業系統 (Receiver)
class FieldOps:
def send_car(self, crew, loc): print(f"✅ 派出 {crew} 前往 {loc}...")
def recall_car(self, crew): print(f"↩️ 撤回 {crew}...")
def block_road(self, loc): print(f"🚧 封鎖 {loc} 道路...")
def unblock_road(self, loc): print(f"✅ 解除 {loc} 道路封鎖...")
def notify(self, block): print(f"📢 通知 {block} 區居民...")
def send_correction(self, block): print(f"📢 發送更正通知給 {block} 區居民...")
# 命令介面
class Command:
def execute(self): raise NotImplementedError
def undo(self): raise NotImplementedError
# --- 具體命令 ---
class DispatchCrewCmd(Command):
def __init__(self, crew, loc, ops: FieldOps):
self.crew, self.loc, self.ops = crew, loc, ops
def execute(self): self.ops.send_car(self.crew, self.loc)
def undo(self): self.ops.recall_car(self.crew)
class BlockRoadCmd(Command):
def __init__(self, loc, ops: FieldOps):
self.loc, self.ops = loc, ops
def execute(self): self.ops.block_road(self.loc)
def undo(self): self.ops.unblock_road(self.loc)
# 註記:通知類命令無法真正「撤銷」,只能用「補償」(compensate)
class NotifyResidentsCmd(Command):
def __init__(self, block, ops: FieldOps):
self.block, self.ops = block, ops
def execute(self): self.ops.notify(self.block)
def undo(self): self.ops.send_correction(self.block)
# 巨集命令:一次執行多個命令
class MacroCommandError(Exception):
"""巨集命令執行失敗後的錯誤型別,攜帶回滾資訊"""
def __init__(self, original: Exception, partial):
super().__init__(str(original))
self.original = original
self.partial = list(partial) # 已成功且已嘗試回滾的子命令
self.rolled_back = True
class MacroCmd(Command):
def __init__(self, commands):
self.commands = commands
def execute(self):
completed_commands = []
try:
for cmd in self.commands:
cmd.execute()
completed_commands.append(cmd)
except Exception as e:
print(f"巨集命令執行失敗: {e},開始局部回滾...")
for cmd in reversed(completed_commands):
try:
cmd.undo()
except Exception as undo_e:
# 如果連 undo 都失敗,必須記錄下來,避免卡死
print(f"🚨 緊急!命令 {cmd.__class__.__name__} 的 undo 操作失敗: {undo_e}")
# 以自訂錯誤型別回報,攜帶回滾與部分進度資訊
raise MacroCommandError(e, completed_commands)
def undo(self):
for cmd in reversed(self.commands):
cmd.undo()
# 命令調度台 (Invoker) 與【修正後】的歷史紀錄
class History:
def __init__(self):
self.undo_stack, self.redo_stack = [], []
def push_after_execute(self, cmd):
self.undo_stack.append(cmd)
self.redo_stack.clear()
def move_undo_to_redo(self, cmd):
self.redo_stack.append(cmd)
def move_redo_to_undo(self, cmd):
self.undo_stack.append(cmd)
class Invoker:
def __init__(self):
self.history = History()
self.current_cmd = None
def set_command(self, cmd: Command):
self.current_cmd = cmd
# 註解:此為本地同步模式示範。若經由 Bus/Queue,
# 應在 Worker 成功回報時,才由對應的 Handler 呼叫 history.push_after_execute()
def execute_command(self):
if self.current_cmd:
self.current_cmd.execute()
self.history.push_after_execute(self.current_cmd)
def undo(self):
cmd = self.history.undo_stack.pop() if self.history.undo_stack else None
if cmd:
cmd.undo()
self.history.move_undo_to_redo(cmd)
def redo(self):
cmd = self.history.redo_stack.pop() if self.history.redo_stack else None
if cmd:
cmd.execute()
self.history.move_redo_to_undo(cmd)
情境題:回頭看 3) 笑中帶淚
的那段 if/else
熱帶雨林程式碼,如果當時總設計師指派你(而不是 Andy)來重構,你會如何建立你的第一個 Command
類別?它會是什麼?需要哪些參數?
實作題:請你動手為 MacroCmd
加上「日誌記錄」功能。在 execute
和 undo
時,都能印出是哪個子命令被執行或撤銷了。
反模式紅旗 (Red Flags):
把撤銷寫成反向 API:當你發現系統裡有大量的 send_car()
和 recall_car()
這種成對出現的 API,卻沒有統一的 undo()
介面時,就是警訊。
Invoker 與 Receiver 強耦合:如果你的 Invoker
直接 new
一個 FieldOps
物件來用,那你就失去了佇列、排程、遠端執行的所有彈性。
巨集命令缺乏回滾策略:一個沒有 try-catch
和處理 undo 失敗機制的 MacroCmd
,就像一串鞭炮,一點燃就停不下來,中間炸膛了也沒人管。
今天我們把「動作」物件化了。在更宏大的架構中,這個觀念會演化:
EIP 層面:命令會被塑造成標準化的可持久訊息,包含 {id, type, payload, runAt, correlationId, idempotencyKey}
等欄位。其中,idempotencyKey (例如由 commandType
和 payload
的 hash 組成) 對於防止重複執行至關重要。流經 Command Bus,支援延遲、重試,失敗則轉入死信佇列(DLQ)。
Actor Model 層面:每個 Worker 都是一個獨立的 Actor,從自己的信箱(Mailbox)中拉取命令來執行。這天然地隔離了錯誤,並允許多個命令並行處理,互不干擾。
MAS 層面:命令不再只是資料,而是帶有「意圖」的溝通語言(ACL)。Liam 只需宣告「我需要一個具備道路維修能力的代理來執行此任務」,DF 就會找到 Rhea 的代理,並將命令派發過去。審計記錄更可用於長期的系統行為重放與分析。
動作物件化,可撤銷可排程;命令進佇列,巨集可回放。
今天,我們給了 Liam 一個強大的武器,讓混亂的調度工作變得井然有序。但新的問題來了:當數百個工單命令在系統中流轉時,Liam 該如何有效率地逐一巡檢所有「未完工」的工單,而不用管它們是單一命令還是巨集命令呢?
明日預告:Day 18|Iterator(逐站巡覽)—— 無論容器結構多複雜,我都能給你一個統一的巡覽介面!
動作物件化,可撤銷可排程;命令進佇列,巨集可回放。
今天,我們給了 Liam 一個強大的武器,讓混亂的調度工作變得井然有序。但新的問題來了:當數百個工單命令在系統中流轉時,Liam 該如何有效率地逐一巡檢所有「未完工」的工單,而不用管它們是單一命令還是巨集命令呢?
明日預告:Day 18|Iterator(逐站巡覽)—— 無論容器結構多複雜,我都能給你一個統一的巡覽介面!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌──────────────────────────────────────────────────────────────────────┐
│ Command 模式結構圖 │
└──────────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ <<interface>> │
│ Command │
├─────────────────────┤
│ + execute() │
│ + undo() │
└──────────┬──────────┘
│
│ implements
┌──────────────────────┼──────────────────────┐
│ │ │
┌───────▼────────┐ ┌─────────▼────────┐ ┌────────▼───────────┐
│ DispatchCrewCmd│ │ BlockRoadCmd │ │NotifyResidentsCmd │
├────────────────┤ ├──────────────────┤ ├────────────────────┤
│ - crew │ │ - loc │ │ - block │
│ - loc │ │ - ops: FieldOps │ │ - ops: FieldOps │
│ - ops:FieldOps │ ├──────────────────┤ ├────────────────────┤
├────────────────┤ │ + execute() │ │ + execute() │
│ + execute() │ │ + undo() │ │ + undo() │
│ + undo() │ └──────────────────┘ └────────────────────┘
└────────┬───────┘
│ ┌─────────────────────┐
│ │ MacroCmd │
│ ├─────────────────────┤
│ │ - commands: [] │
│ ├─────────────────────┤
│ │ + execute() │
│ │ + undo() │
│ └──────┬──────────────┘
│ │
│ │ aggregates many
└─────────────────────────┘ Command objects
┌──────────calls──────────┐
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Invoker │ │ FieldOps │
├────────────────┤ │ (Receiver) │
│ - history │ ├────────────────┤
├────────────────┤ │ + send_car() │
│ + submit(cmd) │ │ + recall_car() │
│ + undo() │ │ + block_road() │
│ + redo() │ │ + unblock_rd() │
└───────┬────────┘ │ + notify() │
│ │ + send_corr() │
│ uses └────────────────┘
▼
┌────────────────┐
│ History │
├────────────────┤
│ - undo_stack │
│ - redo_stack │
├────────────────┤
│ + push() │
│ + move_undo() │
│ + move_redo() │
└────────────────┘
圖示說明:
Command
介面定義統一的 execute()
和 undo()
操作FieldOps
的呼叫MacroCmd
聚合多個命令,形成複合命令Invoker
透過 History
管理命令的執行與撤銷FieldOps
作為真正的接收者,執行實際操作┌──────────────────────────────────────────────────────────────────────┐
│ 命令總線執行流程時序圖 │
└──────────────────────────────────────────────────────────────────────┘
Liam 調度控制台 命令總線/佇列 Worker 現場作業服務
(調度員) (Invoker) (Bus) (執行器) (Receiver)
│ │ │ │ │
│ 提交巨集命令 │ │ │ │
│ (06:00執行) │ │ │ │
├─────────────>│ │ │ │
│ │ enqueue(cmd, │ │ │
│ │ runAt="06:00") │ │
│ ├──────────────>│ │ │
│ │ │ │ │
│ │ │ ⏰ 等待至 │ │
│ │ │ 06:00 │ │
│ │ │ ... │ │
│ │ │ │ │
│ │ │ deliver(cmd) │ │
│ │ ├─────────────>│ │
│ │ │ │ │
│ │ │ │ execute(步驟1)│
│ │ │ ├─────────────>│
│ │ │ │ │
│ │ │ │ execute(步驟2)│
│ │ │ ├─────────────>│
│ │ │ │ │
│ │ │ │ execute(步驟3)│
│ │ │ ├─────────────>│
│ │ │ │ │
│ │ │ │ ack(全部成功) │
│ │ │ │<─────────────┤
│ │ │ │ │
│ │ 回報結果 │ │ │
│ │ (含審計ID) │ │ │
│ │<──────────────┼──────────────┤ │
│ │ │ │ │
│ │ history. │ │ │
│ │ push_after_ │ │ │
│ │ execute(cmd) │ │ │
│ │──┐ │ │ │
│ │ │ │ │ │
│ │<─┘ │ │ │
│ │ │ │ │
│ ✅ 完成 │ │ │ │
│<─────────────┤ │ │ │
│ │ │ │ │
【關鍵點】
① 命令提交後進入佇列等待
② Worker 在指定時間拉取並執行
③ 成功後才寫入 History,此時才支援 Undo
④ 審計 ID 用於追蹤與重放
流程說明:
┌──────────────────────────────────────────────────────────────────────┐
│ 命令生命週期狀態機 │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ 命令建立 │
│ (Created) │
└──────┬───────┘
│
│ submit()
▼
┌──────────────┐
│ 排隊等待 │
│ (Queued) │
└──────┬───────┘
│
│ Worker pulls
▼
┌──────────────┐
│ 執行中 │
│ (Executing) │
└──────┬───────┘
│
┌───────────┴───────────┐
│ │
✅ 成功 ❌ 失敗
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 已完成 │ │ 失敗 │
│ (Completed) │ │ (Failed) │
└──────┬───────┘ └──────┬───────┘
│ │
│ push to History │ 進入 DLQ
▼ │ 或重試
┌──────────────┐ │
│ 可撤銷 │ ▼
│ (Undoable) │ ┌──────────────┐
└──────┬───────┘ │ 等待重試 │
│ │ (Retry Queue)│
│ undo() └──────────────┘
▼
┌──────────────┐
│ 已撤銷 │
│ (Undone) │
└──────┬───────┘
│
│ redo()
▼
┌──────────────┐
│ 重新執行 │
│ (Re-executed)│
└──────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Undo/Redo 堆疊運作示意 │
└──────────────────────────────────────────────────────────────────────┘
初始狀態:
Undo Stack: [Cmd1, Cmd2, Cmd3] ← 最近執行的命令在頂端
Redo Stack: []
執行 undo() 後:
Undo Stack: [Cmd1, Cmd2] ← Cmd3 被 pop 出來
Redo Stack: [Cmd3] ← Cmd3 推入 Redo Stack
再執行一次 undo() 後:
Undo Stack: [Cmd1]
Redo Stack: [Cmd3, Cmd2] ← Cmd2 也推入
執行 redo() 後:
Undo Stack: [Cmd1, Cmd2] ← Cmd2 重新執行並推回
Redo Stack: [Cmd3]
❗ 若此時執行新命令 Cmd4:
Undo Stack: [Cmd1, Cmd2, Cmd4] ← 新命令加入
Redo Stack: [] ← Redo Stack 被清空!
(這是標準的編輯器行為)
狀態說明:
┌──────────────────────────────────────────────────────────────────────┐
│ MacroCmd 錯誤回滾機制 │
└──────────────────────────────────────────────────────────────────────┘
MacroCmd 包含:[Cmd1, Cmd2, Cmd3, Cmd4, Cmd5]
執行流程:
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│Cmd1│──>│Cmd2│──>│Cmd3│──>│Cmd4│──>│Cmd5│
└─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘ └────┘
│ │ │ │
✅ ✅ ✅ ❌ 失敗!
│ │ │
▼ ▼ ▼
已完成 已完成 已完成 ← completed_commands = [Cmd1, Cmd2, Cmd3]
❌ 檢測到錯誤,開始回滾:
回滾順序:Cmd3 → Cmd2 → Cmd1 (reversed)
┌────────────────────────────────────────┐
│ try: │
│ Cmd3.undo() ✅ 成功 │
│ Cmd2.undo() ✅ 成功 │
│ Cmd1.undo() ✅ 成功 │
│ │
│ 結果:全部回滾成功 │
│ 狀態:系統恢復到執行前 │
└────────────────────────────────────────┘
🚨 如果 undo 也失敗:
┌────────────────────────────────────────┐
│ try: │
│ Cmd3.undo() ✅ 成功 │
│ Cmd2.undo() ❌ undo 失敗! │
│ │ │
│ └─> 記錄錯誤日誌 │
│ └─> 發送告警通知 │
│ └─> 繼續嘗試 Cmd1.undo() │
│ │
│ 結果:部分回滾失敗 │
│ 動作:需人工介入處理 │
│ 記錄:MacroCommandError 攜帶詳細資訊 │
└────────────────────────────────────────┘
【最佳實踐】
✓ 每個 Cmd 的 undo() 必須實作錯誤處理
✓ MacroCmd 應記錄每個子命令的執行狀態
✓ 提供 compensate() 方法處理無法撤銷的操作
✓ 將失敗資訊寫入審計日誌供事後分析