go-zero 参数校验:写在 handler 还是 logic?

很多人第一次写 go-zero 都会纠结:字段校验到底放哪?这篇直接给结论,并拆到源码级别——httpx.Parse 在哪被调用、校验规则写在哪、为什么格式校验和业务校验必须分家。

一句话结论 生成的 handler types 上的标签 httpx.Parse 内部 两种校验机制 业务校验在 logic 对照表 常见误区 相关阅读

格式 / 字段校验在 handler,业务校验在 logic

go-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 重新生成覆盖,搬过去等于埋雷。

一句话口诀:handler 只负责「把请求变成干净的 req 结构体」,logic 才负责「用这个 req 做事」。校验失败,handler 直接 400 打回;业务失败,logic 返回业务错误码。

goctl 生成的 handler,校验就长在里面

这是 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)
        }
    }
}
关键点:handler 里除了 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
}

go-zero 原生校验标签速查(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 的那一行里完成。

http.Request 原始请求 ① ParsePath path:"id" ② ParseForm form / query ③ ParseHeader header:"X-Token" ④ JsonBody json:"username" ⑤ Validation 必填/范围/枚举/自定义 干净的 req 交给 logic ↑ ①②③ 绑定来源 → ④ 解析 body → ⑤ 统一校验
执行顺序固定:① 路径 → ② query/表单 → ③ 请求头 → ④ JSON 体 → ⑤ 校验。任一步校验失败(缺必填、越界、不在枚举里),Parse 立刻返回 error,handler 直接 400,根本不会进 logic。

两种校验机制,都在 handler 触发

go-zero 有「原生 tag 校验」和「自定义校验」两套。但不管哪套,触发点都在 httpx.Parse(即 handler 里)——你永远不要把它挪到 logic。

① 原生 tag(开箱即用)

就是上一节的 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"`
}
方式 B 的 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?

① handler 不持有 svcCtx 里的 Model / RPC / Redis 连接(或访问很别扭);② handler 是 goctl 生成的,你手写的业务校验会在下次 goctl api go 时被覆盖;③ 分层原则:handler = 入站适配层(绑定+格式校验),logic = 业务层。把业务塞进 handler 直接破坏这套分层。

handler 与 logic 各放什么

维度handler(含 httpx.Parse)logic
谁写的goctl 生成你手写
负责什么绑定 + 字段格式校验业务逻辑 + 业务校验
校验内容必填、类型、范围、枚举、可选、默认存在性、状态、权限、跨表一致性
数据来源path / query / form / header / bodyModel、RPC、Redis、外部服务
失败返回400 参数错误(框架自动)业务错误码(你定义)
能否被覆盖会(goctl 重新生成)不会(你独占)
典型代码httpx.ParseUserModel.FindOne...

四个最容易踩的坑

误区 1:把 httpx.Parse 搬到 logic。 这会导致 handler 变空壳、且下次 goctl 重新生成 handler 时把你挪走的校验直接覆盖掉。正确做法:handler 保持 goctl 原样,规则写在 types tag。
误区 2:在 logic 里重复做格式校验。 比如再手写 if req.Username == ""——httpx.Parse 已经做了。重复校验既冗余又容易和 tag 规则不一致。
误区 3:为「少写 types tag」而在 logic 手写必填判断。 types tag 是声明式的、统一在 handler 阶段拦截,比散落在 logic 里的一堆 if 清晰得多,也更早返回(省一次查库)。
误区 4:把业务校验塞进 handler。 handler 会被生成覆盖、且没有数据访问能力,业务规则必须留在 logic。
判断口诀:「能不能在不查库、不调服务的情况下判断?」——能,就是格式校验,放 types tag(handler 执行);不能,就是业务校验,放 logic。