🎯 什麼情境該想到我
當你「想讓所有請求都先進同一個入口,再集中做驗證、路由等共同處理」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用單一物件統一接收並處理所有 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。
🔗 相關工具
- 頁面控制器 Page Controller(相對方案:每頁各自一個控制器)
- 模型-視圖-控制器 Model View Controller(前端控制器是其控制器角色的一種實作)
- 應用控制器 Application Controller(常搭配,負責決定命令後的畫面流程)
- 企業應用架構模式
- 頁面控制器 —— ★ 兩者並存
- 應用控制器 —— 流程的下一步
- 裝飾者模式 —— 中間件的本質與順序規則
- 責任鏈模式 —— 相近但會中斷
- 服務層 —— 資源級授權該放的地方
- 關聯識別碼 —— trace 要最外層的理由
- 工具-限流器設計 —— 兩層限流
- 工具-服務容錯設計 —— 超時與斷路器