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。流程是:

  1. manager 必須是 pointer-to-struct,否則直接回 handlers must be a pointer to struct
  2. autoInitHandlers 先把 nil 的 handler 欄位用 reflect.New 補上,所以你不用每個欄位都手動 &UserHandler{}
  3. 逐欄位掃:只處理「exported + pointer-to-struct + 有 url tag」的欄位,沒有 url 就跳過。
  4. 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 還認 middlewareratelimithijackinject 四個 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 名字 → 報錯並列出已知名稱(authrequestidrecoverrbac)。
  • 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 翻新計劃,新漆未乾。