Go 错误处理规范:errors.Is、errors.As 与包装链
Go 1.13 后错误跨层传递应使用 %w 建立包装链,并用 errors.Is 判定哨兵错误、errors.As 提取自定义类型错误;用 %v 会断链,断链应作设计决策,哨兵值和 Unwrap 是识别基础。
Go 错误处理的现代规范只有三条:包装用 %w、判定用 errors.Is、取类型用 errors.As,其余写法(== 比较、strings.Contains(err.Error(), ...)、%v 包装)在 Go 1.13 之后都属于要淘汰的旧习惯。结论:只要你的错误需要跨函数、跨层传递并被上层识别,就必须走"包装链 + Is/As"这一套。
一、先定义可识别的错误:哨兵值或自定义类型
结论:能被 errors.Is 识别的错误,必须在包级别用 var 声明哨兵值,能被 errors.As 提取的信息,必须封装成实现了 error 的结构体。
// 哨兵错误:只表达"哪一类失败"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
// 自定义类型:需要携带字段(SQL、记录 ID、重试次数)时用
type QueryError struct {
SQL string
Err error
}
func (e *QueryError) Error() string { return "query failed: " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err } // 关键:暴露下层错误
注意 Unwrap() 是包装链的接口。没有它,errors.Is/As 走到这一层就断了,链上更内层的错误永远查不到。
二、包装:%w 是链,%v 是断链
结论:fmt.Errorf 只有用 %w 才会建立包装链,用 %v/%s 只是把错误文本拼进消息,上层再也无法识别原始错误。
// 正确:保留链,附带上下文
return fmt.Errorf("load user %d: %w", id, err)
// 错误:链断了,errors.Is(err, ErrNotFound) 会返回 false
return fmt.Errorf("load user %d: %v", id, err)
这是最常见的隐性 bug:日志里错误信息看着"信息很全",但调用方的 errors.Is 全部失效。一个实用判据——**如果你希望上层能判定它,就 %w;如果你刻意不想暴露内部错误类型(例如防止调用方依赖实现细节),才用 %v 主动断链。** 断链应该是设计决策,不是疏忽。
Go 1.20 起 fmt.Errorf 支持多个 %w,但常规工程规范仍建议一条链只包一个 %w,需要聚合多个错误时用 errors.Join。
三、判定:errors.Is 沿链递归比较
结论:判断"是不是某类错误"一律用 errors.Is(err, ErrXxx),它会沿 Unwrap 链逐层比较,并且支持被比较方自定义 Is 方法。
if errors.Is(err, ErrNotFound) {
return c.JSON(404, nil)
}
它比 == 强在两点:一是自动穿透包装链,二是比较目标是自定义类型时可以定义等价语义:
func (e *QueryError) Is(target error) bool {
t, ok := target.(*QueryError)
return ok && t.SQL == e.SQL
}
坑点提醒:errors.Is(nil, nil) 返回 true,errors.Is(nil, ErrNotFound) 返回 false。所以永远先 if err != nil 再判定,别把判空逻辑混进 Is。
四、取类型:errors.As 沿链找第一个匹配类型
结论:当你需要取出错误里的字段(而不是只判类别)时用 errors.As,第二个参数必须是指向目标变量(或接口)的指针。
var qe *QueryError
if errors.As(err, &qe) {
log.Printf("failed sql: %s", qe.SQL)
}
规范要点:目标变量声明为指针类型 var qe *QueryError,传 &qe;如果写成 errors.As(err, qe)(没取地址)会直接 panic。想提取接口时同理,var pe interface{ Timeout() bool } 然后 errors.As(err, &pe),这是 net.Error 之外的通用写法。另外 As 命中的是链上第一个匹配类型,所以包装层不要重复包裹同类型错误。
五、分层规范:中间层只包装,边界层只记录一次
结论:日志只在请求边界(handler / main)打一次,中间层一律只做 %w 包装,否则同一次失败会在日志里出现十几条。
推荐分工:仓储层返回哨兵错误或领域类型;服务层用 %w 补业务上下文("load user 42");handler 层集中判定并映射响应:
switch {
case errors.Is(err, ErrNotFound):
return 404
case errors.Is(err, ErrConflict):
return 409
default:
log.Printf("unhandled: %+v", err) // 全链路文本在这里一次性落盘
return 500
}
如果用了 github.com/pkg/errors 的 %+v 打印堆栈,建议把这种打印也收敛到这一处,别散落在各层。
六、必须避开的四个坑
结论:这四个错误占了 Go 错误处理线上事故的绝大多数。
一是用 == 比较错误——只要中间有一次 %w 包装就会失效,改用 errors.Is。二是字符串匹配 strings.Contains(err.Error(), "not found")——文案一改、国际化一上就崩,且无法区分同文案不同语义。三是 As 的 target 不是指针导致 panic,code review 时重点看这一行。四是自定义错误忘了实现 Unwrap(),导致内层哨兵错误不可见。
另外 Go 1.20 的 errors.Join(err1, err2) 适合"多处失败都要上报"的场景(如批量校验),它同时实现了 Unwrap() []error,Is/As 同样能穿透,但别拿它替代单链包装。
一句话收尾:在包级别定义哨兵值和错误类型,跨层传递时用 %w 保持链完整,判定统一走 errors.Is,取字段统一走 errors.As,日志只在边界打一次——严格执行这五条,错误处理就不会再是靠字符串猜谜的体力活。
转载请注明出处,版权归原作者所有。
见习用户






