🎯 什麼情境該想到我
當你「想把輸入處理、畫面顯示、資料與邏輯三者分離,讓它們各自獨立變動」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把使用者介面拆成三個角色,降低耦合、便於各自替換與測試。
做法要點:
- 模型(Model):領域資料與邏輯,不知道 UI 的存在。
- 視圖(View):把模型呈現到畫面,只負責顯示。
- 控制器(Controller):處理使用者輸入,操作模型並選擇要顯示的視圖。
- 關鍵是把「輸入的處理」(控制器)和「輸出的呈現」(視圖)分開。
★★ 2002 年的 MVC 和今天的「MVC」不是同一件事
★ PoEAA 的 MVC(伺服器端):
Controller 收請求 → 操作 Model → 選一個 View → View 渲染 HTML → 回傳整頁
→ ★ 一次請求產生一整頁
★★ 今天的主流(前後端分離):
Go 後端:handler → service → 回傳 JSON ← ★ 這裡沒有 View
前端: React/Vue 元件樹 + 狀態管理 ← ★ 這裡沒有 Controller
→ ★★ 「MVC」這三個角色被切到了網路的兩側,而中間是 [[資料傳輸物件]]
★★ 所以在 Go 後端談 MVC 容易誤導。 更準確的說法是:
handler(頁面控制器)→ 服務層 → DTO,
而「View」若存在,它是 樣板視圖(SSR)或根本在瀏覽器裡(轉換視圖)。
★★ MVC 真正不變的核心:一條依賴方向
★ 唯一真正重要的規則:Model 不可以認識 View 或 Controller。
→ ① 領域邏輯可以在沒有 HTTP 的情況下測試
② ★ 同一個 Model 可以被 HTTP、gRPC、CLI、訊息、排程器使用(見 [[服務啟動器]])
③ 換 UI 不必改業務規則
// ❌★★ Model 認識了 HTTP —— 這是最常見的違反
func (o *Order) Render(w http.ResponseWriter) { … } // ★ 領域型別依賴 net/http
func (s *Service) Place(w http.ResponseWriter, r *http.Request) error // ★ 同上
// ✅ 依賴方向正確
func (s *Service) Place(ctx context.Context, cmd PlaceCommand) (OrderView, error)
func (h *Handler) Place(w http.ResponseWriter, r *http.Request) { // ★ Controller
cmd, err := decode(r) // ★ HTTP → command
if err != nil { writeErr(w, err); return }
view, err := h.svc.Place(r.Context(), cmd) // ★ 呼叫 Model 側
writeJSON(w, view, err) // ★ view → HTTP
}★★ 判準:grep -rn "net/http" internal/domain/ 有結果嗎?
有 → 依賴方向錯了。這是一個可以放進 CI 的檢查(depguard linter)。
★★ 三個角色的責任清單(照這個分,就不會有「這段該放哪」的爭論)
| 責任 | ★ 不可以做 | |
|---|---|---|
| Controller(handler) | 解析請求、驗證格式、呼叫服務、選擇回應形式與狀態碼 | ★★ 業務規則、交易邊界、授權判斷 |
| Model(領域 + 服務層) | 業務規則、不變式、交易邊界、授權 | ★★ 認識 HTTP/樣板/JSON 格式 |
| View(樣板/JSON 編碼) | 呈現 | ★★ 查資料庫、做業務判斷 |
★★ 最常被放錯的三件事:
① ★★ 授權判斷放 Controller → 訊息/CLI 入口直接繞過(見 [[服務層]])
② ★★ 交易邊界放 Controller → 訊息入口會忘記開交易
③ ★★ View 裡查資料庫(樣板呼叫一個 lazy 的 getter)→ ★ N+1,而且在樣板裡完全看不出來
★★ 「胖 handler」是 Go 最常見的 MVC 退化
// ★★ 一個 300 行的 handler:解析 + 驗證 + 交易 + 業務規則 + 組 JSON
func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
// 50 行解析與驗證
// 30 行權限檢查
// 150 行業務邏輯與 SQL
// 40 行組 response
}
// ★ 後果:① 測一條業務規則要起 HTTP server
// ② 同樣的規則從訊息進來時只能複製一份
// ③ ★★ 這個函式永遠不會被重構,因為它太大了★ 拆的順序:
第 1 步 ★ 把「解析請求 → command struct」抽出去(純函式,好測)
第 2 步 ★★ 把業務邏輯移到 svc.PlaceOrder(ctx, cmd) —— ★ 這一步收益最大
第 3 步 ★ 把「結果 → JSON」抽成 view 組裝函式
→ ★ 三步做完,handler 剩 10 行,而且業務邏輯可以被訊息入口共用
★ 兄弟辨析
| 差別 | |
|---|---|
| 頁面控制器 | ★ 一個 URL 一個 controller(★ Go 的預設) |
| 前端控制器 | ★ 一個入口處理全部請求(= router + middleware) |
| 應用控制器 | ★ 處理「多步驟流程的下一步是什麼」 |
| 樣板視圖、轉換視圖 | 兩種 View 的實作方式 |
| 服務層 | ★ Model 側對外的邊界 |
| 觀察者模式 | ★ 原始 MVC 裡「Model 通知 View」的機制(★ 伺服器端 MVC 用不到) |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
對很簡單的畫面而言,三分拆會顯得過度。
-
原始的視圖觀察模型機制在 Web 無狀態環境下未必適用(多改為請求驅動)。
-
團隊需理解職責邊界,否則邏輯容易又漏回視圖或控制器。
-
★★ 唯一真正重要的規則:Model 不可以認識 View 或 Controller。 可以用
depguard把「domain 不得 import net/http」放進 CI。 -
★★ 授權判斷與交易邊界屬於服務層,不屬於 handler。 放 handler 會讓訊息/CLI 入口繞過。
-
★★ View 裡不可以查資料庫。 樣板呼叫 lazy getter 造成的 N+1 在樣板裡完全看不出來。
-
★★ 「胖 handler」是 Go 最常見的退化。拆解順序:解析 → 業務邏輯移到服務 → 組 view,第二步收益最大。
-
★ 在 Go 後端談「MVC」容易誤導。前後端分離後三個角色被切到網路兩側,更準確的說法是 handler → 服務層 → DTO。
-
★ 原始 MVC 的「Model 通知 View」(觀察者)在伺服器端請求/回應模型裡用不到。 不要為此引入事件機制。
-
Controller 只能決定「回應的形式與狀態碼」,不能決定「這件事該不該做」。
🔗 相關工具
- 頁面控制器 Page Controller(實作控制器角色的一種方式)
- 前端控制器 Front Controller(實作控制器角色的另一種方式)
- 樣板視圖 Template View(實作視圖角色的常見手法)
- 企業應用架構模式
- 頁面控制器、前端控制器 —— 兩種 Controller 組織方式
- 應用控制器 —— 多步驟流程
- 樣板視圖、轉換視圖、兩步驟視圖 —— 三種 View
- 服務層 —— Model 側的邊界
- 資料傳輸物件 —— 跨網路的 View 資料
- 服務啟動器 —— 同一個 Model 服務多個入口