🎯 什麼情境該想到我

當你「想讓所有請求都先進同一個入口,再集中做驗證、路由等共同處理」的時候。

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

意圖:用單一物件統一接收並處理所有 Web 請求,集中共同事務、再分派給對應處理邏輯。

做法要點:

  • 拆成兩部分:處理器(Handler)與命令(Command)。
  • 處理器:所有請求的單一入口,負責共同事務(驗證、授權、記錄)並決定要執行哪個命令。
  • 命令:針對特定請求的實際處理動作,由處理器動態選擇並執行。
  • 新增行為時多半只需加命令,共同邏輯集中在一處。

★★ 今天的名字:router + middleware 鏈

// ★ 前端控制器 = 一個入口 + 一串橫切處理 + 分派
var handler http.Handler = mux                      // ★ 分派([[頁面控制器]] 們)
handler = requestID(handler)                        // ★ 由內而外包
handler = logging(handler)
handler = recoverPanic(handler)
handler = timeout(handler, 10*time.Second)
 
srv := &http.Server{Handler: handler}               // ★ 單一入口

★★ 中間件的順序就是它的語義,而順序寫錯不會有任何錯誤。
上面的包裝順序讓實際執行順序變成:
timeout → recoverPanic → logging → requestID → mux。

★★ 中間件順序的四條硬規則

① ★★ recover 必須在「所有可能 panic 的東西」外面
   ★ 但要在 logging 內側 —— 否則 panic 的請求不會被記錄
   → ✅ timeout → recover → logging → …  ❌ recover → timeout(timeout 裡 panic 就漏了)

② ★★ requestID / trace 必須最外層(或接近最外層)
   → ★ 否則它之外的中間件記的日誌沒有 correlation id(見 [[關聯識別碼]])

③ ★★ 認證要在授權之前,兩者都要在業務之前
   → ★ 而「授權」若依賴資源內容(這筆訂單是不是你的),就不能放中間件
     → ★★ 那屬於 [[服務層]](見該頁的警告)

④ ★★ 限流要在認證「之前」還是「之後」?
   ★ 之前:能擋住未認證的洪水,但無法按使用者限流
   ★ 之後:可以按使用者限流,但認證本身(可能要查 DB)已經被打了
   → ✅ ★ 兩層都要:IP 級在前、使用者級在後(見 [[工具-限流器設計]])

★★ Go 的三個中間件實作陷阱

// ① ★★ recover 中間件必須自己處理「已經寫過 header」的情況
func recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if v := recover(); v != nil {
                if v == http.ErrAbortHandler { panic(v) }    // ★★ 這個 panic 要放行
                log.Error("panic", "v", v, "stack", string(debug.Stack()))
                w.Header().Set("Connection", "close")        // ★ 連線狀態已不可信
                http.Error(w, "internal error", 500)         // ★ 若已寫過 body 這行無效
            }
        }()
        next.ServeHTTP(w, r)
    })
}
// ★★ http.ErrAbortHandler 是標準庫用來「靜默中止」的哨兵 —— 攔下它會讓行為變怪
 
// ② ★★ 想讀回應狀態碼要包一層 ResponseWriter
type statusRecorder struct{ http.ResponseWriter; code int }
func (s *statusRecorder) WriteHeader(c int) { s.code = c; s.ResponseWriter.WriteHeader(c) }
// ⚠️ ★★ 包一層會「弄丟」原本的 http.Flusher / http.Hijacker / io.ReaderFrom
//    → ★ SSE、WebSocket 升級、ServeContent 的零複製會壞掉
//    → ✅ Go 1.20+ 用 http.ResponseController 取得這些能力,或自己轉發介面
 
// ③ ★ timeout 中間件不會停止 handler 的 goroutine
//   http.TimeoutHandler 只保證「回應被截斷」,★★ handler 仍在跑
//   → ★ 真正的取消要靠 handler 自己尊重 ctx.Done()

★★ ② 是最容易被忽略、後果最隱晦的一個:
一個「只是記錄狀態碼」的中間件,讓 SSE 不 flush、讓 WebSocket 升級失敗——
而且錯誤看起來完全不像是中間件造成的。

★★ 前端控制器最大的風險:它變成一個什麼都塞的地方

★ 常見的腐敗過程:
  第 1 週:認證、日誌、trace
  第 8 週:★ 「順便」在中間件裡查使用者資料放進 context
  第 20 週:★★ 中間件裡有業務判斷(「VIP 使用者要走另一條路」)
  → ★ 於是「請求到底經過了什麼」沒有人說得清,而且每個端點都付了全部的成本

★ 兩條紀律:

① ★★ 中間件只做「所有端點都需要」的事。只有三個端點需要 → 那是 handler 的事
② ★★ 不要在中間件裡做 I/O,除非所有端點都需要那份資料
   → ★ 「順便查使用者」讓每個請求都多一次 DB 查詢,包含不需要它的健康檢查端點

★ 兄弟辨析

差別
頁面控制器★ 單一端點的邏輯;★ 兩者並存(middleware + handler)
應用控制器流程的下一步
裝飾者模式★ 中間件就是它(見該頁的順序討論)
責任鏈模式★ 相近,但責任鏈通常「誰能處理就處理並中斷」
訊息路由器訊息世界的對應物

🧪 我實際套用的紀錄

  • (待填)

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

  • 比頁面控制器複雜,簡單網站不划算。

  • 單一入口設計不當可能成為瓶頸或耦合點。

  • 需要對命令的動態載入/分派機制有掌握。

  • ★★ 中間件順序就是語義,而順序寫錯不會有任何錯誤。 recover 要在 logging 內側(否則 panic 不被記錄)、trace 要最外層。

  • ★★ 包一層 ResponseWriter 會弄丟 http.Flusher/Hijacker/io.ReaderFrom → SSE、WebSocket 升級、零複製全壞。用 http.ResponseController 或自己轉發介面。

  • ★★ http.ErrAbortHandler 要在 recover 裡放行,它是標準庫用來靜默中止的哨兵。

  • ★★ http.TimeoutHandler 不會停止 handler 的 goroutine,只截斷回應。真正的取消要靠 handler 尊重 ctx.Done()。

  • ★★ 中間件只做「所有端點都需要」的事。 只有三個端點需要 → 那是 handler 的事。

  • ★★ 不要在中間件裡做 I/O(「順便查使用者」讓健康檢查也多一次 DB 查詢)。

  • ★ 依賴資源內容的授權(這筆訂單是不是你的)不能放中間件,那屬於服務層。

  • ★ 限流要兩層:IP 級在認證前、使用者級在認證後。

  • panic 之後連線狀態不可信,要設 Connection: close。

🔗 相關工具