业务异常必须用自定义error类型封装,因其可携带错误码、消息等结构化信息,支持类型断言和HTTP状态码映射,而errors.New/fmt.Errorf仅返回字符串,无法满足业务分层与前端差异化处理需求。

业务异常必须用自定义 error 类型封装,不能用字符串、整数或 panic 代替。
为什么不能用 errors.New("xxx") 或 fmt.Errorf 表达业务错误
它们只提供字符串描述,无法携带结构化信息(如错误码、请求 ID、上下文字段),调用方只能靠 strings.Contains 或硬匹配判断类型,极易因文案微调而崩溃。比如 "验证码已过期" 改成 "验证码失效",上游 switch-case 就失效。
常见错误现象:API 返回 500 却日志里只写 "user not found",前端无法区分是用户不存在还是 DB 连接失败。
- 业务错误需要可断言、可分类、可映射 HTTP 状态码
-
errors.New和fmt.Errorf适合系统级临时错误(如文件打开失败),不适合业务分层 - 所有业务错误必须能被
errors.As或类型断言捕获,例如if err != nil && errors.As(err, &e) { ... }
UserError 结构体字段设计的关键取舍
字段太少,下游无法做差异化处理;字段太多,每个业务函数都要填一堆空值。核心字段应仅保留三类:
立即学习“go语言免费学习笔记(深入)”;
-
Code int:HTTP 状态码或领域错误码(如404、1001),避免用字符串枚举(不易序列化、难比对) -
Message string:面向前端/用户的简短提示(如"手机号格式不正确"),不带技术细节 -
Details map[string]interface{}或Details string:可选,用于透传调试信息(如"phone=138xxxx1234"),生产环境默认不返回
不要放 Timestamp、Stack、RequestID 这类字段——这些该由中间件统一注入,不是错误类型本身的责任。
Gin 中如何让 *UserError 自动转成对应 HTTP 状态码
别依赖框架默认的 gin.Recovery(),它只会返回 500。必须自己写中间件,在 recover() 后按类型优先级解包:
func CustomRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
var code int
var msg string
switch e := err.(type) {
case *UserError:
code = e.Code
msg = e.Message
case error:
code = http.StatusInternalServerError
msg = "服务内部错误"
default:
code = http.StatusInternalServerError
msg = "未知错误"
}
c.AbortWithStatusJSON(code, gin.H{"error": msg})
}
}()
c.Next()
}
}
注意:panic(*UserError) 是反模式。自定义错误必须通过 return &UserError{...} 正常返回,panic 只用于程序无法继续运行的场景(如配置加载失败)。
容易被忽略的兼容性陷阱:错误链与 Unwrap
如果业务逻辑中用了 fmt.Errorf("xxx: %w", err) 包装自定义错误,下游必须用 errors.As 或 errors.Is 判断,不能直接类型断言:
- ❌ 错误写法:
if e, ok := err.(*UserError); ok { ... }—— 包装后断言失败 - ✅ 正确写法:
var e *UserError; if errors.As(err, &e) { ... } - 所有自定义错误类型都应实现
Unwrap() error方法(返回nil或嵌套错误),否则errors.As无法穿透
最隐蔽的问题:日志中间件里打印 err.Error() 看似正常,但一旦加了错误链,原始 *UserError 的 Code 字段就丢失了——必须显式解包才能拿到。


















