Skip to main content

一行 return err,搞定所有错误响应

为什么 goddd 的接口只需要 return nil, err 就能自动返回结构化的 JSON 错误响应?为什么不用手写 ctx.JSON(500, ...),前端却能拿到机器可读的错误码、用户可读的提示、开发者可追溯的详情?

答案在 pkg/reason——用字符串 reason 代替数字错误码,同时保留面向用户的 msg 和面向开发者的 details。调用方能根据 reason 做分支判断,用户能看到友好的提示,开发者又能拿到足够的排查信息。

Go 谚语:

"Don't just check errors, handle them gracefully."

错误不仅要捕获,还要处理好、传清楚。

1. 传统方案的问题

常见的错误处理方式大致有两类:

直接返回底层错误:

user, err := db.FindUser(id)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
return
}

这种方式会泄露内部信息,比如数据库表名、SQL 细节等。

自定义数字错误码:

ctx.JSON(200, gin.H{"code": 1001, "msg": "数据库查询失败"})

数字没有语义,过段时间连定义者自己都忘了 1001 是什么意思。

2. goddd 的错误结构

goddd 把错误拆成几个字段,定义在 pkg/reason/reason.go 中:

type Error struct {
Reason string `json:"reason"`
Msg string `json:"msg"`
Details []string `json:"details"`
HTTPStatus int `json:"-"`
Cause error `json:"-"`
}
  • reason:英文大驼峰命名,如 ErrBadRequestErrUserNotFound,给程序识别用。
  • msg:中文或本地语言,直接展示给用户看。
  • details:给开发者看的补充信息,比如具体哪个字段校验失败。
  • HTTPStatus:映射到 HTTP 响应状态码,默认 400。
  • Cause:底层原因错误,不序列化到 JSON,仅供 errors.Is/As 链路解包使用。

创建错误时使用 reason.NewError

var ErrBadRequest = reason.NewError("ErrBadRequest", "请求参数有误")
var ErrUserNotFound = reason.NewError("ErrUserNotFound", "用户不存在")

NewError 内部会检查 reason 是否重复,重复会 panic——在启动阶段就暴露冲突。reason 全局唯一,即使不同的包也不允许重复,因为它是面向调用方的机器标识。

为什么用字符串而非数字? 核心原因是可读性。ErrUserNotFound 天然表达含义,代码里一搜就能定位;10023 要先查文档。跨团队、前端分支、监控聚合、国际化等场景,字符串都能胜任——Stripe 用 card_declined,AWS IAM 用 InvalidAction,Google API 用 NOT_FOUND。HTTP 状态码仍然是数字(传输层语义),但业务错误码用字符串更清晰。

3. 在业务代码中使用

在 Core 层链式调用:

func (c *Core) GetUser(id int64) (*UserOutput, error) {
if id <= 0 {
return nil, reason.ErrBadRequest.With("用户 ID 必须大于 0")
}

user, err := c.store.GetUser(id)
if err != nil {
return nil, reason.ErrDB.WithCause(err).With("查询用户失败")
}
if user == nil {
return nil, reason.ErrUserNotFound.WithMsg("未找到该用户")
}

return user, nil
}

链式方法一览:

方法作用示例
With(args...)追加 details(开发者排查信息).With("用户 ID 必须大于 0")
Withf(format, args...)格式化追加 details.Withf("ID=%d 不合法", id)
WithCause(err)包裹底层错误,保留 errors.Is/As 链路.WithCause(err)
WithMsg(s)覆盖面向用户的提示信息.WithMsg("未找到该用户")
WithHTTPStatus(code)覆盖 HTTP 响应状态码.WithHTTPStatus(401)

所有链式方法都返回新对象,不修改原错误——预定义的错误变量始终是安全的。

结合上一篇的 web.WrapH,错误会自动被 web.Fail 转成统一响应:

