2026-04-27 10:10:41 +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 需要遍历什么)
|
|
|
|
|
|
|
2026-04-27 22:55:05 +08:00
|
|
|
|
> 详细解答见:[[2-routing/2-routing-complexity-comparison]]
|
|
|
|
|
|
|
2026-04-27 10:10:41 +08:00
|
|
|
|
## 正文
|
|
|
|
|
|
|
|
|
|
|
|
### 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]]` — 路由冲突排查与陷阱
|