🎯 什麼情境該想到我

當你「想讓每個頁面或動作各自對應一個控制器來處理請求」的時候。

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

意圖:為網站上每一個邏輯頁面(或動作)設一個對應的控制器物件,處理該頁的請求。

做法要點:

  • 每個頁面/動作對應一個控制器(可能是一個類別或一個處理程序)。
  • 控制器負責:解讀該頁請求、呼叫模型、決定用哪個視圖顯示。
  • 結構直覺、對應清楚,適合行為相對單純的網站。

★★ 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 存起來。

🔗 相關工具