九一八事变纪念日|1931年9月18日,日本侵略者制造九一八事变,开启了长达14年的侵华战争。警钟长鸣,吾辈自强!

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

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-18 14:32 ·2 浏览 ·0 回复

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

还没有回复,来抢沙发~