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

294 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]]` — 路由冲突排查与陷阱