# Struct tag 宣告式路由：reflect 實戰

> gortex 用 reflect 在啟動時掃 handler struct 的 tag 自動發現路由表，方法名決定 HTTP method，middleware 沿群組繼承，設定錯誤在啟動期就報錯。

原文：https://sheng.page/posts/gortex-struct-tag-routing/ · 發布：2026-02-08 · 作者：Sheng

傳統路由要你一條條 `r.GET()`。gortex 用 `reflect` 掃 struct tag，自動發現整張路由表。這篇拆 `route_registration.go` 怎麼做到。

> 前置閱讀：〈[struct tag 與 reflect](/posts/go-struct-tags-and-reflection/)〉

## 宣告式路由

大多數 Go 框架走命令式，一條路由一行 `r.GET("/users/:id", h.GetUser)`，路由表散在註冊函式裡，跟 handler 本體分兩處，改了 handler 很容易忘記同步路由。gortex 把路由寫成 handler struct 的 tag，整張表就是一個 struct：

```go
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 一路串下去。

```go
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，比對細節留到〈[Segment-trie 路由器：不靠正則的匹配](/posts/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 底下。

```go
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 會先 clone 一份再加，不會直接 `append`：

```go
currentMiddleware := make([]MiddlewareFunc, len(parentMiddleware), len(parentMiddleware)+1)
copy(currentMiddleware, parentMiddleware)
// 之後才 append 這個欄位自己的 middleware
```

理由是 slice 的 backing array 共享：如果直接在 `parentMiddleware` 上 append，同一個父層底下的兄弟欄位會共用底層陣列、互相蓋掉對方的 middleware。clone 一份，每個欄位才有獨立 chain。這就是 struct tag 那篇講的 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，留到〈[零分配的秘密：Context pool 與 smartParams](/posts/gortex-zero-allocation-context/)〉。

原始碼：[yshengliao/gortex](https://github.com/yshengliao/gortex)。

---

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 環境 Go 1.24（gortex go.mod）· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃，新漆未乾。