vault backup: 2026-04-27 10:10:41
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 索引]
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# Gin 框架学习笔记
|
||||
|
||||
## 概述
|
||||
|
||||
本文件夹为 Go 语言 Gin 框架的系统学习笔记。`[[Go 后端基础]]` 已涵盖 Gin 的快速上手(路由分组、中间件、参数绑定、错误处理),本笔记群在此基础上**深入和拓展**,聚焦 Gin 框架特有的机制和工程实践。
|
||||
|
||||
本索引以 [Gin 官方文档](https://gin-gonic.com/zh-cn/docs/) 目录为骨架,结合教学逻辑重新组织——**不是逐条翻译官网,而是把相关联的知识点合并成体系化的笔记**。
|
||||
|
||||
## 笔记索引
|
||||
|
||||
### 一、核心机制(必读)
|
||||
|
||||
> 这些笔记帮你理解 Gin 的底层工作原理,是读懂源码和排查问题的基础。
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 1 | `gin-architecture.md` | 介绍 + 快速开始 | 整体架构:Engine、RouterGroup、Context 的关系;请求生命周期全链路(Mermaid 时序图);Gin 如何桥接 `net/http` |
|
||||
| 2 | `routing.md` | 路由 + 路由分组 + 重定向 | 路由匹配算法(Radix Tree 基数树);静态/动态/通配符路由的优先级;路由分组与前缀累加原理;重定向 `c.Redirect` |
|
||||
| 3 | `middleware.md` | 使用中间件 + 自定义中间件 + 中间件中的 Goroutine + 安全头 | 中间件链执行顺序(全局 → 分组 → 路由);`gin.HandlerFunc` 的本质;常用中间件实现模板(CORS、限流、鉴权、RequestID、安全头);中间件中启动 Goroutine 的陷阱与 `c.Copy()` 用法 |
|
||||
| 4 | `context-lifecycle.md` | 上下文与取消 | `*gin.Context` 底层设计:keys/values map、Request/RW 包装;`c.Set/Get/GetString`;`c.Copy()` 的深拷贝边界;请求级 `context` 传播与取消 |
|
||||
| 5 | `binding-validation.md` | 模型绑定和验证 + 自定义验证器 + 绑定查询字符串 + 绑定自定义反序列化器 + 绑定请求头 + 绑定 URI + 绑定 HTML 复选框 | `ShouldBind` 全家桶(JSON、form、query、header、uri);`binding` 标签内置规则速查;自定义 Validator(`.RegisterValidation`);自定义反序列化器(`bind.DeferredBinder`);数组集合格式(`UserIds[]`) |
|
||||
|
||||
### 二、进阶功能
|
||||
|
||||
> 这些是日常开发中高频使用、但容易踩坑的场景。
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 6 | `error-handling.md` | 错误处理中间件 | `c.Error` → `c.Errors` 链式错误收集;全局错误处理器 `gin.Recovery` 定制;HTTP 状态码与业务码的映射;统一错误响应中间件 |
|
||||
| 7 | `binding-advanced.md` | Multipart/Urlencoded 表单 + Map 作为参数 + 绑定查询字符串或 POST 数据 + 表单默认值 + 使用自定义结构体标签绑定 + 将请求体绑定到不同的结构体 + 数据绑定 | 表单绑定深入:`c.ShouldBind()` 的多内容类型自动检测;Map 绑定(`binding:"-"` 跳过字段);查询参数与 POST body 混合绑定;字段默认值策略;按条件绑定不同结构体(`ShouldBindBodyWith`) |
|
||||
| 8 | `file-upload.md` | 文件上传(单文件/多文件/限制大小) | `c.ShouldBindFiles`;单文件/多文件上传流程;`MaxMultipartMemory` 内存限制;文件类型/大小校验;分片上传思路 |
|
||||
| 9 | `response-rendering.md` | XML/JSON/YAML/ProtoBuf 渲染 + SecureJSON + JSONP + AsciiJSON + 渲染 + PureJSON | 渲染全家桶:`c.JSON`、`c.XML`、`c.YAML`、`c.ProtoBuf`;`SecureJSON`(防 JSON 劫持);`PureJSON`(保留原始 Unicode);`AsciiJSON`(中文转 Unicode);`JSONP` |
|
||||
| 10 | `template-rendering.md` | HTML 渲染 + 多模板 + 将模板构建到单一二进制中 | `c.HTML` / `LoadHTMLGlob` / `LoadHTMLFiles`;多模板(`Template.FuncMap`);`embed.FS` 将模板打包进二进制 |
|
||||
| 11 | `static-files.md` | 提供静态文件 + 从文件提供数据 + 从 Reader 提供数据 | `Static` / `StaticFS` / `StaticFile`;自定义文件服务器;`io.Reader` 直接返回文件流 |
|
||||
|
||||
### 三、服务器与部署
|
||||
|
||||
> 涉及 Gin 服务器的配置、运行方式和部署策略。
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 12 | `server-config.md` | 自定义 HTTP 配置 + 服务器配置 + 支持 Let's Encrypt + Cookie + 可信代理 | `gin.New()` 自定义 Engine;`http.Server` 高级配置(超时、KeepAlive);TLS/Let's Encrypt;Cookie 操作(`c.SetCookie` / `c.GetCookie`);可信代理链(X-Forwarded-For) |
|
||||
| 13 | `graceful-shutdown.md` | 优雅重启或停止 | `server.Shutdown()` + 信号监听(SIGINT/SIGTERM);等待请求处理完毕再退出;优雅重启(fork + exec)思路 |
|
||||
| 14 | `logging.md` | 如何写入日志文件 + 自定义日志格式 + 跳过日志记录 + 控制输出着色 + 避免记录查询字符串 + 定义路由日志格式 + 日志 + 结构化日志 | 日志器替换(`gin.DefaultWriter`);自定义日志格式;结构化日志(zap/logr 接入);跳过特定路径日志;路由日志格式定制 |
|
||||
| 15 | `advanced-running.md` | 运行多个服务 + HTTP/2 服务器推送 | 单进程多监听端口;gRPC + HTTP 共存;HTTP/2 push 场景 |
|
||||
|
||||
### 四、工程实践
|
||||
|
||||
> 把 Gin 用到生产级别的实践。
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 16 | `project-structure.md` | 依赖注入模式 | 标准项目目录结构(cmd/internal/handler/service/model/repository);模块化路由注册;依赖注入模式(手动 vs 依赖注入容器) |
|
||||
| 17 | `testing.md` | 测试 | `httptest` + `github.com/gin-gonic/gin/test`;Mock `*gin.Context`;中间件单独测试;基准测试(`Benchmark`) |
|
||||
| 18 | `observability.md` | 健康检查 + 指标与监控 | `/health`、`/ready`、`/metrics` 端点;Prometheus 指标接入;链路追踪(OpenTelemetry) |
|
||||
| 19 | `websocket.md` | WebSocket 支持 | Gin + gorilla/websocket 集成;Upgrade 握手;读写超时控制;广播推送 |
|
||||
| 20 | `session-auth.md` | 会话管理 | Cookie Session 实现;JWT 认证中间件;RBAC 权限控制中间件 |
|
||||
| 21 | `grpc-gateway.md` | — | Gin 作为 gRPC 服务的 HTTP 网关(gRPC-Gateway 原理) |
|
||||
|
||||
### 五、构建与优化
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 22 | `build-and-perf.md` | 使用 JSON 替换构建 + 不使用 MsgPack 构建 + 构建标签 + 基准测试 | 构建标签(`// +build`);替换 JSON 编码器(json-iterator);禁用 MsgPack;基准测试编写与解读 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- `[[Go 后端基础]]` — Gin 快速上手,已涵盖基础用法
|
||||
- `[[HTTP 协议]]` — HTTP 协议基础
|
||||
- `[[API 设计]]` — API 设计规范与错误码体系
|
||||
- `[[数据库基础]]` — Go 操作数据库
|
||||
- `[[部署与运维基础]]` — Go 应用部署
|
||||
|
||||
## 学习路线建议
|
||||
|
||||
```
|
||||
快速上手 (Go 后端基础)
|
||||
↓
|
||||
核心机制 (序号1-5) ← 读懂源码、排查问题的关键
|
||||
↓
|
||||
工程实践 (序号16-21) ← 项目实战必备
|
||||
↓
|
||||
进阶功能 (序号6-15) ← 按需深入
|
||||
↓
|
||||
构建与优化 (序号22) ← 性能调优阶段
|
||||
```
|
||||
|
||||
共 **22 篇笔记**,其中 16 篇高优先级(序号1-11、16-21),6 篇按需展开(序号12-15、22)。
|
||||
@@ -0,0 +1,417 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 绑定, 校验]
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# 模型绑定和验证
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的绑定系统是自动将 HTTP 请求数据解析到 Go 结构体的核心机制。一条 `c.ShouldBindJSON(&req)` 背后经历了内容类型检测、格式解析、类型转换、标签校验等多步流程。掌握它的原理和陷阱,能大幅减少 API 开发中的边界 case。
|
||||
|
||||
思考题:Gin 的 `c.ShouldBind()` 能自动区分 JSON 和 form data 吗?它是怎么判断的?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. ShouldBind 全家桶
|
||||
|
||||
Gin 提供了一套统一的绑定方法,根据数据来源选择对应的方法:
|
||||
|
||||
| 方法 | 数据来源 | 适用场景 |
|
||||
|------|----------|----------|
|
||||
| `c.ShouldBindJSON(&v)` | `Content-Type: application/json` | RESTful API body |
|
||||
| `c.ShouldBindJSON(&v)` | `Content-Type: application/xml` | XML 请求 |
|
||||
| `c.ShouldBindQuery(&v)` | URL 查询参数 | `/api/users?page=1` |
|
||||
| `c.ShouldBind(&v)` | 自动检测 | JSON / form / query 自动选 |
|
||||
| `c.ShouldBindUri(&v)` | URL 路径参数 | `/users/:id` |
|
||||
| `c.ShouldBindHeader(&v)` | HTTP 请求头 | Custom headers |
|
||||
| `c.ShouldBindBodyWith(&v, binding.Form)` | 请求体为 form | 兼容 form 和 JSON |
|
||||
| `c.ShouldBindFiles(&v)` | Multipart form file | 文件上传 |
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
// JSON body
|
||||
var jsonReq CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&jsonReq); err != nil { ... }
|
||||
|
||||
// Query string: /users?role=admin&page=1
|
||||
var query PageQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil { ... }
|
||||
|
||||
// 自动检测:JSON body > form data > query string
|
||||
var autoReq Request
|
||||
if err := c.ShouldBind(&autoReq); err != nil { ... }
|
||||
|
||||
// URI params: /users/:id
|
||||
var uri URIParams
|
||||
if err := c.ShouldBindUri(&uri); err != nil { ... }
|
||||
|
||||
// Headers: Authorization: Bearer xxx
|
||||
var header HeaderParams
|
||||
if err := c.ShouldBindHeader(&header); err != nil { ... }
|
||||
}
|
||||
```
|
||||
|
||||
> **关键理解:** `c.ShouldBind()` 不是"万能绑定"——它按顺序检测:先试 JSON(根据 Content-Type),再试 form,最后试 query。如果前端发的 Content-Type 不明确,行为可能不符合预期。
|
||||
|
||||
**ShouldBind vs MustBind:**
|
||||
|
||||
| 方法 | 校验失败时行为 | 推荐使用 |
|
||||
|------|---------------|----------|
|
||||
| `ShouldBind*` | 返回 error,handler 继续 | 推荐,可自定义错误处理 |
|
||||
| `MustBind*` | 自动 400 响应,Abort | 快速原型 |
|
||||
|
||||
### 2. Struct Tag 绑定语法
|
||||
|
||||
Gin 使用 struct tag 指定绑定规则:
|
||||
|
||||
```go
|
||||
type CreateUserRequest struct {
|
||||
// JSON body 绑定,必须字段,长度限制
|
||||
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Age int `json:"age" binding:"required,min=1,max=150"`
|
||||
Role string `json:"role" binding:"oneof=admin user guest"`
|
||||
}
|
||||
```
|
||||
|
||||
**常用 binding 标签:**
|
||||
|
||||
| 标签 | 作用 | 示例 |
|
||||
|------|------|------|
|
||||
| `required` | 必填 | `binding:"required"` |
|
||||
| `email` | 邮箱格式 | `binding:"email"` |
|
||||
| `url` | URL 格式 | `binding:"url"` |
|
||||
| `datetime` | 日期时间格式 | `binding:"datetime=2006-01-02"` |
|
||||
| `min=N` | 最小值/长度 | `binding:"min=1,max=50"` |
|
||||
| `max=N` | 最大值/长度 | `binding:"max=100"` |
|
||||
| `numeric` | 纯数字 | `binding:"numeric"` |
|
||||
| `alphanum` | 字母数字 | `binding:"alphanum"` |
|
||||
| `oneof=X Y Z` | 枚举值 | `binding:"oneof=red green blue"` |
|
||||
| `omitempty` | 可选字段 | 仅 json 标签用 |
|
||||
|
||||
> **提问:** `binding:"required"` 对指针类型 `*string` 和空字符串 `""` 分别怎么处理?
|
||||
|
||||
### 3. 自动校验的底层流程
|
||||
|
||||
```go
|
||||
func createUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
|
||||
// ShouldBindJSON 内部流程:
|
||||
err := c.ShouldBindJSON(&req)
|
||||
// 1. 检查 Content-Type 是否为 application/json
|
||||
// 2. 用 encoding/json 解析 body 到 req
|
||||
// 3. 获取 validator(全局共享的 structv1)
|
||||
// 4. 遍历 struct 的 binding 标签,逐项校验
|
||||
// 5. 返回 *validator.ValidationErrors 或 nil
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"code": 1002, "message": "参数校验失败", "errors": err.Error()})
|
||||
return
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**校验错误的类型:**
|
||||
|
||||
```go
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
// 类型:*validator.ValidationErrors(内部实现可能变化)
|
||||
// 包含所有字段的校验失败信息
|
||||
c.JSON(400, gin.H{
|
||||
"code": 1002,
|
||||
"message": "参数校验失败",
|
||||
"errors": err.Error(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 只绑定查询字符串
|
||||
|
||||
```go
|
||||
type QueryRequest struct {
|
||||
Page int `form:"page" binding:"required,min=1"`
|
||||
Limit int `form:"limit" binding:"required,min=1,max=100"`
|
||||
Role string `form:"role" binding:"omitempty,oneof=admin user"`
|
||||
}
|
||||
|
||||
// URL: /users?role=admin&page=1&limit=20
|
||||
func listUsers(c *gin.Context) {
|
||||
var req QueryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(400, gin.H{"message": "query param error"})
|
||||
return
|
||||
}
|
||||
// req = {Page: 1, Limit: 20, Role: "admin"}
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** `ShouldBindQuery` 只绑定 URL 查询参数(`?key=value`),不绑定 body。
|
||||
|
||||
### 5. 数组集合格式(批量操作)
|
||||
|
||||
前端传数组参数时,Gin 使用 `[]` 后缀:
|
||||
|
||||
```go
|
||||
type BatchDeleteRequest struct {
|
||||
IDs []int `form:"id[]" binding:"required"`
|
||||
}
|
||||
|
||||
// URL: /users?ids[]=1&ids[]=2&ids[]=3
|
||||
// 或: /users?ids[]=1,2,3
|
||||
func batchDelete(c *gin.Context) {
|
||||
var req BatchDeleteRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(400, gin.H{"message": "ids required"})
|
||||
return
|
||||
}
|
||||
// req.IDs = []int{1, 2, 3}
|
||||
}
|
||||
```
|
||||
|
||||
**JSON body 中的数组:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_ids": [1, 2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type BatchRequest struct {
|
||||
UserIDs []int `json:"user_ids" binding:"required"`
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 自定义 Validator
|
||||
|
||||
当内置标签不够用时,注册自定义校验器:
|
||||
|
||||
```go
|
||||
// 注册全局自定义校验器
|
||||
func init() {
|
||||
// 校验用户名:只能包含字母、数字、下划线,3-20 字符
|
||||
validator.Validator.RegisterValidation(
|
||||
"username",
|
||||
func(v validator.FieldLevel) bool {
|
||||
name := v.Field().String()
|
||||
if len(name) < 3 || len(name) > 20 {
|
||||
return false
|
||||
}
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,username"`
|
||||
}
|
||||
```
|
||||
|
||||
**带错误消息的自定义校验器:**
|
||||
|
||||
```go
|
||||
// 在 handler 中动态注册
|
||||
func setupValidator() {
|
||||
v := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
|
||||
return regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(fl.Field().String())
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
思考题:自定义校验器 `func(v validator.FieldLevel) bool` 中的 `v.Field()` 返回的是什么类型?如果要校验一个 `time.Time` 字段,应该怎么断言?
|
||||
|
||||
### 7. 绑定自定义反序列化器
|
||||
|
||||
对于需要自定义解析的类型,实现 `bind.Unmarshaller` 接口:
|
||||
|
||||
```go
|
||||
type DateRange struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// 实现 Unmarshal 接口
|
||||
func (r *DateRange) Unmarshal(param string) error {
|
||||
parts := strings.Split(param, "-")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid date range format")
|
||||
}
|
||||
r.Start, _ = time.Parse("2006-01-02", parts[0])
|
||||
r.End, _ = time.Parse("2006-01-02", parts[1])
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL: /events?range=2026-01-01-2026-01-31
|
||||
func handler(c *gin.Context) {
|
||||
var req struct {
|
||||
Range DateRange `form:"range"`
|
||||
}
|
||||
c.ShouldBindQuery(&req)
|
||||
// req.Range.Start = 2026-01-01, Range.End = 2026-01-31
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 绑定请求头
|
||||
|
||||
```go
|
||||
type HeaderParams struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
ContentType string `header:"X-Request-ID"`
|
||||
Authorization string `header:"Authorization"`
|
||||
}
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
var h HeaderParams
|
||||
if err := c.ShouldBindHeader(&h); err != nil {
|
||||
c.JSON(400, gin.H{"message": "header error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. 条件绑定与绑定不同结构体
|
||||
|
||||
有时需要**同一个接口支持 JSON 和 form 两种格式**,或者**根据条件绑定不同结构体**:
|
||||
|
||||
```go
|
||||
// 场景:同一个 endpoint 接受 JSON 或 form 数据
|
||||
func handler(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" form:"name"`
|
||||
Email string `json:"email" form:"email"`
|
||||
}
|
||||
|
||||
// ShouldBind 自动检测 Content-Type 并选择解析方式
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(400, gin.H{"message": "parse error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 场景:根据条件绑定不同结构体
|
||||
func handler(c *gin.Context) {
|
||||
if c.IsAborted() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
contentType := c.ContentType()
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
var jsonReq JSONRequest
|
||||
if err := c.ShouldBindJSON(&jsonReq); err != nil {
|
||||
c.AbortWithError(400, err)
|
||||
return
|
||||
}
|
||||
// 处理 JSON 请求
|
||||
case "application/x-www-form-urlencoded":
|
||||
var formReq FormRequest
|
||||
if err := c.ShouldBind(&formReq); err != nil {
|
||||
c.AbortWithError(400, err)
|
||||
return
|
||||
}
|
||||
// 处理 Form 请求
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ShouldBindBodyWith — 避免重复读取 body:**
|
||||
|
||||
Gin 的 JSON 解析器**只能读一次 body**(因为 body 是 io.ReadCloser)。`ShouldBindBodyWith` 会在第一次读取后缓存 body,后续绑定复用缓存:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
// 先读取验证(可能读取 body)
|
||||
var auth AuthRequest
|
||||
if err := c.ShouldBindJSON(&auth); err != nil {
|
||||
c.JSON(400, gin.H{"error": "auth failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 再读取不同结构体 — 用 ShouldBindBodyWith 复用 body
|
||||
var body CreateUserRequest
|
||||
if err := c.ShouldBindBodyWith(&body, binding.JSON); err != nil {
|
||||
c.JSON(400, gin.H{"error": "body parse failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 绑定与校验的最佳实践
|
||||
|
||||
```go
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Age int `json:"age" binding:"omitempty,min=0,max=150"`
|
||||
Role string `json:"role" binding:"oneof=admin user guest"`
|
||||
}
|
||||
|
||||
func (r *CreateUserRequest) ValidateCustom() error {
|
||||
// 需要跨字段校验时,自定义校验逻辑
|
||||
if strings.Contains(r.Name, "@") {
|
||||
return errors.New("name cannot contain @")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
|
||||
// 第一步:自动绑定 + 标签校验
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 解析结构化错误
|
||||
if verr, ok := err.(*validator.ValidationErrors); ok {
|
||||
c.JSON(400, gin.H{
|
||||
"code": 1002,
|
||||
"message": "参数校验失败",
|
||||
"errors": formatValidationErrors(verr),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(400, gin.H{"code": 1002, "message": "解析失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 第二步:自定义校验(跨字段或数据库查询)
|
||||
if err := req.ValidateCustom(); err != nil {
|
||||
c.JSON(422, gin.H{"code": 1006, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(201, gin.H{"message": "created"})
|
||||
}
|
||||
|
||||
// formatValidationErrors 把校验错误格式化为用户友好的消息
|
||||
func formatValidationErrors(err *validator.ValidationErrors) map[string]string {
|
||||
errors := make(map[string]string)
|
||||
for _, e := range err.Errors {
|
||||
field := e.Field()
|
||||
tag := e.Tag()
|
||||
switch tag {
|
||||
case "required":
|
||||
errors[field] = "必填字段"
|
||||
case "email":
|
||||
errors[field] = "邮箱格式不正确"
|
||||
case "min":
|
||||
errors[field] = fmt.Sprintf("最小值为 %s", e.Param())
|
||||
default:
|
||||
errors[field] = fmt.Sprintf("%s 不满足 %s 要求", field, tag)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
```
|
||||
|
||||
> **提问:** `ShouldBindJSON` 遇到未知字段(JSON 中有 struct 没有的 key)时,默认行为是什么?会报错吗?如果不想报错,有什么办法忽略未知字段?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- `[[GIN/gin-architecture]]` — Engine 如何初始化 validator
|
||||
- `[[GIN/binding-advanced]]` — Map 绑定、默认值、条件绑定
|
||||
- `[[GIN/error-handling]]` — 校验错误的全局处理中间件
|
||||
- `[[Go 后端基础]]` — 结构体标签与 JSON 序列化基础
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, Context, 底层]
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# Context 生命周期与底层设计
|
||||
|
||||
## 概述
|
||||
|
||||
`*gin.Context` 是 Gin 框架中最核心的类型——它封装了整个请求的生命周期:参数解析、请求/响应读写、错误收集、数据传递、取消传播。理解它的底层设计,能帮你避开绝大多数陷阱,尤其是 goroutine 安全和内存泄漏。
|
||||
|
||||
思考题:`gin.Context` 和 Go 标准库的 `context.Context` 是同一个东西吗?如果不是,它们怎么协作?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. gin.Context 的结构
|
||||
|
||||
```go
|
||||
type Context struct {
|
||||
writermem *responseWriter // 缓冲的响应写入器
|
||||
Request *http.Request // 原始 HTTP 请求(只读)
|
||||
fullPath string // 完整路由路径,如 /api/v1/users/:id
|
||||
handlers HandlersChain // 待执行的 handler 链
|
||||
index int8 // 当前执行到的 handler 索引
|
||||
engine *Engine // 回指全局引擎
|
||||
params *Params // 路径参数
|
||||
keys map[string]any // 请求级 key-value 存储
|
||||
errors ErrorList // 错误链(c.Error 收集的)
|
||||
accepted []string // Accept 头解析
|
||||
flush func() // 刷写回调
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
|
||||
| 字段 | 类型 | 作用 | 备注 |
|
||||
|------|------|------|------|
|
||||
| `keys` | `map[string]any` | handler 间数据传递 | 请求级隔离,每次请求独立 |
|
||||
| `errors` | `ErrorList` | 错误链收集 | 多个 `c.Error()` 可以累积 |
|
||||
| `writermem` | `*responseWriter` | 缓冲写入 | 默认 4KB 缓冲区,减少 syscall |
|
||||
| `params` | `*Params` | 路径参数缓存 | 避免重复解析 |
|
||||
| `handlers` | `HandlersChain` | handler 链 | 全局复用切片,`c.Next()` 推进 |
|
||||
|
||||
### 2. Context 的创建与回收
|
||||
|
||||
Gin 使用 `sync.Pool` 复用 Context 对象,避免频繁 GC:
|
||||
|
||||
```
|
||||
请求进来: pool.Get() → 初始化 → 路由匹配 → 执行 handler → pool.Put()
|
||||
```
|
||||
|
||||
```go
|
||||
// gin/engine.go
|
||||
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// 从 pool 取出一个 Context
|
||||
c := engine.getContext() // sync.Pool.Get()
|
||||
defer engine.freeContext(c) // pool.Put() — 请求结束归还
|
||||
|
||||
c.writermem.reset(w) // 重置响应写入器
|
||||
c.Request = req // 挂上请求
|
||||
c.index = -1 // 索引归零
|
||||
c.errors = c.errors[:0] // 清空错误链
|
||||
c.keys = nil // 清空键值存储
|
||||
|
||||
// 路由匹配 → 分配 handler 链
|
||||
handlers, params, _ := engine.tree.match(req.URL.Path, req.Method)
|
||||
c.handlers = handlers
|
||||
c.params = params
|
||||
|
||||
// 开始执行
|
||||
c.Next() // 推进 handler 链
|
||||
}
|
||||
```
|
||||
|
||||
**关键结论:** Context 的生命周期 = 单次请求。请求结束后 Context 立即被回收复用,**绝不能持有 Context 引用**。
|
||||
|
||||
> **提问:** 如果一个 handler 中 `go func() { _ = c.Request }()` 启动了一个 goroutine,请求结束后 Context 被 pool 回收,这个 goroutine 会 panic 吗?为什么 `c.Copy()` 能解决这个问题?
|
||||
|
||||
### 3. c.Set / c.Get — 请求间数据传递
|
||||
|
||||
`c.Set` 和 `c.Get` 是在 handler 之间传递数据的标准方式:
|
||||
|
||||
```go
|
||||
// 中间件中设置
|
||||
authMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, _ := parseJWT(c.GetHeader("Authorization"))
|
||||
c.Set("userID", claims.UserID) // 存入 Context
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// handler 中读取
|
||||
func profileHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID") // "123"
|
||||
role := c.GetString("role") // "admin"
|
||||
|
||||
// 如果 key 不存在,GetString 返回零值
|
||||
if userID == "" {
|
||||
c.JSON(401, gin.H{"message": "未认证"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"user_id": userID, "role": role})
|
||||
}
|
||||
```
|
||||
|
||||
**类型安全的读取方式:**
|
||||
|
||||
```go
|
||||
// GetString — key 不存在时返回 ""
|
||||
v := c.GetString("key")
|
||||
|
||||
// GetStringOk — 返回 (value, ok)
|
||||
v, ok := c.GetStringOk("key")
|
||||
|
||||
// Get — 返回任意类型,需要类型断言
|
||||
v, ok := c.Get("key")
|
||||
name, ok := v.(string)
|
||||
|
||||
// 强类型封装
|
||||
func getUserID(c *gin.Context) string {
|
||||
id, _ := c.GetStringOk("userID")
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
> **提问:** `c.Set` 的 `keys` map 在每次请求结束后会被清空吗?如果不清空,pool 复用下一个请求时会读到上一个请求的数据吗?
|
||||
|
||||
### 4. c.Next() 与 c.Abort() 的控制流
|
||||
|
||||
这是理解中间件执行顺序的核心:
|
||||
|
||||
```
|
||||
c.Next() → 继续执行下一个 handler
|
||||
c.Abort() → 停止执行后续 handler
|
||||
c.AbortWithStatus(code) → 停止执行,并设置 HTTP 状态码
|
||||
c.AbortWithStatusJSON(code, obj) → 停止执行,返回 JSON 响应
|
||||
```
|
||||
|
||||
```go
|
||||
func authMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
if !validateToken(token) {
|
||||
// 终止中间件链
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||
return // 注意:return 仍然执行,但 Abort 已经阻止了 Next()
|
||||
}
|
||||
c.Next() // 继续
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**三种控制流模式:**
|
||||
|
||||
```go
|
||||
// 模式1:正常传递 — c.Next() 前后都有代码
|
||||
func middleware1(c *gin.Context) {
|
||||
fmt.Println("before")
|
||||
c.Next() // 传递
|
||||
fmt.Println("after") // handler 返回后继续
|
||||
}
|
||||
|
||||
// 模式2:提前终止 — c.Abort()
|
||||
func middleware2(c *gin.Context) {
|
||||
fmt.Println("before")
|
||||
c.Abort()
|
||||
fmt.Println("never reached") // 这行不会执行(因为 return 了)
|
||||
}
|
||||
|
||||
// 模式3:条件传递 — 不满足条件直接 return,不调用 Next()
|
||||
func middleware3(c *gin.Context) {
|
||||
if skipCondition {
|
||||
return // 不传递到 handler,直接结束
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
```
|
||||
|
||||
### 5. c.Copy() 深拷贝原理
|
||||
|
||||
`c.Copy()` 创建一个独立的 `*gin.Context` 副本,用于异步 goroutine:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
c.Set("userID", "123")
|
||||
|
||||
copy := c.Copy() // 创建副本
|
||||
go func() {
|
||||
// 安全的异步操作 — 只读
|
||||
log.Printf("用户 %s 的异步任务完成", copy.GetString("userID"))
|
||||
// 注意:不能调用 copy.JSON() 写响应!
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
**`c.Copy()` 的深拷贝范围:**
|
||||
|
||||
| 复制到副本 | 说明 |
|
||||
|-----------|------|
|
||||
| `c.Request` | 原始请求的浅拷贝(`*http.Request`) |
|
||||
| `c.Keys` | 完整深拷贝 `map[string]any` |
|
||||
| `c.Params` | `*Params` 指针引用(相同数据) |
|
||||
| `c.Writer` | **不会被复制**,副本的 Writer 无效 |
|
||||
| `c.Errors` | 不会被复制 |
|
||||
| `c.handlers` / `c.index` | 不会被复制 |
|
||||
|
||||
**重要限制:** 副本不能写响应,因为 `Writer` 没有被复制。这是有意设计的——异步 goroutine 不应该修改当前请求的响应。
|
||||
|
||||
思考题:`c.Keys` 是深拷贝,意味着对副本中 `keys` map 的修改不会影响原始 Context。但如果 map 中存的是一个指针(`c.Set("db", &DB{})`),那拷贝的是指针还是指针指向的对象?
|
||||
|
||||
### 6. gin.Context 与 context.Context 的关系
|
||||
|
||||
很多人混淆这两个 Context,它们**完全不同**:
|
||||
|
||||
```
|
||||
gin.Context — Gin 的请求封装,包含请求/响应/参数/错误等
|
||||
context.Context — Go 标准的上下文传递,主要用于取消传播和超时控制
|
||||
```
|
||||
|
||||
**它们的交集:** `c.Request.Context()`
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
// gin.Context → Go 标准 context
|
||||
ctx := c.Request.Context() // 获取标准 context
|
||||
ctx.Done() // 监听客户端断开
|
||||
ctx.Err() // 获取取消原因
|
||||
|
||||
// 可以创建带超时的 context
|
||||
newCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 在 context 取消时立即停止数据库查询
|
||||
result, err := db.Query(newCtx, "SELECT * FROM users")
|
||||
if newCtx.Err() != nil {
|
||||
c.JSON(504, gin.H{"error": "request timeout"})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关系图:**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["gin.Context"] -->|"包含"| B["*http.Request"]
|
||||
B -->|"包含"| C["context.Context"]
|
||||
A -->|"提供"| D["c.Set / c.Get — 请求数据"]
|
||||
A -->|"提供"| E["c.JSON / c.String — 响应"]
|
||||
A -->|"提供"| F["c.Param — 路径参数"]
|
||||
C -->|"提供"| G["Done / Err — 取消传播"]
|
||||
C -->|"提供"| H["WithTimeout / WithCancel — 超时控制"]
|
||||
|
||||
style A fill:#e3f2fd,stroke:#1565c0
|
||||
style C fill:#f3e5f5,stroke:#7b1fa2
|
||||
```
|
||||
|
||||
> **关键理解:** `gin.Context` 包含 `context.Context`(通过 Request),但不**是** `context.Context`。`gin.Context` 不能直接传给需要 `context.Context` 的函数。
|
||||
|
||||
**常见错误:**
|
||||
|
||||
```go
|
||||
// 错误:把 gin.Context 当 context.Context 用
|
||||
func doSomething(ctx context.Context) { ... }
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
doSomething(c) // ❌ 类型不匹配!
|
||||
}
|
||||
|
||||
// 正确:提取 c.Request.Context()
|
||||
func handler(c *gin.Context) {
|
||||
doSomething(c.Request.Context()) // ✅
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 请求取消与超时控制
|
||||
|
||||
生产环境中必须处理客户端断开连接的场景:
|
||||
|
||||
```go
|
||||
func longRunningHandler(c *gin.Context) {
|
||||
// 获取标准 context(包含客户端断开信号)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 同时设置请求级超时
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 把 context 传递给数据库/HTTP 调用
|
||||
user, err := db.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.Canceled {
|
||||
c.JSON(499, gin.H{"error": "client disconnected"})
|
||||
} else if ctx.Err() == context.DeadlineExceeded {
|
||||
c.JSON(504, gin.H{"error": "timeout"})
|
||||
} else {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"user": user})
|
||||
}
|
||||
```
|
||||
|
||||
**超时控制的层级:**
|
||||
|
||||
```
|
||||
1. Gin 配置 — r.MaxRequestDuration(已废弃,用 context.WithTimeout)
|
||||
2. context.WithTimeout — handler 级超时
|
||||
3. context.WithCancel — handler 级取消
|
||||
4. context.WithValue — 传递请求级值
|
||||
5. gin.Recovery — 极端情况下的 panic 恢复
|
||||
```
|
||||
|
||||
### 8. Context 使用的常见陷阱
|
||||
|
||||
| 陷阱 | 说明 | 正确做法 |
|
||||
|------|------|----------|
|
||||
| 持有 Context 引用 | 请求结束后 Context 被 pool 回收 | 用 `c.Copy()` 或只传值 |
|
||||
| 在 goroutine 中写响应 | `c.JSON()` 在 goroutine 中会导致并发问题 | 异步任务不写响应 |
|
||||
| 用 gin.Context 替代 context.Context | 类型不匹配,API 不通用 | 用 `c.Request.Context()` |
|
||||
| 在 handler 中修改 c.Request.Body | 只能读一次,重复读取为空 | 提前读取并缓存 |
|
||||
| keys map 存大对象 | 每个请求的 map 都是新分配 | 只存引用类型(指针)或基础类型 |
|
||||
|
||||
思考题:如果在一个 handler 中调用了 `c.Set("data", largeStruct)`,而这个 struct 很大(比如 1MB),这会造成什么问题?Pool 复用的时机对内存管理有什么影响?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- `[[GIN/gin-architecture]]` — Context 的创建/回收流程
|
||||
- `[[GIN/middleware]]` — 中间件中用 `c.Copy()` 的陷阱
|
||||
- `[[GIN/request-context]]` — 请求级 context 传播实战
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 架构, 原理]
|
||||
create time: 2026-04-27 10:01
|
||||
---
|
||||
|
||||
# Gin 整体架构
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的本质是一个 **`http.Handler`**——它没有脱离 Go 标准库 `net/http`,而是在其之上做了三层增强:
|
||||
|
||||
1. **高性能路由匹配**(Radix Tree 基数树)
|
||||
2. **中间件链**(可组合的横切逻辑)
|
||||
3. **上下文对象**(`*gin.Context` 统一管理请求/响应/参数/错误)
|
||||
|
||||
理解 Gin 架构的起点,是认清它和 `net/http` 的边界——Gin 不替代标准库,而是包装和增强它。
|
||||
|
||||
思考题:`gin.Default()` 返回的是一个 `*Engine`,而 `*Engine` 实现了 `http.Handler` 接口。这意味着什么?能不能直接把 Gin Engine 传给标准库的 `http.ListenAndServe`?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. Gin 的核心组件
|
||||
|
||||
Gin 有三大核心对象,它们的关系可以一句话概括:
|
||||
|
||||
> **Engine 是引擎,RouterGroup 是路由组织单元,Context 是请求的生命周期容器。**
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["*gin.Engine"] -->|"管理"| B["*routerGroup[]"]
|
||||
A -->|"提供"| C["*gin.Context"]
|
||||
A -->|"包含"| D["radix tree"]
|
||||
B -->|"挂载路由"| D
|
||||
B -->|"生成"| C
|
||||
style A fill:#e3f2fd,stroke:#1565c0
|
||||
style B fill:#fff3e0,stroke:#e65100
|
||||
style C fill:#e8f5e9,stroke:#2e7d32
|
||||
```
|
||||
|
||||
#### Engine — 全局引擎
|
||||
|
||||
`Engine` 是 Gin 的心脏,一个 Gin 应用有且只有一个 Engine:
|
||||
|
||||
```go
|
||||
type Engine struct {
|
||||
RouterGroup // 嵌入,Engine 本身就是最大的 RouterGroup
|
||||
redirectTrailingSlash bool
|
||||
redirectFixedPath bool
|
||||
handleMethodNotAllowed bool
|
||||
trees methodTrees // 按 HTTP 方法组织的路由树
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**关键细节:**
|
||||
- `Engine` 嵌入了 `RouterGroup`,所以 `r := gin.New()` 创建的 Engine 本身就是一个 `RouterGroup`,可以直接调 `.GET()`、`.Use()`
|
||||
- `trees` 是一个 `methodTrees`(本质是 `[*tree]`),**每种 HTTP 方法一棵独立的 Radix Tree**——这就是 Gin 高性能的核心原因之一
|
||||
- Engine 实现了 `http.Handler` 接口的 `ServeHTTP(c http.ResponseWriter, r *http.Request)` 方法
|
||||
|
||||
思考题:为什么 Gin 要为每个 HTTP 方法建一棵独立的树,而不是把所有方法塞进同一棵?
|
||||
|
||||
#### RouterGroup — 路由组织单元
|
||||
|
||||
`RouterGroup` 负责路由的层级组织:
|
||||
|
||||
```go
|
||||
type RouterGroup struct {
|
||||
RelativePath string // 相对路径前缀,如 "/api/users"
|
||||
handlers HandlersChain // 该分组下的中间件链
|
||||
engine *Engine // 指向全局引擎
|
||||
basePath string // 绝对路径前缀
|
||||
}
|
||||
```
|
||||
|
||||
- `Group(path string)` 创建子分组,自动继承父分组的中间件和前缀
|
||||
- 注册路由时,最终路径 = `basePath` + `relativePath`
|
||||
- Engine 本身就是根分组(`basePath = ""`)
|
||||
|
||||
#### Context — 请求生命周期容器
|
||||
|
||||
`*gin.Context` 贯穿单个请求的整个生命周期:
|
||||
|
||||
```go
|
||||
type Context struct {
|
||||
writermem ResponseWriter // 响应缓冲区
|
||||
Request *http.Request // 原始请求
|
||||
fullPath string // 完整路由路径
|
||||
handlers HandlersChain // 待执行的 handler 链
|
||||
index int8 // 当前执行到第几个 handler
|
||||
engine *Engine // 回指引擎
|
||||
params *Params // 路径参数
|
||||
errors ErrorList // 错误链
|
||||
keys map[string]interface{} // 请求级存储
|
||||
}
|
||||
```
|
||||
|
||||
**重点理解:** 每个请求都会创建一个独立的 `*gin.Context`,对象会复用(sync.Pool),但 `keys` map 是每个请求隔离的。
|
||||
|
||||
> **提问:** `gin.Context` 被 `sync.Pool` 复用,那如果在 handler 中把 `c` 保存到全局变量里,下次请求读到的是什么?
|
||||
|
||||
### 2. 请求生命周期(从 HTTP 到 Handler)
|
||||
|
||||
这是 Gin 最核心的流程——从收到一个原始 HTTP 请求到最终返回响应,中间经历了什么:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant L as ListenAndServe
|
||||
participant E as Engine.ServeHTTP
|
||||
participant R as Route Match
|
||||
participant M as Middleware Chain
|
||||
participant H as User Handler
|
||||
participant W as Response
|
||||
|
||||
C->>L: HTTP Request
|
||||
L->>E: ServeHTTP(rw, req)
|
||||
E->>E: 按方法查找路由树 (trees[method])
|
||||
E->>R: 匹配路径 → handler chain
|
||||
alt 匹配成功
|
||||
E->>M: 创建 gin.Context,执行中间件链
|
||||
M->>H: c.Next() 进入用户 handler
|
||||
H->>W: c.JSON() / c.String() 写入响应
|
||||
H->>M: 返回,中间件继续执行
|
||||
M->>E: 所有 handler 执行完毕
|
||||
else 匹配失败:路径存在但方法不对 (405)
|
||||
E->>W: c.JSON(405, "Method Not Allowed")
|
||||
else 匹配失败:路径不存在 (404)
|
||||
E->>W: 404 Not Found
|
||||
end
|
||||
E->>L: rw 写入 http.ResponseWriter
|
||||
L->>C: HTTP Response
|
||||
```
|
||||
|
||||
**逐步拆解:**
|
||||
|
||||
**第一步:路由匹配**
|
||||
|
||||
```go
|
||||
// gin/engine.go — 简化版
|
||||
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// 1. 按方法找到对应的 Radix Tree
|
||||
c := engine.getContext() // 从 sync.Pool 取 Context
|
||||
c.Reset() // 清空旧数据
|
||||
c.Request = req
|
||||
c.writermem.reset(w)
|
||||
|
||||
// 2. 匹配路径
|
||||
handlers, c.Params, search := engine.tree.match(req.URL.Path, req.Method)
|
||||
|
||||
// 3. 分配 handler 链
|
||||
c.handlers = handlers
|
||||
c.index = -1 // 从第一个 handler 开始
|
||||
|
||||
// 4. 分发执行
|
||||
c.Next() // ← 进入中间件链
|
||||
engine.freeContext(c) // 归还到 sync.Pool
|
||||
}
|
||||
```
|
||||
|
||||
**第二步:中间件链执行**
|
||||
|
||||
```go
|
||||
// gin/context.go — Next() 的核心逻辑
|
||||
func (c *Context) Next() {
|
||||
c.index++ // 推进索引
|
||||
// 执行当前 index 对应的 handler
|
||||
for ; c.index < int8(len(c.handlers)); c.index++ {
|
||||
c.handlers[c.index](c) // 执行中间件/handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这就是经典的 **倒序执行模式**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["中间件A (index=0)"] -->|"c.Next()"| B["中间件B (index=1)"]
|
||||
B -->|"c.Next()"| C["Handler (index=2)"]
|
||||
C -->|"执行完毕"| B2["中间件B 继续"]
|
||||
B2 -->|"继续"| A2["中间件A 继续"]
|
||||
|
||||
style C fill:#e8f5e9,stroke:#2e7d32
|
||||
style B fill:#fff3e0,stroke:#e65100
|
||||
style B2 fill:#fff3e0,stroke:#e65100
|
||||
style A2 fill:#e3f2fd,stroke:#1565c0
|
||||
```
|
||||
|
||||
思考题:如果中间件 A 中 `c.Next()` 之前打印 "before",之后打印 "after",中间件 B 也这样做,那一个请求经过 A→B→Handler 后,输出的顺序是什么?
|
||||
|
||||
### 3. Gin 与标准库 `net/http` 的关系
|
||||
|
||||
很多开发者误以为 Gin 替代了 `net/http`,实际上它只是在标准库之上做了一个**有选择的增强**:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["net/http"] -->|"提供"| B["Server, Listener, ResponseWriter"]
|
||||
A -->|"提供"| C["Request, Response, Header"]
|
||||
A -->|"提供"| D["ServeMux 路由"]
|
||||
|
||||
E["Gin"] -->|"替代"| F["ServeMux → Radix Tree 路由"]
|
||||
E -->|"包装"| G["ResponseWriter → buffered writer"]
|
||||
E -->|"增强"| H["Request → gin.Context 包装"]
|
||||
E -->|"扩展"| I["中间件链 + Context + 绑定 + 渲染"]
|
||||
|
||||
B --> E
|
||||
C --> E
|
||||
style E fill:#fff9c4,stroke:#f57f17,stroke-width:3px
|
||||
```
|
||||
|
||||
**Gin 替代了什么:**
|
||||
- `http.ServeMux` → Radix Tree 路由(支持通配符、参数、更优性能)
|
||||
- `http.HandlerFunc` → `gin.HandlerFunc` + 中间件链
|
||||
- 手动 `json.NewEncoder` → `c.JSON()`
|
||||
|
||||
**Gin 保留了什么:**
|
||||
- `http.Request`(`c.Request` 原封不动)
|
||||
- `http.ResponseWriter`(用 `responseWriter` 包装,加了缓冲)
|
||||
- `http.ListenAndServe`(`r.Run()` 最终调的也是这个)
|
||||
- 所有 `net/http` 的类型和接口
|
||||
|
||||
> **核心结论:** Gin 不是另一个 HTTP 框架,它是 `net/http` 的**增强层**。所有标准库的知识完全适用于 Gin,反之则不然——你会 Gin 不代表你懂 `net/http`。
|
||||
|
||||
### 4. Gin 的初始化链
|
||||
|
||||
`gin.Default()` 背后做了三件事:
|
||||
|
||||
```go
|
||||
// gin.Default() 等价于:
|
||||
func Default() *Engine {
|
||||
debugPrintWARNINGDefault() // 调试模式下打印提示
|
||||
engine := New() // 创建不带中间件的 Engine
|
||||
engine.Use(Logger(), Recovery()) // 挂载日志和恢复中间件
|
||||
return engine
|
||||
}
|
||||
```
|
||||
|
||||
| 初始化方式 | 包含中间件 | 适用场景 |
|
||||
|-----------|-----------|----------|
|
||||
| `gin.New()` | 无 | 完全自定义日志、测试、最小化开销 |
|
||||
| `gin.Default()` | Logger + Recovery | 快速开发、默认推荐 |
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default() // = gin.New() + Logger() + Recovery()
|
||||
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// r.Run() 内部实现:
|
||||
// http.ListenAndServe(":8080", r)
|
||||
// 注意:Engine 实现了 http.Handler,可以直接传给标准库
|
||||
r.Run()
|
||||
}
|
||||
```
|
||||
|
||||
**提问:** `gin.New()` 不包含 Logger 中间件,那如果直接用 `gin.New()` 启动服务,请求进来时你会看到什么日志?如果想自己加日志但用自定义格式,应该怎么做?
|
||||
|
||||
### 5. 对象复用与性能设计
|
||||
|
||||
Gin 在几个关键地方做了对象复用,减少 GC 压力:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["sync.Pool"] -->|"复用"| B["*gin.Context"]
|
||||
A -->|"复用"| C["*responseWriter"]
|
||||
A -->|"复用"| D["*params"]
|
||||
|
||||
E["*Engine"] -->|"全局单例,不复用"| F["路由树"]
|
||||
|
||||
style A fill:#e8f5e9,stroke:#2e7d32
|
||||
style F fill:#fff3e0,stroke:#e65100
|
||||
```
|
||||
|
||||
- `*gin.Context`:每次请求从 pool 取出,用完归还(`engine.freeContext(c)`)
|
||||
- `*responseWriter`:缓冲写入器,复用减少分配
|
||||
- 路由树 `*tree`:全局唯一,永久驻留内存
|
||||
- `*Engine`:全局唯一,永久驻留
|
||||
|
||||
**思考题:** Context 被池化复用,那在 handler 中启动 goroutine 并在 goroutine 里使用 `c`,会遇到什么问题?这和我们后面要讲的 `c.Copy()` 有什么关系?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- `[[GIN/routing]]` — 路由匹配算法深入(Radix Tree)
|
||||
- `[[GIN/middleware]]` — 中间件机制与执行顺序
|
||||
- `[[GIN/context-lifecycle]]` — Context 的底层设计与陷阱
|
||||
- `[[Go 后端基础]]` — Gin 基础用法入门
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 中间件, 架构]
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# 中间件完整机制
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的中间件是一个轻量但强大的抽象——本质就是一个 `func(*gin.Context)` 类型的函数,通过 `c.Next()` 决定是否把控制权交给下一个 handler。Gin 的中间件系统有三层作用域,可以精确控制作用范围。
|
||||
|
||||
思考题:Gin 的中间件和 Go 标准库 `net/http` 的 `func(http.Handler) http.Handler` 中间件模式有什么本质区别?Gin 的方案更简单还是更灵活?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 中间件的本质
|
||||
|
||||
中间件的类型签名就是 `gin.HandlerFunc`:
|
||||
|
||||
```go
|
||||
type HandlerFunc func(*Context)
|
||||
```
|
||||
|
||||
它接收一个 `*gin.Context`,可以:
|
||||
1. **前置处理**(读取请求、校验、记录日志等)
|
||||
2. **调用 `c.Next()`**(把控制权交给下一个 handler)
|
||||
3. **后置处理**(修改响应、收集指标等)
|
||||
|
||||
```go
|
||||
func logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now() // 前置:记录开始时间
|
||||
|
||||
c.Next() // ← 必须调用,把控制权交给下一个
|
||||
|
||||
// 后置:请求结束后的处理
|
||||
latency := time.Since(start)
|
||||
log.Printf("[%d] %s %s — %v", c.Writer.Status(), c.Request.Method, c.Request.URL.Path, latency)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 三级作用域
|
||||
|
||||
Gin 中间件可以在三个层级注册,形成**作用域嵌套**:
|
||||
|
||||
```
|
||||
全局中间件(Engine 级)
|
||||
├── 分组中间件(RouterGroup 级)
|
||||
│ ├── 路由中间件(单个路由级)
|
||||
│ └── 路由中间件
|
||||
└── 分组中间件
|
||||
└── 路由中间件
|
||||
```
|
||||
|
||||
**执行顺序:** 全局 → 分组 → 路由 → handler
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 全局中间件 — 所有路由都经过
|
||||
r.Use(globalMiddleware())
|
||||
|
||||
// 分组中间件 — 仅该分组下的路由经过
|
||||
v1 := r.Group("/api/v1", v1Middleware())
|
||||
{
|
||||
// 路由中间件 — 仅此路由经过
|
||||
v1.GET("/users", routeMiddleware(), listUsers)
|
||||
v1.GET("/posts", listPosts) // 不经过 routeMiddleware
|
||||
}
|
||||
```
|
||||
|
||||
**执行链路示意:**
|
||||
|
||||
```
|
||||
请求: GET /api/v1/users
|
||||
|
||||
全局中间件(前置处理)
|
||||
↓
|
||||
v1 分组中间件(前置处理)
|
||||
↓
|
||||
路由中间件(前置处理)
|
||||
↓
|
||||
handler: listUsers
|
||||
↓
|
||||
路由中间件(c.Next() 返回后的代码)
|
||||
↓
|
||||
v1 分组中间件(c.Next() 返回后的代码)
|
||||
↓
|
||||
全局中间件(c.Next() 返回后的代码)
|
||||
```
|
||||
|
||||
> **关键理解:** 中间件链是**先入后出**的——`c.Next()` 之前的代码按注册顺序执行,`c.Next()` 之后的代码按**逆序**执行。
|
||||
|
||||
```go
|
||||
// 验证执行顺序
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
fmt.Println("1-before")
|
||||
c.Next()
|
||||
fmt.Println("1-after")
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
fmt.Println("2-before")
|
||||
c.Next()
|
||||
fmt.Println("2-after")
|
||||
})
|
||||
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
fmt.Println("handler")
|
||||
})
|
||||
|
||||
// 输出:
|
||||
// 1-before
|
||||
// 2-before
|
||||
// handler
|
||||
// 2-after
|
||||
// 1-after
|
||||
```
|
||||
|
||||
思考题:如果中间件 A 中调用了 `c.Abort()`(不调用 `c.Next()`),中间件 B 和 handler 还会执行吗?那 A 中 `c.Abort()` 之后的代码还会执行吗?
|
||||
|
||||
### 3. 中间件链的构成
|
||||
|
||||
当你调用 `c.Next()` 时,内部执行的是 `c.handlers` 切片:
|
||||
|
||||
```go
|
||||
// gin/context.go
|
||||
func (c *Context) Next() {
|
||||
c.index++
|
||||
for ; c.index < int8(len(c.handlers)); c.index++ {
|
||||
c.handlers[c.index](c)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`c.handlers` 的来源是 **全局中间件 + 分组中间件 + 路由中间件** 的拼接:
|
||||
|
||||
```
|
||||
全局中间件: [Logger, Recovery]
|
||||
v1 分组中间件: [V1Middleware]
|
||||
路由中间件: [RouteMiddleware]
|
||||
handler: [listUsers]
|
||||
|
||||
c.handlers = [Logger, Recovery, V1Middleware, RouteMiddleware, listUsers]
|
||||
```
|
||||
|
||||
注册顺序就是执行顺序:
|
||||
|
||||
```go
|
||||
r := gin.Default() // Logger, Recovery
|
||||
|
||||
v1 := r.Group("/api/v1", authMiddleware()) // Logger, Recovery, authMiddleware
|
||||
{
|
||||
v1.GET("/users", corsMiddleware(), listUsers) // Logger, Recovery, authMiddleware, corsMiddleware, listUsers
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 常用中间件实现
|
||||
|
||||
#### CORS 中间件
|
||||
|
||||
```go
|
||||
func cors() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin,Content-Type,Authorization,X-Token")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
// 处理 OPTIONS 预检请求
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 认证中间件(JWT)
|
||||
|
||||
```go
|
||||
func jwtAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "missing token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := parseJWT(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 把用户信息存入 Context,后续 handler 可直接获取
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 请求日志(结构化)
|
||||
|
||||
```go
|
||||
func structuredLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"method": c.Request.Method,
|
||||
"path": path,
|
||||
"query": query,
|
||||
"status": status,
|
||||
"latency_ms": latency.Milliseconds(),
|
||||
"client_ip": c.ClientIP(),
|
||||
}).Info("request")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 请求 ID 中间件
|
||||
|
||||
```go
|
||||
const requestIDKey = "X-Request-ID"
|
||||
|
||||
func requestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader(requestIDKey)
|
||||
if id == "" {
|
||||
id = uuid.New().String()
|
||||
}
|
||||
c.Set(requestIDKey, id)
|
||||
c.Header(requestIDKey, id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **提问:** 上面的 JWT 中间件中,`c.Set("userID", ...)` 把用户信息存进了 Context。如果认证失败(`c.Abort()`),那这个 `userID` 还会被后面的 handler 读到吗?为什么?
|
||||
|
||||
### 5. 中间件中启动 Goroutine 的陷阱
|
||||
|
||||
这是 Gin 中间件最常见的坑:**在中间件中启动 goroutine 后,goroutine 可能引用已释放的 Context**。
|
||||
|
||||
**错误写法:**
|
||||
|
||||
```go
|
||||
func asyncProcessor() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// 危险!c.Next() 返回后,Context 可能已经被回收
|
||||
// 但 goroutine 还在运行,可能会 panic
|
||||
go func() {
|
||||
log.Printf("处理完成: %s", c.Request.URL.Path) // ← c 可能已被 pool 回收
|
||||
}()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**正确写法:用 `c.Copy()` 创建独立副本**
|
||||
|
||||
```go
|
||||
func safeAsyncProcessor() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// c.Copy() 创建请求副本,包含独立的 Request 和 Context
|
||||
// 注意:copy 中的 Writer 是无效的,只能读 Request 和 Context keys
|
||||
copy := c.Copy()
|
||||
go func() {
|
||||
// 安全的异步处理,只读取数据,不能写入响应
|
||||
log.Printf("异步处理完成: %s", copy.Request.URL.Path)
|
||||
userID := copy.GetString("userID")
|
||||
log.Printf("用户 %s 的请求已异步处理", userID)
|
||||
}()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`c.Copy()` 的限制:**
|
||||
|
||||
| 可以复制的 | 不可以复制的 |
|
||||
|-----------|-------------|
|
||||
| `c.Request`(原始请求) | `c.Writer`(ResponseWriter 无法复制) |
|
||||
| `c.Keys`(已设置的键值对) | `c.Errors`(错误链不能写) |
|
||||
| `c.Params`(路径参数) | 不能调用 `c.JSON()` 等写响应的方法 |
|
||||
| `c.ClientIP()` | 不能修改请求体 |
|
||||
|
||||
> **核心规则:** `c.Copy()` 出的 goroutine **只能读不能写**。如果需要在异步中写数据,用数据库/队列等持久化方式,不要依赖 Context。
|
||||
|
||||
### 6. 跳过中间件
|
||||
|
||||
有时不想让特定路由经过某些中间件,有两种方式:
|
||||
|
||||
**方式一:将中间件注册到子分组而非全局**
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 只挂载到 /api 分组
|
||||
api := r.Group("/api", rateLimitMiddleware())
|
||||
{
|
||||
api.GET("/public", publicHandler) // 经过 rateLimit
|
||||
api.GET("/private", privateHandler) // 经过 rateLimit
|
||||
}
|
||||
|
||||
r.GET("/health", healthHandler) // 不经过 rateLimit
|
||||
```
|
||||
|
||||
**方式二:中间件内部判断跳过**
|
||||
|
||||
```go
|
||||
func logging() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 跳过健康检查端点
|
||||
if c.Request.URL.Path == "/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 正常日志逻辑
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
log.Printf("[%d] %s", c.Writer.Status(), time.Since(start))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 中间件的常见应用场景
|
||||
|
||||
| 场景 | 方案 |
|
||||
|------|------|
|
||||
| 跨域处理 | CORS 中间件 |
|
||||
| 身份认证 | JWT / Session 中间件 |
|
||||
| 权限控制 | RBAC 中间件 |
|
||||
| 请求限流 | Token Bucket / 滑动窗口中间件 |
|
||||
| 请求日志 | 结构化日志中间件 |
|
||||
| 请求追踪 | RequestID 中间件 |
|
||||
| 异常恢复 | `gin.Recovery()` |
|
||||
| 缓存 | 响应缓存中间件 |
|
||||
| 数据预处理 | 数据注入中间件(如把数据库对象注入 Context) |
|
||||
|
||||
思考题:如果需要在多个分组之间共享中间件(比如 `api/v1` 和 `api/v2` 都需要 CORS),是把 CORS 注册到全局好,还是注册到各自分组好?为什么?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- `[[GIN/gin-architecture]]` — 中间件链的底层执行机制
|
||||
- `[[GIN/context-lifecycle]]` — `c.Copy()` 的深拷贝原理
|
||||
- `[[GIN/session-auth]]` — 认证/授权中间件实战
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 路由, 架构]
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# 路由匹配与分组机制
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的路由系统是整个框架的性能核心——它用 **Radix Tree(基数树/前缀树)** 替代了标准库的线性匹配,在 O(n) 复杂度(n = URL 深度)内完成路由匹配。同时,路由分组(RouterGroup)提供层级组织,让 RESTful 风格的路由结构清晰可维护。
|
||||
|
||||
思考题:Gin 的路由匹配比 `http.ServeMux` 快多少?为什么?(提示:思考标准库 ServeMux 匹配一个 URL 需要遍历什么)
|
||||
|
||||
## 正文
|
||||
|
||||
### 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]]` — 路由冲突排查与陷阱
|
||||
@@ -38,5 +38,15 @@ create time: YYYY-MM-DD HH:mm
|
||||
|
||||
- **教学者模式**: 假设你是教学者,你需要先规划如何记录这个知识点,让读者能够容易理解。你也可以穿插问题在文档中,启发学生的思考。
|
||||
- **代码示例**: 优先 Go (后端) + React/TS (前端),代码示例点到为止,不要过于冗长。可以适当通过注释省略一部分代码增强可读性,体现核心逻辑即可。当你给出代码时,一定要给出对应的文本解释。
|
||||
- **图表**: 遇到关键概念、流程,仅仅靠文字不容易清晰说明,此时应该使用 Mermaid。
|
||||
- **图表**: 遇到关键概念、流程,仅仅靠文字不容易清晰说明,此时应该使用 Mermaid。避免使用纯文本 ASCII 图表。
|
||||
- **风格**: 详略得当,注重实用性。文风严谨但是不失“活人感”,循序渐进,深入浅出。
|
||||
|
||||
## 附录 Mermaid 规范
|
||||
|
||||
"All content in the Mermaid code, especially Chinese content, MUST be enclosed exclusively in English double quotes "". Never use Chinese double quotes “” to enclose any content. "
|
||||
"Inside English double quotes, no punctuation or special characters are allowed, except for the English comma ,. "
|
||||
"Do not use \n for line breaks. "
|
||||
"Node IDs MUST be in English. If Chinese text is required for display, it should be placed within double quotes. For example, use A["中文显示文本"] instead of 中文节点["文本"]. "
|
||||
"When defining nodes with explanatory information using [], all information inside [] MUST be enclosed in double quotes, like A["Interface MethodSet"]. "
|
||||
"For quadrant charts, do not use [label] for points, as this syntax format is incompatible. Points should be defined as A: [x, y]. "
|
||||
"When generating requirement diagrams, replace fulfills with SATISFIES (满足) and calls with TRACES (追踪) to meet the required syntax. "
|
||||
|
||||
Reference in New Issue
Block a user