iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Software Development

Codetopia 新手日記:設計模式與原則的 30 天學習之旅系列 第 23

Day 23:Memento(時光局):尖峰時段的一鍵「回到剛剛」

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (23)|Memento(時光局):尖峰時段的一鍵「回到剛剛」

1) 今日熱點 (故事開場 & 痛點) ⚡️

時間:早上 07:58。地點:Codetopia 捷運中樞站。

營運中心的巨大螢幕牆上,數百個綠色光點穩定地流動,像城市清晨平穩的呼吸。空氣中瀰漫著咖啡香與一種名為「準點」的集體默契。但在交響曲即將進入最高潮的前一刻,一個不該出現的音符劃破了和諧。

牆上,代表著四線交會樞紐的主動脈號誌,那個本該如心跳般穩定閃爍的綠色光點,無預警地,轉為一片死寂的紅色。

對外行人來說,這只是一個燈號改變。但對營運中心的專家而言,這是一個災難性的預兆——這個號誌的失效,意味著後方數十班列車將發生連鎖延誤,月台人流密度將在三分鐘內衝破安全臨界點。

寂靜,只持續了半秒。他們沒有時間等待問題擴大。隨後是鍵盤急促的敲擊聲與營運官壓低聲音、不帶一丝情感的指令:「_預防性_啟動臨時人流導引 SOP!」

  • 三分之二的閘機(GateCluster)應聲關閉,螢幕上的平滑曲線瞬間被代表擁堵的紅色塊取代。

  • 月台的電子看板(StationGuideBoard)切換了導引方向,將人潮這股溫馴的河流強行改道。

  • 廣播系統開始放送制式文案,同時地面接駁巴士計畫(BusBridgePlan)的圖標被緊急點亮。

一陣高度緊張但訓練有素的操作後,混亂暫時被約束在螢幕的方格之內。

時間:08:06。

「報告!號誌回穩!」控制台传来回報。

「太好了!全體注意,恢復原態!」

然而,「恢復」這個指令,有時比觸發警報更考驗一個系統的靈魂。現場陷入了所謂的「半套恢復」窘境:一部分閘機開了,但月台看板還在指引大家去搭接駁車;廣播裡還在高喊「列車暫停服務」,而接駁巴士已經準備收隊了。

系統沒有第二次崩潰,但信心崩潰了。大家都說要「回到之前」,但問題來了:「之前」是哪一個時間點的狀態? 是關閉閘機前?還是啟動接駁車後?更重要的是,哪些單位要一起回滾?又要用什麼順序?

如果沒有一個封存了關鍵時刻的「時間膠囊」,所謂的「恢復」,不過是第二次手忙腳亂的即興演出,風險甚至更高。

(旁白曰:今天的故事,正是要為我們在 Day 20 建立的**狀態機(State)**和 Day 21 的 SOP 骨架(Template Method),裝上那個至關重要的「復原鍵」。至於 Day 22 的稽核員 Visitor?他負責看清當下,而我們今天的主角 Memento,負責回到當時。分工明確,絕不越界!)

2) 術語卡 🧭

  • GoF|Memento(時光局模式):在不破壞物件封裝的前提下,擷取其內部狀態,儲存於一個名為 Memento(備忘錄)的外部物件中,以便未來能將該物件還原到先前儲存的狀態。

  • Originator(狀態本人):真正持有狀態的物件。在今天的故事裡,SignalControlContextStationGuideBoardGateClusterBusBridgePlan 都是 Originator。

  • Memento(時間膠囊):儲存 Originator 內部狀態的快照。它對外部世界是不透明的 (opaque),只有創建它的那個 Originator 才能讀懂並用它來還原自己。

  • Caretaker(時光局管理員):負責保管 Memento 的角色,但絕對不能窺探或修改其內容。它只負責儲存、傳遞、管理這些時間膠囊。今天由新成立的「時光局」擔任此職。

3) 笑中帶淚 (反例/壞味道) 😭

讓我們倒帶一下,看看營運中心那位可憐的工程師在沒有「時光局」時,是如何把情況搞得更糟的,並附上程式碼災情現場:

  1. 暴力硬改欄位:他直接繞過狀態管理的 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}")
    
  2. 把資料庫備份當回滾鍵:更狠的一招,直接申請整庫還原到 15 分鐘前。這下好了,不只捷運系統,連隔壁的票務、支付系統時間都亂了套,事件流和指令順序完全錯亂,導致旅客被重複扣款、收到錯誤通知。

    # 壞味道:用粗暴的全域備份處理精細的業務回滾
    def emergency_rollback():
        print("請求還原整個捷運資料庫到 07:45 的備份...")
        # db.restore_from_snapshot("metro_db_snapshot_0745") # <-- Boom!
        print("結果:票務系統時間戳錯亂,交易紀錄出現未來時間...")
    
    emergency_rollback()
    
  3. 大方外洩內部狀態:為了「方便」,他乾脆把 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}")
    
  4. 錯把報表當膠囊:他找到稽核員 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! 報表沒有還原需要的方法和資料
    

4) 王牌出手 (核心觀念/何時用/不適用) 👑

一句話總結:當你需要「原子化地回到某個特定狀態」,同時又必須「維持物件的封裝性」時,請呼叫時光局(Memento)。

今天,時光局的兩位王牌英雄登場了:Yuki|時光局紀錄官 負責制定政策,而 Atlas|回滾工程師 負責維運執行。

  • 何時用 (When to Use)

    • 狀態變更昂貴或高風險時:就像我們的交通號誌、站務導引、票閘策略,改錯一步就可能造成巨大混亂。

    • 需要「瞬時回復」且附帶「可稽核證據」時:在執行一個複雜的 SOP 前後,先拍下快照(Checkpoint)。一旦中間出錯,就能立刻一鍵還原,並且有明確的 run_id 可以追查。

    • 需要「局部回滾」時:只想恢復某個車站、某個子系統的狀態,不希望影響到整個城市。

  • 何時不要用 (When NOT to Use)

    • 狀態極度龐大或高頻變動時:如果狀態是像串流影片一樣連續不斷,那 Memento 的成本會太高。這種場景更適合**事件溯源(Event Sourcing)**搭配定期的快照。

    • 僅是簡單的策略切換或固定流程:如果只是換個演算法,請優先考慮 Strategy 模式;如果是固定步驟,Template Method 就夠用了,別拿牛刀來殺雞。

5) 導播切景 (三層並置圖) 🎬

導播,鏡頭拉一下!讓我們從三個不同視角,看看「時光局」在 Codetopia 藍圖中的位置。

5.1 視角鳥瞰表

視角 觀念/模式 在城市的說法 本篇落點
微觀 (GoF) Memento (Originator/Caretaker/Memento) 物件狀態擷取、封裝、還原 閘機/看板/號誌/接駁巴士都是各自的 Originator
中觀 (EIP/EDA) Event Sourcing + Snapshotting (可選) 事件流 + 檢查點 run_id=PEAK-0800 作為所有快照的關聯檢查點
宏觀 (MAS) Time Bureau Agent 代理受理回滾請求,協調分域還原 依「號誌→看板→閘機→接駁」的順序策略進行回滾

5.2 微觀類圖(簡化)

https://ithelp.ithome.com.tw/upload/images/20251007/20178500OcxGS62i36.png

5.3 中觀流程(SOP × 時光局)

https://ithelp.ithome.com.tw/upload/images/20251007/20178500Yj5rT0bu5V.png

這個流程完美地與 Day 21 的 SOP 骨架(Template Method)對齊:在最關鍵的 switchOver 步驟前後設置檢查點,確保通知(notify)只會在一切成功後才發出。

6) 最小實作 (Python 風 pseudo code) 💻

這就是時光局紀錄官 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, "已自動回滾至切換前狀態。")

7) 鄉民出題 (動手+反模式紅旗) ❓

