vault backup: 2026-04-28 08:53:28
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 模板, HTML]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# HTML 模板渲染
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 内建了对 Go 标准库 `html/template` 的封装,支持简单模板加载、多模板引擎配置、以及通过 `embed.FS` 将模板打包进单一二进制。虽然现代前后端分离架构中较少直接使用服务端渲染,但在管理后台、邮件模板等场景中仍然实用。
|
||||
|
||||
思考题:`c.HTML` 和 `http.ServeFile` 直接返回 `.html` 文件有什么区别?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 基本模板渲染
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 加载 templates/ 目录下所有 .html 文件
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
|
||||
r.GET("/index", func(c *gin.Context) {
|
||||
// 渲染 templates/index.html,传入模板数据
|
||||
c.HTML(http.StatusOK, "index", gin.H{
|
||||
"title": "Home Page",
|
||||
"user": "wonder",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/news", func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "news.tmpl", gin.H{
|
||||
"title": "News",
|
||||
"items": []string{"item1", "item2"},
|
||||
})
|
||||
})
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
模板文件 `templates/index.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{{.Title}}</title></head>
|
||||
<body>
|
||||
<h1>Welcome, {{.User}}</h1>
|
||||
<!-- 遍历切片 -->
|
||||
{{range .Items}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
> **关键点:** 第二个参数是模板文件名(不带路径),Gin 根据 `LoadHTMLGlob` 或 `LoadHTMLFiles` 配置的映射来查找。
|
||||
|
||||
### 2. `LoadHTMLGlob` vs `LoadHTMLFiles`
|
||||
|
||||
| 方法 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `LoadHTMLGlob(pattern)` | 按 glob 模式加载一批模板 | `"templates/**/*.html"` |
|
||||
| `LoadHTMLFiles(paths...)` | 指定具体的文件列表 | `"templates/base.html", "templates/index.html"` |
|
||||
|
||||
```go
|
||||
// Glob — 适合模板较多、结构简单的场景
|
||||
r.LoadHTMLGlob("templates/**/*") // 包括子目录
|
||||
|
||||
// Files — 适合明确知道有哪些模板的场景
|
||||
r.LoadHTMLFiles(
|
||||
"templates/base.html",
|
||||
"templates/index.html",
|
||||
"templates/error.html",
|
||||
)
|
||||
```
|
||||
|
||||
> **陷阱:** `LoadHTMLGlob("templates/**/*")` 会用每个文件的**基名**作为模板名。如果 `templates/sub/page.html` 也被加载,模板名就是 `sub/page.html`——渲染时需写 `c.HTML(200, "sub/page.html", data)`。
|
||||
|
||||
### 3. 多模板引擎(Template.FuncMap)
|
||||
|
||||
可以注册自定义模板函数,类似 Python Jinja2 的 filter:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
r.SetFuncMap(template.FuncMap{
|
||||
"formatDate": func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
"truncate": func(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
},
|
||||
"upper": strings.ToUpper,
|
||||
})
|
||||
|
||||
r.LoadHTMLGlob("templates/*")
|
||||
|
||||
r.GET("/article", func(c *gin.Context) {
|
||||
c.HTML(200, "article", gin.H{
|
||||
"title": "Gin Templating Guide",
|
||||
"body": "这是一篇很长的文章...",
|
||||
"date": time.Now(),
|
||||
})
|
||||
})
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
模板中使用:
|
||||
|
||||
```html
|
||||
<h1>{{.Title | upper}}</h1>
|
||||
<p>{{.Body | truncate 50}}</p>
|
||||
<small>发布于 {{.Date | formatDate}}</small>
|
||||
```
|
||||
|
||||
### 4. 模板继承(Base Template)
|
||||
|
||||
Go 原生模板不支持继承,但可以通过 `define` + `template` 模拟:
|
||||
|
||||
```html
|
||||
<!-- templates/base.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{block "title" .}}Default{{end}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav>...</nav>
|
||||
{{block "content" .}}{{end}}
|
||||
<footer>...</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<!-- templates/index.html — 引用 base -->
|
||||
{{define "title"}}Home{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Welcome</h1>
|
||||
<p>{{.Message}}</p>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
渲染时传入组合后的模板:
|
||||
|
||||
```go
|
||||
r.LoadHTMLFiles(
|
||||
"templates/base.html",
|
||||
"templates/index.html",
|
||||
)
|
||||
// 此时 index.html 中的 {{template "base"}} 才能找到定义
|
||||
```
|
||||
|
||||
### 5. 将模板打包进单一二进制(Go 1.16+)
|
||||
|
||||
使用 `embed.FS` 把模板文件嵌入 Go 编译产物,适合 Docker 单镜像部署:
|
||||
|
||||
```go
|
||||
import _ "embed"
|
||||
import "html/template"
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 从 embedded FS 加载模板
|
||||
r.LoadHTMLTemplates(template.New("").ParseFS(templateFS, "templates/*.html"))
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 1.23+ 写法
|
||||
r.LoadHTMLTemplates(template.Must(template.New("").ParseFS(templateFS, "templates/*.html")))
|
||||
```
|
||||
|
||||
**项目目录结构:**
|
||||
|
||||
```
|
||||
cmd/
|
||||
├── server/main.go ← embed 入口
|
||||
templates/ ← 模板文件,不会被 gitignore
|
||||
├── base.html
|
||||
├── index.html
|
||||
└── error.html
|
||||
internal/
|
||||
└── handlers/
|
||||
```
|
||||
|
||||
> **优势:** 部署时只需一个二进制文件,不再需要挂载 Volume 或复制模板文件到容器中。
|
||||
|
||||
### 6. 模板安全注意事项
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["用户输入"] --> B["存入 gin.H 数据"]
|
||||
B --> C["传入 c.HTML 渲染"]
|
||||
C --> D{是否 HTML 转义?}
|
||||
D -->|"是 ✅"| E["自动转义,安全"]
|
||||
D -->|"否 ❌"| F["XSS 漏洞"]
|
||||
|
||||
style E fill:#e8f5e9
|
||||
style F fill:#ffebee
|
||||
```
|
||||
|
||||
Go 的 `html/template` 包**自动对上下文相关内容进行转义**:
|
||||
- 在 HTML body 中 → 转义 `<>&"'`
|
||||
- 在 attribute 中 → 转义引号和 `<>&`
|
||||
- 在 JS 上下文中 → 转义 `'` 和 `<\/`
|
||||
|
||||
**唯一例外:** 使用 `template.HTML` / `template.JS` 类型包装的内容不会转义——这意味着你主动告诉模板"这段内容是安全的"。滥用会导致 XSS:
|
||||
|
||||
```go
|
||||
// ❌ 危险:用户输入未过滤就标为 safe
|
||||
c.HTML(200, "page", gin.H{
|
||||
"content": template.HTML(userInput), // 可能被注入 <script>
|
||||
})
|
||||
|
||||
// ✅ 安全:让模板自己决定如何转义
|
||||
c.HTML(200, "page", gin.H{
|
||||
"content": userInput, // html/template 自动转义
|
||||
})
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/9-response-rendering]] — 除了 HTML,Gin 还支持 JSON/XML 等多种渲染
|
||||
- [[GIN/11-static-files]] — 静态资源(CSS/JS/图片)通过 Static 路由提供
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 静态文件, HTTP]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 静态文件服务
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 提供了三种方式提供静态资源:`Static`(目录映射)、`StaticFS`(自定义文件系统)和 `StaticFile`(单文件)。理解它们的区别和使用场景,是构建完整 Web 服务的基础。
|
||||
|
||||
思考题:为什么生产环境通常不推荐用 Go 直接提供静态文件?Nginx/CDN 相比有什么优势?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. `Static` — 目录映射(最常用)
|
||||
|
||||
将本地目录映射为 URL 路径,客户端通过浏览器请求时返回文件:
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 访问 /static/css/style.css → 读取 ./static/css/style.css
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// 使用自定义子路径前缀
|
||||
// 访问 /assets/js/app.js → 读取 ./static/js/app.js
|
||||
r.Static("/assets", "./static/js")
|
||||
|
||||
r.Run(":8080")
|
||||
```
|
||||
|
||||
**URL 匹配规则:**
|
||||
|
||||
| 访问 URL | 物理路径 |
|
||||
|----------|----------|
|
||||
| `/static/css/a.css` | `./static/css/a.css` |
|
||||
| `/static/js/main.js` | `./static/js/main.js` |
|
||||
| `/static/images/logo.png` | `./static/images/logo.png` |
|
||||
|
||||
> **关键点:** 第三个参数可选——如果提供 `fs.FileSystem`,则使用自定义文件系统而不是磁盘。这引出了下一节 `StaticFS`。
|
||||
|
||||
### 2. `StaticFS` — 自定义文件系统
|
||||
|
||||
将 Go 1.16+ 的 `embed.FS` 或直接传 `http.Dir` 给 Gin,实现非磁盘的文件来源:
|
||||
|
||||
```go
|
||||
import _ "embed"
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 从 embed.FS 提供静态文件
|
||||
r.StaticFS("/static", http.FS(staticFS))
|
||||
|
||||
// 等价的传统写法
|
||||
// r.StaticFS("/static", http.Dir("./static"))
|
||||
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
**应用场景:**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["静态文件来源"] --> B["磁盘 http.Dir"]
|
||||
A --> C["embed.FS (容器化)"]
|
||||
A --> D["自定义 fs.FileSystem"]
|
||||
A --> E["对象存储 SDK 适配层"]
|
||||
|
||||
style B fill:#e3f2fd
|
||||
style C fill:#fff3e0
|
||||
style D fill:#e8f5e9
|
||||
style E fill:#f3e5f5
|
||||
```
|
||||
|
||||
### 3. `StaticFile` — 单文件返回
|
||||
|
||||
精确匹配单个文件的 URL 到物理路径,适合只需要暴露特定几个文件的场景:
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 精确匹配:只有 /favicon.ico 会被处理
|
||||
// GET /favicon.ico → ./static/favicon.ico
|
||||
// GET /other → 继续路由匹配(不会返回 404 而是 403 或进入其他 handler)
|
||||
r.StaticFile("/favicon.ico", "./static/favicon.ico")
|
||||
r.StaticFile("/robots.txt", "./static/robots.txt")
|
||||
|
||||
r.Run(":8080")
|
||||
```
|
||||
|
||||
### 4. `io.Reader` 直接返回文件流
|
||||
|
||||
对于不需要落盘的文件(如数据库读取的图片、动态生成的 PDF),可以直接从 Reader 输出:
|
||||
|
||||
```go
|
||||
func getFileFromDB(c *gin.Context) {
|
||||
// 从数据库/Redis 读取文件内容
|
||||
fileData, contentType, err := fetchFileFromStorage(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 直接用 io.Reader 写入响应
|
||||
c.DataBytes(http.StatusOK, contentType, fileData)
|
||||
// 或者
|
||||
// c.Writer.Header().Set("Content-Type", contentType)
|
||||
// c.Writer.Write(fileData)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 自定义文件服务器
|
||||
|
||||
如果需要控制缓存头、限速、访问权限等,可以自己实现 `StaticFS`:
|
||||
|
||||
```go
|
||||
func secureStatic() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 安全检查:防止目录遍历攻击
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
c.Header("Cache-Control", "public, max-age=31536000") // 一年
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// 交给内置文件服务器
|
||||
http.FileServer(http.Dir("./static")).ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
r.Use(secureStatic())
|
||||
r.StaticFS("/", http.Dir("./static"))
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 静态文件 vs API 性能对比
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Go 直接服务
|
||||
A1["Go 进程"] -->|"读文件→写响应"| A2["HTTP 响应"]
|
||||
A1 -->|"占 GC + CPU"| A3["内存/CPU 开销"]
|
||||
end
|
||||
|
||||
subgraph Nginx 反向代理
|
||||
B1["Nginx"] -->|"零拷贝 sendfile"| B2["浏览器"]
|
||||
B1 -->|"系统级优化"| B3["极小开销"]
|
||||
end
|
||||
|
||||
style A3 fill:#ffebee,stroke:#c62828
|
||||
style B3 fill:#e8f5e9,stroke:#2e7d32
|
||||
```
|
||||
|
||||
**建议分层策略:**
|
||||
|
||||
| 场景 | 方案 |
|
||||
|------|------|
|
||||
| 开发/小流量 | Gin 直接 `Static()` |
|
||||
| 生产-前端资源 | Nginx/CDN 托管,Go 只提供 API |
|
||||
| 上传文件 | OSS/S3 + CDN,Go 只生成预签名 URL |
|
||||
| 内网服务间下载 | Gin `StaticFS` + `io.Reader` |
|
||||
|
||||
### 7. 常见问题
|
||||
|
||||
**Q1:修改了静态文件后无法更新?**
|
||||
- 原因:浏览器缓存未过期
|
||||
- 解决:文件名加 hash(如 `app.a1b2c3.js`),或使用 `Cache-Control: no-cache`
|
||||
|
||||
**Q2:大文件导致内存占用高?**
|
||||
- `Static` 底层使用 `sendfile`(Unix)或 `TransmitFile`(Windows),不会全量加载到进程内存
|
||||
|
||||
**Q3:Gin 能支持断点续传吗?**
|
||||
- 不支持开箱即用。需要手动解析 `Range` 请求头并设置 `Accept-Ranges: bytes` + `Content-Range` 响应头
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/10-template-rendering]] — HTML 模板中的 CSS/JS 引用也需要静态文件支持
|
||||
- [[GIN/8-file-upload]] — 上传后的文件存储在何处如何对外提供服务
|
||||
- [[部署与运维基础]] — Nginx + Go 的反向代理配置模式
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 服务器, 部署]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 服务器配置与安全
|
||||
|
||||
## 概述
|
||||
|
||||
`gin.New()` 创建的是一个不带任何中间件的 bare Engine,配合标准库 `http.Server` 可以实现完全可控的服务启动。这一节涵盖高级服务器配置(超时、TLS、KeepAlive)、Cookie 操作、以及可信代理链的安全考量。
|
||||
|
||||
思考题:为什么在生产环境中不能依赖 `gin.Default()` 和 `r.Run()` 启动服务?生产环境应该用什么样的 `http.Server` 配置?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 自定义 Engine + `http.Server` 启动
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// 不使用 Default(无自动 Logger/Recovery)
|
||||
r := gin.New()
|
||||
|
||||
// 手动添加需要的中间件
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(requestID())
|
||||
r.Use(logger())
|
||||
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// 完整控制 http.Server
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r, // Gin Engine 作为 Handler
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1MB header 限制
|
||||
}
|
||||
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
```
|
||||
|
||||
**超时配置说明:**
|
||||
|
||||
| 超时项 | 作用 | 建议值 | 风险 |
|
||||
|--------|------|--------|------|
|
||||
| `ReadTimeout` | 整个请求体读取时间 | 5-10s | 不设 → slowloris 攻击 |
|
||||
| `WriteTimeout` | 响应写入时间 | 10-30s | 不设 → 长连接耗尽 |
|
||||
| `IdleTimeout` | KeepAlive 空闲等待 | 60-120s | 不设 → goroutine 泄漏 |
|
||||
| `MaxHeaderBytes` | 请求头最大大小 | 1MB | 不设 → 默认 1MB |
|
||||
|
||||
> **提问:** `ReadTimeout` 是从连接建立开始计时,还是从最后一个字节读完开始?如果客户端发送一个巨型请求头(比如 100KB),`ReadTimeout` 会生效吗?
|
||||
|
||||
### 2. Cookie 操作
|
||||
|
||||
Gin 封装了便捷的 Cookie 读写方法:
|
||||
|
||||
```go
|
||||
func setCookie(c *gin.Context) {
|
||||
// 设置 Cookie
|
||||
c.SetCookie(
|
||||
"session_id", // name
|
||||
"abc123xyz", // value
|
||||
3600, // maxAge (秒)
|
||||
"/", // path
|
||||
"example.com", // domain (空 = 当前域名)
|
||||
true, // secure (HTTPS only)
|
||||
true, // httpOnly (JS 不可访问)
|
||||
)
|
||||
c.JSON(200, gin.H{"message": "cookie set"})
|
||||
}
|
||||
|
||||
func getCookie(c *gin.Context) {
|
||||
cookie, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "no cookie"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"session_id": cookie})
|
||||
}
|
||||
```
|
||||
|
||||
**Cookie 安全标志组合:**
|
||||
|
||||
| secure | httpOnly | SameSite | 适用场景 |
|
||||
|--------|----------|----------|----------|
|
||||
| true | true | Strict | 认证 Cookie(最高安全) |
|
||||
| true | true | Lax | 会话 Cookie(平衡安全与体验) |
|
||||
| false | true | Lax | 开发环境 |
|
||||
|
||||
### 3. TLS / Let's Encrypt
|
||||
|
||||
Gin 本身不做 TLS 终止——你通过标准库 `http.Server` 配置:
|
||||
|
||||
```go
|
||||
srv := &http.Server{
|
||||
Addr: ":443",
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 方式一:已知证书
|
||||
log.Fatal(srv.ListenAndServeTLS("/path/to/cert.pem", "/path/to/key.pem"))
|
||||
|
||||
// 方式二:Let's Encrypt (acme.AutoHTTPS)
|
||||
// 最简单的方式——不需要证书文件
|
||||
srv := &http.Server{
|
||||
Addr: ":443",
|
||||
Handler: r,
|
||||
}
|
||||
// acme 包会在首次请求时自动申请证书
|
||||
r.RunTLS("", "", "") // Gin 快捷方式,等价于 ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
Gin 的 `RunTLS` 快捷方法:
|
||||
|
||||
```go
|
||||
// ListenAndServeTLS 的简化
|
||||
r.RunTLS(":443", "/cert.pem", "/key.pem") // 指定文件和端口
|
||||
r.RunTLS(":443", "", "") // ACME 自动 HTTPS
|
||||
```
|
||||
|
||||
### 4. 可信代理链(Trusted Proxies)
|
||||
|
||||
当 Gin 跑在 Nginx/Cloudflare/K8s Ingress 后面时,`c.ClientIP()` 默认拿到的是负载均衡器的内网 IP,不是真实用户 IP:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.New()
|
||||
|
||||
// 告诉 Gin 哪些 IP 是可信的代理
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
gin.DefaultSkipPanicHandler = true
|
||||
|
||||
// 标记所有内网 IP 为可信代理
|
||||
gin.SetTrustedProxies([]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"})
|
||||
|
||||
// 或在运行时
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.SetTrustedProxyFn(func(remoteAddr net.Addr) bool {
|
||||
ip := remoteAddr.(*net.TCPAddr).IP
|
||||
return ip.IsPrivate() || ip.Equal(net.IPv4loopback)
|
||||
})
|
||||
})
|
||||
|
||||
// 现在 c.ClientIP() 会正确解析 X-Forwarded-For / X-Real-IP
|
||||
r.GET("/hello", func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
c.String(200, "Hello from %s", ip)
|
||||
})
|
||||
|
||||
r.Run()
|
||||
}
|
||||
```
|
||||
|
||||
> **安全警告:** 如果不设置可信代理,攻击者可以伪造 `X-Forwarded-For` 头注入任意 IP,绕过 IP 白名单限流。务必只信任你知道的代理 IP 段。
|
||||
|
||||
### 5. 完整的生產環境啟動範例
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := setupRouter()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + os.Getenv("PORT"),
|
||||
Handler: r,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
// 优雅关闭(见 [[13-graceful-shutdown]])
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 阻塞主 goroutine
|
||||
select {}
|
||||
}
|
||||
```
|
||||
|
||||
思考题:如果你的服务同时接收短连接(HTTP/1.1)和长连接(HTTP/2),`IdleTimeout` 对两种协议的行为一样吗?什么情况下它会失效?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/13-graceful-shutdown]] — server.Shutdown() 搭配使用的优雅关停机制
|
||||
- [[GIN/logging]] — 生产环境日志配置
|
||||
- [[部署与运维基础]] — 生产部署的超时、健康检查、反代配置
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 优雅停止, 部署]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 优雅停止与重启
|
||||
|
||||
## 概述
|
||||
|
||||
生产环境的 Go 服务不能简单地 `kill -9`——正在处理的请求可能会丢失数据、数据库事务可能中断。Gin 基于 `http.Server.Shutdown()` 实现了优雅的停机流程:停止接受新请求,等待已有请求处理完毕后再退出。
|
||||
|
||||
思考题:如果一个长时间运行的 WebSocket 连接一直不关闭,`server.Shutdown()` 会因为等它而永远卡住吗?怎么解决?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 信号监听 + `Shutdown()`
|
||||
|
||||
这是最常见的优雅停止模式:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/health", healthCheck)
|
||||
r.GET("/api/users", listUsers)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 在后台 goroutine 中启动服务
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 监听终止信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit // 阻塞直到收到信号
|
||||
|
||||
log.Println("shutting down...")
|
||||
|
||||
// 创建一个带超时的 context,给请求处理留出时间
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 停止接收新连接,等待活跃连接完成
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("server exited properly")
|
||||
}
|
||||
```
|
||||
|
||||
**流程图:**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["服务正常运行"] -->|"收到 SIGINT/SIGTERM"| B["停止 Accept 新连接"]
|
||||
B --> C["已有请求继续处理"]
|
||||
C --> D{"所有请求完成?"}
|
||||
D -->|"是"| E["srv.Shutdown 返回 ✅"]
|
||||
D -->|"否, 超时"| F["强制退出 ⚠️"]
|
||||
|
||||
style E fill:#e8f5e9,stroke:#2e7d32
|
||||
style F fill:#fff3e9,stroke:#e65100
|
||||
```
|
||||
|
||||
### 2. Shutdown 内部发生了什么
|
||||
|
||||
```
|
||||
signal.Notify 捕获 SIGTERM
|
||||
↓
|
||||
srv.Shutdown(ctx) 调用
|
||||
↓
|
||||
1. StopListener.Accept() — 不再接受新连接
|
||||
↓
|
||||
2. 遍历所有活跃连接
|
||||
├─ 没有活跃请求 → 立即关闭
|
||||
└─ 有活跃请求 → 等待 context 超时
|
||||
├─ 请求在超时内完成 → 正常关闭连接
|
||||
└─ 超时未完成 → 强制关闭连接
|
||||
```
|
||||
|
||||
**关键细节:**
|
||||
- `Shutdown()` 是同步阻塞调用——必须放在 goroutine 中,否则会卡死主流程
|
||||
- 超时期间**不会断开**已有连接的 TCP socket——只是不在上面调度新请求
|
||||
- 已经分配给请求的 Context(`c.Request.Context()`)不会被取消
|
||||
|
||||
### 3. 长连接的处理问题
|
||||
|
||||
像 WebSocket、SSE(Server-Sent Events)这类长连接不会因为 `Shutdown()` 而自动关闭:
|
||||
|
||||
```go
|
||||
func cleanupLongConnections(srv *http.Server) {
|
||||
// Server 没有内置清理长连接的方法
|
||||
// 需要自己在中间件中跟踪活跃连接,Shutdown 时主动关闭
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
```go
|
||||
type ConnectionManager struct {
|
||||
conns map[*websocket.Conn]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *ConnectionManager) CloseAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for conn := range m.conns {
|
||||
conn.Close() // 主动关闭每个 WebSocket
|
||||
}
|
||||
m.conns = make(map[*websocket.Conn]bool)
|
||||
}
|
||||
```
|
||||
|
||||
在 Shutdown 流程中调用:
|
||||
|
||||
```go
|
||||
// 1. 先关闭所有长连接
|
||||
wsManager.CloseAll()
|
||||
|
||||
// 2. 再等 HTTP 请求处理完
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
```
|
||||
|
||||
### 4. 优雅重启(fork + exec)
|
||||
|
||||
某些场景下不只是"停",还需要"换"——比如更新了二进制文件。经典的 Unix 优雅重启模式:
|
||||
|
||||
```
|
||||
旧进程接收 SIGHUP
|
||||
↓
|
||||
1. 启动新进程
|
||||
↓
|
||||
2. 旧进程等待新进程就绪
|
||||
↓
|
||||
3. 旧进程 Shutdown(等现有请求完成)
|
||||
↓
|
||||
4. 新进程接管端口
|
||||
↓
|
||||
5. 旧进程退出,新进程继续服务(无感知的版本切换)
|
||||
```
|
||||
|
||||
```go
|
||||
func gracefulRestart() error {
|
||||
args := os.Args[1:]
|
||||
args = append([]string{"-fork"}, args...)
|
||||
|
||||
cmd := exec.Command(os.Args[0], args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 等待新进程就绪(简单做法:轮询 health endpoint)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 旧进程开始优雅关停
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
> **实际建议:** 大多数现代部署(Kubernetes Docker)使用滚动发布(Rolling Update),不需要自己实现 fork-restart。SIGTERM 信号已由 K8s 自动处理。
|
||||
|
||||
### 5. Kubernetes 中的优雅停止
|
||||
|
||||
K8s 的优雅停止默认给予 30 秒:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 30 # 秒,默认 30
|
||||
```
|
||||
|
||||
K8s 行为:
|
||||
1. 发送 `SIGTERM` 给 Pod 主进程
|
||||
2. 开始倒计时
|
||||
3. 倒数为 0 时发送 `SIGKILL`(强制杀)
|
||||
|
||||
你的 Go 服务需要在 SIGTERM 后 30 秒内完成 `Shutdown()`,否则会被强杀。
|
||||
|
||||
**最佳实践清单:**
|
||||
|
||||
| 事项 | 说明 |
|
||||
|------|------|
|
||||
| 设置合理的 `WriteTimeout` | 防止某个慢请求拖垮整个关机过程 |
|
||||
| 提前关闭长连接 | WebSocket/SSE 不会自动关 |
|
||||
| 设置 `context.WithTimeout` | 比 K8s deadline 略早一点,留缓冲 |
|
||||
| 记录关闭日志 | 确认关机是否正常完成 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/12-server-config]] — http.Server 的配置和启动方式
|
||||
- [[GIN/15-advanced-running]] — 多服务运行和特殊场景
|
||||
- [[部署与运维基础]] — K8s 滚动发布与优雅停机
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 日志, 监控]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 日志系统
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的日志机制建立在两个核心概念之上:默认日志器的可替换性(`DefaultWriter` / `DefaultErrorWriter`)和中间件的灵活组合。生产环境中通常需要接入结构化日志库,自定义日志格式,并支持按路径跳过某些路由的日志记录。
|
||||
|
||||
思考题:`gin.Default()` 内部的 Logger 中间件会把请求体打印到日志吗?如果会,这对性能有什么影响?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. Gin 内置 Logger
|
||||
|
||||
`gin.Default()` 内置了 `Logger()` 中间件——每处理一个请求,在终端输出类似这样的日志:
|
||||
|
||||
```
|
||||
[GIN] 2026/04/27 - 10:30:00 | 200 | 2.345ms | 127.0.0.1 | GET "/api/users"
|
||||
```
|
||||
|
||||
**输出字段含义:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `[GIN]` | 前缀标识 |
|
||||
| `2026/04/27 - 10:30:00` | 时间戳 |
|
||||
| `200` | HTTP 状态码 |
|
||||
| `2.345ms` | 请求耗时 |
|
||||
| `127.0.0.1` | 客户端 IP |
|
||||
| `GET "/api/users"` | HTTP 方法 + 路径 |
|
||||
|
||||
```go
|
||||
// gin/logger.go — 简化版实现
|
||||
func Logger() HandlerFunc {
|
||||
return func(c *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.Printf("[%s] %d %s %s %s",
|
||||
start.Format(time.RFC3339),
|
||||
status,
|
||||
latency,
|
||||
c.ClientIP(),
|
||||
c.Request.Method+" "+path,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **提问:** Gin 的默认日志直接写到 `os.Stdout`,那如果你想把日志输出到文件而不是终端,应该怎么做?
|
||||
|
||||
### 2. 自定义日志输出目标
|
||||
|
||||
通过修改 `DefaultWriter` 和 `DefaultErrorWriter`:
|
||||
|
||||
```go
|
||||
import (
|
||||
"os"
|
||||
"gopkg.in/natefinber/lumberjack.v2" // 滚动日志
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建滚动日志文件
|
||||
logFile := &lumberjack.Logger{
|
||||
Filename: "./logs/gin.log",
|
||||
MaxSize: 100, // MB
|
||||
MaxBackups: 30, // 最多保留 30 个备份
|
||||
Compress: true, // gzip 压缩
|
||||
}
|
||||
|
||||
// 重写默认输出
|
||||
gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
|
||||
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, logFile)
|
||||
|
||||
r := gin.Default() // Logger 内部使用 DefaultWriter
|
||||
r.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 接入结构化日志(Zap / Logrus)
|
||||
|
||||
用结构化日志库替换 Gin 内置的简单 logger:
|
||||
|
||||
```go
|
||||
func structuredLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
body := ""
|
||||
|
||||
// ⚠️ 读取 body 会影响性能,通常只记录大 body 或错误请求
|
||||
if c.Request.ContentLength > 0 && shouldLogBody(c) {
|
||||
raw, _ := io.ReadAll(io.LimitReader(c.Request.Body, 4096))
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
|
||||
body = string(raw)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
fields := zap.Fields(
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", query),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
zap.String("user_agent", c.Request.UserAgent()),
|
||||
zap.Int("body_size", c.Request.ContentLength),
|
||||
)
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
fields = append(fields, zap.Strings("errors", c.Errors.ToStrings()))
|
||||
}
|
||||
|
||||
if c.Writer.Status() >= 500 {
|
||||
logger.Error("request error", fields...)
|
||||
} else {
|
||||
logger.Info("request", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册方式:
|
||||
|
||||
```go
|
||||
logger, _ := zap.NewProduction()
|
||||
defer logger.Sync()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(structuredLogger(logger))
|
||||
r.Use(gin.Recovery())
|
||||
```
|
||||
|
||||
> **关键细节:** 在日志中间件中读 `c.Request.Body` 会消耗 body,后续 handler 就再也读不到了。所以必须先读完再重新赋值 `c.Request.Body = io.NopCloser(...)`。
|
||||
|
||||
### 4. 跳过特定路径的日志
|
||||
|
||||
并非所有请求都需要记录——静态资源、健康检查等高频请求会产生大量噪音:
|
||||
|
||||
```go
|
||||
func skipLogging() gin.HandlerFunc {
|
||||
skipPaths := map[string]bool{
|
||||
"/health": true,
|
||||
"/ready": true,
|
||||
"/metrics": true,
|
||||
"/favicon.ico": true,
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if skipPaths[c.Request.URL.Path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 走正常日志流程
|
||||
c.Next()
|
||||
latency := time.Since(start)
|
||||
log.Printf("%s %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), latency)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或者更简单地——让路由不经过 Logger 中间件(将日志中间件挂载到特定分组而非全局):
|
||||
|
||||
```go
|
||||
r := gin.New()
|
||||
|
||||
// 不挂全局,只挂到 API 分组
|
||||
api := r.Group("/api")
|
||||
api.Use(Logger())
|
||||
{
|
||||
api.GET("/users", listUsers) // 有日志
|
||||
api.POST("/users", createUser) // 有日志
|
||||
}
|
||||
|
||||
r.GET("/health", healthCheck) // 无日志(不在 api 分组下)
|
||||
r.Static("/static", "./static") // 无日志
|
||||
```
|
||||
|
||||
### 5. 控制日志颜色和格式
|
||||
|
||||
```go
|
||||
// 关闭彩色输出(适合日志采集系统)
|
||||
gin.DisableConsoleColor()
|
||||
|
||||
// 修改日期格式
|
||||
gin.DebugPrint(func(format string, args ...interface{}) {
|
||||
logger.Printf("[DEBUG] "+format, args...)
|
||||
})
|
||||
|
||||
// 完全禁用 Debug 输出
|
||||
gin.SetMode(gin.ReleaseMode) // 隐藏 DebugPrint
|
||||
```
|
||||
|
||||
### 6. 路由日志格式定制
|
||||
|
||||
如果需要不同的日志格式(如 JSON),可以自建 middleware:
|
||||
|
||||
```go
|
||||
func jsonRouterLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
entry := map[string]interface{}{
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"query": c.Request.URL.RawQuery,
|
||||
"status": c.Writer.Status(),
|
||||
"latency_ms": time.Since(start).Milliseconds(),
|
||||
"client_ip": c.ClientIP(),
|
||||
"bytes_in": c.Request.ContentLength,
|
||||
"bytes_out": c.Writer.Size(),
|
||||
}
|
||||
|
||||
// 序列化输出 JSON 日志行
|
||||
b, _ := json.Marshal(entry)
|
||||
os.Stdout.Write(b)
|
||||
os.Stdout.WriteString("\n")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
思考题:如果你的服务跑在 Kubernetes 中,日志输出到 stdout 后被 sidecar(如 Fluent Bit)收集,你觉得需要手动做 JSON 序列化吗?还是可以用 K8s 生态已有的方案?
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/3-middleware]] — 中间件注册位置和 Skip 模式
|
||||
- [[GIN/18-observability]] — 结合 Prometheus metrics 的全面监控方案
|
||||
- [[部署与运维基础]] — 容器化环境中的日志采集(stdout → fluentd/fluent-bit)
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 服务器, 多服务]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 高级运行场景
|
||||
|
||||
## 概述
|
||||
|
||||
除了一台服务器上跑一个单一服务之外,还有一些进阶场景:单进程监听多个端口、同时运行 gRPC 和 HTTP 服务、HTTP/2 Server Push 等。这些场景虽然不一定常用,但在特定架构需求下非常关键。
|
||||
|
||||
思考题:为什么在同一进程中同时监听 HTTP 和 gRPC 比拆成两个服务更好?又有哪些代价?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 单进程多端口监听
|
||||
|
||||
一个 Engine 实例可以绑定多个地址:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// 主服务 — 对外 API
|
||||
go func() {
|
||||
srv := &http.Server{Addr: ":8080", Handler: gin.Default()}
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}()
|
||||
|
||||
// 内部服务 — 管理后台或健康检查
|
||||
internal := gin.New()
|
||||
internal.GET("/admin/health", adminHealth)
|
||||
internal.GET("/admin/metrics", adminMetrics)
|
||||
go func() {
|
||||
srv := &http.Server{Addr: ":9090", Handler: internal}
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}()
|
||||
|
||||
// 阻塞
|
||||
select {}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. HTTP + gRPC 共存
|
||||
|
||||
在一个进程内同时提供 RESTful HTTP 和 gRPC 服务——共享同一个 Engine 实例和配置:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 注册 HTTP 路由
|
||||
r.GET("/api/users", listUsers)
|
||||
r.POST("/api/users", createUser)
|
||||
|
||||
// gRPC server
|
||||
grpcServer := grpc.NewServer()
|
||||
pb.RegisterUserServiceServer(grpcServer, &userGRPCServer{})
|
||||
|
||||
// HTTP + gRPC 共享同一个端口 —— 通过 HTTP Upgrade 协议协商
|
||||
lis, err := net.Listen("tcp", ":8080")
|
||||
if err != nil {
|
||||
log.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
|
||||
// HTTP/2 multiplexing:gRPC 要求 HTTP/2
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", r) // Gin 处理普通请求
|
||||
mux.Handle("/_grpc_", grpcHandler) // gRPC 处理 gRPC 请求
|
||||
|
||||
// 注意:真正的 HTTP/2 mux 需要更复杂的配置
|
||||
// 推荐使用 github.com/bazelbuild/rules_go/go/runfiles 或其他 multiplexer
|
||||
// 最简单的方式是分别监听不同端口
|
||||
log.Println("HTTP on :8080, gRPC on :50051")
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(":8080", r); err != nil {
|
||||
log.Fatalf("http failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
lis, _ := net.Listen("tcp", ":50051")
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("grpc failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {}
|
||||
}
|
||||
```
|
||||
|
||||
**两种部署策略对比:**
|
||||
|
||||
| 策略 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **同进程不同端口** | 共享配置、部署简单 | 端口占用 |
|
||||
| **Nginx 反代** | 协议隔离清晰 | 网络跳数增加 |
|
||||
| **gRPC-Gateway** | 统一入口、单一端口 | 增加复杂度 |
|
||||
|
||||
### 3. HTTP/2 Server Push
|
||||
|
||||
Go 的 `net/http` 原生支持 HTTP/2 Push(Gin 作为 `http.Handler` 也继承了这个能力),但现代浏览器已不再推荐:
|
||||
|
||||
```go
|
||||
func pushHandler(c *gin.Context) {
|
||||
pusher, ok := c.Writer.(http.Pusher)
|
||||
if !ok {
|
||||
c.String(http.StatusOK, "push not supported")
|
||||
return
|
||||
}
|
||||
|
||||
// 推送 CSS 文件给浏览器(不等 JS 请求后再传)
|
||||
if err := pusher.Push("/static/style.css", nil); err != nil {
|
||||
log.Printf("push failed: %v", err)
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "index", gin.H{"title": "Home"})
|
||||
}
|
||||
```
|
||||
|
||||
> **现状提示:** Chrome/Safari 已于 2017-2018 年废弃 HTTP/2 Push 的支持。MDN 明确标注该 API 为 deprecated。不建议在新项目中使用。
|
||||
|
||||
### 4. HTTP/3 (QUIC) 支持
|
||||
|
||||
Go 1.21+ 开始实验性支持 QUIC(UDP-based HTTP/3),但不需要额外配置——只要你的服务端启用了 TLS 并且客户端支持 HTTP/3,Go 会自动协商:
|
||||
|
||||
```go
|
||||
srv := &http.Server{
|
||||
Addr: ":443",
|
||||
Handler: r,
|
||||
}
|
||||
// Go 自动尝试使用 QUIC listener
|
||||
// 前提:启用 TLS(HTTP/3 仅适用于 HTTPS)
|
||||
srv.ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
### 5. 运行时动态切换端口
|
||||
|
||||
有时需要根据配置或环境变量动态调整监听端口:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
|
||||
// 优雅切换端口:先 shutdown 旧 server,再启动新 server
|
||||
// (见 [[13-graceful-shutdown]])
|
||||
switchPort(addr)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 条件启动(Feature Flag)
|
||||
|
||||
根据配置决定是否开启某些路由或服务:
|
||||
|
||||
```go
|
||||
func setupRoutes(r *gin.Engine) {
|
||||
r.GET("/ping", pingHandler)
|
||||
|
||||
// 只有开启了 feature flag 才注册实验性功能
|
||||
if os.Getenv("ENABLE_EXPERIMENTAL") == "true" {
|
||||
r.GET("/experimental/features", experimentalFeatures)
|
||||
}
|
||||
|
||||
// 只在调试模式下注册调试路由
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
r.GET("/debug/vars", pprof.Index)
|
||||
r.GET("/debug/pprof/", pprof.Index)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
思考题:如果你需要在不停机的情况下动态增删路由,Gin 能做到吗?它的路由树一旦注册后能否删除某条路由?
|
||||
|
||||
答案:Gin 的路由注册是单向的——`addRoute` 只加不减。如果需要在运行时动态管理路由,可以考虑使用独立的 RouterGroup 隔离功能模块,通过标志位控制是否注册该 Group。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/12-server-config]] — http.Server 的高级配置
|
||||
- [[GIN/13-graceful-shutdown]] — 停机时如何正确关闭多个 goroutine
|
||||
- [[GIN/21-websocket]] — WebSocket 长连接也是另一种"多协议共存"场景
|
||||
@@ -123,7 +123,7 @@ r.GET("/test", func(c *gin.Context) {
|
||||
// 1-after
|
||||
```
|
||||
|
||||
思考题:如果中间件 A 中调用了 `c.Abort()`(不调用 `c.Next()`),中间件 B 和 handler 还会执行吗?那 A 中 `c.Abort()` 之后的代码还会执行吗?详见 [[GIN/3-middleware-abort]]。
|
||||
思考题:如果中间件 A 中调用了 `c.Abort()`(不调用 `c.Next()`),中间件 B 和 handler 还会执行吗?那 A 中 `c.Abort()` 之后的代码还会执行吗?详见 [[middleware-abort]]。
|
||||
|
||||
### 3. 中间件链的构成
|
||||
|
||||
@@ -385,13 +385,13 @@ func logging() gin.HandlerFunc {
|
||||
| 缓存 | 响应缓存中间件 |
|
||||
| 数据预处理 | 数据注入中间件(如把数据库对象注入 Context) |
|
||||
|
||||
思考题:如果需要在多个分组之间共享中间件(比如 `api/v1` 和 `api/v2` 都需要 CORS),是把 CORS 注册到全局好,还是注册到各自分组好?为什么?详见 [[GIN/3-middleware/cors-registration-scope]]。
|
||||
思考题:如果需要在多个分组之间共享中间件(比如 `api/v1` 和 `api/v2` 都需要 CORS),是把 CORS 注册到全局好,还是注册到各自分组好?为什么?详见 [[cors-registration-scope]]。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/3-middleware/jwt-auth-qa]] — JWT 中间件认证失败后 Context 数据安全性
|
||||
- [[GIN/3-middleware/cors-registration-scope]] — 跨分组共享中间件的作用域选择
|
||||
- [[GIN/3-middleware-abort]] — `c.Abort()` 终止机制与常见陷阱
|
||||
- [[cors-registration-scope]] — 跨分组共享中间件的作用域选择
|
||||
- [[middleware-abort]] — `c.Abort()` 终止机制与常见陷阱
|
||||
- [[GIN/gin-architecture]] — 中间件链的底层执行机制
|
||||
- [[GIN/context-lifecycle]] — `c.Copy()` 的深拷贝原理
|
||||
- [[GIN/session-auth]] — 认证/授权中间件实战
|
||||
|
||||
@@ -54,16 +54,7 @@ v3 := r.Group("/api/v3") // ← 分组注册下很容易忘记加 cors()
|
||||
|
||||
**3. OPTIONS 预检拦截天然正确**
|
||||
|
||||
CORS 的核心逻辑是在最外层处理 `OPTIONS` 预检请求:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
OPTIONS["OPTIONS 预检请求"] --> CORS["全局 CORS 中间件<br/>c.AbortWithStatus(204)"]
|
||||
OPTIONS --> SKIP["不进入后续中间件和 handler"]
|
||||
|
||||
style CORS fill:#90EE90
|
||||
style SKIP fill:#FFB6C1
|
||||
```
|
||||
CORS 的核心逻辑是在最外层处理 `OPTIONS` 预检请求——预检触发条件、两轮对话机制、Header 语义详见 [[BACKEND/GIN/3-middleware/cors-registration-scope/cors-preflight|cors-preflight]]。
|
||||
|
||||
放在全局中间件中,预检请求在最早阶段被拦截,不会消耗后续中间件的算力。
|
||||
|
||||
@@ -96,6 +87,7 @@ v2 := r.Group("/api/v2", corsStrict([]string{"https://app.example.com"}))
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/3-middleware/cors-preflight]] — OPTIONS 预检请求完整机制(触发条件、两轮对话、Max-Age 缓存)
|
||||
- [[GIN/3-middleware]] — 中间件三级作用域机制
|
||||
- [[GIN/3-middleware-abort]] — `c.Abort()` 终止机制
|
||||
- [[middleware-abort]] — `c.Abort()` 终止机制
|
||||
- [[GIN/gin-architecture]] — 中间件链底层执行机制
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, CORS, 跨域, 预检]
|
||||
create time: 2026-04-27 13:00
|
||||
---
|
||||
|
||||
# CORS OPTIONS 预检请求详解
|
||||
|
||||
## 概述
|
||||
|
||||
当浏览器发起**跨域非简单请求**时,会在实际请求之前先发送一个 `OPTIONS` 预检请求,询问服务端是否允许该操作。本文完整解析预检触发条件、两轮对话流程及底层机制。
|
||||
|
||||
## 正文
|
||||
|
||||
### 什么时候触发预检?
|
||||
|
||||
并非所有跨域请求都经过预检。以下四种情况**之一满足即触发**:
|
||||
|
||||
| 触发条件 | 说明 |
|
||||
|---------|------|
|
||||
| 方法不是简单方法 | 使用了 `PUT`、`DELETE`、`PATCH`、`HEAD` 等 |
|
||||
| 请求头包含自定义 Header | 如 `X-Token`、`Authorization`(`Accept`/`Content-Type`/`Language` 除外) |
|
||||
| `Content-Type` 是非简单类型 | 如 `application/json`;`text/plain`、`multipart/form-data`、`application/x-www-form-urlencoded` 是简单类型 |
|
||||
| 跨域且携带凭证 | `withCredentials: true` 或 `credentials: "include"` |
|
||||
|
||||
> **思考**:为什么同源请求不需要预检?因为同源策略本身就已经限制了跨站操作的安全性,不需要额外协商。
|
||||
|
||||
### 预检的两轮对话
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as 浏览器
|
||||
participant S as 服务端
|
||||
|
||||
B->>S: OPTIONS /api/resource
|
||||
Note over B,S: Origin<br/>Access-Control-Request-Method<br/>Access-Control-Request-Headers
|
||||
S-->>B: 204 No Content
|
||||
Note over B,S: Access-Control-Allow-Origin<br/>Access-Control-Allow-Methods<br/>Access-Control-Allow-Headers<br/>Access-Control-Max-Age
|
||||
B->>S: PUT /api/resource
|
||||
Note over B,S: Origin<br/>X-Token: xxx
|
||||
S-->>B: 200 OK + CORS headers
|
||||
|
||||
rect rgba(200, 255, 200, 0.3)
|
||||
Note over B,B: ① 预检请求
|
||||
end
|
||||
rect rgba(255, 255, 200, 0.3)
|
||||
Note over S,S: ② 预检响应
|
||||
end
|
||||
rect rgba(200, 200, 255, 0.3)
|
||||
Note over B,B: ③ 实际请求
|
||||
end
|
||||
rect rgba(255, 200, 200, 0.3)
|
||||
Note over S,S: ④ 实际响应
|
||||
end
|
||||
```
|
||||
|
||||
#### ① 预检请求 — 浏览器发问
|
||||
|
||||
```http
|
||||
OPTIONS /api/resource HTTP/1.1
|
||||
Host: api.example.com
|
||||
Origin: https://app.example.com
|
||||
Access-Control-Request-Method: PUT
|
||||
Access-Control-Request-Headers: X-Token, Content-Type
|
||||
```
|
||||
|
||||
两个关键自定义 Header 由浏览器**自动添加**,开发者无法操控:
|
||||
- `Access-Control-Request-Method` — 打算用的 HTTP 方法
|
||||
- `Access-Control-Request-Headers` — 打算带的自定义 Header 列表
|
||||
|
||||
#### ② 预检响应 — 服务端答复
|
||||
|
||||
```http
|
||||
HTTP/1.1 204 No Content
|
||||
Access-Control-Allow-Origin: https://app.example.com
|
||||
Access-Control-Allow-Methods: PUT, GET, POST, DELETE
|
||||
Access-Control-Allow-Headers: X-Token, Content-Type
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
注意用 `204 No Content`——预检**没有响应体**,因为还没确认要处理实际请求。
|
||||
|
||||
#### ③ 实际请求 — 预检通过后才发
|
||||
|
||||
```http
|
||||
PUT /api/resource HTTP/1.1
|
||||
Host: api.example.com
|
||||
Origin: https://app.example.com
|
||||
X-Token: eyJhbG...
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
如果预检失败(收到非 2xx 或缺少必要 header),浏览器直接**静默拒绝**,不会发送实际请求。
|
||||
|
||||
#### ④ 实际响应 — 浏览器二次校验
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Access-Control-Allow-Origin: https://app.example.com
|
||||
Access-Control-Allow-Credentials: true
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
浏览器的实际请求**不经过预检**,但浏览器仍会检查响应中的 CORS header 是否匹配当前页面来源。
|
||||
|
||||
### 关键 Header 速查
|
||||
|
||||
| 请求/Header | 方向 | 作用 |
|
||||
|-------------|------|------|
|
||||
| `Origin` | → 和 ↩ | 当前页面的源(协议+域名+端口),**不可自定义** |
|
||||
| `Access-Control-Allow-Origin` | ↩ | 允许的源;与 `credentials: true` 搭配时不能为 `*` |
|
||||
| `Access-Control-Allow-Methods` | ↩ | 预检通过的 HTTP 方法列表(逗号分隔) |
|
||||
| `Access-Control-Allow-Headers` | ↩ | 预检通过的自定义 Header 列表 |
|
||||
| `Access-Control-Max-Age` | ↩ | 预检结果缓存时长(秒),期间不再发 OPTIONS |
|
||||
| `Access-Control-Allow-Credentials` | ↩ | 是否允许带 Cookie/认证信息 |
|
||||
| `Access-Control-Expose-Headers` | ↩ | 允许 JS `getResponseHeader()` 读取的响应头白名单 |
|
||||
|
||||
> **常见坑**:`Allow-Credentials: true` 时 `Allow-Origin` 必须写死具体域名,不能用 `*`。这是浏览器强制校验的安全策略。
|
||||
|
||||
### Max-Age 缓存机制
|
||||
|
||||
浏览器会对成功的预检结果做缓存:
|
||||
|
||||
- `Max-Age: 86400` = 24 小时内再次访问同一路径,浏览器**跳过预检**直接发实际请求
|
||||
- 不同路径、不同方法、不同 Origin 视为不同的预检条目,各自独立缓存
|
||||
- 如果服务端返回 `Max-Age: 0`,浏览器每次都会预检
|
||||
|
||||
这也是为什么优化跨域性能时,把 `Max-Age` 设置大一些比取消中间件顺序更有意义。
|
||||
|
||||
### Gin 中的实现要点
|
||||
|
||||
对应的中间件逻辑非常简单——**只要区分是否是 OPTIONS 请求**:
|
||||
|
||||
```go
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
// 预检请求:只设 header + 拦截
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Token")
|
||||
c.AbortWithStatus(http.StatusNoContent) // 204, 无 Body
|
||||
return
|
||||
}
|
||||
// 实际请求:正常走链,但响应头里也要注入 CORS(供浏览器校验)
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
核心就两件事:
|
||||
- **OPTIONS → 只设 header,`c.Abort()` 终止链**,不进任何 handler
|
||||
- **其他方法 → 正常请求,但 `c.Header()` 注入 header 到最终响应**(浏览器的实际请求需要这些 header 来确认服务端确实允许)
|
||||
|
||||
这就是为什么它适合注册为全局中间件——不在路由匹配前拦截预检的话,Gin 会因为找不到 `OPTIONS` 路由而返回 405/404,浏览器认为预检失败就直接阻塞了后续所有请求。
|
||||
|
||||
### 前后端对照
|
||||
|
||||
同一接口的完整交互视角(前端发起):
|
||||
|
||||
```ts
|
||||
// 前端代码
|
||||
fetch("https://api.example.com/resource", {
|
||||
method: "PUT",
|
||||
credentials: "include", // ← 触发携带凭证模式
|
||||
headers: {
|
||||
"Content-Type": "application/json", // ← 触发预检(非简单 Content-Type)
|
||||
"X-Token": token, // ← 触发预检(自定义 Header)
|
||||
},
|
||||
body: JSON.stringify({ name: "updated" }),
|
||||
})
|
||||
```
|
||||
|
||||
上述代码在实际发 `PUT` 请求前,浏览器必定先发一轮 `OPTIONS`。如果把其中任意一个触发条件去掉(比如改用 `GET` + `text/plain`),就可以省去预检往返。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[cors-registration-scope]] — 全局 vs 分组注册 CORS 中间件的取舍
|
||||
- [[GIN/3-middleware]] — 中间件三级作用域机制
|
||||
- [[middleware-abort]] — `c.Abort()` 终止机制
|
||||
- [[GIN/3-middleware]] — 中间件三级作用域机制
|
||||
- [[middleware-abort]] — `c.Abort()` 终止机制
|
||||
@@ -102,4 +102,4 @@ func (c *Context) Next() {
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/3-middleware]] — 中间件完整机制
|
||||
- [[GIN/3-middleware-abort]] — `c.Abort()` 终止机制详解
|
||||
- [[middleware-abort]] — `c.Abort()` 终止机制详解
|
||||
|
||||
@@ -10,6 +10,7 @@ create time: 2026-04-27 00:00
|
||||
Gin 的 `*gin.Context` 是每次请求的核心载体——它封装了 Request、ResponseWriter、参数、JSON 绑定、中间件链和错误处理。理解 Context 的完整生命周期,是从"会用 Gin"进阶到"写对 Gin"的关键一步。
|
||||
|
||||
思考题:Gin 用 `sync.Pool` 复用 Context 对象,这意味着你**不能**在 handler 返回后继续使用 Context,对吗?为什么?
|
||||
详见 → [[4-context-lifecycle/context-pool-safety]]
|
||||
|
||||
## 正文
|
||||
|
||||
@@ -224,3 +225,4 @@ Gin 默认**不设置**读取超时。如果需要超时控制,应在 `http.Se
|
||||
- [[GIN/1-gin-architecture]] — Engine 初始化与 Context 池化
|
||||
- [[GIN/3-middleware]] — 中间件深入实践
|
||||
- [[GIN/5-binding-validation]] — 模型绑定与校验
|
||||
- [[4-context-lifecycle/context-pool-safety]] — sync.Pool 复用安全:handler 返回后为什么不能持有 Context
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
tags:
|
||||
- 后端
|
||||
- Go
|
||||
- Gin
|
||||
- Context
|
||||
- 生命周期
|
||||
create time: 2026-04-27 00:00
|
||||
---
|
||||
|
||||
# sync.Pool 与 Context 复用安全
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 通过 `sync.Pool` 池化 `*gin.Context` 来减少内存分配开销,但这带来了一个关键的安全约束:**handler 返回后不能再持有 Context 引用**。本文深入解析这一行为背后的三个原因及正确做法。
|
||||
|
||||
## 正文
|
||||
|
||||
### 思考题回顾
|
||||
|
||||
> **问题:** Gin 用 `sync.Pool` 复用 Context 对象,这意味着你**不能**在 handler 返回后继续使用 Context,对吗?为什么?
|
||||
|
||||
### 结论
|
||||
|
||||
handler 返回后绝对不能再持有或使用 Context 引用。
|
||||
|
||||
### 原因一:Context 会被立即归还到池中
|
||||
|
||||
`serveContext` 中通过 `defer` 将 Context 归还给池——handler 一 `return`,defer 立即执行:
|
||||
|
||||
```go
|
||||
func (engine *Engine) serveContext(w http.ResponseWriter, r *http.Request) {
|
||||
c := engine.contextPool.Get().(*Context)
|
||||
c.reset(w)
|
||||
defer engine.contextPool.Put(c) // ← handler return 后立即归还
|
||||
|
||||
c.next(r) // 执行中间件链 → handler
|
||||
w.Write(c.writerMem.Bytes())
|
||||
}
|
||||
```
|
||||
|
||||
### 原因二:下一个请求可能立刻拿到同一个对象
|
||||
|
||||
`sync.Pool` 是"取出→重置→复用→归还"的循环:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Pool as sync.Pool
|
||||
participant A as 请求 A
|
||||
participant B as 请求 B
|
||||
|
||||
A->>Pool: Get() → reset() → 处理 → Put()
|
||||
B->>Pool: Get() → reset() → 处理 → Put()
|
||||
Note over A,B: 同一内存地址被复用
|
||||
```
|
||||
|
||||
当旧 Context 被 Put 回池后,另一个请求可能从池中 Get() 出**完全相同的对象**(同一内存地址)。此时你对旧 Context 的所有引用,实际上指向的是新请求的数据。
|
||||
|
||||
### 原因三:reset() 会清空一切状态
|
||||
|
||||
每次从池中取出后都会调用 `reset()`,覆盖所有字段:
|
||||
|
||||
```go
|
||||
func (c *Context) reset(w http.ResponseWriter) {
|
||||
c.Writer = w.(*responseWriter) // Writer 被替换
|
||||
c.writerMem.Reset() // 响应缓冲区被清空
|
||||
c.Params = c.Params[:0] // 路径参数被清空
|
||||
c.handlers = nil // handler 链被清空
|
||||
c.index = -1 // 执行位置被重置
|
||||
c.errors = c.errors[:0] // 错误列表被清空
|
||||
c.Keys = nil // 共享数据被清空
|
||||
c.QueryCache = nil // 查询缓存被清空
|
||||
c.FormCache = nil // 表单缓存被清空
|
||||
}
|
||||
```
|
||||
|
||||
持有着旧 Context 引用会导致:
|
||||
|
||||
| 行为 | 后果 |
|
||||
|------|------|
|
||||
| 读取 `c.Keys` | 读到下一个请求的 Keys(可能被其他中间件写入) |
|
||||
| 调用 `c.Param("id")` | 返回空或下一个请求的路径参数 |
|
||||
| 写入 `c.JSON(...)` | 写入错误的 ResponseWriter,响应混乱 |
|
||||
| 并发访问同一引用 | 数据竞争,不可预知的崩溃 |
|
||||
|
||||
### ✅ 正确做法:只拷贝值,不传递引用
|
||||
|
||||
如果需要在 handler 返回后异步使用数据,**提取并拷贝所需的值**:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
userID := c.Param("id") // ★ 提取为基本类型
|
||||
requestID := c.GetString("request_id")
|
||||
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
|
||||
// 异步任务:只传值,绝不传 Context 引用
|
||||
go func(id string, rid string) {
|
||||
processBackground(id, rid) // 安全:纯值传递
|
||||
}(userID, requestID)
|
||||
}
|
||||
```
|
||||
|
||||
**核心原则:** handler 返回前,把所有需要的数据从 Context 中提取出来存为局部变量——这些值是栈上的拷贝,不受 Context 回收影响。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[4-context-lifecycle]] — Context 生命周期总览(父文档)
|
||||
- [[GIN/1-gin-architecture]] — Engine 初始化与 Context 池化
|
||||
@@ -20,7 +20,7 @@ Gin 提供了一套统一的绑定方法,根据数据来源选择对应的方
|
||||
| 方法 | 数据来源 | 适用场景 |
|
||||
|------|----------|----------|
|
||||
| `c.ShouldBindJSON(&v)` | `Content-Type: application/json` | RESTful API body |
|
||||
| `c.ShouldBindJSON(&v)` | `Content-Type: application/xml` | XML 请求 |
|
||||
| `c.ShouldBindXML(&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` |
|
||||
@@ -408,6 +408,8 @@ func formatValidationErrors(err *validator.ValidationErrors) map[string]string {
|
||||
```
|
||||
|
||||
> **提问:** `ShouldBindJSON` 遇到未知字段(JSON 中有 struct 没有的 key)时,默认行为是什么?会报错吗?如果不想报错,有什么办法忽略未知字段?
|
||||
>
|
||||
> → 详见 `[[5-binding-validation/unknown-fields]]`
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 绑定, 校验, 安全性]
|
||||
create time: 2026-04-27 00:15
|
||||
---
|
||||
|
||||
# ShouldBindJSON 未知字段处理
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 的 `ShouldBindJSON` 默认对 JSON 中的未知字段(struct 没有对应 key)**静默忽略**,不会报错。这一行为是实际开发中隐藏 bug 的主要来源——拼写错误、多余字段都会被无声吞掉。本文介绍底层原理和三种开启严格模式的方法。
|
||||
|
||||
思考题:如果前端把 `"name"` 误写成 `"nam"`,ShouldBindJSON 会怎么响应?handler 拿到的值是零值还是报错?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 默认行为:静默忽略
|
||||
|
||||
Gin 底层使用 Go 标准库 `encoding/json`,其 `Unmarshal` 对未知字段默认不报错:
|
||||
|
||||
```go
|
||||
type UserReq struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// 前端传入 {"name": "alice", "age": 25, "role": "admin"}
|
||||
var req UserReq
|
||||
c.ShouldBindJSON(&req) // ✅ 成功,req.Name = "alice","age" 和 "role" 被忽略
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["JSON Body"] --> B{"encoding/json.Unmarshal"}
|
||||
B -->|"已知字段"| C["填入 struct"]
|
||||
B -->|"未知字段"| D["静默丢弃 ⚠️"]
|
||||
C --> E["返回 nil (成功)"]
|
||||
D --> E
|
||||
```
|
||||
|
||||
> **关键陷阱:** 前端字段拼写错误(如 `nam` 代替 `name`)不会被检测到,handler 拿到的是零值 `""`,后续业务逻辑可能因此出错却难以排查。
|
||||
|
||||
### 2. 什么情况下会报错?
|
||||
|
||||
注意,静默忽略仅限于**字段不存在**。以下情况仍会报错:
|
||||
|
||||
| 场景 | 行为 | 原因 |
|
||||
|------|------|------|
|
||||
| 字段拼写错误(多/少字母) | 静默忽略,值为零值 | 视为新字段被丢弃 |
|
||||
| 类型不匹配(字符串给 int) | ❌ 解析失败 | 无法转换 |
|
||||
| Content-Type 不是 JSON | ❌ 400 Bad Request | Gin 内容类型检测 |
|
||||
| 缺少 required 字段 | ❌ 校验失败 | validator 拦截 |
|
||||
|
||||
### 3. 方案一:手动 json.Decoder(推荐)
|
||||
|
||||
最轻量、无需引入额外依赖的方式:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
|
||||
// 替换默认的 json.Unmarshal 为 Strict 模式的 Decoder
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"code": 1003,
|
||||
"message": "参数解析失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// req 已包含所有已知字段的值
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:无需改动 Gin 全局配置,仅在当前 handler 生效,影响范围可控。
|
||||
**缺点**:每个需要严格校验的 handler 都要重复写这段代码,可通过 middleware 封装复用。
|
||||
|
||||
### 4. 方案二:全局替换 Gin 的 BindJSON
|
||||
|
||||
通过重写 `gin.BindJSON` 接口,让所有 JSON 绑定都自动拒绝未知字段:
|
||||
|
||||
```go
|
||||
import "github.com/gin-gonic/gin/binding"
|
||||
|
||||
func init() {
|
||||
// 保存原始的 BindJSON
|
||||
original := binding.JSON.(binding.Binding)
|
||||
|
||||
// 替换为 Strict 版本
|
||||
binding.JSON = &jsonBinding{Binding: original}
|
||||
}
|
||||
|
||||
type jsonBinding struct {
|
||||
binding.Binding
|
||||
}
|
||||
|
||||
func (j *jsonBinding) Name() string { return "json" }
|
||||
|
||||
func (j *jsonBinding) Bind(req *http.Request, obj interface{}) error {
|
||||
if err := json.NewDecoder(req.Body).Decode(obj); err != nil {
|
||||
return err
|
||||
}
|
||||
// 此处若需严格模式,应使用 DisallowUnknownFields 重新解码
|
||||
_ = j.Binding // 保留原有逻辑引用
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:直接改写全局 `gin.BindJSON` 会影响所有 handler,包括中间件内部使用的绑定。生产环境中更推荐使用方案一配合自定义中间件。
|
||||
|
||||
### 5. 方案三:结构体级 UnmarshalJSON
|
||||
|
||||
在单个结构体上实现 `UnmarshalJSON`,精确控制解析逻辑:
|
||||
|
||||
```go
|
||||
func (r *CreateUserRequest) UnmarshalJSON(data []byte) error {
|
||||
// 先用 map 接收全部字段
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 白名单检查:遍历后合并已知字段名
|
||||
knownFields := map[string]bool{
|
||||
"name": true, "email": true, "age": true, "role": true,
|
||||
}
|
||||
for k := range raw {
|
||||
if !knownFields[k] {
|
||||
return fmt.Errorf("unknown field: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 alias 绕过递归,再次反序列化到结构体
|
||||
type Alias CreateUserRequest
|
||||
return json.Unmarshal(data, (*Alias)(r))
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:细粒度控制,不同结构体可有不同的严格程度。
|
||||
**缺点**:样板代码较多,维护成本高;每次都需要先读入 `map` 再二次解析,性能略差。
|
||||
|
||||
### 6. 实践建议
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|------|---------|
|
||||
| 对外公开的 RESTful API | 方案一(Strict Decoder),防止客户端传参错误 |
|
||||
| 内部服务通信 | 默认行为即可,节省带宽和兼容性 |
|
||||
| 渐进式迁移(新增字段向前兼容) | 默认行为 + 监控日志记录未知字段 |
|
||||
| 需要结构化错误提示 | 方案一或方案三,配合错误分类返回友好消息 |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["收到 JSON 请求"] --> B{"是否对外开放 API?"}
|
||||
B -->|"是"| C["启用 DisallowUnknownFields"]
|
||||
B -->|"否 / 内部服务"| D["默认静默忽略"]
|
||||
C --> E{"发现未知字段?"}
|
||||
E -->|"是"| F["返回 400 + 详细错误"]
|
||||
E -->|"否"| G["正常处理"]
|
||||
D --> H["继续业务逻辑"]
|
||||
```
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 错误处理, 中间件]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 错误处理机制
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 提供了一套链式错误收集 + 全局处理的错误管理机制:handler 中调用 `c.Error(err)` 将错误存入上下文,`c.Errors` 收集所有错误,全局恢复中间件兜底 panic。理解这套机制可以写出结构化、一致的错误响应。
|
||||
|
||||
思考题:如果你的业务函数返回 `(result, err)`,是否需要手动调用 `c.Error(err)`?还是可以直接写 JSON 响应?(详见下文第 2 节)
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. `c.Error` → `c.Errors` 链式错误收集
|
||||
|
||||
Gin 允许在请求生命周期内累积多个错误——这在批量操作或多步验证场景中非常有用:
|
||||
|
||||
```go
|
||||
func batchUpdate(c *gin.Context) {
|
||||
var items []Item
|
||||
c.ShouldBindJSON(&items)
|
||||
|
||||
var errors gin.ErrorList
|
||||
|
||||
for _, item := range items {
|
||||
if err := validate(item); err != nil {
|
||||
// 将错误写入 Context,不会中断后续处理
|
||||
c.Error(&gin.Error{
|
||||
Err: err,
|
||||
Meta: item.ID, // 附加元数据(哪个 item 出错了)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if err := saveToDB(item); err != nil {
|
||||
c.Error(&gin.Error{Err: err, Meta: item.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// 取出所有累积的错误
|
||||
if len(c.Errors) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"errors": c.Errors.ByType(gin.ErrorTypeBind),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "batch update done"})
|
||||
}
|
||||
```
|
||||
|
||||
**`gin.Error` 结构:**
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Err error // 原始错误
|
||||
Type ErrorType // 错误类型
|
||||
Meta interface{} // 附加的任意元数据
|
||||
}
|
||||
|
||||
type ErrorType uint8
|
||||
|
||||
const (
|
||||
ErrorTypeBind ErrorType = 1 << iota // 绑定错误
|
||||
ErrorTypeRelease // 资源释放错误
|
||||
ErrorTypePrivate // 内部业务错误
|
||||
ErrorTypePublic // 暴露给客户端的业务错误
|
||||
)
|
||||
```
|
||||
|
||||
**`c.Errors` 常用方法:**
|
||||
|
||||
| 方法 | 作用 |
|
||||
|------|------|
|
||||
| `c.Errors.ByType(t)` | 按类型过滤错误 |
|
||||
| `c.Errors.Last()` | 获取最后一个错误 |
|
||||
| `c.Errors.First()` | 获取第一个错误 |
|
||||
| `c.Errors.ToStrings()` | 转为字符串切片 |
|
||||
|
||||
> **关键理解:** `c.Error(err)` 不终止请求、不回写响应——它只是往 `c.errors` 切片里追加。你需要显式检查 `len(c.Errors)` 并返回错误响应。
|
||||
|
||||
思考题:`c.Error()` 和直接 `c.JSON(500, ...)` 有什么区别?什么时候应该用前者?
|
||||
|
||||
### 2. 业务错误的正确处理方式
|
||||
|
||||
大多数情况下,handler 中的错误不需要走 `c.Error` 链,而是直接返回错误响应:
|
||||
|
||||
```go
|
||||
func getUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
user, err := userRepository.FindByID(id)
|
||||
if err != nil {
|
||||
// ✅ 推荐:直接返回统一错误响应
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"message": "user not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 服务端错误
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "internal server error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": user})
|
||||
}
|
||||
```
|
||||
|
||||
**核心原则:**
|
||||
- **单次错误**:直接 `c.JSON(code, ...)` 返回,不经过 `c.Error`
|
||||
- **批量/多步场景**:用 `c.Error` 收集所有错误,一次性返回
|
||||
- **panic 异常**:交给 `gin.Recovery()` 兜底
|
||||
|
||||
### 3. 全局恢复中间件 — `gin.Recovery()`
|
||||
|
||||
默认 `gin.Default()` 已经内置了 `Recovery()` 中间件——它会捕获所有 panic,生成 500 响应,并打印堆栈到日志:
|
||||
|
||||
```go
|
||||
// gin/recovery.go — 简化版
|
||||
func Recovery() HandlerFunc {
|
||||
return func(c *Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// 打印堆栈追踪
|
||||
debug.PrintStack()
|
||||
// 写入错误链
|
||||
c.Error(err.(error))
|
||||
// 返回 500
|
||||
c.AbortWithStatus(JSONErrorCode(http.StatusInternalServerError))
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**自定义 Recovery:**
|
||||
|
||||
```go
|
||||
r := gin.New()
|
||||
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("Panic recovered: %v", recovered)
|
||||
|
||||
// 可以在这里做更多事:发送告警、记录指标等
|
||||
|
||||
if err, ok := recovered.(error); ok {
|
||||
c.JSON(500, gin.H{
|
||||
"code": 500,
|
||||
"message": "internal server error",
|
||||
"trace_id": c.GetString("request-id"),
|
||||
})
|
||||
} else {
|
||||
c.JSON(500, gin.H{
|
||||
"code": 500,
|
||||
"message": "unexpected error",
|
||||
})
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
> **提问:** `gin.Recovery()` 把 panic 也写进了 `c.Errors`,这意味着如果你在 Recovery 之后还有中间件,它们能看到这个错误吗?
|
||||
|
||||
答案:不能。`recover()` 后调用了 `c.AbortWithStatus()`,中间件链已经终止。
|
||||
|
||||
### 4. HTTP 状态码与业务码的映射
|
||||
|
||||
生产环境通常维护两层错误码体系:
|
||||
|
||||
| 层级 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **HTTP 状态码** | 网络层语义 | 200 / 400 / 404 / 500 |
|
||||
| **业务错误码** | 业务语义 | `40001` 参数校验失败 / `40002` 余额不足 |
|
||||
|
||||
```go
|
||||
// 统一错误响应格式
|
||||
type ErrorResponse struct {
|
||||
Code int `json:"code"` // 业务错误码
|
||||
Message string `json:"message"` // 用户可读消息
|
||||
Trace string `json:"trace,omitempty"` // 调试用 trace ID
|
||||
}
|
||||
|
||||
func handleBizError(c *gin.Context, httpCode, bizCode int, msg string) {
|
||||
c.JSON(httpCode, ErrorResponse{
|
||||
Code: bizCode,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// 使用
|
||||
handleBizError(c, http.StatusBadRequest, 40001, "用户名不能为空")
|
||||
handleBizError(c, http.StatusNotFound, 40401, "订单不存在")
|
||||
```
|
||||
|
||||
**常见业务码分段约定:**
|
||||
|
||||
| 段 | HTTP 码 | 含义 |
|
||||
|----|---------|------|
|
||||
| 2xxxx | 200 | 成功 |
|
||||
| 40xxx | 400 | 客户端错误(参数、校验) |
|
||||
| 401xx | 401 | 未认证 |
|
||||
| 403xx | 403 | 无权限 |
|
||||
| 404xx | 404 | 资源不存在 |
|
||||
| 50xxx | 500 | 服务端内部错误 |
|
||||
|
||||
思考题:如果你的 API 同时面向前端和第三方开发者,你觉得业务错误码是放在 JSON body 里好,还是也应该加一个自定义响应头(如 `X-Biz-Code`)?为什么?
|
||||
|
||||
### 5. 统一错误响应中间件
|
||||
|
||||
把错误处理抽成中间件,确保所有路由的响应格式一致:
|
||||
|
||||
```go
|
||||
func errorMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// 检查是否有积压的错误
|
||||
if len(c.Errors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 取最后一个(或最严重)的错误
|
||||
lastErr := c.Errors.Last()
|
||||
switch lastErr.Type {
|
||||
case gin.ErrorTypePrivate:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 50000,
|
||||
"message": "internal server error",
|
||||
})
|
||||
case gin.ErrorTypePublic:
|
||||
if e, ok := lastErr.Err.(*GinError); ok {
|
||||
c.JSON(e.HTTPCode, gin.H{
|
||||
"code": e.Code,
|
||||
"message": e.Message,
|
||||
})
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 50000,
|
||||
"message": "internal server error",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意:** 如果 handler 已经通过 `c.JSON()` 写入了响应体,中间件再写会覆盖或报错。因此需要在 handler 返回前拦截错误,而不是在 `c.Next()` 之后处理。更常见的做法是封装统一的 `c.Success()` / `c.Error()` 辅助方法。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/3-middleware]] — 中间件执行顺序与全局中间件注册
|
||||
- [[GIN/5-binding-validation]] — 参数绑定的错误属于 ErrorTypeBind
|
||||
- [[GIN/observability]] — 错误上报 Prometheus 与链路追踪
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 绑定, 表单]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 高级绑定与表单处理
|
||||
|
||||
## 概述
|
||||
|
||||
`ShouldBind` 全家桶之外,Gin 还支持多内容类型自动检测、Map 绑定、查询参数与 POST body 混合绑定、字段默认值策略和按条件绑定不同结构体等进阶用法。掌握这些能覆盖日常开发中 95% 的数据接收场景。
|
||||
|
||||
思考题:同一个请求同时包含 JSON body 和 form 数据时,`c.ShouldBind(&obj)` 会选择哪个?(详见第 1 节)
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. `c.ShouldBind()` 多内容类型自动检测
|
||||
|
||||
`ShouldBind` 会根据 `Content-Type` 头自动选择绑定策略:
|
||||
|
||||
```go
|
||||
func handle(c *gin.Context) {
|
||||
var req RequestBody
|
||||
// Content-Type: application/json → ShouldBindJSON
|
||||
// Content-Type: application/x-www-form-urlencoded → ShouldBindForm
|
||||
// Content-Type: multipart/form-data → ShouldBindMultipart
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**自动检测优先级:**
|
||||
|
||||
| Content-Type | 绑定方式 |
|
||||
|--------------|----------|
|
||||
| `application/json` | `ShouldBindJSON()` |
|
||||
| `application/xml` | `ShouldBindXML()` |
|
||||
| `form/urlencoded` | `ShouldBindBodyWith(bind.Form, bind.Encoder{})` |
|
||||
| `multipart/form-data` | `ShouldBindMultipart()` |
|
||||
| 无或未知 | 尝试 form binding |
|
||||
|
||||
> **提问:** 如果客户端发送了 `application/json` 但没有设置 Content-Type 头,`ShouldBind` 会成功吗?
|
||||
|
||||
答案:会,但可能不如预期——Gin 的 fallback 逻辑会尝试用 form binding 解析,对于 JSON 格式的请求体会报解析错误。生产环境建议要求客户端显式声明 Content-Type。
|
||||
|
||||
### 2. Map 作为绑定参数
|
||||
|
||||
当接口参数不固定时,可以用 `map[string]interface{}` 接收任意字段:
|
||||
|
||||
```go
|
||||
func updateFields(c *gin.Context) {
|
||||
var fields map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&fields); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// fields = {"name": "new name", "email": "new@email.com"}
|
||||
// 动态更新指定字段,忽略未提供的字段
|
||||
}
|
||||
```
|
||||
|
||||
跳过绑定的字段:使用 ``binding:"-"`` 标签排除:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Admin bool `json:"admin" binding:"-"` // 不参与绑定
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 查询参数与 POST body 混合绑定
|
||||
|
||||
Gin 默认支持一次只从一种来源绑定。如果需要同时读取 query 和 body,有几种方案:
|
||||
|
||||
**方案一:手动分别绑定**
|
||||
|
||||
```go
|
||||
func mixedBind(c *gin.Context) {
|
||||
// 从 URL query 读取分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
|
||||
// 从 body 读取业务数据
|
||||
var payload struct {
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
c.ShouldBindJSON(&payload)
|
||||
|
||||
// 拼接条件
|
||||
offset := (page - 1) * pageSize
|
||||
results := db.Offset(offset).Limit(pageSize).
|
||||
Where("name LIKE ?", "%"+keyword+"%").Find(&users)
|
||||
}
|
||||
```
|
||||
|
||||
**方案二:自定义结构体同时使用 `query` 和 `json` 标签**
|
||||
|
||||
```go
|
||||
type ListRequest struct {
|
||||
Page int `json:"page" query:"page"`
|
||||
PageSize int `json:"pageSize" query:"pageSize"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
|
||||
// 需要分别调用
|
||||
func handler(c *gin.Context) {
|
||||
c.ShouldBindQuery(&req) // 绑定 query
|
||||
c.ShouldBindJSON(&req) // 绑定 body —— 注意这会覆盖 query 同名字段!
|
||||
}
|
||||
```
|
||||
|
||||
> **陷阱:** 如果 query 和 body 都有 `page` 字段,先 bind query 再 bind JSON,body 的值会覆盖 query。这通常不是期望的行为。
|
||||
|
||||
**推荐做法:** 分开两个结构体,或者像方案一那样手动提取。
|
||||
|
||||
### 4. 字段默认值策略
|
||||
|
||||
Go 零值机制可以部分替代默认值,但 HTTP 场景下有时需要区分"未提供"和"提供了零值":
|
||||
|
||||
```go
|
||||
// 方法一:使用 pointer 类型判断是否被设置
|
||||
type CreateReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Age *int `json:"age"` // nil = 未提供, 非 nil = 已提供
|
||||
Priority *int `json:"priority"` // 默认值为 1
|
||||
}
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
var req CreateReq
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
age := 0
|
||||
if req.Age != nil {
|
||||
age = *req.Age
|
||||
}
|
||||
|
||||
priority := 1
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
}
|
||||
|
||||
// 方法二:手动填充默认值(最常用)
|
||||
type SearchReq struct {
|
||||
Page int `json:"page"`
|
||||
Status string `json:"status"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
var req SearchReq
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
// Gin 内置的默认值助手
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
// keyword 允许为空字符串,不需要设默认值
|
||||
}
|
||||
|
||||
// 方法三:利用 query binding 的 DefaultQuery / DefaultString
|
||||
func handler(c *gin.Context) {
|
||||
page := c.DefaultInt("page", 1) // 查询参数默认为 1
|
||||
status := c.DefaultQuery("status", "all") // 查询参数默认为 "all"
|
||||
}
|
||||
```
|
||||
|
||||
思考题:为什么 Gin 没有像某些框架那样提供 `default` 结构体标签(如 Spring Boot 的 `@DefaultValue`)?你觉得这种设计的好处是什么?
|
||||
|
||||
提示:考虑 Go 的零值语义 vs 其他语言的区别。
|
||||
|
||||
### 5. 按条件绑定不同结构体
|
||||
|
||||
`ShouldBindBodyWith` 可以在同一请求上多次绑定到不同结构体(内部会缓存请求体):
|
||||
|
||||
```go
|
||||
func flexibleHandler(c *gin.Context) {
|
||||
var typeField struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
// 先提取 type 字段
|
||||
c.ShouldBindBodyWith(&typeField, bind.JSON)
|
||||
|
||||
switch typeField.Type {
|
||||
case "user":
|
||||
var user CreateUserRequest
|
||||
c.ShouldBindBodyWith(&user, bind.JSON)
|
||||
userService.Create(user)
|
||||
case "company":
|
||||
var co CompanyRequest
|
||||
c.ShouldBindBodyWith(&co, bind.JSON)
|
||||
companyService.Create(co)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **核心原理:** 第一次调用 `ShouldBindBodyWith` 时会完整读取并缓存 `request.Body`,后续调用直接复用缓存。所以性能代价是**额外占用内存**存储一份请求体副本——只应在需要解耦的场景使用。
|
||||
|
||||
### 6. 常见绑定标签速查
|
||||
|
||||
| 标签 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `binding:"required"` | 必填 | `"name" binding:"required"` |
|
||||
| `binding:"omitempty"` | 可选,有值则验证 | |
|
||||
| `binding:"email"` | 邮箱格式 | |
|
||||
| `binding:"url"` | URL 格式 | |
|
||||
| `binding:"numeric"` | 纯数字 | |
|
||||
| `binding:"gte=0,lte=100"` | 范围限制 | 分数 0-100 |
|
||||
| `binding:"len=11"` | 长度限制 | 手机号 |
|
||||
| `binding:"iscolor"` | 颜色值 | |
|
||||
| `` binding:"-" `` | 跳过绑定 | |
|
||||
| `json:"-"` | 跳过序列化 | |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/5-binding-validation]] — 基础绑定与校验(ShouldBind 全家桶、自定义验证器)
|
||||
- [[GIN/8-file-upload]] — 文件上传属于 multipart/form-data 的特殊场景
|
||||
- [[API 设计]] — API 参数设计规范
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 文件上传, 表单]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 文件上传
|
||||
|
||||
## 概述
|
||||
|
||||
文件上传在 Gin 中通过 `multipart/form-data` 实现——支持单文件和多文件上传、大小限制、类型校验,以及与业务逻辑的结合。理解底层 `MaxMultipartMemory` 行为是避免 OOM 的关键。
|
||||
|
||||
思考题:Gin 默认把超过内存阈值的临时文件存在哪里?这在容器化部署中会带来什么问题?(详见第 3 节)
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 单文件上传
|
||||
|
||||
```go
|
||||
func uploadSingle(c *gin.Context) {
|
||||
// 获取表单中的文件(最大 8MB 默认)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 获取文件名
|
||||
filename := header.Filename
|
||||
|
||||
// 保存到新位置
|
||||
dst := filepath.Join("./uploads", filename)
|
||||
if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil {
|
||||
c.JSON(500, gin.H{"error": "save failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"filename": filename,
|
||||
"size": header.Size,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**`SaveUploadedFile` 签名:**
|
||||
|
||||
```go
|
||||
func (c *Context) SaveUploadedFile(file *multipart.File, dst string, maxBytes int64, matchers ...mimeType.Matcher) error
|
||||
```
|
||||
|
||||
- `maxBytes`:文件大小限制(0 = 不限制,默认 8MB)
|
||||
- `matchers`:可选的文件类型校验函数
|
||||
|
||||
### 2. 多文件上传
|
||||
|
||||
```go
|
||||
func uploadMultiple(c *gin.Context) {
|
||||
// 获取表单中的多个文件(最多 100 个)
|
||||
form, _ := c.MultipartForm()
|
||||
files := form.File["files"] // []*multipart.FileHeader
|
||||
|
||||
saved := make([]string, 0, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
filename := filepath.Base(file.Filename)
|
||||
dst := filepath.Join("./uploads", filename)
|
||||
|
||||
if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil {
|
||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save %s", filename)})
|
||||
return
|
||||
}
|
||||
saved = append(saved, filename)
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"saved": saved,
|
||||
"count": len(saved),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
前端传参示例(multipart/form-data):
|
||||
|
||||
```
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="files"; filename="photo1.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
[binary data]
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="files"; filename="photo2.png"
|
||||
Content-Type: image/png
|
||||
|
||||
[binary data]
|
||||
--boundary--
|
||||
```
|
||||
|
||||
> **关键区别:** 单文件用 `c.Request.FormFile("key")`;多文件用 `c.MultipartForm()` 拿到完整的 `*multipart.Form`。两者操作的是同一个底层结构,只是访问粒度不同。
|
||||
|
||||
### 3. `MaxMultipartMemory` — 内存管理
|
||||
|
||||
Gin 底层继承自 `http.Server`,控制内存/磁盘切换的阈值是 `MaxMultipartMemory`(默认 8MB):
|
||||
|
||||
```
|
||||
请求体大小 ≤ MaxMultipartMemory → 全部存内存
|
||||
请求体大小 > MaxMultipartMemory → 超出部分写入临时文件(os.TempDir())
|
||||
```
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 调大内存阈值(谨慎使用)
|
||||
r.MaxMultipartMemory = 32 << 20 // 32MB
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
MaxHeaderBytes: 1 << 20, // 头部最大 1MB
|
||||
}
|
||||
```
|
||||
|
||||
**容器化环境的陷阱:**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["上传 50MB 文件"] --> B{"≤ 8MB?"}
|
||||
B -->|"否"| C["超出部分写入 /tmp"]
|
||||
C --> D["容器 /tmp 空间有限"]
|
||||
D --> E["磁盘满 → 服务崩溃"]
|
||||
|
||||
style E fill:#ffebee,stroke:#c62828
|
||||
```
|
||||
|
||||
**最佳实践:**
|
||||
- 不要过度放大 `MaxMultipartMemory`——用流式处理代替全量加载
|
||||
- 容器环境中监控 `/tmp` 用量
|
||||
- 考虑使用对象存储(S3/OSS)直传,服务端只生成预签名 URL
|
||||
|
||||
### 4. 文件类型与大小校验
|
||||
|
||||
仅靠大小限制不够,还需要校验实际文件内容类型(MimeType),防止恶意文件伪装:
|
||||
|
||||
```go
|
||||
import "golang.org/x/exp/mime/multipart" // or use net/textproto
|
||||
|
||||
func validateFile(file multipart.File) error {
|
||||
// 读取前 512 字节检测真实 MIME 类型
|
||||
buf := make([]byte, 512)
|
||||
_, err := file.Read(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(buf)
|
||||
|
||||
// 只允许图片
|
||||
allowed := map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
if !allowed[contentType] {
|
||||
return fmt.Errorf("unsupported file type: %s", contentType)
|
||||
}
|
||||
|
||||
// 重置读取位置以便后续保存
|
||||
file = multipart.NopCloser(bytes.NewReader(buf))
|
||||
_ = file
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
> **安全提醒:** 永远不要用文件扩展名判断类型(如 `.jpg`)。攻击者可以轻松绕过——应该基于文件内容的 magic bytes(魔数)检测。
|
||||
|
||||
### 5. 分片上传思路
|
||||
|
||||
超大文件(GB 级别)不适合直接上传,应使用分片上传:
|
||||
|
||||
```
|
||||
流程:
|
||||
1. 客户端将大文件切分为 N 个小片(如每片 5MB)
|
||||
2. 依次上传每个分片 → POST /upload/chunk?fileName=x&chunkIndex=0
|
||||
3. 服务端将分片暂存到临时目录
|
||||
4. 所有分片上传完成后,POST /upload/merge?fileName=x
|
||||
5. 服务端合并分片为最终文件
|
||||
```
|
||||
|
||||
```go
|
||||
func uploadChunk(c *gin.Context) {
|
||||
fileName := c.Query("fileName")
|
||||
chunkIndex := c.Query("chunkIndex")
|
||||
totalChunks := c.Query("totalChunks")
|
||||
|
||||
file, _ := c.FormFile("chunk")
|
||||
dst := filepath.Join("./tmp-chunks", fileName, chunkIndex)
|
||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
||||
c.SaveUploadedFile(file, dst, 10<<20, nil)
|
||||
|
||||
// 检查是否最后一个分片
|
||||
if chunkIndex == totalChunks {
|
||||
mergeChunks(fileName, int64(totalChunks))
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "chunk uploaded"})
|
||||
}
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[GIN/7-binding-advanced]] — multipart/form-data 是表单绑定的一个特例
|
||||
- [[GIN/11-static-files]] — 上传后的文件如何通过静态文件服务对外暴露
|
||||
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 响应渲染, JSON]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
# 响应渲染
|
||||
|
||||
## 概述
|
||||
|
||||
Gin 提供了丰富的响应渲染方法——不仅是基础的 JSON 返回,还包括防劫持的 SecureJSON、保留中文的 PureJSON、ASCII 转换、XML/YAML/ProtoBuf 输出等。理解各渲染方法的差异和适用场景,能写出更健壮、兼容的 API。
|
||||
|
||||
思考题:为什么 Gin 要同时提供 `JSON` 和 `PureJSON`?它们什么时候输出相同的结果,什么时候不同?
|
||||
|
||||
## 正文
|
||||
|
||||
### 1. 标准 JSON 渲染
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
data := gin.H{
|
||||
"message": "你好",
|
||||
"items": []string{"a", "b", "c"},
|
||||
}
|
||||
|
||||
// 等价于 c.Render(200, render.JSON{Data: data})
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
```json
|
||||
{"items":["a","b","c"],"message":"你好"}
|
||||
```
|
||||
|
||||
**注意:** 标准 `c.JSON()` 会对非 ASCII 字符做 Unicode 转义(`\uXXXX`),这是 RFC 4627 的要求,但现代浏览器和客户端都无需此限制。
|
||||
|
||||
### 2. PureJSON — 保留原始 Unicode
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
c.PureJSON(http.StatusOK, gin.H{
|
||||
"message": "你好世界",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
```json
|
||||
{"message":"你好世界"}
|
||||
```
|
||||
|
||||
**`PureJSON` vs `JSON` 对比:**
|
||||
|
||||
| 特性 | `c.JSON()` | `c.PureJSON()` |
|
||||
|------|-----------|----------------|
|
||||
| 中文编码 | `你好` | `你好` |
|
||||
| HTML 转义 | `<script>` → `\<script\>` | 不转义 |
|
||||
| 性能 | 略快(stdlib) | 略慢(gjson) |
|
||||
| 安全性 | 更安全(防 XSS) | 需注意 XSS |
|
||||
|
||||
> **何时选哪个:** 如果是面向浏览器的 SPA 应用,用 `JSON`(防 XSS);如果是移动端 API 或后端服务间调用,用 `PureJSON`(可读性更好、带宽更小)。
|
||||
|
||||
### 3. SecureJSON — 防 JSON 劫持
|
||||
|
||||
针对老式浏览器的安全保护——在输出前加上 `)]}',\n` 前缀,阻止 JSON 被邪恶的 `<script src=...>` 跨域加载:
|
||||
|
||||
```go
|
||||
func secureHandler(c *gin.Context) {
|
||||
c.SecureJSON(200, gin.H{"name": "wonder"})
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
```
|
||||
)];},{"name":"wonder"}
|
||||
```
|
||||
|
||||
**适用场景:**
|
||||
- API 需要被不受信任的第三方域名引用
|
||||
- 遗留系统无法升级 CSP 头
|
||||
- 安全合规要求严格的场景
|
||||
|
||||
**不适用场景:**
|
||||
- 现代 SPA(前后端分离,不用 script 标签加载 JSON)
|
||||
- 移动端 API
|
||||
- 需要解析该输出的自动化测试
|
||||
|
||||
### 4. AsciiJSON — 中文转 Unicode
|
||||
|
||||
与 `PureJSON` 相反,强制将所有非 ASCII 字符转为 Unicode 逃逸序列:
|
||||
|
||||
```go
|
||||
func asciiHandler(c *gin.Context) {
|
||||
c.AsciiJSON(200, gin.H{
|
||||
"name": "张三",
|
||||
"data": []int{1, 2, 3},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
```json
|
||||
{"data":[1,2,3],"name":"\xe5\xbc\xa0\xe4\xb8\x89"}
|
||||
```
|
||||
|
||||
> **注意:** `AsciiJSON` 使用的是 UTF-8 字节的十六进制转义(`\xe5\xbc\xa0...`),不是 `\uXXXX`。这是 Gin 的独特实现——客户端解码时需要特殊处理,一般不太常用。
|
||||
|
||||
### 5. XML / YAML / ProtoBuf 渲染
|
||||
|
||||
```go
|
||||
func multiFormat(c *gin.Context) {
|
||||
data := gin.H{"title": "Go Guide", "version": "1.0"}
|
||||
|
||||
switch c.ContentType() {
|
||||
case "application/xml":
|
||||
c.XML(200, data)
|
||||
case "application/yaml":
|
||||
c.YAML(200, data)
|
||||
default:
|
||||
c.JSON(200, data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**XML 输出示例:**
|
||||
```xml
|
||||
<map><title>Go Guide</title><version>1.0</version></map>
|
||||
```
|
||||
|
||||
**YAML 输出示例:**
|
||||
```yaml
|
||||
map:
|
||||
title: Go Guide
|
||||
version: "1.0"
|
||||
```
|
||||
|
||||
> **提问:** Gin 的 XML 渲染用的是 `gin.H`(map[string]interface{}),输出的 XML 根节点总是 `<map>`。如果需要自定义 XML 标签,应该怎么改结构体?
|
||||
|
||||
答案:用结构体的 `xml` 标签:
|
||||
|
||||
```go
|
||||
type Article struct {
|
||||
Title string `xml:"title"`
|
||||
Version string `xml:"version,attr"` // attr 表示属性
|
||||
}
|
||||
```
|
||||
|
||||
### 6. JSONP — JSON with Padding
|
||||
|
||||
通过脚本回调的方式实现跨域 GET 请求。由于涉及 eval 执行,安全风险高,现代项目中已很少使用:
|
||||
|
||||
```go
|
||||
func jsonpHandler(c *gin.Context) {
|
||||
c.JSONP(http.StatusOK, gin.H{"callback": "getData"})
|
||||
}
|
||||
```
|
||||
|
||||
浏览器端:
|
||||
```html
|
||||
<script>
|
||||
function getData(data) { console.log(data); }
|
||||
</script>
|
||||
<script src="https://api.example.com/data?callback=getData"></script>
|
||||
```
|
||||
|
||||
> **安全警告:** JSONP 要求回调函数名白名单校验,否则攻击者可构造 `callback=<script>alert(1)</script>` 注入 XSS。
|
||||
|
||||
### 7. 渲染方法速查
|
||||
|
||||
| 方法 | 内容类型 | 特点 |
|
||||
|------|----------|------|
|
||||
| `c.JSON(code, obj)` | `application/json` | 标准 JSON,ASCII 转义 |
|
||||
| `c.PureJSON(code, obj)` | `application/json` | 保留原始 Unicode |
|
||||
| `c.SecureJSON(code, obj)` | `application/json` | 加前缀防劫持 |
|
||||
| `c.AsciiJSON(code, obj)` | `application/json` | 强制 ASCII 编码 |
|
||||
| `c.XML(code, obj)` | `application/xml` | XML 序列化 |
|
||||
| `c.YAML(code, obj)` | `application/x-yaml` | YAML 序列化 |
|
||||
| `c.ProtoBuf(code, obj)` | `application/x-protobuf` | Protobuf 序列化 |
|
||||
| `c.String(code, format, vals)` | `text/plain` | 字符串格式化 |
|
||||
| `c.Data(code, data)` | 自定义 | 原始字节流 |
|
||||
| `c.HTML(code, tmplName, obj)` | `text/html` | HTML 模板渲染 |
|
||||
|
||||
思考题:如果你的 API 同时需要提供 JSON 和 CSV 两种格式,Gin 本身没有 `c.CSV()`,你应该怎么做?
|
||||
|
||||
提示:`c.Data()` 和 `c.Writer.Write()` 的组合。
|
||||
+23
-19
@@ -18,13 +18,13 @@ create time: 2026-04-27 00:00
|
||||
> 这些笔记帮你理解 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 | `handler-relationship.md` | — | Gin 与 `http.Handler` 的关系:`*gin.Engine` 实现 `http.Handler` 接口的含义;等价于 `http.ListenAndServe` 启动;标准库与 Gin 的兼容边界 |
|
||||
| --- | ------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | [[1-gin-architecture]] | 介绍 + 快速开始 | 整体架构:Engine、RouterGroup、Context 的关系;请求生命周期全链路(Mermaid 时序图);Gin 如何桥接 `net/http` |
|
||||
| 2 | [[2-routing]] | 路由 + 路由分组 + 重定向 | 路由匹配算法(Radix Tree 基数树);静态/动态/通配符路由的优先级;路由分组与前缀累加原理;重定向 `c.Redirect` |
|
||||
| 3 | [[3-middleware]] | 使用中间件 + 自定义中间件 + 中间件中的 Goroutine + 安全头 | 中间件链执行顺序(全局 → 分组 → 路由);`gin.HandlerFunc` 的本质;常用中间件实现模板(CORS、限流、鉴权、RequestID、安全头);中间件中启动 Goroutine 的陷阱与 `c.Copy()` 用法 |
|
||||
| 4 | [[4-context-lifecycle]] | 上下文与取消 | `*gin.Context` 底层设计:keys/values map、Request/RW 包装;`c.Set/Get/GetString`;`c.Copy()` 的深拷贝边界;请求级 `context` 传播与取消 |
|
||||
| 5 | [[5-binding-validation]] | 模型绑定和验证 + 自定义验证器 + 绑定查询字符串 + 绑定自定义反序列化器 + 绑定请求头 + 绑定 URI + 绑定 HTML 复选框 | `ShouldBind` 全家桶(JSON、form、query、header、uri);`binding` 标签内置规则速查;自定义 Validator(`.RegisterValidation`);自定义反序列化器(`bind.DeferredBinder`);数组集合格式(`UserIds[]`) |
|
||||
|
||||
|
||||
### 二、进阶功能
|
||||
|
||||
@@ -32,12 +32,12 @@ create time: 2026-04-27 00:00
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 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` 直接返回文件流 |
|
||||
| 6 | [[6-error-handling]] | 错误处理中间件 | `c.Error` → `c.Errors` 链式错误收集;全局错误处理器 `gin.Recovery` 定制;HTTP 状态码与业务码的映射;统一错误响应中间件 |
|
||||
| 7 | [[7-binding-advanced]] | Multipart/Urlencoded 表单 + Map 作为参数 + 绑定查询字符串或 POST 数据 + 表单默认值 + 使用自定义结构体标签绑定 + 将请求体绑定到不同的结构体 + 数据绑定 | 表单绑定深入:`c.ShouldBind()` 的多内容类型自动检测;Map 绑定(`binding:"-"` 跳过字段);查询参数与 POST body 混合绑定;字段默认值策略;按条件绑定不同结构体(`ShouldBindBodyWith`) |
|
||||
| 8 | [[8-file-upload]] | 文件上传(单文件/多文件/限制大小) | `c.ShouldBindFiles`;单文件/多文件上传流程;`MaxMultipartMemory` 内存限制;文件类型/大小校验;分片上传思路 |
|
||||
| 9 | [[9-response-rendering]] | XML/JSON/YAML/ProtoBuf 渲染 + SecureJSON + JSONP + AsciiJSON + 渲染 + PureJSON | 渲染全家桶:`c.JSON`、`c.XML`、`c.YAML`、`c.ProtoBuf`;`SecureJSON`(防 JSON 劫持);`PureJSON`(保留原始 Unicode);`AsciiJSON`(中文转 Unicode);`JSONP` |
|
||||
| 10 | [[10-template-rendering]] | HTML 渲染 + 多模板 + 将模板构建到单一二进制中 | `c.HTML` / `LoadHTMLGlob` / `LoadHTMLFiles`;多模板(`Template.FuncMap`);`embed.FS` 将模板打包进二进制 |
|
||||
| 11 | [[11-static-files]] | 提供静态文件 + 从文件提供数据 + 从 Reader 提供数据 | `Static` / `StaticFS` / `StaticFile`;自定义文件服务器;`io.Reader` 直接返回文件流 |
|
||||
|
||||
### 三、服务器与部署
|
||||
|
||||
@@ -45,10 +45,10 @@ create time: 2026-04-27 00:00
|
||||
|
||||
| 序号 | 笔记 | 官网对照 | 内容概要 |
|
||||
|------|------|----------|----------|
|
||||
| 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 场景 |
|
||||
| 12 | [[12-server-config]] | 自定义 HTTP 配置 + 服务器配置 + 支持 Let's Encrypt + Cookie + 可信代理 | `gin.New()` 自定义 Engine;`http.Server` 高级配置(超时、KeepAlive);TLS/Let's Encrypt;Cookie 操作(`c.SetCookie` / `c.GetCookie`);可信代理链(X-Forwarded-For) |
|
||||
| 13 | [[13-graceful-shutdown]] | 优雅重启或停止 | `server.Shutdown()` + 信号监听(SIGINT/SIGTERM);等待请求处理完毕再退出;优雅重启(fork + exec)思路 |
|
||||
| 14 | [[14-logging]] | 如何写入日志文件 + 自定义日志格式 + 跳过日志记录 + 控制输出着色 + 避免记录查询字符串 + 定义路由日志格式 + 日志 + 结构化日志 | 日志器替换(`gin.DefaultWriter`);自定义日志格式;结构化日志(zap/logr 接入);跳过特定路径日志;路由日志格式定制 |
|
||||
| 15 | [[15-advanced-running]] | 运行多个服务 + HTTP/2 服务器推送 | 单进程多监听端口;gRPC + HTTP 共存;HTTP/2 push 场景 |
|
||||
|
||||
### 四、工程实践
|
||||
|
||||
@@ -69,7 +69,11 @@ create time: 2026-04-27 00:00
|
||||
|------|------|----------|----------|
|
||||
| 22 | `build-and-perf.md` | 使用 JSON 替换构建 + 不使用 MsgPack 构建 + 构建标签 + 基准测试 | 构建标签(`// +build`);替换 JSON 编码器(json-iterator);禁用 MsgPack;基准测试编写与解读 |
|
||||
|
||||
## 关联笔记
|
||||
## 已创建笔记
|
||||
|
||||
> 快速跳转链接:`[[1-gin-architecture]]` · `[[2-routing]]` · `[[3-middleware]]` · `[[4-context-lifecycle]]` · `[[5-binding-validation]]` · `[[6-error-handling]]` · `[[7-binding-advanced]]` · `[[8-file-upload]]` · `[[9-response-rendering]]` · `[[10-template-rendering]]` · `[[11-static-files]]` · `[[12-server-config]]` · `[[13-graceful-shutdown]]` · `[[14-logging]]` · `[[15-advanced-running]]`
|
||||
|
||||
---
|
||||
|
||||
- `[[Go 后端基础]]` — Gin 快速上手,已涵盖基础用法
|
||||
- `[[HTTP 协议]]` — HTTP 协议基础
|
||||
@@ -91,4 +95,4 @@ create time: 2026-04-27 00:00
|
||||
构建与优化 (序号22) ← 性能调优阶段
|
||||
```
|
||||
|
||||
共 **22 篇笔记**,其中 16 篇高优先级(序号1-11、16-21),6 篇按需展开(序号12-15、22)。
|
||||
共 **22 篇笔记**,已创建 16 篇(核心机制 + 进阶功能 + 服务器与部署),6 篇待完成(工程实践 #16-21、构建与优化 #22)。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
在初始化和完成文件时,必须**完整**读取参照 `./config/agent/DOCUMENT_OPERATION.md
|
||||
了解结构、规范和惯例。
|
||||
如果用户提及创建子文档,意思为创建同名子文件夹,在子文件夹下创建文档,父文档中适当位置插入新文档链接。
|
||||
如果用户提及创建子文档,意思为在同一目录下先创建该父文件的同名子文件夹,再在子文件夹下创建文档,最后父文档中适当位置插入新文档链接。
|
||||
在初次完善后,读取文件并二次检查是否符合文档的每一条内容规范——
|
||||
- **教学者模式** 提问设计
|
||||
- **代码示例** 合理注释
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
---
|
||||
tags: [后端, Go, Gin, 复习, 测试]
|
||||
create time: 2026-04-28
|
||||
---
|
||||
|
||||
# Gin 框架复习题库(Level 1→5)
|
||||
|
||||
## 使用说明
|
||||
|
||||
本题库覆盖 `[[GIN/1-gin-architecture]]` ~ `[[GIN/5-binding-validation]]` 全部核心知识点,共 **30 道题**:
|
||||
|
||||
| 题型 | 数量 | 每题分 | 合计 |
|
||||
|------|------|--------|------|
|
||||
| 选择题(单选) | 15 题 | 4 分 | 60 分 |
|
||||
| 填空题 | 8 题 | 5 分 | 40 分 |
|
||||
| 代码补全 | 7 题 | 约 6 分 | ~42 分 |
|
||||
|
||||
**总分:约 142 分。建议用时 60~90 分钟。**
|
||||
|
||||
---
|
||||
|
||||
## 一、选择题(每题 4 分,共 60 分)
|
||||
|
||||
### Q1 【架构】`gin.Default()` 默认挂载了哪两个中间件?
|
||||
|
||||
A. Logger + CORS
|
||||
B. Logger + Recovery
|
||||
C. Recovery + JWT Auth
|
||||
D. Logger + RateLimit
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`gin.Default()` = `gin.New()` + `Use(Logger())` + `Use(Recovery())`。
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q2 【架构/Context】Gin 的 `*gin.Engine` 实现了标准库中哪个接口,从而可以无缝传给 `http.ListenAndServe`?
|
||||
|
||||
A. `http.RoundTripper`
|
||||
B. `http.Handler`
|
||||
C. `http.ResponseWriter`
|
||||
D. `http.ServeMux`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`ServeHTTP(ResponseWriter, *Request)` 签名匹配。
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture/engine-handler]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q3 【路由】Gin 的路由匹配算法使用什么数据结构?
|
||||
|
||||
A. `map[string]Handler`
|
||||
B. Radix Tree(基数树/压缩前缀树)
|
||||
C. AVL Tree(平衡二叉树)
|
||||
D. Trie(朴素前缀树,未压缩)
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Radix Tree 通过前缀共享实现 O(d) 匹配,d 为 URL 深度。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §3;`[[GIN/2-routing-complexity-comparison]]`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q4 【路由优先级】当同时注册 `/users/list`(静态)、`/users/:id`(动态)、`/users/*path`(通配符)时,请求 `/users/5` 会命中哪个路由?
|
||||
|
||||
A. `/users/list` — 因为它是第一个注册的
|
||||
B. `/users/:id` — 静态优先于动态
|
||||
C. `/users/*path` — 通配符是兜底规则
|
||||
D. 取决于注册顺序
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。优先级:静态 > 动态参数 > 通配符,与注册顺序无关。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q5 【路由分组】以下代码输出的完整路径是什么?
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
api := r.Group("/api")
|
||||
v1 := api.Group("/v1")
|
||||
users := v1.Group("/users/:id")
|
||||
users.GET("", handler)
|
||||
```
|
||||
|
||||
A. `/api/v1/users/:id`
|
||||
B. `/api/v1/users/` (`:id` 被忽略,因为放在 Group path 里)
|
||||
C. `/api/v1/users/:id` — 正确
|
||||
D. `/:id` — 只取最后一段
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。`basePath` 逐层累加:`"" → "/api" → "/api/v1" → "/api/v1/users/:id"`。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §5
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q6 【路由复杂度对比】如果有 10000 条路由,用 Gin 的 Radix Tree 和 `http.ServeMux` 分别匹配一个 URL,大致需要多少步?
|
||||
|
||||
A. Gin: 10000 步,ServeMux: 10000 步
|
||||
B. Gin: ~4 步,ServeMux: ~10000 次比较
|
||||
C. Gin: ~10000 步,ServeMux: ~4 步
|
||||
D. Gin: ~4 步,ServeMux: ~4 步
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Gin 复杂度 O(d),与路由总数 R 无关;ServeMux O(R×L)。
|
||||
|
||||
> 来源:`[[GIN/2-routing-complexity-comparison]]` §3
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q7 【中间件执行顺序】注册了两个全局中间件 A 和 B(A 先注册),一个 handler。完整的输出顺序是?
|
||||
|
||||
```go
|
||||
// A: fmt.Println("A-before"); c.Next(); fmt.Println("A-after")
|
||||
// B: fmt.Println("B-before"); c.Next(); fmt.Println("B-after")
|
||||
// handler: fmt.Println("handler")
|
||||
```
|
||||
|
||||
A. A-before → B-before → handler → B-after → A-after
|
||||
B. A-before → B-before → handler → A-after → B-after
|
||||
C. A-before → handler → A-after → B-before → B-after
|
||||
D. handler → A-before → B-before → A-after → B-after
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:A**。前置按注册顺序,后置按逆序——栈式行为。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §2
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q8 【中间件 vs 标准库】Go 标准库 `net/http` 中间件的类型签名是?
|
||||
|
||||
A. `func(*gin.Context)`
|
||||
B. `func(http.ResponseWriter, *http.Request)`
|
||||
C. `func(http.Handler) http.Handler`
|
||||
D. `func(*http.Server) http.Handler`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。这是装饰器模式,层层嵌套包装。
|
||||
|
||||
> 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q9 【c.Abort()】中间件 A 中调用了 `c.Abort()`(紧接 `return`),以下说法正确的是?
|
||||
|
||||
A. 后续中间件和 handler 都跳过,但 A 的后置逻辑会执行
|
||||
B. 后续中间件跳过,handler 执行
|
||||
C. 整个链(包括后续中间件、handler、A 的所有剩余代码)都不再执行
|
||||
D. 只有同一路由的中间件被跳过,其他路由不受影响
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。`c.Abort()` 将 index 设为链长度,`c.Next()` 循环条件立即不满足。Abort 后必须紧跟 return。
|
||||
|
||||
> 来源:`[[GIN/middleware-abort]]`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q10 【中间件 Goroutine】在中间件中启动 goroutine 异步处理日志,正确的做法是?
|
||||
|
||||
A. 直接在 goroutine 中使用 `c`
|
||||
B. 先用 `c.Copy()` 创建副本,在 goroutine 中使用副本
|
||||
C. 把 `c` 保存到全局变量,在 goroutine 中读取
|
||||
D. 使用 `context.WithCancel` 取消原 context
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`c.Copy()` 创建独立副本,goroutine 中只能读不能写响应。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q11 【Context 池化】如果在 handler 中把 `*gin.Context` 保存到全局变量,下次请求时会发生什么?
|
||||
|
||||
A. 读到的是上一次请求的数据,完全安全
|
||||
B. 可能读到任意并发请求正在使用的数据,造成数据错乱
|
||||
C. Go 运行时会 panic,因为存在数据竞争
|
||||
D. Context 会自动深拷贝,所以没问题
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。sync.Pool 复用对象,字段被 reset 覆盖,全局引用指向的是被新请求改写后的同一个内存地址。
|
||||
|
||||
> 来源:`[[GIN/context-pool]]` §2
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q12 【Context reset】Context 从 pool 取出后,`reset()` 方法把 `index` 重置为多少?为什么?
|
||||
|
||||
A. `0` — 表示从头开始
|
||||
B. `-1` — 表示还未开始执行,第一次 `c.Next()` 走到 index+1=0
|
||||
C. `-1` — 表示无效值,需要用 -1 做判断
|
||||
D. `nil` — 空表示未初始化
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。`-1` 表示还未开始,`c.Next()` 先 `index++` 到 0,再执行 `handlers[0]`。
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §3
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q13 【超时控制】Gin 默认是否有 HTTP 请求超时控制?如果需要超时,应该在哪里配置?
|
||||
|
||||
A. 有,默认 30 秒超时
|
||||
B. 没有,应在 `http.Server` 层配置 `ReadTimeout`/`WriteTimeout`
|
||||
C. 没有,但可用 `c.WithTimeout()` 设置
|
||||
D. 有,在 `gin.Default()` 内部已经设置了
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。Gin 本身不内置超时,需在 `http.Server{ReadTimeout: ...}` 配置。
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §7
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q14 【绑定校验】`c.ShouldBind(&req)` 自动检测内容类型的顺序是?
|
||||
|
||||
A. form data → query string → JSON
|
||||
B. query string → form data → JSON
|
||||
C. JSON → form data → query string
|
||||
D. 根据 Content-Type 头直接判定,不按顺序
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:C**。先试 JSON(检查 Content-Type),再试 form,最后试 query。
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q15 【未知字段】`ShouldBindJSON` 对 JSON body 中的未知字段(struct 中没有对应 key)的默认行为是?
|
||||
|
||||
A. 返回 error,绑定失败
|
||||
B. 静默忽略,值为零值
|
||||
C. 抛出 panic
|
||||
D. 打印警告日志但仍继续
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:B**。底层用 `encoding/json.Unmarshal`,对多余字段静默丢弃。
|
||||
|
||||
> 来源:`[[GIN/unknown-fields]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 二、填空题(每题 5 分,共 40 分)
|
||||
|
||||
### Q16 【路由优先级】Gin 路由匹配的优先级从高到低依次是:________ > ________ > ________。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:静态字符串 > 动态参数 (`:param`) > 通配符 (`*rest`)**
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q17 【Engine 结构】`*gin.Engine` 嵌入了 `RouterGroup`,这意味着 Engine 本身就是一颗最大的 RouterGroup,可以直接调用 `.GET()`、`.Use()` 等方法。Engine 中还包含一个 `trees` 字段,其类型是 `methodTrees`(本质是 `[*tree]`),它的作用是:_________________________。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:每种 HTTP 方法维护一棵独立的 Radix Tree(例如 GET 一棵、POST 一棵)**
|
||||
|
||||
> 来源:`[[GIN/1-gin-architecture]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q18 【中间件作用域】Gin 中间件的三级作用域分别是:________、________、________。执行顺序为:________ → ________ → ________ → handler。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:全局(Engine 级) / 分组(RouterGroup 级) / 路由(单条路由);全局 → 分组 → 路由 → handler**
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §2
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q19 【中间件设计模式】Go 标准库 `net/http` 中间件使用 ________ 模式,而 Gin 中间件使用 ________ 模式。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:装饰器(Decorator)/ 责任链(Chain of Responsibility)**
|
||||
|
||||
> 来源:`[[GIN/gin-vs-std]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q20 【c.Abort() 系列方法】Gin 提供了三种 Abort 相关方法:`c.Abort()`、`c.AbortWithStatus(code)`、`_______________`(Abort 同时写入 JSON 响应体)。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.AbortWithStatusJSON(code, json)`**
|
||||
|
||||
> 来源:`[[GIN/middleware-abort]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q21 【c.Copy() 限制】在通过 `c.Copy()` 创建的 goroutine 副本中,________(能/不能)调用 `c.JSON()` 写入响应,但可以读取 `c.Request` 和 `c.Keys`。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:不能**。`c.Writer` 无法复制,Copy 出的 goroutine 只能读不能写。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q22 【绑定方法速记】Gin 提供了多种绑定方法:`c.ShouldBindJSON` 绑定 JSON body,`_______________` 绑定 URL 查询参数,`c.ShouldBindUri` 绑定 URI 路径参数,`c.ShouldBindHeader` 绑定 HTTP 请求头。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.ShouldBindQuery`**
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §1
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q23 【ShouldBindBodyWith】标准库的 `io.ReadCloser` 类型的 body 只能读取一次。当需要在同一个 handler 中对不同结构体多次解析 body 时,应使用 `_______________` 来缓存 body,避免二次读取报错。
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`c.ShouldBindBodyWith(&obj, binding.JSON)`**
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §9
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 三、代码补全题(每题约 6 分,共 ~42 分)
|
||||
|
||||
### Q24 【CORS 中间件】补全以下 CORS 中间件,处理 OPTIONS 预检请求:
|
||||
|
||||
```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")
|
||||
}
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
_______ // ← 补全此行:立即返回,不再执行后续逻辑
|
||||
}
|
||||
|
||||
c.Next() // 非 OPTIONS 请求,继续执行
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`return`**
|
||||
|
||||
CORS 中间件中 OPTIONS 预检请求处理后必须 `return`,否则会继续执行 `c.Next()` 并可能触发下游 handler。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q25 【JWT 认证中间件】补全 JWT 认证中间件中的认证失败处理和用户信息存储:
|
||||
|
||||
```go
|
||||
func jwtAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
c.Abort()
|
||||
_______ // ← 补全:阻止代码继续向下执行
|
||||
}
|
||||
|
||||
claims, err := parseJWT(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
c.Abort()
|
||||
_______ // ← 补全:同上
|
||||
}
|
||||
|
||||
// 认证成功,将用户信息存入 Context
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Set("role", claims.Role)
|
||||
_______ // ← 补全:将控制权交给后续中间件/handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:两行 `return`,最后一行 `c.Next()`**
|
||||
|
||||
核心规则:`c.Abort()` 之后必须紧跟 `return`;认证成功后调用 `c.Next()` 推进链。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §4
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q26 【异步 Goroutine 安全修复】下面的代码有线程安全问题,请修复:
|
||||
|
||||
```go
|
||||
func asyncProcessor() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// ❌ 危险:c 可能在 goroutine 运行时被回收并复用于其他请求
|
||||
go func() {
|
||||
log.Printf("处理完成: %s, 用户: %s", c.Request.URL.Path, c.GetString("userID"))
|
||||
}()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修正:**
|
||||
|
||||
```go
|
||||
func safeAsyncProcessor() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// ✅ 修复:用 c.Copy() 创建独立副本
|
||||
copy := c.Copy()
|
||||
go func() {
|
||||
log.Printf("处理完成: %s, 用户: %s",
|
||||
copy.Request.URL.Path,
|
||||
copy.GetString("userID")) // ← 补全:使用副本而不是 c
|
||||
}()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>查看要点</summary>
|
||||
|
||||
关键改动:① `c.Copy()` 创建副本;② goroutine 中使用 `copy` 而非 `c`。
|
||||
|
||||
> 来源:`[[GIN/3-middleware]]` §5
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q27 【Context 生命周期 —— reset 清空项】补全 `reset` 方法中被清空的字段名(至少写出 4 个):
|
||||
|
||||
```go
|
||||
func (c *Context) reset(w http.ResponseWriter) {
|
||||
c.Writer = w.(*responseWriter)
|
||||
c.writerMem.Reset()
|
||||
c.Params = c.Params[:0] // ← 清空路径参数
|
||||
c.handlers = nil // ← 清空 handler 链
|
||||
c.index = -1 // ← 重置执行位置
|
||||
c.errors = c.errors[:0] // ← 清空错误列表
|
||||
c.Keys = nil // ← 清空共享数据
|
||||
c.QueryCache = nil // ← 清空查询缓存
|
||||
c.FormCache = nil // ← 清空表单缓存
|
||||
}
|
||||
```
|
||||
|
||||
请从上方列出你记得住的所有被清空字段:
|
||||
|
||||
1. `c.Params = ______________`
|
||||
2. `c.handlers = ______________`
|
||||
3. `c.index = ______________`
|
||||
4. `c.errors = ______________`
|
||||
5. `c.Keys = ______________`
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
1. `c.Params[:0]`
|
||||
2. `nil`
|
||||
3. `-1`
|
||||
4. `c.errors[:0]`
|
||||
5. `nil`
|
||||
|
||||
> 来源:`[[GIN/4-context-lifecycle]]` §3
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q28 【自定义 Validator】补全自定义校验器 `username` 的实现:用户名需满足字母数字下划线组合,长度 3~20 字符。
|
||||
|
||||
```go
|
||||
func init() {
|
||||
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(`_______________`).MatchString(name)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**答案:`^[a-zA-Z0-9_]+$`**
|
||||
|
||||
完整正则确保只允许字母、数字和下划线。
|
||||
|
||||
> 来源:`[[GIN/5-binding-validation]]` §6
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q29 【HTTP 启动方式】补全三种 Gin 启动方式的等价代码:
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
// 方式一:框架封装(最常用)
|
||||
r.Run(":8080")
|
||||
|
||||
// 方式二:标准库直接启动(完全等价)
|
||||
http.ListenAndServe(":8080", _______)
|
||||
|
||||
// 方式三:高级控制(推荐生产环境)
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: _______, // 传入 Gin Engine
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
_______ // ← 补全第三行的启动调用
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
三个空依次为:`r`、`r`、`srv.ListenAndServe()`
|
||||
|
||||
> 来源:`[[GIN/engine-handler]]` §2
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q30 【严格 JSON 模式】补全使用 `json.Decoder.DisallowUnknownFields()` 实现严格模式的方法:
|
||||
|
||||
```go
|
||||
func strictJSONHandler(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder._______________ // ← 禁止未知字段
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"code": 1003,
|
||||
"message": "参数解析失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
_______ // ← 补全:停止处理
|
||||
}
|
||||
// req 已包含所有已知字段,且无拼写错误
|
||||
c.JSON(201, gin.H{"message": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
两个空依次为:`DisallowUnknownFields()`、`return`
|
||||
|
||||
> 来源:`[[GIN/unknown-fields]]` §3
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 四、综合场景题(附加挑战,可选)
|
||||
|
||||
### Q31 【场景题】一个线上服务出现偶发的 "user not found" 错误。排查发现某个 handler 中有一段类似这样的代码:
|
||||
|
||||
```go
|
||||
var savedUserID string
|
||||
|
||||
func myHandler(c *gin.Context) {
|
||||
savedUserID = c.GetString("user_id") // 保存到一个全局变量
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func backgroundWorker() {
|
||||
_ = savedUserID // 在其他地方读取这个全局变量
|
||||
}
|
||||
```
|
||||
|
||||
请问这段代码可能引发什么问题?应该如何修复?
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
**问题:** `savedUserID = c.GetString("user_id")` 虽然是拷贝值,但如果改为 `globalC = c`(保存 Context 引用),则会导致数据错乱——因为 Context 被 sync.Pool 复用,另一个请求的 reset() 会清空该对象的 Keys。即使拷贝值,在全局变量中也会有并发写的竞态。
|
||||
|
||||
**修复方案:**
|
||||
1. 不要使用全局变量保存请求相关数据
|
||||
2. 如果确实需要异步使用数据,用 `c.Copy()` 创建副本,或在 goroutine 中只传基本类型值
|
||||
3. 参考:`[[GIN/context-pool]]` §2 和 `[[GIN/context-pool-safety]]`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Q32 【场景题】你的 API 有以下路由注册顺序:
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/users/:id", getUser) // 先注册动态参数
|
||||
r.GET("/users/list", listAll) // 后注册静态路由
|
||||
```
|
||||
|
||||
当客户端请求 `GET /users/list` 时,会命中哪个 handler?为什么?注册顺序会影响结果吗?
|
||||
|
||||
<details><summary>点击查看答案</summary>
|
||||
|
||||
会命中 `listAll`(`/users/list`)。**注册顺序不影响匹配结果**。Gin 的 Radix Tree 按优先级决定匹配:静态路由优先级高于动态参数,无论谁先注册,`/users/list` 都是精确匹配静态字符串,必优于 `/users/:id` 的动态参数匹配。
|
||||
|
||||
> 来源:`[[GIN/2-routing]]` §4 — "注意:如果有两条同类型的路由,Gin 注册时会 panic——不允许重复。" 不同类型的优先级由 Radix Tree 结构保证,与注册顺序无关。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 五、速查表
|
||||
|
||||
### 中间件执行链路速记
|
||||
|
||||
```
|
||||
请求进来 → Logger(前置) → Recovery(前置) → Auth(前置) → Handler →
|
||||
Auth(后置) → Recovery(后置) → Logger(后置) → 归还 Context 到 pool
|
||||
```
|
||||
|
||||
### ShouldBind 全家桶速记
|
||||
|
||||
| 数据源 | 方法 |
|
||||
|--------|------|
|
||||
| JSON body | `c.ShouldBindJSON(&v)` |
|
||||
| Query string | `c.ShouldBindQuery(&v)` |
|
||||
| 自动检测 | `c.ShouldBind(&v)` |
|
||||
| URI 路径参数 | `c.ShouldBindUri(&v)` |
|
||||
| HTTP 请求头 | `c.ShouldBindHeader(&v)` |
|
||||
| Form + JSON 兼容 | `c.ShouldBindBodyWith(&v, binding.Form)` |
|
||||
|
||||
### binding 标签速记
|
||||
|
||||
`required` `email` `url` `min=N` `max=N` `numeric` `alphanum` `oneof=X Y Z`
|
||||
|
||||
---
|
||||
|
||||
*本题库基于 `[[GIN/README]]` 索引下的 Level 1→5 共 16 篇笔记整理。*
|
||||
Reference in New Issue
Block a user