Files
cs-note/hhs/GIN/2-routing.md
T

294 lines
9.3 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [后端, Go, Gin, 路由, 架构]
create time: 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:
```go
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)
```
**源码级理解:**
```go
// 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 /*` | 匹配所有路径 |
```go
// 静态路由 — 最优性能,精确匹配
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)
```
**提取参数:**
```go
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 结构示意:**
```mermaid
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` 为例):**
```mermaid
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 按以下优先级规则决定:
```mermaid
graph LR
A["静态路由"] --> B["动态参数"]
B --> C["通配符"]
D["最高"] -.-> A
E["中"] -.-> B
F["最低"] -.-> C
```
```go
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)
路由分组的核心价值:**自动拼接路径前缀 + 中间件继承**,避免在每个路由上重复写相同的前缀。
```go
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
}
```
**源码级原理:**
```go
// 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`:共享同一个引擎,所有分组共享同一个路由树
**嵌套分组示意:**
```mermaid
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`
**实际项目中常用的分组模式:**
```go
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. 路由调试
排查路由问题的实用方法:
```go
// 打印所有已注册的路由(含方法、路径、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]]` — 路由冲突排查与陷阱