This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/BACKEND/GIN/5-binding-validation.md

420 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [后端, Go, Gin, 绑定, 校验]
create time: 2026-04-27 00:00
---
# 模型绑定和验证
## 概述
Gin 的绑定系统是自动将 HTTP 请求数据解析到 Go 结构体的核心机制。一条 `c.ShouldBindJSON(&req)` 背后经历了内容类型检测、格式解析、类型转换、标签校验等多步流程。掌握它的原理和陷阱,能大幅减少 API 开发中的边界 case。
思考题:Gin 的 `c.ShouldBind()` 能自动区分 JSON 和 form data 吗?它是怎么判断的?
## 正文
### 1. ShouldBind 全家桶
Gin 提供了一套统一的绑定方法,根据数据来源选择对应的方法:
| 方法 | 数据来源 | 适用场景 |
|------|----------|----------|
| `c.ShouldBindJSON(&v)` | `Content-Type: application/json` | RESTful API body |
| `c.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` |
| `c.ShouldBindHeader(&v)` | HTTP 请求头 | Custom headers |
| `c.ShouldBindBodyWith(&v, binding.Form)` | 请求体为 form | 兼容 form 和 JSON |
| `c.ShouldBindFiles(&v)` | Multipart form file | 文件上传 |
```go
func handler(c *gin.Context) {
// JSON body
var jsonReq CreateUserRequest
if err := c.ShouldBindJSON(&jsonReq); err != nil { ... }
// Query string: /users?role=admin&page=1
var query PageQuery
if err := c.ShouldBindQuery(&query); err != nil { ... }
// 自动检测:JSON body > form data > query string
var autoReq Request
if err := c.ShouldBind(&autoReq); err != nil { ... }
// URI params: /users/:id
var uri URIParams
if err := c.ShouldBindUri(&uri); err != nil { ... }
// Headers: Authorization: Bearer xxx
var header HeaderParams
if err := c.ShouldBindHeader(&header); err != nil { ... }
}
```
> **关键理解:** `c.ShouldBind()` 不是"万能绑定"——它按顺序检测:先试 JSON(根据 Content-Type),再试 form,最后试 query。如果前端发的 Content-Type 不明确,行为可能不符合预期。
**ShouldBind vs MustBind:**
| 方法 | 校验失败时行为 | 推荐使用 |
|------|---------------|----------|
| `ShouldBind*` | 返回 error,handler 继续 | 推荐,可自定义错误处理 |
| `MustBind*` | 自动 400 响应,Abort | 快速原型 |
### 2. Struct Tag 绑定语法
Gin 使用 struct tag 指定绑定规则:
```go
type CreateUserRequest struct {
// JSON body 绑定,必须字段,长度限制
Name string `json:"name" binding:"required,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"required,min=1,max=150"`
Role string `json:"role" binding:"oneof=admin user guest"`
}
```
**常用 binding 标签:**
| 标签 | 作用 | 示例 |
|------|------|------|
| `required` | 必填 | `binding:"required"` |
| `email` | 邮箱格式 | `binding:"email"` |
| `url` | URL 格式 | `binding:"url"` |
| `datetime` | 日期时间格式 | `binding:"datetime=2006-01-02"` |
| `min=N` | 最小值/长度 | `binding:"min=1,max=50"` |
| `max=N` | 最大值/长度 | `binding:"max=100"` |
| `numeric` | 纯数字 | `binding:"numeric"` |
| `alphanum` | 字母数字 | `binding:"alphanum"` |
| `oneof=X Y Z` | 枚举值 | `binding:"oneof=red green blue"` |
| `omitempty` | 可选字段 | 仅 json 标签用 |
> **提问:** `binding:"required"` 对指针类型 `*string` 和空字符串 `""` 分别怎么处理?
### 3. 自动校验的底层流程
```go
func createUser(c *gin.Context) {
var req CreateUserRequest
// ShouldBindJSON 内部流程:
err := c.ShouldBindJSON(&req)
// 1. 检查 Content-Type 是否为 application/json
// 2. 用 encoding/json 解析 body 到 req
// 3. 获取 validator(全局共享的 structv1)
// 4. 遍历 struct 的 binding 标签,逐项校验
// 5. 返回 *validator.ValidationErrors 或 nil
if err != nil {
c.JSON(400, gin.H{"code": 1002, "message": "参数校验失败", "errors": err.Error()})
return
}
// ...
}
```
**校验错误的类型:**
```go
if err := c.ShouldBind(&req); err != nil {
// 类型:*validator.ValidationErrors(内部实现可能变化)
// 包含所有字段的校验失败信息
c.JSON(400, gin.H{
"code": 1002,
"message": "参数校验失败",
"errors": err.Error(),
})
}
```
### 4. 只绑定查询字符串
```go
type QueryRequest struct {
Page int `form:"page" binding:"required,min=1"`
Limit int `form:"limit" binding:"required,min=1,max=100"`
Role string `form:"role" binding:"omitempty,oneof=admin user"`
}
// URL: /users?role=admin&page=1&limit=20
func listUsers(c *gin.Context) {
var req QueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"message": "query param error"})
return
}
// req = {Page: 1, Limit: 20, Role: "admin"}
}
```
**注意:** `ShouldBindQuery` 只绑定 URL 查询参数(`?key=value`),不绑定 body。
### 5. 数组集合格式(批量操作)
前端传数组参数时,Gin 使用 `[]` 后缀:
```go
type BatchDeleteRequest struct {
IDs []int `form:"id[]" binding:"required"`
}
// URL: /users?ids[]=1&ids[]=2&ids[]=3
// 或: /users?ids[]=1,2,3
func batchDelete(c *gin.Context) {
var req BatchDeleteRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"message": "ids required"})
return
}
// req.IDs = []int{1, 2, 3}
}
```
**JSON body 中的数组:**
```json
{
"user_ids": [1, 2, 3]
}
```
```go
type BatchRequest struct {
UserIDs []int `json:"user_ids" binding:"required"`
}
```
### 6. 自定义 Validator
当内置标签不够用时,注册自定义校验器:
```go
// 注册全局自定义校验器
func init() {
// 校验用户名:只能包含字母、数字、下划线,3-20 字符
validator.Validator.RegisterValidation(
"username",
func(v validator.FieldLevel) bool {
name := v.Field().String()
if len(name) < 3 || len(name) > 20 {
return false
}
return regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(name)
},
)
}
type RegisterRequest struct {
Username string `json:"username" binding:"required,username"`
}
```
**带错误消息的自定义校验器:**
```go
// 在 handler 中动态注册
func setupValidator() {
v := binding.Validator.Engine().(*validator.Validate)
v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(fl.Field().String())
})
}
```
思考题:自定义校验器 `func(v validator.FieldLevel) bool` 中的 `v.Field()` 返回的是什么类型?如果要校验一个 `time.Time` 字段,应该怎么断言?
### 7. 绑定自定义反序列化器
对于需要自定义解析的类型,实现 `bind.Unmarshaller` 接口:
```go
type DateRange struct {
Start time.Time
End time.Time
}
// 实现 Unmarshal 接口
func (r *DateRange) Unmarshal(param string) error {
parts := strings.Split(param, "-")
if len(parts) != 2 {
return fmt.Errorf("invalid date range format")
}
r.Start, _ = time.Parse("2006-01-02", parts[0])
r.End, _ = time.Parse("2006-01-02", parts[1])
return nil
}
// URL: /events?range=2026-01-01-2026-01-31
func handler(c *gin.Context) {
var req struct {
Range DateRange `form:"range"`
}
c.ShouldBindQuery(&req)
// req.Range.Start = 2026-01-01, Range.End = 2026-01-31
}
```
### 8. 绑定请求头
```go
type HeaderParams struct {
ContentType string `header:"Content-Type"`
ContentType string `header:"X-Request-ID"`
Authorization string `header:"Authorization"`
}
func handler(c *gin.Context) {
var h HeaderParams
if err := c.ShouldBindHeader(&h); err != nil {
c.JSON(400, gin.H{"message": "header error"})
return
}
}
```
### 9. 条件绑定与绑定不同结构体
有时需要**同一个接口支持 JSON 和 form 两种格式**,或者**根据条件绑定不同结构体**:
```go
// 场景:同一个 endpoint 接受 JSON 或 form 数据
func handler(c *gin.Context) {
var req struct {
Name string `json:"name" form:"name"`
Email string `json:"email" form:"email"`
}
// ShouldBind 自动检测 Content-Type 并选择解析方式
if err := c.ShouldBind(&req); err != nil {
c.JSON(400, gin.H{"message": "parse error"})
return
}
}
// 场景:根据条件绑定不同结构体
func handler(c *gin.Context) {
if c.IsAborted() {
c.Next()
return
}
contentType := c.ContentType()
switch contentType {
case "application/json":
var jsonReq JSONRequest
if err := c.ShouldBindJSON(&jsonReq); err != nil {
c.AbortWithError(400, err)
return
}
// 处理 JSON 请求
case "application/x-www-form-urlencoded":
var formReq FormRequest
if err := c.ShouldBind(&formReq); err != nil {
c.AbortWithError(400, err)
return
}
// 处理 Form 请求
}
}
```
**ShouldBindBodyWith — 避免重复读取 body:**
Gin 的 JSON 解析器**只能读一次 body**(因为 body 是 io.ReadCloser)。`ShouldBindBodyWith` 会在第一次读取后缓存 body,后续绑定复用缓存:
```go
func handler(c *gin.Context) {
// 先读取验证(可能读取 body)
var auth AuthRequest
if err := c.ShouldBindJSON(&auth); err != nil {
c.JSON(400, gin.H{"error": "auth failed"})
return
}
// 再读取不同结构体 — 用 ShouldBindBodyWith 复用 body
var body CreateUserRequest
if err := c.ShouldBindBodyWith(&body, binding.JSON); err != nil {
c.JSON(400, gin.H{"error": "body parse failed"})
return
}
}
```
### 10. 绑定与校验的最佳实践
```go
type CreateUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"omitempty,min=0,max=150"`
Role string `json:"role" binding:"oneof=admin user guest"`
}
func (r *CreateUserRequest) ValidateCustom() error {
// 需要跨字段校验时,自定义校验逻辑
if strings.Contains(r.Name, "@") {
return errors.New("name cannot contain @")
}
return nil
}
func createUser(c *gin.Context) {
var req CreateUserRequest
// 第一步:自动绑定 + 标签校验
if err := c.ShouldBindJSON(&req); err != nil {
// 解析结构化错误
if verr, ok := err.(*validator.ValidationErrors); ok {
c.JSON(400, gin.H{
"code": 1002,
"message": "参数校验失败",
"errors": formatValidationErrors(verr),
})
return
}
c.JSON(400, gin.H{"code": 1002, "message": "解析失败"})
return
}
// 第二步:自定义校验(跨字段或数据库查询)
if err := req.ValidateCustom(); err != nil {
c.JSON(422, gin.H{"code": 1006, "message": err.Error()})
return
}
c.JSON(201, gin.H{"message": "created"})
}
// formatValidationErrors 把校验错误格式化为用户友好的消息
func formatValidationErrors(err *validator.ValidationErrors) map[string]string {
errors := make(map[string]string)
for _, e := range err.Errors {
field := e.Field()
tag := e.Tag()
switch tag {
case "required":
errors[field] = "必填字段"
case "email":
errors[field] = "邮箱格式不正确"
case "min":
errors[field] = fmt.Sprintf("最小值为 %s", e.Param())
default:
errors[field] = fmt.Sprintf("%s 不满足 %s 要求", field, tag)
}
}
return errors
}
```
> **提问:** `ShouldBindJSON` 遇到未知字段(JSON 中有 struct 没有的 key)时,默认行为是什么?会报错吗?如果不想报错,有什么办法忽略未知字段?
>
> → 详见 `[[5-binding-validation/unknown-fields]]`
## 关联笔记
- `[[GIN/gin-architecture]]` — Engine 如何初始化 validator
- `[[GIN/binding-advanced]]` — Map 绑定、默认值、条件绑定
- `[[GIN/error-handling]]` — 校验错误的全局处理中间件
- `[[Go 后端基础]]` — 结构体标签与 JSON 序列化基础