Go 错误处理:从 if err != nil 到统一错误响应
if err != nil 本身不难,难的是错误在不同层该怎么处理:
- 这个错误该给用户看"用户名已存在",还是"服务器内部错误"?
- 为什么生产日志里全是
ERROR create user failed: duplicate key value violates unique constraint "idx_users_email",而用户看到的是白屏? - 为什么前端拿到的 409 里带出了数据库表名?
- 为什么一条请求失败,日志里出现了 3 条错误记录?
关键是约定好错误如何转换、对谁展示,以及由哪一层记录日志。下面以 gin-web-api 为例,介绍这套处理方式及其取舍。
起点:三种常见写法,三种麻烦
没有统一约定时,常见的写法有几种。
写法一:一路 return err
func (h *UserHandler) Get(c *gin.Context) {
user, err := h.svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
response.OK(c, user)
}
这样一来,记录不存在也会返回 500。前端无法区分请求错误和服务故障,重试与告警也会失准。
如果直接把 err.Error() 返回给前端,还可能泄露数据库或网络等内部信息。
写法二:handler 里遍地 errors.Is
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "not found"})
return
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
c.JSON(409, gin.H{"error": "conflict"})
return
}
状态码虽然正确了,但 HTTP 层也因此依赖 GORM。更换存储或 RPC 实现时 handler 也要改,相同判断还容易散落在多个 handler 中。
写法三:哨兵错误 + 字符串比较
var ErrUserNotFound = errors.New("user not found")
if err.Error() == "user not found" { ... }
if strings.Contains(err.Error(), "not found") { ... }
字符串比较很脆弱:错误被包装或文案变动后,判断就会失效,编译器也无法检查。
我想要的四条约束
因此,错误处理需要满足几条约束:
| 约束 | 含义 |
|---|---|
| 分流 | 面向用户的信息和面向开发的信息必须分开存放 |
| 解耦 | HTTP 状态码是传输层的事,业务码是领域的事,两者可独立演进 |
| 兼容 | 必须支持 errors.Is / errors.As,不能破坏标准库的错误链 |
| 唯一 | 一条错误只在一处记日志,避免同一条错误刷三条 |
设计:AppError
核心就一个类型,四个字段:
// AppError 表示一个可安全暴露给客户端的业务错误。
type AppError struct {
Code int // 业务错误码
HTTPStatus int // HTTP 状态码
Message string // 面向客户端的提示信息
Cause error // 内部原因,仅用于日志,不对外暴露
}
Code、HTTPStatus 和 Message 用于构造客户端响应;Cause 保留内部原因,供日志排查。响应只使用 Message,不要直接返回 Error()。
实现 error 并保留内部原因
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// Unwrap 支持 errors.Is / errors.As 链式判断。
func (e *AppError) Unwrap() error { return e.Cause }
Error() 包含 Cause,便于日志记录底层原因;不要用它生成客户端响应。
Unwrap 则保留了错误链,翻译成业务错误后仍可使用 errors.Is / errors.As:
appErr := apperror.Internal("查询用户失败").WithCause(gorm.ErrRecordNotFound)
errors.Is(appErr, gorm.ErrRecordNotFound) // true
为什么 WithCause 要返回副本
func (e *AppError) WithCause(err error) *AppError {
clone := *e
clone.Cause = err
return &clone
}
业务错误可能定义为包级变量复用:
var ErrUserNotFound = apperror.NotFound("用户不存在")
如果 WithCause 原地修改,并发请求就可能竞争写入,并让一个请求的错误原因出现在另一个请求的日志里。
返回副本可以避免修改共享错误值。
构造函数一族 + 一个入口
func BadRequest(message string) *AppError // 400
func Unauthorized(message string) *AppError // 401
func Forbidden(message string) *AppError // 403
func NotFound(message string) *AppError // 404
func Conflict(message string) *AppError // 409
func TooManyRequests(message string) *AppError // 429
func Internal(message string) *AppError // 500
业务码按段划分,便于定位:
| 段 | 含义 | 示例 |
|---|---|---|
10xxx | 通用 / 参数 | 10001 参数错误 |
11xxx | 认证鉴权 | 11001 未认证、11002 无权限 |
12xxx | 业务 | 12001 资源不存在、12002 资源冲突 |
13xxx | 限流 | 13001 请求过于频繁 |
50xxx | 系统 | 50000 内部错误 |
然后是兜底入口 From:
// From 将任意 error 归一化为 *AppError;未知错误统一视为内部错误且不暴露细节。
func From(err error) *AppError {
if err == nil {
return nil
}
var appErr *AppError
if errors.As(err, &appErr) {
return appErr
}
return Internal("服务器内部错误").WithCause(err)
}
From 的处理分两种情况:
- 已经是
*AppError(包括被%w包装的情况),则取出并保留。 - 其他错误统一转成 500,原始错误放入
Cause,不暴露给客户端。
这样,即使调用方忘了翻译错误,客户端也不会看到底层错误细节。
边界:错误在哪一层翻译
各层的职责约定如下:service 将底层错误翻译为业务错误,handler 上报错误,中间件负责响应和日志。
| 层 | 职责 | 是否 import apperror |
|---|---|---|
| repository | 只返回底层错误原样(gorm.ErrRecordNotFound) | 否 |
| service | 把底层错误翻译成业务语义,用 WithCause 保留现场 | 是 |
| handler | 只做 c.Error(err) + return,不做任何判断 | 否 |
| middleware | 统一渲染响应体、决定日志级别 | 是 |
repository:不表态
func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
return gen.User[model.User](r.db).GetByID(ctx, id)
}
仓储层只返回存储操作的结果,不依赖 HTTP 或业务错误类型,便于被其他入口复用。
service:翻译 + 保留现场
// mapNotFound 将 GORM 的记录不存在错误映射为业务错误,其余归为内部错误。
func mapNotFound(err error, message string) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperror.NotFound(message)
}
return apperror.Internal("数据库操作失败").WithCause(err)
}
在 service 层用 errors.Is 将存储错误映射为业务语义,可以避免让 HTTP 层依赖数据库实现。
一个真实的业务错误长这样:
if err := s.repo.Create(ctx, user); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, apperror.Conflict("用户名或邮箱已存在").WithCause(err)
}
return nil, apperror.Internal("创建用户失败").WithCause(err)
}
客户端得到 409 和业务提示,日志则保留数据库错误,便于排查。
同理,路径参数解析这种"输入校验"也在 service 层翻译:
func parseID(id string) (uuid.UUID, error) {
uid, err := uuid.Parse(id)
if err != nil {
return uuid.Nil, apperror.BadRequest("无效的 ID")
}
return uid, nil
}
handler:只上报,不判断
func (h *PostHandler) Get(c *gin.Context) {
resp, err := h.svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
return
}
response.OK(c, resp)
}
handler 只处理成功响应;发生错误时交给后续中间件,不判断错误类型或设置状态码。
渲染:c.Error 与那个绕不开的 return
c.Error(err) 只会把错误追加到 c.Errors,不会写响应,也不会中断执行:
func (c *Context) Error(err error) *Error {
// 追加到 c.Errors,仅此而已
}
因此调用后必须 return。如果遗漏,handler 可能继续写出 200 响应,之后 ErrorHandler 再写 500:
[WARNING] Headers were already written. Wanted to override status code 404 with 500
客户端可能拿到 200 和空响应,同时日志出现容易被忽略的 warning。
渲染在中间件里完成,顺序是关键:
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next() // 先让整条链路跑完
if len(c.Errors) == 0 {
return
}
appErr := apperror.From(c.Errors.Last().Err)
if appErr.HTTPStatus >= 500 {
log := logger.FromGin(c)
log.Error("request failed",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Error(appErr),
)
}
response.Fail(c, appErr)
}
}
中间件的几个选择:
- 取
Last():一条请求可能记录多个错误,这里用最后一个错误生成响应。 - 只对 5xx 记 error 日志:项目的访问日志已记录 4xx,避免重复。
- 集中在中间件渲染:让错误响应格式保持一致。
错误响应只包含必要字段:
type ErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
}
{ "code": 12001, "message": "用户不存在", "request_id": "01JB8Z9K4K3M2N1P0Q" }
request_id 可用于根据用户反馈定位对应日志。
panic:不走错误链,但要汇入同一个出口
panic 不走普通错误链,但可以返回相同格式的错误响应:
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if rec := recover(); rec != nil {
log := logger.FromGin(c)
log.Error("panic recovered",
zap.Any("panic", rec),
zap.String("path", c.Request.URL.Path),
zap.Stack("stack"),
)
response.Fail(c, apperror.Internal("服务器内部错误"))
}
}()
c.Next()
}
}
要点:
- 记录堆栈,便于定位 panic。
- 直接调用
response.Fail,避免再把错误塞入c.Errors。 - 不要用 panic 处理常规业务分支。
测试:断言契约,而不是断言字符串
测试重点应放在状态码、业务码和敏感信息是否泄露,而不是固定提示文案:
func TestGetUserNotFound(t *testing.T) {
w := httptest.NewRecorder()
// ... 构造请求
assert.Equal(t, http.StatusNotFound, w.Code)
var body response.ErrorBody
json.Unmarshal(w.Body.Bytes(), &body)
assert.Equal(t, apperror.CodeNotFound, body.Code)
assert.NotEmpty(t, body.RequestID)
assert.NotContains(t, body.Message, "record not found") // 内部细节不得泄露
}
还可以单测 apperror.From,确认未知错误不会暴露给客户端,同时保留错误链:
func TestFromUnknownErrorHidesCause(t *testing.T) {
secret := errors.New(`pq: password authentication failed for user "postgres"`)
appErr := apperror.From(fmt.Errorf("query: %w", secret))
assert.Equal(t, http.StatusInternalServerError, appErr.HTTPStatus)
assert.Equal(t, "服务器内部错误", appErr.Message)
assert.NotContains(t, appErr.Message, "password")
assert.True(t, errors.Is(appErr, secret)) // 现场仍在,只是不外露
}
总结
错误处理的关键是明确边界:repository 返回底层错误,service 将它翻译成业务错误,handler 上报错误,中间件统一记录日志并生成响应。客户端只拿到安全、明确的提示,内部原因仍保留在错误链中供排查。再用测试守住状态码、业务码和信息不泄露这几条约定,错误处理就不必散落在每个 handler 里。