🎯 什麼情境該想到我

當你「有複雜的畫面流程與導覽邏輯,想用一個集中物件決定下一步顯示什麼」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:用一個集中的物件管理畫面的流程與導覽——決定處理完某動作後,接著該執行什麼、顯示哪個畫面。

做法要點:

  • 由應用控制器持有「應用程式狀態 → 下一步動作/畫面」的對應。
  • 輸入控制器(頁面/前端控制器)只負責接請求,把流程決策交給應用控制器。
  • 特別適合多步驟精靈、狀態機式的流程。
  • 讓「畫面順序」這種領域決策集中、可測試、可重用於多種 UI。

★★ 它要解決的問題:「下一步是什麼」變成散落在各處的隱性知識

★ 沒有應用控制器時的註冊流程:
  handler1(填基本資料)→ 硬寫 redirect 到 /signup/verify
  handler2(驗證 email)→ 硬寫 redirect 到 /signup/profile
  handler3(填個人資料)→ 硬寫 redirect 到 /signup/plan
→ ★★ 問題:
  ① 流程長什麼樣,沒有任何一處看得到 —— 要讀完五個 handler 才能拼出來
  ② ★ 改順序(把驗證挪到最後)要改每一個 handler
  ③ ★★ 使用者直接打 /signup/plan → 沒有人檢查前面的步驟做完了沒

★★ ③ 是最嚴重的:多步驟流程若沒有集中的狀態檢查,
使用者(或攻擊者)可以跳步——而每個 handler 都以為「能到這裡就是前面做完了」。

★★ Go 的實作:狀態機 + 純函式的 transition

type SignupState string
const (
    StateBasicInfo SignupState = "basic_info"
    StateVerify    SignupState = "verify"
    StateProfile   SignupState = "profile"
    StatePlan      SignupState = "plan"
    StateDone      SignupState = "done"
)
 
// ★★ 流程定義集中在一個地方,而且是純函式 → 可以窮舉測試
func next(s SignupState, ev Event) (SignupState, error) {
    switch {
    case s == StateBasicInfo && ev == EvBasicSubmitted: return StateVerify, nil
    case s == StateVerify    && ev == EvEmailVerified:  return StateProfile, nil
    case s == StateProfile   && ev == EvProfileSaved:   return StatePlan, nil
    case s == StatePlan      && ev == EvPlanChosen:     return StateDone, nil
    }
    return s, fmt.Errorf("狀態 %s 不接受事件 %s", s, ev)   // ★★ 非法轉移明確報錯
}
 
// ★★ 每個 handler 只做自己那一步,不知道下一步是什麼
func (h *Handler) SubmitProfile(w http.ResponseWriter, r *http.Request) {
    sess, err := h.sessions.Get(r)                       // ★ 目前狀態從 session/DB 來
    if err != nil { … }
    if sess.State != StateProfile {                      // ★★ 集中的跳步檢查
        http.Redirect(w, r, urlFor(sess.State), http.StatusSeeOther)
        return
    }
    // …處理這一步…
    ns, err := next(sess.State, EvProfileSaved)
    if err != nil { … }
    sess.State = ns
    if err := h.sessions.Save(r.Context(), sess); err != nil { … }
    http.Redirect(w, r, urlFor(ns), http.StatusSeeOther) // ★ 303,避免重送
}

★★ 三個關鍵設計:
① next 是純函式 → 用表格測試窮舉所有 (狀態, 事件) 組合。
② ★★ 跳步檢查集中在一處(if sess.State != expected)——而且是「導回正確的步驟」而非報錯。
③ ★ 303 See Other(POST 之後)避免使用者重新整理時重送表單。

★★ 狀態存哪裡:這個決定比流程本身重要

存哪說明
★ URL/隱藏欄位(用戶端會話狀態)★★ 可被使用者篡改 → 一定要簽章,而且流程狀態通常不該讓客戶端決定
★ session(伺服器會話狀態)最常見;★ 但多實例時需要共享儲存
★★ 資料庫(資料庫會話狀態)★ 中斷可續(使用者三天後回來繼續)、可觀測、可分析漏斗

★★ 判準:這個流程可以「三天後繼續」嗎?
可以(KYC 審核、多階段申請)→ ★ 必須存資料庫,session 會過期。
不可以(一次填完的三頁表單)→ session 就好。

★ 存資料庫還有一個常被忽略的好處:漏斗分析。
「有多少人卡在第三步」是產品最想知道的問題,而 session 裡的狀態查不到。

★★ 它和流程管理器的界線

應用控制器流程管理器
驅動★ 使用者的下一次請求★ 訊息/事件
時間尺度分鐘★ 小時到天
參與者★ 使用者 + 一個系統多個服務
補償★ 通常不需要(沒提交就沒發生)★★ 必須有(Saga)

★ 判準:推進流程的是「人的下一個動作」還是「系統的下一個事件」?
人 → 應用控制器;系統 → 流程管理器。

★★ 兩者會並存:使用者完成註冊(應用控制器)→ 觸發開通流程(流程管理器)。

★ 什麼時候不需要應用控制器

  • ★ 只有兩步,而且順序不會變 → 硬寫 redirect 更簡單。
  • ★★ 流程在前端(SPA 自己管 wizard 狀態)→ ★ 後端只需要「每一步的驗證」與
    「最後的整體驗證」。⚠️ 但後端仍必須做跳步檢查——前端的流程控制不是安全邊界。
  • ★ 步驟之間完全獨立(可以任意順序填)→ 那不是流程,是一組欄位。

★ 兄弟辨析

差別
前端控制器★ 橫切處理與分派;應用控制器管「流程順序」
頁面控制器單一步驟的處理
流程管理器★ 系統事件驅動的長流程
以子類取代型別碼狀態機的多型實作方式

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 流程簡單(多數頁面各自獨立)時是多餘的一層。

  • 只有在畫面流程本身具備明顯邏輯/狀態時才划算。

  • ★★ 跳步檢查必須集中在一處。 每個 handler 各自假設「能到這裡就是前面做完了」= 使用者可以跳步。

  • ★★ 前端的流程控制不是安全邊界。 SPA 自己管 wizard 狀態時,後端仍必須做跳步檢查。

  • ★★ 流程狀態不該存在客戶端可篡改的地方(URL、隱藏欄位),除非有簽章。

  • ★★ 判準:這個流程可以「三天後繼續」嗎? 可以 → 必須存資料庫(session 會過期),而且順便能做漏斗分析。

  • ★ next 要是純函式,才能用表格測試窮舉所有(狀態, 事件)組合。

  • ★ POST 之後用 303 See Other,避免重新整理時重送表單。

  • ★ 跳步時要「導回正確的步驟」而非報錯,這是體驗差別很大的一個細節。

  • ★ 判準:推進流程的是人的動作還是系統的事件? 人 → 應用控制器;系統 → 流程管理器。

  • 只有兩步且順序不變 → 硬寫 redirect 更簡單,不要引入狀態機。

🔗 相關工具