客户端 POST /api/users/0
→ WrapH 绑定参数,调用 Core.GetUser(0)
→ Core 返回 reason.ErrBadRequest.With("用户 ID 必须大于 0")
→ WrapH 调用 web.Fail
→ Fail 通过 reason.ErrorInfoer 接口提取 reason/msg/details/HTTPStatus
→ 客户端收到 HTTP 400:

{
"reason": "ErrBadRequest",
"msg": "请求参数有误",
"details": ["用户 ID 必须大于 0"]
}

Core 返回 nil error 时,WrapH 调用 web.Success 直接输出业务数据(HTTP 200)。

4. 与标准库 errors 的关系

reason.Error 完全兼容 Go 标准库,实现了三个关键接口:

Unwrap() error:返回通过 WithCause 包裹的底层错误。

cause := errors.New("connection refused")
err := reason.ErrDB.WithCause(cause).With("查询用户失败")
errors.Is(err, cause) // true

Is(target error) bool:按 Reason 字符串比较,而非指针比较。即使经过 With/WithMsg 产生了新对象,Reason 相同就匹配:

e2 := reason.ErrBadRequest.With("参数错误")
e3 := fmt.Errorf("包装: %w", e2)
errors.Is(e3, reason.ErrBadRequest) // true

As(target any) bool:支持将错误提取为 *reason.Error

var e *reason.Error
if errors.As(err, &e) {
fmt.Println(e.GetReason(), e.GetMessage())
}

5. 预定义错误

goddd 内置了一组常用错误(参考 Google API 错误规范),按 HTTP 状态码分组:

客户端错误(默认 400):

变量ReasonHTTP含义
ErrBadRequestErrBadRequest400参数无效
ErrNotFoundErrNotFound400资源未找到
ErrConflictErrConflict400操作冲突
ErrAbortedErrAborted400操作被中止
ErrJSONErrJSON400JSON 编解码出错
ErrUnauthorizedErrUnauthorized401未登录或凭证已过期
ErrPermissionDeniedErrPermissionDenied403权限不足
ErrFileTooLargeErrFileTooLarge413文件大小超出限制
ErrContentTooLargeErrContentTooLarge413请求体过大
ErrTooManyRequestsErrTooManyRequests429请求频率过高

服务端错误(默认 500):

变量ReasonHTTP含义
ErrInternalErrInternal500服务器内部错误
ErrDBErrStore500数据层错误
ErrServerErrServer500服务器错误
ErrUnimplementedErrUnimplemented501功能尚未实现
ErrServiceUnavailableErrServiceUnavailable503服务暂时不可用
ErrTimeoutErrTimeout504请求超时

业务项目根据实际需求,用 NewError 定义领域专属的错误:

var ErrUserNotFound = reason.NewError("ErrUserNotFound", "用户不存在")
var ErrOrderExpired = reason.NewError("ErrOrderExpired", "订单已过期")
var ErrStockInsufficient = reason.NewError("ErrStockInsufficient", "库存不足")

命名建议:Err 前缀 + 领域名 + 具体错误(如 ErrUserNotFound),确保跨模块不冲突。

6. 生产环境隐藏 details

开发时 details 越详细越好,但生产环境可能包含敏感信息。pkg/web 提供了开关:

web.SetRelease() // 生产环境调用,错误响应不再输出 details

web.Fail 会根据这个开关决定是否把 details 放进响应体。默认是 debug 模式,会输出 details。

7. 多语言支持

reason 天然支持多语言:用 reason 字符串作为翻译 key,通过 WithMsg 传入翻译后的文本即可。

return nil, reason.ErrNotFound.WithMsg(i18n.T(lang, "ErrNotFound"))

details 不需要翻译——它记录的是面向开发者的错误详情、解决方案或文档地址,与语言无关。

关于 goddd

goddd 是一个 AI 驱动的 Go 语言脚手架,基于领域驱动设计(DDD)思想搭建,提供了一套适合中小项目快速启动的工程结构和最佳实践。

如果你想了解更多细节,可以访问: