Struct tag 宣告式路由:reflect 實戰
Struct tag 宣告式路由:reflect 實戰
傳統路由要你一條條 r.GET()。gortex 用 reflect 掃 struct tag,自動發現整張路由表。這篇拆 route_registration.go 怎麼做到。
前置:A2 struct tag 與 reflect(
go-struct-tags-and-reflection)。
宣告式路由
大多數 Go 框架走命令式,一條路由一行 r.GET("/users/:id", h.GetUser),路由表散在註冊函式裡,跟 handler 本體分兩處,改了 handler 很容易忘記同步路由。gortex 把路由寫成 handler struct 的 tag,整張表就是一個 struct:
type HandlersManager struct {
Home *HomeHandler `url:"/"`
Users *UserHandler `url:"/users/:id"` // 動態參數
Static *FileHandler `url:"/static/*"` // 萬用字元
API *APIGroup `url:"/api"` // 巢狀群組
Profile *ProfileHandler `url:"/profile" middleware:"auth"` // 受保護路由
Chat *ChatHandler `url:"/chat" hijack:"ws"` // WebSocket
}
app, _ := app.NewApp(app.WithHandlers(&HandlersManager{}))
reflect 在啟動時掃這個 struct,把 tag 變成實際路由。代價是啟動期的反射開銷;gortex 的對策是 production build(go build -tags production)改用產生好的靜態註冊碼,把反射留在開發期換即時回饋。
用 reflect 把 tag 變路由
進入點是 RegisterRoutes(app, manager),底層是一支遞迴函式 registerRoutesRecursiveWithMiddleware。流程是:
manager必須是 pointer-to-struct,否則直接回handlers must be a pointer to struct。autoInitHandlers先把 nil 的 handler 欄位用reflect.New補上,所以你不用每個欄位都手動&UserHandler{}。- 逐欄位掃:只處理「exported + pointer-to-struct + 有
urltag」的欄位,沒有url就跳過。 fullPath := pathPrefix + urlTag,巢狀群組把父層 prefix 一路串下去。
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
urlTag := field.Tag.Get("url")
if urlTag == "" {
continue // 沒掛 url 的欄位不是路由
}
fullPath := pathPrefix + urlTag
// …接著讀 middleware / ratelimit / hijack tag,註冊或往下遞迴…
}
(簡化自 core/app/route_registration.go。)url 之外,gortex 還認 middleware、ratelimit、hijack、inject 四個 tag,後面逐一講。掃出來的路由會送進 gortex 自己的 segment-trie router,比對細節留到 B3〈Segment-trie 路由器〉(gortex-segment-trie-router)。
方法名對應 method、kebab 路徑
路由的 HTTP method 不寫在 tag,而是看 handler 上的方法名:
- 方法名剛好是標準 verb(
GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS)→ 註冊成對應 verb,路徑就是欄位的url。 - 其他名字一律當「自訂方法」→ 方法名 camelCase 轉 kebab-case,一律註冊成 POST,路徑接在欄位 url 底下。
func (h *UserHandler) GET(c types.Context) error { /* GET /users/:id */ }
func (h *UserHandler) DELETE(c types.Context) error { /* DELETE /users/:id */ }
func (h *UserHandler) Profile(c types.Context) error { /* POST /users/:id/profile */ }
注意 Profile 不會變成 GET,是 POST;就算命名成 GetProfile 也一樣是 POST。想替自訂端點指定 verb,目前只能用標準方法名,method tag 在原始碼註解裡標為「未來版本」,還沒實作(後續版本需再驗證)。
middleware 遞迴繼承
群組掛的 middleware 會沿著 struct 樹往下傳:/admin 群組掛 middleware:"auth",底下所有子 handler 都自動帶 auth。實作上有個容易踩雷的細節:父層的 chain 不是直接 append,而是先 clone 一份再加:
currentMiddleware := make([]MiddlewareFunc, len(parentMiddleware), len(parentMiddleware)+1)
copy(currentMiddleware, parentMiddleware)
// 之後才 append 這個欄位自己的 middleware
理由是 slice 的 backing array 共享:如果直接在 parentMiddleware 上 append,同一個父層底下的兄弟欄位會共用底層陣列、互相蓋掉對方的 middleware。clone 一份,每個欄位才有獨立 chain。這就是 A2 講的 reflect 撞上 Go slice 語意的實戰版。
ratelimit:"100/min" 也是在這層解析(parseRateLimit);它建立的 limiter store 會註冊回 app,Shutdown 時一起收掉背景 cleanup goroutine,不會每條路由漏一隻。
失敗要大聲
宣告式路由最大的風險是「設定錯了卻靜默放行」。gortex 的選擇是啟動就炸,不要等上線才發現:
middleware:"auth"但沒註冊對應的MiddlewareFunc→ 報錯,不會把你以為受保護的路由裸著註冊。原始碼註解講得很直接:少掉auth會「expose a route the developer believes is protected」。middleware:"rbac"→ RBAC 沒實作,直接報錯,要你自己註冊一個叫rbac的 middleware。- 不認得的 middleware 名字 → 報錯並列出已知名稱(
auth、requestid、recover、rbac)。 inject:""tag → DI 還沒實作;欄位若是 nil 會報錯要你手動賦值,而不是留個 nil pointer 等之後 panic。ratelimit格式壞掉(不是<數字>/<sec|min|hour>)→ 報錯。
這些錯誤都發生在 NewApp / RegisterRoutes 當下,不是 request 進來才爆。宣告式的便利,建立在「錯誤無法被忽略」上。
重點整理
- 宣告式路由把整張路由表集中到一個 handler struct,reflect 啟動掃一次;production build 改用 codegen 把反射省掉。
- HTTP method 由方法名決定:標準 verb 對應同名方法,其餘一律 POST + kebab 路徑(要指定 verb 目前得用標準名)。
- middleware 沿群組樹遞迴繼承;務必 clone 父 chain,否則兄弟欄位會因共用 slice backing array 互相汙染。
- 設定錯就在啟動報錯:未知 middleware、未實作的 rbac / inject、壞掉的 ratelimit,都不靜默放行。
- reflect 的成本一次付在啟動期,hot path 不碰反射,request 路徑怎麼做到 0 allocation,留到 B4〈零分配的秘密:Context pool 與 smartParams〉(
gortex-zero-allocation-context)。
原始碼:yshengliao/gortex。
大綱 Sheng,內文 Claude 協助 · 環境 Go 1.24(gortex go.mod)· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃,新漆未乾。