Go 错误处理规范:errors.Is、errors.As 与包装链
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`,日志只在边界打一次——严格执行这五条,错误处理就不会再是靠字符串猜谜的体力活。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





