错误处理的工程实践:包装、分类与日志怎么配合?
一句话结论:Go 的错误处理要遵循“每层最多处理一次”:内层用 %w 包装并附加上下文向上抛,最外层(handler/worker 边界)统一做分类决策并记录日志;根因通过错误链保留,用 errors.Is 判哨兵、errors.As 取类型、领域错误码做对外契约,避免每层都打日志造成重复噪音,也避免吞错与拿 panic 当流程控制。
三层职责划分
| 层 | 该做什么 | 不该做什么 |
|---|---|---|
| 底层(repo/网络) | 返回原始错误或轻包装 | 打完整日志 |
| 中层(service) | %w 包装并加业务上下文 | 决定“是否 404/500” |
| 边界(handler/入口) | Is/As 分类、转状态码、日志一次 | 忽略 err |
代码示例
var ErrNotFound = errors.New("not found")
type BizError struct {
Code int
Msg string
Err error
}
func (e *BizError) Error() string { return e.Msg }
func (e *BizError) Unwrap() error { return e.Err }
// 中层:只包装,不记录
func getUser(ctx context.Context, id int) (*User, error) {
u, err := repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}
// 边界:分类 + 日志只打一次
func handleGetUser(w http.ResponseWriter, r *http.Request) {
u, err := getUser(r.Context(), 1)
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.As(err, &be):
http.Error(w, be.Msg, be.Code)
default:
log.Error("get user failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
注意:errors.As 前要先声明 var be *BizError,目标必须是指向错误类型的指针。
配套习惯
- 错误分类三件套:哨兵(Is)、类型(As 带数据)、错误码(对外契约)。对外接口的错误码放响应体里,HTTP 状态码只管大类。
- 可恢复 vs 致命:网络抖动类可重试/降级,数据不一致类直接失败并告警。
- recover 只在 goroutine 顶层兜底防崩,业务错误一律 return error,不用 panic。
- 日志要带请求上下文:request id、用户、耗时,配合 middleware 统一注入。
常见追问 / 记忆点
- 追问:为什么不让每层都 log.Error?——同一错误会被打 N 遍,上下文碎片化,排查时反而更难串联。
- 追问:底层错误直接 return 不包装行不行?——行,但上层会丢失“在哪一层、哪个参数”的上下文。
- 记忆点:包装一次、分类一次、记录一次;%w 保根因、Is/As 做决策、边界层统一收口。