現在,換你來當 Codetopia 的英雄了!

  1. 如果你是當時的英雄:回想一下「笑中帶淚」章節中那位工程師犯的錯。如果讓你來設計 _compensate 函式,你會如何利用 run_id 確保那四個子系統 (Signal, Board, Gates, Bus) 是以正確且安全的順序被還原的?請描述你的順序策略與理由。

  2. 設計題 (二選一):假設氣象局的訊號不穩,在 10 秒內發出了 豪雨特報 -> 解除 -> 又發布豪雨特報 的抖動訊號。這導致我們的 SOP 被反覆觸發與回滾。你會選擇:

    A. 在 SOP 骨架的 preCheck 步驟裡加入一個「抑制器 (debounce)」,防止短時間內重複執行。

    B. 把「抑制器」的邏輯放到更下層的**狀態機(State)或調度中心(Mediator)**裡。

    請選擇一個方案,並用一句話說明你的分層考量。(提示:思考一下「SOP 骨架的不變性」和「觸發條件的可變性」應該由誰負責。)

🚩 反模式紅旗 (Red Flags):當你在程式碼中聞到以下味道,就該警惕 Memento 是否被誤用了:

  • 破壞封裝:Caretaker (時光局) 開始嘗試讀取或修改 Memento 的內容。

  • 膠囊過於肥胖:一個 Memento 儲存了過於龐大的狀態,導致記憶體或效能問題。

  • 缺乏管理:產生了大量的 Memento 卻沒有清理機制 (TTL),最終塞爆儲存空間。

  • 混淆職責:把 Memento 當成「傳遞資料的 DTO」來用,或反過來把 DTO 當 Memento。

8) 城市望遠鏡 (進階視野) 🔭

Yuki 和 Atlas 的目光,已經望向了更遠的未來。他們知道,單純的物件回滾只是第一步。

  • 從物件到事件流 (EIP/EDA):在事件驅動的架構中,Memento 可以作為事件流的快照邊界。當系統需要從故障中恢復時,不必從頭重放所有事件,而是先載入最近的 Memento 快照,再從那一點開始重放增量事件,大幅提升恢復速度。

  • 從物件到代理協作 (MAS):我們可以將 Yuki 和 Atlas 的職責,具象化為一個 Time Bureau Agent。這個代理透過城市的黃頁服務(Directory Facilitator)找到所有需要協調的子系統代理,並根據預設的協定,安全地指揮它們各自執行還原操作,實現了城市級別的、去中心化的回滾治理。

9) 驗收檢查點 (Implementation Checklist) ✅

當你在 Codetopia 實作自己的時光局時,請用這張清單確認:

  • [ ] 還原走狀態機restore() 是否透過狀態機的 API 觸發,而非直接覆寫欄位?
  • [ ] 版本化快照Memento 是否包含 v 版本號,並在還原時進行檢查?
  • [ ] 簽章驗證TimeBureau 是否在還原前,驗證了快照的數位簽章?
  • [ ] 順序與護欄:補償流程 _compensate 是否有明確的回滾順序,並在執行期間加上了只讀鎖?
  • [ ] 職責分離:唯讀的 AuditReport 是否在型別層級就與可還原的 Memento 徹底隔離?

10) 結語 & 預告

一句話總結:把錯誤關進時間膠囊,讓恢復成為「按流程回放」,而不是「再演一次即興災難」。

今天,我們為 Codetopia 的關鍵系統裝上了可靠的「復原鍵」。透過 Memento 模式,我們在不破壞封裝的前提下,為高風險操作提供了原子性的回滾能力,並將它與狀態機(State)的副作用重放、SOP 骨架(Template Method)的流程節點進行了深度整合。

明日預告:有了可靠的回滾機制後,城市系統的演進之路才算真正穩固。明天,我們將探討版本演進與跨域一致性的終極戰略,看看時光局如何從「回到過去」走向「治理未來」。


附錄:ASCII 版圖示

為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:

A.1 微觀類圖 (Memento Pattern Structure)

┌─────────────────┐    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     │                 └──────────────────┘
└──────────────────┘

A.2 三層視角對照表 (ASCII)

