Files
cs-note/hzh/GIN/2-routing.md
T
2026-05-24 11:42:38 +08:00

9.3 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
路由
架构
2026-04-27 00:00

路由匹配与分组机制

概述

Gin 的路由系统是整个框架的性能核心——它用 Radix Tree(基数树/前缀树) 替代了标准库的线性匹配,在 O(n) 复杂度(n = URL 深度)内完成路由匹配。同时,路由分组(RouterGroup)提供层级组织,让 RESTful 风格的路由结构清晰可维护。

思考题:Gin 的路由匹配比 http.ServeMux 快多少?为什么?(提示:思考标准库 ServeMux 匹配一个 URL 需要遍历什么)

详细解答见:2-routing/2-routing-complexity-comparison

正文

1. 路由注册

Gin 支持所有标准 HTTP 方法,每种方法维护一棵独立的 Radix Tree:

r := gin.Default()

// 注册路由 — 本质上都是调 engine.handle(method, path, handlers)
r.GET("/users", listUsers)
r.POST("/users", createUser)
r.PUT("/users/:id", updateUser)
r.DELETE("/users/:id", deleteUser)
r.PATCH("/users/:id", patchUser)
r.HEAD("/users/:id", headUser)
r.OPTIONS("/users/:id", optionsUser)

源码级理解:

// gin/engine.go — handle 方法
func (engine *Engine) handle(httpMethod, path string, handlers HandlersChain) {
    // 1. 递增索引,生成唯一 handler 序号
    seq := engine.incrementHandlerNum()

    // 2. 把 handler 注册到对应方法的 Radix Tree
    engine.trees = engine.trees.addRoute(path, handlers)
    //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //              这就是路由树的 addRoute 操作

    // 3. 绑定 HTTP 方法 + handler 序号,供 ServeHTTP 分发
    //    最终调 handlers[seq]()
}

关键结论: Gin 路由树不是扁平的 map[string]Handler,而是 map[HTTP方法] *radix.Tree。注册/匹配路由时先按方法查,再按路径查。

2. 路由类型

Gin 支持四种路由模式,它们的匹配优先级各不相同:

