註冊表分派 side effect(Registry + Strategy Handler)

核心概念:用一張 以 key 為索引的 handler 註冊表(factory/registry)+ Strategy 介面(每個 handler 自帶判斷、去重、執行),把「一堆 closed job 各自要觸發什麼副作用」從一長串 if/else 收斂成開放封閉的分派。dispatcher 只負責查表、去重、呼叫,完全不認得任何一種 workflow 的細節。 具體案例:CarbonX / Souffle MR !1134 — closed-job toast handler framework。

我做了什麼

在 project activity indicator 的輪詢資料上,加一層通用的「closed job → 副作用」分派框架:

  • 註冊表(factory) CLOSED_JOB_HANDLERS: Record<workflowType, ClosedJobHandler>。以 workflowType 字串當 key 選出行為;兩個 bulk-create workflow 共用同一個 handler 實例。
  • Strategy 介面 ClosedJobHandler:三個 seam 分工清楚
    • shouldHandle(job) — 這筆該不該處理(例如 COMPLETEDhasNewTransportationSegments
    • getDedupeKey(job) — 去重的 key(用 runId
    • run(job, ctx) — 真正的副作用,但只能透過 ctx 的能力做事
  • 能力介面(依賴反轉) ClosedJobHandlerContext — handler 不 import 任何 React hook / toast,只拿到被注入的 showTransportationSegmentsToastshowBulkCreatedActivityDataToast。handler 因此是純資料/邏輯,好測、好搬。
  • dispatcher hook useClosedJobHandlers:讀 closedJobs → 查表 → shouldHandle → 去重 → 先 mark 再 run(擋 re-entrant 重複觸發)。
  • 共用去重 infra closedJobHandledStorage:全域 localStorage { runId: timestamp },15 分 TTL、寫入時 prune,try/catch fail-safe。跨輪詢、跨重新整理都只觸發一次。
  • presenter 各自成 hookuseNewTransportationSegmentsToast(常駐可關、per-runId toast id、切換專案時關掉)、useBulkCreatedActivityDataToast(自動消失的成功 toast)。決策在 dispatcher、呈現在 presenter,分離。
flowchart TD
    poll["closedJobs(來自輪詢 store)"] --> disp
    subgraph disp["useClosedJobHandlers(dispatcher)"]
        lookup["查 CLOSED_JOB_HANDLERS[job.workflowType]"]
        guard{"shouldHandle(job)?"}
        dedupe{"hasHandledJob(dedupeKey)?"}
        mark["markJobHandled → handler.run(job, ctx)"]
        lookup --> guard
        guard -->|否| skip["跳過"]
        guard -->|是| dedupe
        dedupe -->|已處理| skip
        dedupe -->|沒有| mark
    end
    mark -->|ctx 能力| pres["presenter hook(toast)"]

    classDef reg fill:#e3f2fd,stroke:#1565c0,color:#0d1b2a;
    classDef infra fill:#fff8e1,stroke:#f9a825,color:#0d1b2a;
    class lookup reg;
    class dedupe,mark infra;

關鍵程式碼(只留骨架)

// closedJobHandlers.ts — Strategy 介面 + 註冊表(factory)
interface ClosedJobHandlerContext {          // 注入能力:依賴反轉,handler 不碰 React/toast
  showTransportationSegmentsToast: (runId: string) => void;
  showBulkCreatedActivityDataToast: (count: number) => void;
}
interface ClosedJobHandler {
  shouldHandle: (job: InFlightJobItem) => boolean;   // 該不該處理
  getDedupeKey: (job: InFlightJobItem) => string;    // 去重 key
  run: (job: InFlightJobItem, ctx: ClosedJobHandlerContext) => void;  // 只透過 ctx 做事
}
const CLOSED_JOB_HANDLERS: Record<string, ClosedJobHandler> = {
  [TRANSPORTATION_AGGREGATE_WORKFLOW]: {
    shouldHandle: (job) => job.status === COMPLETED && job.hasNewTransportationSegments === true,
    getDedupeKey: (job) => job.runId,
    run: (job, ctx) => ctx.showTransportationSegmentsToast(job.runId),
  },
  // 兩個 bulk-create workflow 共用同一 handler 實例…
};
// useClosedJobHandlers.ts — dispatcher 核心:查表 → 守衛 → 去重 → 先 mark 再 run
closedJobs.forEach((job) => {
  const handler = CLOSED_JOB_HANDLERS[job.workflowType];
  if (!handler?.shouldHandle(job)) return;
  const key = handler.getDedupeKey(job);
  if (hasHandledJob(key)) return;
  markJobHandled(key);        // ★ 先 mark 再 run,擋 re-entrant 重複觸發
  handler.run(job, ctx);
});
// closedJobHandledStorage.ts — 去重 infra:localStorage + TTL + fail-safe
export const markJobHandled = (runId: string) => {
  try {
    const now = Date.now();
    const pruned = prune(read(), now);   // 寫入時剪掉過期,storage 不會無限成長
    pruned[runId] = now;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
  } catch { /* best-effort:storage 掛了就降級成「去重失效」,不弄壞頁面 */ }
};

優點

依賴圖說明為什麼這些優點成立:ClosedJobHandlerContext(抽象)是樞紐,handler(高層策略)和 presenter(低層細節)都指向它 → 依賴反轉;dispatcher 只依賴註冊表 + 去重 infra + 抽象,不認得任何 concrete workflow → 開放封閉。

graph TD
    disp["useClosedJobHandlers<br/>dispatcher"]
    reg["CLOSED_JOB_HANDLERS<br/>註冊表(factory)"]
    infra["closedJobHandledStorage<br/>去重 infra"]
    ctx(["ClosedJobHandlerContext<br/>«能力介面 / 抽象»"])
    h["ClosedJobHandler<br/>strategy(高層策略)"]
    p1["useNewTransportationSegmentsToast<br/>presenter(低層細節)"]
    p2["useBulkCreatedActivityDataToast<br/>presenter(低層細節)"]

    disp --> reg
    disp --> infra
    reg --> h
    disp -. 建構並注入 .-> ctx
    h -. 只依賴抽象 .-> ctx
    p1 -. 實作能力 .-> ctx
    p2 -. 實作能力 .-> ctx

    classDef abs fill:#ede7f6,stroke:#5e35b1,color:#0d1b2a,stroke-width:2px;
    classDef hi fill:#e3f2fd,stroke:#1565c0,color:#0d1b2a;
    classDef lo fill:#e8f5e9,stroke:#2e7d32,color:#0d1b2a;
    class ctx abs;
    class disp,reg,infra,h hi;
    class p1,p2 lo;
  • 開放封閉:新增一種 workflow 反應=在註冊表加一筆 + (必要時) 一個 presenter,dispatcher 迴圈完全不動
  • 關注點分離:判斷(shouldHandle)/去重(getDedupeKey)/副作用(run)/呈現(presenter)四層各管一件事。
  • 依賴反轉、好測:handler 不碰 React / toast,靠 ctx 注入能力 → 測試直接 mock presenter,不用真的渲染 toast。
  • 冪等內建:localStorage + TTL + 「先 mark 再 run」把「至多觸發一次」做進基礎設施,撐得住輪詢與 reload。
  • fail-safe:storage 例外時吞掉、降級成「去重失效」而非整頁壞掉。
  • 可複用:多個 workflow 共用同一 handler 實例。
  • 測試涵蓋完整(去重、TTL、專案切換關 toast、count=0 不觸發…)。

缺點 / 取捨

  • 能力介面會膨脹 → 詳見 共用能力 context 會隨副作用種類膨脹:每加一種副作用就撐大共用的 ClosedJobHandlerContext,dispatcher 又受 rules of hooks 限制必須無條件備齊。改善方案見 用 reaction hook 讓副作用各自帶依賴
  • handler 難以吃 runtime 狀態:handler 是 module-level 靜態物件,任何動態值(enterprise 設定、feature flag)都得先在 hook 裡塞進 ctx 才拿得到。
  • 隱性跨服務不變式:15 分 TTL 必須 > 後端 CLOSED_DISPLAY_WINDOW_MS(5 分),這條契約只寫在註解裡。後端一改,前端可能悄悄重複觸發或漏觸發。
  • 1 個 workflowType 只能對 1 個 handler:同一 workflow 想有多種反應得手動組合。
  • 全域單一 localStorage key、未依 enterprise/project 分租:runId 是 UUID 幾乎不撞,但多分頁併發寫入時 prune 的 last-write-wins 有輕微 race,且無跨分頁鎖。
  • 呈現策略微微外洩:哪種 toast 切換專案要關(只有 transportation 記進 shownRunIdsRef)這個政策寫在 dispatcher 裡。

之後可以怎麼擴充

  1. 新增反應(最常見):加一個 WORKFLOW 常數 → 註冊表加一筆 handler →(若需新副作用)擴 ctx + 新 presenter。既有 dispatcher 不動。
  2. 一對多 handler:把 Record<string, ClosedJobHandler> 改成 Record<string, ClosedJobHandler[]> 並迭代,支援同 workflow 多反應。
  3. 讓副作用各自帶依賴 → 詳見 用 reaction hook 讓副作用各自帶依賴:改成每個反應是一個自帶依賴的 hook,解決 ctx bloat 與靜態限制。
  4. 拆掉跨服務隱性契約:把 close-window 從後端 DTO 帶出來,或加一條 contract test,把 15 分 vs 5 分的耦合顯性化。
  5. 去重 key 泛化getDedupeKey 已是 seam,可改成 runId + segmentType 之類更細的粒度。

相關