時間:早上 07:58。地點:Codetopia 捷運中樞站。
營運中心的巨大螢幕牆上,數百個綠色光點穩定地流動,像城市清晨平穩的呼吸。空氣中瀰漫著咖啡香與一種名為「準點」的集體默契。但在交響曲即將進入最高潮的前一刻,一個不該出現的音符劃破了和諧。
牆上,代表著四線交會樞紐的主動脈號誌,那個本該如心跳般穩定閃爍的綠色光點,無預警地,轉為一片死寂的紅色。
對外行人來說,這只是一個燈號改變。但對營運中心的專家而言,這是一個災難性的預兆——這個號誌的失效,意味著後方數十班列車將發生連鎖延誤,月台人流密度將在三分鐘內衝破安全臨界點。
寂靜,只持續了半秒。他們沒有時間等待問題擴大。隨後是鍵盤急促的敲擊聲與營運官壓低聲音、不帶一丝情感的指令:「_預防性_啟動臨時人流導引 SOP!」
三分之二的閘機(GateCluster
)應聲關閉,螢幕上的平滑曲線瞬間被代表擁堵的紅色塊取代。
月台的電子看板(StationGuideBoard
)切換了導引方向,將人潮這股溫馴的河流強行改道。
廣播系統開始放送制式文案,同時地面接駁巴士計畫(BusBridgePlan
)的圖標被緊急點亮。
一陣高度緊張但訓練有素的操作後,混亂暫時被約束在螢幕的方格之內。
時間:08:06。
「報告!號誌回穩!」控制台传来回報。
「太好了!全體注意,恢復原態!」
然而,「恢復」這個指令,有時比觸發警報更考驗一個系統的靈魂。現場陷入了所謂的「半套恢復」窘境:一部分閘機開了,但月台看板還在指引大家去搭接駁車;廣播裡還在高喊「列車暫停服務」,而接駁巴士已經準備收隊了。
系統沒有第二次崩潰,但信心崩潰了。大家都說要「回到之前」,但問題來了:「之前」是哪一個時間點的狀態? 是關閉閘機前?還是啟動接駁車後?更重要的是,哪些單位要一起回滾?又要用什麼順序?
如果沒有一個封存了關鍵時刻的「時間膠囊」,所謂的「恢復」,不過是第二次手忙腳亂的即興演出,風險甚至更高。
(旁白曰:今天的故事,正是要為我們在 Day 20 建立的**狀態機(State)**和 Day 21 的 SOP 骨架(Template Method),裝上那個至關重要的「復原鍵」。至於 Day 22 的稽核員 Visitor?他負責看清當下,而我們今天的主角 Memento,負責回到當時。分工明確,絕不越界!)
GoF|Memento(時光局模式):在不破壞物件封裝的前提下,擷取其內部狀態,儲存於一個名為 Memento
(備忘錄)的外部物件中,以便未來能將該物件還原到先前儲存的狀態。
Originator(狀態本人):真正持有狀態的物件。在今天的故事裡,SignalControlContext
、StationGuideBoard
、GateCluster
、BusBridgePlan
都是 Originator。
Memento(時間膠囊):儲存 Originator 內部狀態的快照。它對外部世界是不透明的 (opaque),只有創建它的那個 Originator 才能讀懂並用它來還原自己。
Caretaker(時光局管理員):負責保管 Memento 的角色,但絕對不能窺探或修改其內容。它只負責儲存、傳遞、管理這些時間膠囊。今天由新成立的「時光局」擔任此職。
讓我們倒帶一下,看看營運中心那位可憐的工程師在沒有「時光局」時,是如何把情況搞得更糟的,並附上程式碼災情現場:
暴力硬改欄位:他直接繞過狀態管理的 handle()
方法,手動把 _policy
改回去。結果呢?狀態機的 entry/exit
函式完全沒被觸發!硬體閘門沒收到指令、看板跟廣播也沒更新,留下一堆幽靈般的副作用在系統裡飄蕩。
# 壞味道:直接修改內部狀態,繞過應有的副作用觸發
class GateClusterStateful:
def __init__(self):
self._policy = "PeakLimited"
def handle(self, event):
if event == "resume":
# 正確的做法:透過狀態模式的方法來變更
print("觸發 entry/exit:關閉部分閘門、更新廣播...")
self._policy = "Normal"
gate_cluster = GateClusterStateful()
# 錯誤示範:工程師直接覆寫屬性
print(f"修改前 policy: {gate_cluster._policy}")
gate_cluster._policy = "Normal" # <-- 災難源頭!副作用全沒發生
print(f"修改後 policy: {gate_cluster._policy}")
把資料庫備份當回滾鍵:更狠的一招,直接申請整庫還原到 15 分鐘前。這下好了,不只捷運系統,連隔壁的票務、支付系統時間都亂了套,事件流和指令順序完全錯亂,導致旅客被重複扣款、收到錯誤通知。
# 壞味道:用粗暴的全域備份處理精細的業務回滾
def emergency_rollback():
print("請求還原整個捷運資料庫到 07:45 的備份...")
# db.restore_from_snapshot("metro_db_snapshot_0745") # <-- Boom!
print("結果:票務系統時間戳錯亂,交易紀錄出現未來時間...")
emergency_rollback()
大方外洩內部狀態:為了「方便」,他乾脆把 GateCluster
的內部屬性全部設成 public,讓另一個「恢復服務」可以直接讀寫。結果 Memento
快照裡的狀態,跟物件的真實狀態很快就脫鉤了。
# 壞味道:Originator 未保護其內部狀態,導致外部可任意修改
class LeakyGateCluster: # 洩漏的 Originator
def __init__(self, policy):
self.policy = policy # <-- 應為 _policy
def recovery_service(cluster):
# 外部服務直接修改了理應被封裝的狀態
cluster.policy = "Normal"
leaky_cluster = LeakyGateCluster("PeakLimited")
memento = leaky_cluster.policy # 假設簡單備份
recovery_service(leaky_cluster) # 外部服務介入
# 此時 memento 的 "PeakLimited" 跟 leaky_cluster.policy 的 "Normal" 已不一致
print(f"快照狀態: {memento}, 物件當前狀態: {leaky_cluster.policy}")
錯把報表當膠囊:他找到稽核員 Visitor 產出的「唯讀」狀態報表,試圖用它來還原系統。(旁白吐槽:兄弟,那是用來看的,不是用來吃的啊!)結果當然是還原失敗,還順便把寶貴的證據鏈給污染了。
# 壞味道:混淆唯讀報表 (DTO) 與可還原快照 (Memento) 的語義
class AuditReport: # Visitor 產出的唯讀報表
def __init__(self, policy, gate_count):
self._policy = policy
self._gate_count = gate_count
def display(self):
return f"稽核報表:當前策略 {self._policy}, 開啟閘門 {self._gate_count} 個。"
class GateCluster:
def restore(self, memento):
# 這個 restore 方法需要一個真正的 Memento 物件
# 而不是一個 AuditReport
if isinstance(memento, AuditReport):
raise TypeError("稽核報表是唯讀的,不能用於還原!")
# ...
report = AuditReport("PeakLimited", 5)
cluster = GateCluster()
# cluster.restore(report) # <-- TypeError! 報表沒有還原需要的方法和資料
一句話總結:當你需要「原子化地回到某個特定狀態」,同時又必須「維持物件的封裝性」時,請呼叫時光局(Memento)。
今天,時光局的兩位王牌英雄登場了:Yuki|時光局紀錄官 負責制定政策,而 Atlas|回滾工程師 負責維運執行。
✅ 何時用 (When to Use)
狀態變更昂貴或高風險時:就像我們的交通號誌、站務導引、票閘策略,改錯一步就可能造成巨大混亂。
需要「瞬時回復」且附帶「可稽核證據」時:在執行一個複雜的 SOP 前後,先拍下快照(Checkpoint)。一旦中間出錯,就能立刻一鍵還原,並且有明確的 run_id
可以追查。
需要「局部回滾」時:只想恢復某個車站、某個子系統的狀態,不希望影響到整個城市。
⛔ 何時不要用 (When NOT to Use)
狀態極度龐大或高頻變動時:如果狀態是像串流影片一樣連續不斷,那 Memento 的成本會太高。這種場景更適合**事件溯源(Event Sourcing)**搭配定期的快照。
僅是簡單的策略切換或固定流程:如果只是換個演算法,請優先考慮 Strategy 模式;如果是固定步驟,Template Method 就夠用了,別拿牛刀來殺雞。
導播,鏡頭拉一下!讓我們從三個不同視角,看看「時光局」在 Codetopia 藍圖中的位置。
視角 | 觀念/模式 | 在城市的說法 | 本篇落點 |
---|---|---|---|
微觀 (GoF) | Memento (Originator/Caretaker/Memento) | 物件狀態擷取、封裝、還原 | 閘機/看板/號誌/接駁巴士都是各自的 Originator |
中觀 (EIP/EDA) | Event Sourcing + Snapshotting (可選) | 事件流 + 檢查點 | run_id=PEAK-0800 作為所有快照的關聯檢查點 |
宏觀 (MAS) | Time Bureau Agent | 代理受理回滾請求,協調分域還原 | 依「號誌→看板→閘機→接駁」的順序策略進行回滾 |
這個流程完美地與 Day 21 的 SOP 骨架(Template Method)對齊:在最關鍵的
switchOver
步驟前後設置檢查點,確保通知(notify
)只會在一切成功後才發出。
這就是時光局紀錄官 Yuki 和回滾工程師 Atlas 聯手打造的、具備工業級強度的解決方案。
import hashlib
import time
import copy
# --- 輔助型別定義 ---
class AuditReport: # Visitor 專用唯讀 DTO,確保型別護欄可運作
__slots__ = ("summary",)
# --- 內部不透明的 Memento 型別 (版本化、帶雜湊) ---
class _SigMemento:
__slots__ = ("v", "payload", "hash")
def __init__(self, v, payload):
self.v = v
# 確保 payload 是不可變的,避免外部修改影響快照內容
self.payload = copy.deepcopy(payload)
self.hash = hashlib.blake2b(repr(self.payload).encode()).hexdigest()
# --- 領域物件: Originator 範例 ---
class SignalControlContext:
def __init__(self, state_machine):
self._sm = state_machine
def create_memento(self):
snapshot = {
"state": self._sm.current_state_name,
"schedule": self._sm.schedule
}
return _SigMemento(v="v1", payload=snapshot)
def restore(self, memento):
# 註解:嚴禁欄位直改!副作用需由 State entry/exit 重放,呼應反例
assert isinstance(memento, _SigMemento) and memento.v == "v1"
target_state = memento.payload["state"]
self._sm.transition_to(target_state, context={"source": "restore"})
print(f"號誌控制器:已透過狀態機還原至 {target_state} 狀態。")
def already_at(self, memento) -> bool:
# 用於冪等檢查,避免重複還原
return self._sm.current_state_name == memento.payload["state"]
# --- Caretaker(時光局) ---
class TimeBureau:
def __init__(self, store, signer):
self._store = store
self._signer = signer
def checkpoint(self, run_id, key, originator):
memento = originator.create_memento()
meta = {"v": memento.v, "created_at": time.time(), "domain": key}
signature = self._signer.sign(memento.hash)
self._store.save(run_id, key, memento, signature, meta=meta, ttl="7d")
print(f"✅ 時光局:已為 {key} 建立檢查點,ID: {run_id}")
def restore(self, run_id, key, originator):
memento, signature, meta = self._store.latest(run_id, key)
self._signer.verify(memento.hash, signature)
# 冪等性檢查:如果已經在目標狀態,則跳過
if originator.already_at(memento):
print(f"冪等操作:{key} 已在目標狀態,跳過還原。")
return
originator.restore(memento)
print(f"回滾!時光局:已將 {key} 還原至檢查點 {run_id}")
# --- SOP 骨架(對齊 Day 21 Template Method) ---
class PeakPerturbationSOP(SOPTemplate):
RESTORE_ORDER = ["Signal", "Board", "Gates", "Bus"]
def switchOver(self, ctx, scope, run_id):
# 1) 切換前強制 checkpoint(四子域)
for key, originator in ctx.principals(scope):
ctx.time_bureau.checkpoint(run_id, key, originator)
# 2) 依序執行(上游→下游),觸發狀態機與副作用
# 以 try/except 包裹,若任一步驟失敗則進入 _compensate
try:
ctx.signal(scope).handle("resume")
ctx.board(scope).handle("normalize")
ctx.gates(scope).handle("open")
ctx.bus(scope).handle("standby_off")
except Exception as e:
# 3) 失敗 → 啟動補償回滾
self._compensate(ctx, run_id)
raise
else:
# 4) 成功 → 才發出通知與稽核
ctx.audit("switchOver_ok", run_id, f"{scope} 完成")
def _compensate(self, ctx, run_id):
print("\n--- 啟動回滾程序 ---")
# 註解:回滾期間應暫存或丟棄新指令,避免狀態污染
ctx.system_guard.set_readonly(True)
try:
for key in self.RESTORE_ORDER:
originator = ctx.lookup(key)
ctx.time_bureau.restore(run_id, key, originator)
finally:
ctx.system_guard.set_readonly(False)
ctx.audit("restored", run_id, "已自動回滾至切換前狀態。")
現在,換你來當 Codetopia 的英雄了!
如果你是當時的英雄:回想一下「笑中帶淚」章節中那位工程師犯的錯。如果讓你來設計 _compensate
函式,你會如何利用 run_id
確保那四個子系統 (Signal
, Board
, Gates
, Bus
) 是以正確且安全的順序被還原的?請描述你的順序策略與理由。
設計題 (二選一):假設氣象局的訊號不穩,在 10 秒內發出了 豪雨特報 -> 解除 -> 又發布豪雨特報 的抖動訊號。這導致我們的 SOP 被反覆觸發與回滾。你會選擇:
A. 在 SOP 骨架的 preCheck 步驟裡加入一個「抑制器 (debounce)」,防止短時間內重複執行。
B. 把「抑制器」的邏輯放到更下層的**狀態機(State)或調度中心(Mediator)**裡。
請選擇一個方案,並用一句話說明你的分層考量。(提示:思考一下「SOP 骨架的不變性」和「觸發條件的可變性」應該由誰負責。)
🚩 反模式紅旗 (Red Flags):當你在程式碼中聞到以下味道,就該警惕 Memento 是否被誤用了:
破壞封裝:Caretaker (時光局) 開始嘗試讀取或修改 Memento 的內容。
膠囊過於肥胖:一個 Memento 儲存了過於龐大的狀態,導致記憶體或效能問題。
缺乏管理:產生了大量的 Memento 卻沒有清理機制 (TTL),最終塞爆儲存空間。
混淆職責:把 Memento 當成「傳遞資料的 DTO」來用,或反過來把 DTO 當 Memento。
Yuki 和 Atlas 的目光,已經望向了更遠的未來。他們知道,單純的物件回滾只是第一步。
從物件到事件流 (EIP/EDA):在事件驅動的架構中,Memento 可以作為事件流的快照邊界。當系統需要從故障中恢復時,不必從頭重放所有事件,而是先載入最近的 Memento 快照,再從那一點開始重放增量事件,大幅提升恢復速度。
從物件到代理協作 (MAS):我們可以將 Yuki 和 Atlas 的職責,具象化為一個 Time Bureau Agent。這個代理透過城市的黃頁服務(Directory Facilitator)找到所有需要協調的子系統代理,並根據預設的協定,安全地指揮它們各自執行還原操作,實現了城市級別的、去中心化的回滾治理。
當你在 Codetopia 實作自己的時光局時,請用這張清單確認:
restore()
是否透過狀態機的 API 觸發,而非直接覆寫欄位?Memento
是否包含 v
版本號,並在還原時進行檢查?TimeBureau
是否在還原前,驗證了快照的數位簽章?_compensate
是否有明確的回滾順序,並在執行期間加上了只讀鎖?AuditReport
是否在型別層級就與可還原的 Memento
徹底隔離?一句話總結:把錯誤關進時間膠囊,讓恢復成為「按流程回放」,而不是「再演一次即興災難」。
今天,我們為 Codetopia 的關鍵系統裝上了可靠的「復原鍵」。透過 Memento 模式,我們在不破壞封裝的前提下,為高風險操作提供了原子性的回滾能力,並將它與狀態機(State)的副作用重放、SOP 骨架(Template Method)的流程節點進行了深度整合。
明日預告:有了可靠的回滾機制後,城市系統的演進之路才算真正穩固。明天,我們將探討版本演進與跨域一致性的終極戰略,看看時光局如何從「回到過去」走向「治理未來」。
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌─────────────────┐ creates ┌─────────────────┐
│ Originator │◇──────────────→│ Memento │
│─────────────────│ │─────────────────│
│+createMemento() │ │ <<opaque>> │
│+restore(m) │ │ # state data │
└─────────────────┘ │ (no public │
△ │ methods) │
│ └─────────────────┘
│ inherits △
│ │ stores
┌────────┴─────────┐ │
│ │ ┌────┴─────────────┐
│ Signal Station │ │ Caretaker │
│ Control Guide │ │ (TimeBureau) │
│ Context Board │ │──────────────────│
│ │ │+save(id,key,m) │
│ Gate Bus │ │+latest(id,key) │
│ Cluster Bridge │ │+restore(id,key,o)│
│ Plan │ └──────────────────┘
└──────────────────┘
┌──────────────────┬──────────────────────────┬─────────────────────┬─────────────────────────┐
│ 視角層級 │ 觀念/模式 │ 在城市的說法 │ 本篇落點 │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 微觀 (GoF) │ Memento Pattern │ 物件狀態擷取、封裝、 │ 閘機/看板/號誌/接駁巴士 │
│ │ Originator/Caretaker/ │ 還原 │ 都是各自的 Originator │
│ │ Memento │ │ │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 中觀 (EIP/EDA) │ Event Sourcing + │ 事件流 + 檢查點 │ run_id=PEAK-0800 作為 │
│ │ Snapshotting (可選) │ │ 所有快照的關聯檢查點 │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 宏觀 (MAS) │ Time Bureau Agent │ 代理受理回滾請求, │ 依「號誌→看板→閘機→接駁」│
│ │ │ 協調分域還原 │ 的順序策略進行回滾 │
└──────────────────┴──────────────────────────┴─────────────────────┴─────────────────────────┘
SOP Template 流程:
┌─────────────────┐ 成功 ┌─────────────────┐
│ pre-switchOver │─────────────→│ notify & audit │
│ checkpoint │ └─────────────────┘
└─────────────────┘
│
▼
┌─────────────────┐ 失敗 ┌─────────────────┐
│ switchOver │─────────────→│ restore(run_id) │
│ 執行 │ │ 補償回滾 │
└─────────────────┘ └─────────────────┘
Time Bureau 操作:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 操作前 │───→│createMemento │───→│save(run_id) │
└─────────────┘ └──────────────┘ └─────────────┘
│
失敗時觸發 ▼
┌──────────────┐ ┌─────────────┐
│latest(run_id)│───→│ 執行還原 │
└──────────────┘ └─────────────┘
正常啟動順序: 緊急回滾順序:
▼ ▲
┌─────────┐ ┌─────────┐
│ Signal │ │ Signal │ ← 4. 最後恢復 (上游)
│ 號誌 │ │ 號誌 │
└─────────┘ └─────────┘
▼ ▲
┌─────────┐ ┌─────────┐
│ Board │ │ Board │ ← 3. 看板導引復位
│ 看板 │ │ 看板 │
└─────────┘ └─────────┘
▼ ▲
┌─────────┐ ┌─────────┐
│ Gates │ │ Gates │ ← 2. 閘機重開
│ 閘機 │ │ 閘機 │
└─────────┘ └─────────┘
▼ ▲
┌─────────┐ ┌─────────┐
│ Bus │ │ Bus │ ← 1. 先停接駁車 (下游)
│ 接駁 │ │ 接駁 │
└─────────┘ └─────────┘
原則:回滾時反向操作,避免依賴衝突
┌──────────────────────────────────────────────────────────────┐
│ TimeBureau (Caretaker) │
│ │
│ ┌─────────────┐ store/retrieve ┌─────────────────────┐ │
│ │ run_id: │◇────────────────→│ Memento │ │
│ │ PEAK-0800 │ │ ┌───────────────┐ │ │
│ │ │ │ │ <<OPAQUE>> │ │ │
│ │ + save() │ │ │ v: "v1" │ │ │
│ │ + restore() │ │ │ payload: {...}│ │ │
│ │ + latest() │ │ │ hash: abc123 │ │ │
│ └─────────────┘ │ └───────────────┘ │ │
│ └─────────────────────┘ │
│ ▲ ▲ │
│ │ 管理 │ 不可窺探內容 │
│ ▼ ▼ │
│ ✅ 可以:儲存、傳遞、TTL管理 ❌ 禁止:讀取、修改內部狀態 │
└──────────────────────────────────────────────────────────────┘
▲ 唯一合法的讀取/還原路徑 ▲
│ │
┌─────────────────┐ ┌─────────────────┐
│ Originator │ │ Originator │
│ │ │ │
│ + createMemento()│ │ + restore(m) │
│ │ │ │
└─────────────────┘ └─────────────────┘
創建時封裝狀態 還原時解封狀態