Go 请求验证
Go HTTP 入口的请求验证:手动 Validate、encoding 边界、结构体标签校验器、错误映射与分层放置。
[!info] related notes
Go 请求验证
这个概念为什么会出现
Handler 收到的 JSON/表单/query 不可信。未校验就进入业务层会导致:
- 空字段写库、超长字符串打爆存储
- 枚举越界、负数金额
- 注入与逻辑绕过(字段验证不能代替 SQL 参数化或资源授权)
验证要把“格式与约束”挡在边界;业务规则(“余额是否足够”)仍属 service。Go 没有语言级 bean validation,工程上在 手动 Validate() 与 标签驱动校验器(如 go-playground/validator)之间选择。
[!abstract] 一句话理解 先解码到强类型 DTO,再按规则聚合字段错误;验证失败返回 400 与可机读错误列表,通过后再交给 service。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:注册接口在入库前拦下脏 JSON 与非法字段
用户点「注册」时,前端 POST /users 提交 name/email/age。
Handler 不能直接把 body 塞进 service:
- JSON 可能坏掉、夹带未知字段、体过大
- name 空串、email 不像邮箱、age 负数
应在边界解码 + Validate(),失败返回 400 + 字段级错误列表,让前端高亮;通过后再交给业务层。
本例需 Go 1.22+,会
ListenAndServe;本地可用 curl 打两种请求观察 400 / 201。 若只想测 Validate,可直接在main里构造CreateUserRequest调Validate()。
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"unicode/utf8"
)
// CreateUserRequest 是注册接口的入站 DTO(不是领域 User 实体)。
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
// FieldError 单字段问题,便于前端按 field 高亮。
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidationError 聚合多字段错误;一次返回全部,避免用户改完一个再撞下一个。
type ValidationError struct {
Errors []FieldError `json:"errors"`
}
func (e *ValidationError) Error() string { return "validation failed" }
// Validate 做「格式与约束」校验,不含“邮箱是否已注册”等业务规则。
//
// 业务意图:脏数据挡在 Handler;领域规则留给 Service。
// 教学点:聚合 errs,而不是第一个错误就 return。
func (r CreateUserRequest) Validate() error {
var errs []FieldError
name := strings.TrimSpace(r.Name)
if name == "" {
errs = append(errs, FieldError{"name", "required"})
} else if utf8.RuneCountInString(name) > 100 {
// 用 rune 计长,避免中文被 byte 长度误伤。
errs = append(errs, FieldError{"name", "max 100 runes"})
}
// 演示用极简邮箱规则;生产可换更严的校验或专用库。
if r.Email == "" || !strings.Contains(r.Email, "@") {
errs = append(errs, FieldError{"email", "invalid email"})
}
if r.Age < 0 || r.Age > 150 {
errs = append(errs, FieldError{"age", "must be 0..150"})
}
if len(errs) > 0 {
return &ValidationError{Errors: errs}
}
return nil
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// createUser 注册接口:解码 → 校验 →(演示)直接 201。
// 真实项目里 Validate 通过后才调 UserService.Register。
func createUser(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req *CreateUserRequest
if err := decodeJSON(w, r, &req, 1<<20); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "body too large"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected JSON object"})
return
}
// 规范化与验证分开;后续 service 应使用同一个已规范化 DTO。
req.Name = strings.TrimSpace(req.Name)
if err := req.Validate(); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
writeJSON(w, http.StatusBadRequest, ve)
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
// 演示成功路径;真实实现写库后返回用户 id。
writeJSON(w, http.StatusCreated, map[string]string{
"status": "ok",
"name": strings.TrimSpace(req.Name),
})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, maxBytes int64) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode: %w", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return errors.New("expected one JSON value")
}
return fmt.Errorf("trailing data: %w", err)
}
return nil
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", createUser)
fmt.Println("listen :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
建议运行:
go run .
# 另开终端——非法字段:
curl -s -X POST localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"","email":"bad","age":-1}'
# 合法:
curl -s -X POST localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"Ada","email":"ada@example.com","age":30}'
期望:
- 非法:HTTP 400,body 含
errors列表(name/email/age) - 合法:HTTP 201,
{"status":"ok","name":"Ada"}
结合场景再看三个关注点
-
解码错误 ≠ 字段校验错误
坏 JSON 返回通用invalid json;字段问题返回可机读列表。 -
边界加固
MaxBytesReader限制应用可读字节,超限返回 413;第二次 Decode 必须是 EOF,拒绝多对象和尾部垃圾。它不代替网络超时或流量限制。 -
多错误一次返回
注册表单可同时标红多个输入,减少来回提交。
核心概念与准确模型
验证发生在哪一层
| 层 | 验证什么 |
|---|---|
| Transport/Handler | JSON 形状、必填、长度、格式、枚举 |
| Service | 领域不变量、权限、与状态相关的规则 |
| Repository | 极少;DB 约束是最后防线,错误需翻译 |
手动验证 vs 标签校验器
手动 Validate():透明、易调试、无反射成本;字段多时冗长。
go-playground/validator:
import "github.com/go-playground/validator/v10"
type CreateUserRequest struct {
Name string `json:"name" validate:"required,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=150"`
}
var validate = validator.New(validator.WithRequiredStructEnabled())
func (r CreateUserRequest) Validate() error { return validate.Struct(r) }
错误信息需映射为 API 字段名(validate tag vs json 名)。这不是手动版本的逐项等价替换:required 不会自动 TrimSpace,email 规则也不同;规范化与字段必填语义仍需明确。
错误响应形状
{"errors":[{"field":"email","message":"invalid email"}]}
避免把内部校验库英文长句原样甩给客户端。
Query / Path / Header
- Path:
r.PathValue("id")后解析 UUID/int - Query:
r.URL.Query().Get+ 默认值与范围 - 与 Body 使用同一套
FieldError风格
设计动机
- 快速失败:坏请求不进事务、不打下游。
- 契约清晰:OpenAPI/文档与 DTO 可对齐。
- 安全基线:长度与类型限制是 DoS 与脏数据的第一道闸。
边界情况与反直觉行为
1. JSON 数字与 Go 类型
age: "18" 解码进 int 失败——属于解码错误,不是 Validate 阶段。
2. 空字符串 vs 省略字段
新 DTO 中 *string 可区分 nil 与显式空串,但缺失与显式 null 都得到 nil。要三态需记录字段是否出现。示例 Age 是 int:省略或 null 都保持 0 并通过范围校验;若年龄必填,应另加存在性检查。json 的 omitempty 只管编码,validator 的 omitempty 是另一套规则。
3. Unicode 长度
len(s) 是字节数;utf8.RuneCountInString 计 Unicode 码点,不等于用户感知字素(如组合音标、emoji 序列)。示例契约限制 100 个 rune。
4. 未知字段
默认 encoding/json 忽略未知字段;DisallowUnknownFields 拒绝未映射字段,但不拒绝重复键或大小写变体。字符串中的无效 UTF-8 会被替换;若契约要求更严格,需额外校验。
常见误区
[!warning] 常见误区:只在前端校验
服务端永远是权威边界。
[!warning] 常见误区:用 panic 表达校验失败
应返回 error / 问题详情。
[!warning] 常见误区:验证与鉴权混为一谈
格式验证在边界;授权在 service。
[!warning] 常见误区:校验失败返回 500
应用 400/422。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| JSON 解码 | 类型与语法;不负责业务范围 |
| DB 约束 | 最后防线;错误码需翻译为 409/400 |
| Schema(OpenAPI) | 文档/契约;运行时仍要执行等价检查 |
| 净化(sanitize) | 改写输入;验证通常只拒绝不改写 |
工程实践
- DTO 与领域模型分离:
CreateUserRequest≠User实体。 - 单一入口函数:
decodeAndValidate[T](r) (T, error)。 - 国际化:错误用稳定 code(
name_required),展示文案可本地化。 - 测试:表驱动覆盖缺字段、边界值、未知 JSON 字段、超大 body。
- 与中间件:认证后的 handler 仍要校验 body。
复用上方 decodeJSON:它只负责限体、解码与单值检查;对象非 null、字段约束及 Content-Type 策略由入口处理。本例未强制请求 Content-Type;若接口合同要求 application/json,应解析媒体类型并对不支持的类型返回 415。
可验证实验
实验 1:缺字段
POST {},应 400 且 errors 含 name/email。
实验 2:超大 body
超过 MaxBytesReader 限制应返回 413;也测试合法对象后接大量空白,防止只读首个值便返回成功。读取限额不是整个进程的内存上限。
实验 3:未知字段
带额外 "admin":true 在 DisallowUnknownFields 下应 400。
实验 4:rune 长度
name 为 101 个中文,确认按 rune 而非 byte 拒绝。
本节总结
- 本质:不信任边界输入,把约束变成显式错误。
- 关键规则:先 decode 再 validate;多错误聚合;分层清晰。
- 最易错:只靠前端、混淆授权、忽略 body 大小与未知字段。
- 下一步:分层 把 DTO 留在 handler;API 约定 统一错误形状。
自测题
概念题
- 解码失败与校验失败应如何区分处理?
- 为何常用 DTO 而不是直接绑定领域实体?
len(string)与用户“字符数”有何不同?
代码推理题
Validate 返回 errors.New("bad"),handler 用 errors.As 找 *ValidationError 失败后统一 500——合理吗?
工程思考题
同一规则在 JSON API 与 gRPC 都要执行,验证逻辑应放哪?
参考答案
展开
- 解码:非法 JSON/类型 → 400 通用消息;校验:字段级 errors 列表。
- 传输形状与持久化/领域模型演化节奏不同,避免 tag 与泄漏耦合。
len是字节;多字节字符应按 rune 或字素计。
代码题:取决于错误合同。预期的输入错误应有可识别类型并映射为 400/422;未分类错误可能是程序故障,不能仅因出自 Validate 就泄露 err.Error() 或归咎客户端。本例已知字段错误用 ValidationError,其他错误返回通用 500。 工程题:放进与传输无关的Validate()/领域服务,handler/gRPC 适配层只做转换。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| encoding/json | 标准库 | Decode、DisallowUnknownFields |
| net/http MaxBytesReader | 标准库 | 限制 body |
| go-playground/validator | 库 | 标签校验 |
| Go Blog | 官方 | 检索 JSON 实践 |
| go-json-and-serialization · go-struct-tags · go-error-handling | 本库 | 相关基础 |