Go 请求验证
Go HTTP 入口的请求验证:手动 Validate、encoding 边界、结构体标签校验器、错误映射与分层放置。
[!info] 关联笔记
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 + 字段级错误列表,让前端高亮;通过后再交给业务层。
本例会
ListenAndServe;本地可用 curl 打两种请求观察 400 / 201。
若只想测 Validate,可直接在main里构造CreateUserRequest调Validate()。
package main
import (
"encoding/json"
"errors"
"fmt"
"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()
// 防止超大 body 占内存 / 打满带宽。
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req CreateUserRequest
dec := json.NewDecoder(r.Body)
// 拒绝前端拼错的未知字段,避免静默丢数据。
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
// 解码失败与字段校验失败分开:前者是 JSON 形状问题。
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := req.Validate(); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
writeJSON(w, http.StatusBadRequest, ve)
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// 演示成功路径;真实实现写库后返回用户 id。
writeJSON(w, http.StatusCreated, map[string]string{
"status": "ok",
"name": strings.TrimSpace(req.Name),
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", createUser)
fmt.Println("listen :8080")
_ = 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限体;DisallowUnknownFields拒未知键。 -
多错误一次返回
注册表单可同时标红多个输入,减少来回提交。
核心概念与准确模型
验证发生在哪一层
| 层 | 验证什么 |
|---|---|
| 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 名)。
错误响应形状
{"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 省略字段
required 对指针/omitempty 语义不同;*string 可区分“未传”与“传空”。
3. Unicode 长度
len(s) 是字节数;用户可见长度常用 utf8.RuneCountInString。
4. 未知字段
默认 encoding/json 忽略未知字段;严格 API 用 DisallowUnknownFields。
常见误区
[!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。
func decodeJSON(r *http.Request, dst any, maxBytes int64) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
}
可验证实验
实验 1:缺字段
POST {},应 400 且 errors 含 name/email。
实验 2:超大 body
超过 MaxBytesReader 限制,应失败且不爆内存。
实验 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 或映射,不该 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 | 本库 | 相关基础 |