diff --git a/BACKEND/GIN/README.md b/BACKEND/GIN/README.md new file mode 100644 index 0000000..c5ad035 --- /dev/null +++ b/BACKEND/GIN/README.md @@ -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)。 diff --git a/BACKEND/GIN/binding-validation.md b/BACKEND/GIN/binding-validation.md new file mode 100644 index 0000000..15ffd3f --- /dev/null +++ b/BACKEND/GIN/binding-validation.md @@ -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 序列化基础 diff --git a/BACKEND/GIN/context-lifecycle.md b/BACKEND/GIN/context-lifecycle.md new file mode 100644 index 0000000..9aa1719 --- /dev/null +++ b/BACKEND/GIN/context-lifecycle.md @@ -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 传播实战 diff --git a/BACKEND/GIN/gin-architecture.md b/BACKEND/GIN/gin-architecture.md new file mode 100644 index 0000000..8712947 --- /dev/null +++ b/BACKEND/GIN/gin-architecture.md @@ -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 基础用法入门 diff --git a/BACKEND/GIN/middleware.md b/BACKEND/GIN/middleware.md new file mode 100644 index 0000000..91489ee --- /dev/null +++ b/BACKEND/GIN/middleware.md @@ -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]]` — 认证/授权中间件实战 diff --git a/BACKEND/GIN/routing.md b/BACKEND/GIN/routing.md new file mode 100644 index 0000000..a381280 --- /dev/null +++ b/BACKEND/GIN/routing.md @@ -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]]` — 路由冲突排查与陷阱 diff --git a/config/agent/DOCUMENT_OPERATION.md b/config/agent/DOCUMENT_OPERATION.md index 492db7f..b35585c 100644 --- a/config/agent/DOCUMENT_OPERATION.md +++ b/config/agent/DOCUMENT_OPERATION.md @@ -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. "