┌──────────────────┬──────────────────────────┬─────────────────────┬─────────────────────────┐
│    視角層級        │       觀念/模式             │    在城市的說法        │        本篇落點            │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 微觀 (GoF)       │ Memento Pattern         │ 物件狀態擷取、封裝、   │ 閘機/看板/號誌/接駁巴士   │
│                  │ Originator/Caretaker/   │ 還原                 │ 都是各自的 Originator     │
│                  │ Memento                 │                     │                         │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 中觀 (EIP/EDA)   │ Event Sourcing +        │ 事件流 + 檢查點       │ run_id=PEAK-0800 作為   │
│                  │ Snapshotting (可選)     │                     │ 所有快照的關聯檢查點     │
├──────────────────┼──────────────────────────┼─────────────────────┼─────────────────────────┤
│ 宏觀 (MAS)       │ Time Bureau Agent       │ 代理受理回滾請求,    │ 依「號誌→看板→閘機→接駁」│
│                  │                         │ 協調分域還原          │ 的順序策略進行回滾       │
└──────────────────┴──────────────────────────┴─────────────────────┴─────────────────────────┘

A.3 中觀流程圖 (SOP × 時光局)

SOP Template 流程:
┌─────────────────┐     成功     ┌─────────────────┐
│ pre-switchOver  │─────────────→│ notify & audit  │
│   checkpoint    │              └─────────────────┘
└─────────────────┘
         │
         ▼
┌─────────────────┐     失敗     ┌─────────────────┐
│  switchOver     │─────────────→│ restore(run_id) │
│    執行         │              │   補償回滾       │
└─────────────────┘              └─────────────────┘

Time Bureau 操作:
┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│  操作前     │───→│createMemento │───→│save(run_id) │
└─────────────┘    └──────────────┘    └─────────────┘
                            │
                   失敗時觸發 ▼
                   ┌──────────────┐    ┌─────────────┐
                   │latest(run_id)│───→│ 執行還原     │
                   └──────────────┘    └─────────────┘

A.4 回滾順序策略示意圖

正常啟動順序:         緊急回滾順序:
     ▼                     ▲
┌─────────┐          ┌─────────┐
│ Signal  │          │ Signal  │ ← 4. 最後恢復 (上游)
│ 號誌    │          │ 號誌    │
└─────────┘          └─────────┘
     ▼                     ▲
┌─────────┐          ┌─────────┐
│ Board   │          │ Board   │ ← 3. 看板導引復位
│ 看板    │          │ 看板    │
└─────────┘          └─────────┘
     ▼                     ▲
┌─────────┐          ┌─────────┐
│ Gates   │          │ Gates   │ ← 2. 閘機重開
│ 閘機    │          │ 閘機    │
└─────────┘          └─────────┘
     ▼                     ▲
┌─────────┐          ┌─────────┐
│ Bus     │          │ Bus     │ ← 1. 先停接駁車 (下游)
│ 接駁    │          │ 接駁    │
└─────────┘          └─────────┘

原則:回滾時反向操作,避免依賴衝突

A.5 Memento 封裝性示意圖

┌──────────────────────────────────────────────────────────────┐
│                    TimeBureau (Caretaker)                   │
│                                                              │
│  ┌─────────────┐  store/retrieve  ┌─────────────────────┐    │
│  │  run_id:    │◇────────────────→│     Memento         │    │
│  │ PEAK-0800   │                  │   ┌───────────────┐ │    │
│  │             │                  │   │ <<OPAQUE>>    │ │    │
│  │ + save()    │                  │   │ v: "v1"       │ │    │
│  │ + restore() │                  │   │ payload: {...}│ │    │
│  │ + latest()  │                  │   │ hash: abc123  │ │    │
│  └─────────────┘                  │   └───────────────┘ │    │
│                                   └─────────────────────┘    │
│       ▲                                     ▲                │
│       │ 管理                                 │ 不可窺探內容     │
│       ▼                                     ▼                │
│ ✅ 可以:儲存、傳遞、TTL管理      ❌ 禁止:讀取、修改內部狀態     │
└──────────────────────────────────────────────────────────────┘

                          ▲ 唯一合法的讀取/還原路徑 ▲
                          │                      │
                ┌─────────────────┐    ┌─────────────────┐
                │   Originator    │    │   Originator    │
                │                 │    │                 │
                │ + createMemento()│    │ + restore(m)   │
                │                 │    │                 │
                └─────────────────┘    └─────────────────┘
                  創建時封裝狀態          還原時解封狀態

上一篇
Day 22:Visitor (訪客模式):十分鐘產出三份報表,不動原物件!
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言