Go 请求验证

Go HTTP 入口的请求验证:手动 Validate、encoding 边界、结构体标签校验器、错误映射与分层放置。

#type / concept #status / growing #tech / dev / backend #resource / go

[!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 里构造 CreateUserRequestValidate()

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"}

结合场景再看三个关注点

  1. 解码错误 ≠ 字段校验错误
    坏 JSON 返回通用 invalid json;字段问题返回可机读列表。

  2. 边界加固
    MaxBytesReader 限体;DisallowUnknownFields 拒未知键。

  3. 多错误一次返回
    注册表单可同时标红多个输入,减少来回提交。

核心概念与准确模型

验证发生在哪一层

验证什么
Transport/HandlerJSON 形状、必填、长度、格式、枚举
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 风格

设计动机

  1. 快速失败:坏请求不进事务、不打下游。
  2. 契约清晰:OpenAPI/文档与 DTO 可对齐。
  3. 安全基线:长度与类型限制是 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)改写输入;验证通常只拒绝不改写

工程实践

  1. DTO 与领域模型分离CreateUserRequestUser 实体。
  2. 单一入口函数decodeAndValidate[T](r) (T, error)
  3. 国际化:错误用稳定 code(name_required),展示文案可本地化。
  4. 测试:表驱动覆盖缺字段、边界值、未知 JSON 字段、超大 body。
  5. 与中间件:认证后的 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":trueDisallowUnknownFields 下应 400。

实验 4:rune 长度

name 为 101 个中文,确认按 rune 而非 byte 拒绝。

本节总结

  • 本质:不信任边界输入,把约束变成显式错误。
  • 关键规则:先 decode 再 validate;多错误聚合;分层清晰。
  • 最易错:只靠前端、混淆授权、忽略 body 大小与未知字段。
  • 下一步分层 把 DTO 留在 handler;API 约定 统一错误形状。

自测题

概念题

  1. 解码失败与校验失败应如何区分处理?
  2. 为何常用 DTO 而不是直接绑定领域实体?
  3. len(string) 与用户“字符数”有何不同?

代码推理题

Validate 返回 errors.New("bad"),handler 用 errors.As*ValidationError 失败后统一 500——合理吗?

工程思考题

同一规则在 JSON API 与 gRPC 都要执行,验证逻辑应放哪?

参考答案

展开
  1. 解码:非法 JSON/类型 → 400 通用消息;校验:字段级 errors 列表。
  2. 传输形状与持久化/领域模型演化节奏不同,避免 tag 与泄漏耦合。
  3. 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本库相关基础

创建于 2026/6/25 更新于 2026/7/15