handler 还是 logic?很多人第一次写 go-zero 都会纠结:字段校验到底放哪?这篇直接给结论,并拆到源码级别——httpx.Parse 在哪被调用、校验规则写在哪、为什么格式校验和业务校验必须分家。
handler,业务校验在 logicgo-zero 把「请求绑定 + 字段格式校验」固化在了生成的 handler.go 里,通过 httpx.Parse(r, &req) 完成;规则声明在 types 结构体的 tag 上。而「这个用户存不存在、密码对不对、状态能不能操作」这种业务校验,只能写在你手写的 logic 里。
记住这三条就够了:
① 格式校验(必填 / 类型 / 范围 / 枚举 / 可选 / 默认):规则写在 types 结构体 tag,由 handler 里的 httpx.Parse 自动执行——你不用在 handler 手写 if。
② 业务校验(存在性 / 状态 / 权限 / 跨表一致性):写在 logic,因为要查库、调 RPC、查缓存,handler 阶段根本没这些数据。
③ 不要为了「整洁」把 httpx.Parse 搬到 logic:handler 会被 goctl 重新生成覆盖,搬过去等于埋雷。
这是 goctl api go 实际吐出来的 loginhandler.go。注意看:httpx.Parse 是 goctl 帮你写好的,不是你手写的。它一执行,path / query / form / header / body 就绑定好了,字段格式也校验完了。
package handler import ( "net/http" "hello/internal/logic" "hello/internal/svc" "hello/internal/types" "github.com/zeromicro/go-zero/rest/httpx" ) func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.LoginRequest // ↓↓↓ 字段格式校验就在这里发生(goctl 生成)↓↓↓ if err := httpx.Parse(r, &req); err != nil { httpx.ErrorCtx(r.Context(), w, err) // 自动 400 + 错误信息 return } // 校验通过后才进 logic,req 已经是「干净」的结构体 l := logic.NewLoginLogic(r.Context(), svcCtx) resp, err := l.Login(&req) if err != nil { httpx.ErrorCtx(r.Context(), w, err) } else { httpx.OkJsonCtx(r.Context(), w, resp) } } }
httpx.Parse 和调用 logic,几乎不该有别的业务逻辑。所以「字段校验」天然属于 handler 阶段——它不是你写的,是框架在 handler 阶段替你做的。types 结构体的 tag 上go-zero 原生支持一组「开箱即用、零配置」的校验标签(由 core/mapping 在 httpx.Parse 时执行)。默认所有字段都是必填,加 optional 才变可选。
type LoginRequest struct { // 绑定来源 + 校验,全部用 tag 声明,不用手写 if Username string `json:"username"` // 必填(默认),来自 body Password string `json:"password,optional"` // 可选 Page int64 `form:"page,default=1"` // query,默认 1 Size int64 `form:"size,default=20,range=[1:100]"` // 范围 1~100 Role string `form:"role,options=admin|user"` // 枚举值 Token string `header:"X-Token"` // 来自请求头 UserID int64 `path:"id"` // 来自路径 /api/users/:id }
httpx.Parse 自动执行,无需额外配置)| 标签 | 含义 | 示例 |
|---|---|---|
json / form / path / header | 绑定来源(body / query+表单 / 路径 / 请求头) | json:"name" |
optional | 字段非必填,缺失不报错(保持零值) | json:"age,optional" |
default=... | 缺失时填默认值 | form:"page,default=1" |
range=[a:b] | 数值 / 长度范围限制(含端点) | form:"size,range=[1:100]" |
options=a|b|c | 枚举,只能是其中之一 | form:"role,options=admin|user" |
| (无标签) | 默认必填:缺失即报错 | json:"username" |
optional 是 go-zero 自己的标签,和标准库 encoding/json 的 omitempty 不是一回事。omitempty 只影响「序列化」,不影响参数校验;go-zero 里控制「要不要必填」用的是 optional。httpx.Parse 内部到底做了什么Parse(r, &req) 按固定顺序把四个来源依次绑进结构体,最后跑校验。这就是「参数校验在 handler」的底层依据——整条链路都在 handler 调用 Parse 的那一行里完成。
Parse 立刻返回 error,handler 直接 400,根本不会进 logic。go-zero 有「原生 tag 校验」和「自定义校验」两套。但不管哪套,触发点都在 httpx.Parse(即 handler 里)——你永远不要把它挪到 logic。
就是上一节的 optional / default / range / options,由 core/mapping 在 Parse 时执行。零配置、推荐首选。覆盖 80% 的「格式校验」场景。
两种方式,都仍在 handler 的 Parse 内被调用:
A 给 req 结构体实现 Validate() error 接口;
B 启动时 httpx.SetValidator(...) 注册全局校验器(如 go-playground,启用 validate:"required,min=18,email" 这类标签)。
// 方式 A:在 types 结构体上实现 Validate() error,Parse 后自动调用 func (req RegisterRequest) Validate() error { if len(req.Password) < 8 { return errors.New("密码至少 8 位") } return nil } // 方式 B:注册全局校验器后,结构体可用 validate 标签 // 在 svc 初始化或 main 里:httpx.SetValidator(myValidator) type RegisterRequest struct { Email string `json:"email" validate:"required,email"` }
validate 标签不是默认开启的——必须你自己 httpx.SetValidator(...) 注册校验器(常见是封装 go-playground/validator)。而原生 tag(optional/range/options)不需要任何注册,Parse 直接生效。logic「用户存不存在」「密码对不对」「账号是否被禁」这类规则,handler 阶段还没法判断——因为要查库 / 调 RPC / 读缓存。所以它们天然落在 logic。注意 logic 拿到的 req 已经是「格式校验通过」的了。
func (l *LoginLogic) Login(req *types.LoginRequest) (*types.LoginResponse, error) { // req 已经过 httpx.Parse 校验:username 必填、role 在枚举内…… // 下面才是「业务校验」,只能在这做: u, err := l.svcCtx.UserModel.FindOneByUsername(l.ctx, req.Username) if err == model.ErrNotFound { return nil, errorx.New(code.UserNotFound) // 业务错误码 } if !checkPassword(req.Password, u.Password) { return nil, errorx.New(code.PasswordWrong) } if u.Status == Banned { return nil, errorx.New(code.AccountBanned) } // ... 真正业务逻辑 }
① handler 不持有 svcCtx 里的 Model / RPC / Redis 连接(或访问很别扭);② handler 是 goctl 生成的,你手写的业务校验会在下次 goctl api go 时被覆盖;③ 分层原则:handler = 入站适配层(绑定+格式校验),logic = 业务层。把业务塞进 handler 直接破坏这套分层。
| 维度 | handler(含 httpx.Parse) | logic |
|---|---|---|
| 谁写的 | goctl 生成 | 你手写 |
| 负责什么 | 绑定 + 字段格式校验 | 业务逻辑 + 业务校验 |
| 校验内容 | 必填、类型、范围、枚举、可选、默认 | 存在性、状态、权限、跨表一致性 |
| 数据来源 | path / query / form / header / body | Model、RPC、Redis、外部服务 |
| 失败返回 | 400 参数错误(框架自动) | 业务错误码(你定义) |
| 能否被覆盖 | 会(goctl 重新生成) | 不会(你独占) |
| 典型代码 | httpx.Parse | UserModel.FindOne... |
httpx.Parse 搬到 logic。 这会导致 handler 变空壳、且下次 goctl 重新生成 handler 时把你挪走的校验直接覆盖掉。正确做法:handler 保持 goctl 原样,规则写在 types tag。if req.Username == ""——httpx.Parse 已经做了。重复校验既冗余又容易和 tag 规则不一致。