类型 示例 匹配规则
静态路由 GET /users/list 精确匹配 /users/list
动态参数 GET /users/:id 匹配 /users/任意值,:id 提取值
通配符路由 GET /src/*filepath 匹配 /src/anything/here,*filepath 提取剩余路径
根路由 GET /* 匹配所有路径
// 静态路由 — 最优性能,精确匹配
r.GET("/users/list", listHandler)

// 动态参数 — 用 : 前缀
r.GET("/users/:id", getUserHandler)
// 匹配: /users/123  →  id = "123"

// 通配符 — 用 * 前缀,必须放在路径末尾
r.GET("/src/*filepath", fileHandler)
// 匹配: /src/js/app.js  →  filepath = "/js/app.js"

// 根通配符 — 兜底,匹配一切
r.GET("/*path", catchAll)

提取参数:

r.GET("/users/:id/posts/:postId", func(c *gin.Context) {
    id := c.Param("id")           // "123"
    postId := c.Param("postId")   // "abc"
    c.JSON(200, gin.H{"user": id, "post": postId})
})

思考题:为什么通配符 *filepath 只能放在路径末尾?如果写成 GET /*path/info 会发生什么?

3. Radix Tree 匹配原理

Radix Tree(压缩前缀树)是 Gin 路由的核心数据结构。对比标准库 http.ServeMux 的 map[string]Handler 线性匹配,Radix Tree 的优势在于:

实现 匹配复杂度 说明
ServeMux O(n) 逐个比较 100 条路径
Radix Tree O(d) d = URL 深度,通常 3-5 层

Radix Tree 结构示意:

graph TD
    root["根节点 /"] --> users["users"]
    root --> blog["blog"]
    users --> usersList["list 路由"]
    users --> usersId[":id 动态参数"]
    blog --> blog2024["2024"]
    blog2024 --> slug[":slug 动态参数"]

    classDef leaf fill:#eee,stroke-dasharray: 3 3
    classDef branch fill:#e1f5fe
    class usersList,usersId,slug leaf
    class root,users,blog,blog2024 branch

注册路由: /users, /users/list, /users/:id, /blog/2024, /blog/2024/:slug

匹配过程(以 /users/123 为例):

flowchart LR
    A["请求 /users/123"] --> B["根节点 /"]
    B --> C["匹配 users — 命中"]
    C --> D["匹配 123 — 动态参数 :id"]
    D --> E["到达叶子节点"]
    E --> F["返回 handler, 存入 c.Params"]

为什么快: Radix Tree 是前缀共享的——/users 和 /users/list 共享 /users 这条边,匹配一次就能决定走向。而 ServeMux 的 map 没有前缀信息,每次都要完整比较整个路径字符串。

提问: 如果一个路由树有 1000 条路由,匹配 /users/5 需要比较多少次?用 Radix Tree 和用 map 各是多少次?

4. 路由优先级

当多条路由可能匹配同一个 URL 时,Gin 按以下优先级规则决定:

graph LR
    A["静态路由"] --> B["动态参数"]
    B --> C["通配符"]
    D["最高"] -.-> A
    E["中"] -.-> B
    F["最低"] -.-> C
r := gin.Default()

// 以下三条路由,URL 为 /users/5 时匹配结果:
r.GET("/users/list", listAll)     // ← 最高优先级,精确匹配 /users/list
r.GET("/users/:id", getOne)       // ← 次优先,动态参数匹配 /users/5
r.GET("/users/*path", catchAll)   // ← 最低,通配符匹配 /users/5(如果能到达这里的话)

优先级规则总结:

路径片段类型 优先级 示例
静态字符串 最高 /users/list
动态参数 :param 中 /users/:id
通配符 *rest 最低 /users/*path

注意: 如果有两条同类型的路由(比如两条都是 :id),Gin 注册时会 panic——不允许重复。

思考题:注册路由的顺序会影响匹配结果吗?假设先注册 /users/:id 再注册 /users/list,当请求 /users/list 时,是匹配到 :id 还是 /users/list?

5. 路由分组(RouterGroup)

路由分组的核心价值:自动拼接路径前缀 + 中间件继承,避免在每个路由上重复写相同的前缀。

r := gin.Default()

// 创建分组 — 自动继承父分组的中间件
api := r.Group("/api/v1")
{
    // 完整路径: /api/v1/users
    api.GET("/users", listUsers)
    api.POST("/users", createUser)
}

users := api.Group("/users")
{
    // 完整路径: /api/v1/users/:id
    users.GET("/:id", getUser)
    users.PUT("/:id", updateUser)
    users.DELETE("/:id", deleteUser)
}

// 分组可以挂载自己的中间件 — 仅影响该分组
admin := api.Group("/admin", authMiddleware())
{
    admin.GET("/stats", adminStats)  // /api/v1/admin/stats,经过 authMiddleware
}

源码级原理:

// gin/group.go — Group() 方法
func (group *RouterGroup) Group(path string, handlers ...HandlerFunc) *RouterGroup {
    return &RouterGroup{
        handlers: group.combineHandlers(handlers),  // 合并父分组中间件 + 新中间件
        basePath: group.basePath + path,             // 前缀累加
        engine:   group.engine,                      // 共享同一个 Engine
    }
}
  • basePath:绝对路径,每次 Group() 都累加前缀
  • handlers:合并后的中间件链,子分组继承父分组的
  • engine:共享同一个引擎,所有分组共享同一个路由树

嵌套分组示意:

graph LR
    baseRoot["gin.Default"] --> apiGroup["/api"]
    apiGroup --> v1Group["/v1"]
    v1Group --> usersGroup["/users"]
    usersGroup --> finalRoute["GET /:id"]

    classDef base fill:#e1f5fe,stroke:#0288d1
    classDef leaf fill:#f3e5f5,stroke:#7b1fa2
    class baseRoot,apiGroup,v1Group,usersGroup base
    class finalRoute leaf

完整路径累加过程:/api → /api/v1 → /api/v1/users → /api/v1/users/:id

实际项目中常用的分组模式:

r := gin.Default()

// 按功能模块分组
v1 := r.Group("/api/v1")
{
    // 用户模块
    users := v1.Group("/users")
    users.Use(requireAuth())
    {
        users.GET("", listUsers)
        users.GET("/:id", getUser)
        users.PUT("/:id", updateUser)
        users.DELETE("/:id", deleteUser)
    }

    // 订单模块
    orders := v1.Group("/orders")
    {
        orders.GET("", listOrders)
        orders.POST("", createOrder)
    }
}

// 公开路由(不需要认证)
r.GET("/health", healthCheck)
r.POST("/api/v1/auth/login", login)
r.POST("/api/v1/auth/register", register)

6. 路由调试

排查路由问题的实用方法:

// 打印所有已注册的路由(含方法、路径、handler 数量)
r.GET("/ping", func(c *gin.Context) { c.String(200, "pong") })
r.GET("/users/:id", func(c *gin.Context) { c.String(200, "user") })

r.PrintRoute() // 打印到 stdout

输出示例:

│ METHOD   │ PATH         │ HANDLERS           │
├──────────┼──────────────┼────────────────────┤
│ GET      │ /ping        │ main.main.func1    │
│ GET      │ /users/:id   │ main.main.func2    │

思考题:如果注册了两条路径完全相同但 HTTP 方法不同的路由(GET /users 和 POST /users),它们会共享同一棵 Radix Tree 还是各用一棵?这对路由匹配有什么影响?

关联笔记

  • [[GIN/gin-architecture]] — Engine 如何管理路由树
  • [[GIN/middleware]] — 路由分组与中间件的继承关系
  • [[GIN/routing-advanced]] — 路由冲突排查与陷阱