⭐ 推荐:社区规则条款 V1.0

Go 错误处理规范:errors.Is、errors.As 与包装链

zero
zero 见习用户见习用户
发布于 2026-07-31 07:37 ·76 浏览 ·0 回复
内容摘要

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,日志只在边界打一次——严格执行这五条,错误处理就不会再是靠字符串猜谜的体力活。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-483.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~