🎯 什麼情境該想到我
當你「想讓每個頁面或動作各自對應一個控制器來處理請求」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:為網站上每一個邏輯頁面(或動作)設一個對應的控制器物件,處理該頁的請求。
做法要點:
- 每個頁面/動作對應一個控制器(可能是一個類別或一個處理程序)。
- 控制器負責:解讀該頁請求、呼叫模型、決定用哪個視圖顯示。
- 結構直覺、對應清楚,適合行為相對單純的網站。
★★ Go 的預設做法,而 Go 1.22 之後標準庫就夠用了
// ★ 一個路由一個 handler —— 這就是頁面控制器
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", h.ShowOrder) // ★ Go 1.22+:方法 + 路徑參數
mux.HandleFunc("POST /orders", h.CreateOrder)
mux.HandleFunc("GET /orders", h.ListOrders)★★ Go 1.22 的 ServeMux 增強模式(go doc net/http.ServeMux 查證,2026-09-25):
★ 模式格式:[METHOD ][HOST]/[PATH]
★ "GET /static/" → GET 開頭為 /static/ 的路徑
★ "example.com/{$}" → ★ {$} 表示「精確結尾」,不當前綴匹配
★ "/b/{bucket}/o/{objectname...}" → ★ {name} 單段、{name...} 吃剩餘全部
★★ 兩個容易踩的細節:
① GET 模式「同時匹配 GET 和 HEAD」
② ★ 通配必須是完整路徑段 —— "/b_{bucket}" 不是合法模式
★ 這讓「為了路由而引入 chi/gin」的理由消失了大半。
仍需要第三方的情況:路由群組的中間件、正則路由、大量路由的效能。
★★ handler 應該多薄:一個可操作的標準
// ★ 一個健康的頁面控制器 —— 四個步驟,每步一到三行
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
cmd, err := decodeCreateOrder(r) // ① ★ HTTP → command(純函式)
if err != nil { h.writeError(w, r, err); return }
view, err := h.svc.PlaceOrder(r.Context(), cmd) // ② ★ 呼叫服務層(唯一一行業務)
if err != nil { h.writeError(w, r, err); return }
w.Header().Set("Location", "/orders/"+view.ID) // ③ ★ HTTP 特有的事
h.writeJSON(w, http.StatusCreated, view) // ④ ★ 編碼回應
}★★ 判準:handler 裡有 if 做業務判斷嗎?
有(if order.Status == "shipped" { … })→ 那是業務規則,搬到服務層。
handler 裡的 if 只該是「錯誤處理」與「HTTP 協商」。
★★ 錯誤 → 狀態碼的對映必須集中在一個地方
// ★★ 每個 handler 各自判斷狀態碼 → 同一種錯誤在不同端點回不同的碼
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
var code int
switch {
case errors.Is(err, ErrNotFound): code = http.StatusNotFound
case errors.Is(err, ErrForbidden): code = http.StatusForbidden
case errors.Is(err, ErrConflict): code = http.StatusConflict // ★ 樂觀鎖衝突
case errors.Is(err, ErrValidation): code = http.StatusUnprocessableEntity
case errors.Is(err, context.Canceled): code = 499 // ★ 客戶端斷線
default:
h.log.ErrorContext(r.Context(), "unhandled", "err", err) // ★★ 記完整錯誤
code = http.StatusInternalServerError
}
writeJSON(w, code, problemDetail(code, err)) // ★ 只回安全的訊息給客戶端
}★★ default 分支的兩件事都不可省略:
① 在伺服器記完整錯誤(含堆疊與 correlation id);
② ★★ 不要把 err.Error() 直接回給客戶端——
它可能含 SQL、內部路徑、連線字串。★ 這是很常見的資訊洩漏。
★★ 三個 Go 特有的 handler 陷阱
// ① ★★ 寫了 body 之後不能再改狀態碼或 header
w.Write(data)
w.WriteHeader(http.StatusInternalServerError) // ★ 無效,而且 log 會出現 superfluous WriteHeader
// → ★ 所以「先組好完整回應,再一次寫出」
// ② ★★ r.Context() 在客戶端斷線時會被取消
// → ★ 這是好事(可以提早放棄),★★ 但不要用它當背景工作的 ctx
go doWork(r.Context()) // ★★ 請求結束就被取消
go doWork(context.WithoutCancel(r.Context())) // ✅ Go 1.21+:保留值、去掉取消
// ③ ★ r.Body 只能讀一次
// → 需要讀兩次(驗簽 + 解析)就先 io.ReadAll 存起來
// ⚠️ 而且要限制大小:http.MaxBytesReader(w, r.Body, 1<<20)
// ★★ 沒有上限 = 一個請求就能耗盡記憶體★ 什麼時候頁面控制器會撐不住
- ★ 大量端點共用同一組前置處理(認證、限流、trace)→ 用 前端控制器(middleware 鏈)。
★ 注意:兩者不衝突——middleware 處理橫切,頁面控制器處理單一端點。 - ★ 多步驟流程的「下一步是什麼」變成 handler 之間的隱性知識 → 應用控制器。
- ★ 同一個 URL 依內容有十幾種分支 → 那通常是 URL 設計問題,不是控制器問題。
★ 兄弟辨析
| 差別 | |
|---|---|
| 前端控制器 | ★ 一個入口 + 分派;★ 兩者常並存(middleware + 各端點 handler) |
| 應用控制器 | 決定「流程的下一步」 |
| 遠端外觀 | 粗粒度的遠端介面,減少往返 |
| 服務啟動器 | ★ 讓同一個服務方法也能被訊息觸發 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
各頁共通的事務(驗證、授權)容易在多個控制器間重複。
-
頁面很多且共同邏輯多時,維護分散、易漏改。
-
此時可改用前端控制器集中共同處理。
-
★★ handler 裡的
if只能是錯誤處理與 HTTP 協商。 業務判斷要搬到服務層。 -
★★ 不要把
err.Error()回給客戶端——它可能含 SQL、內部路徑、連線字串。要在伺服器記完整錯誤、只回安全訊息。 -
★★ 錯誤 → 狀態碼的對映要集中在一個函式,否則同一種錯誤在不同端點回不同的碼。
-
★★ 寫了 body 之後不能再改狀態碼。 先組好完整回應再一次寫出。
-
★★
r.Context()在客戶端斷線時被取消,不要用它當背景工作的 ctx——用context.WithoutCancel。 -
★★
r.Body必須限制大小(http.MaxBytesReader)。沒有上限,一個請求就能耗盡記憶體。 -
★ Go 1.22+ 的
ServeMux已支援方法與路徑參數,{$}表示精確結尾,GET同時匹配 HEAD,通配必須是完整路徑段。 -
r.Body只能讀一次,需要讀兩次要先io.ReadAll存起來。
🔗 相關工具
- 前端控制器 Front Controller(相對方案:所有請求集中單一入口)
- 模型-視圖-控制器 Model View Controller(頁面控制器是其控制器角色的一種實作)
- 應用控制器 Application Controller(可搭配,集中管理畫面流程)
- 企業應用架構模式
- 前端控制器 —— ★ 兩者常並存
- 應用控制器 —— 多步驟流程
- 模型-視圖-控制器 —— 三個角色的責任劃分
- 服務層 —— handler 該呼叫的對象
- 遠端外觀 —— 減少往返的粗粒度介面
- 服務啟動器 —— 多入口共用服務方法