登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

错误链

来源:17golang原创

时间:2026-11-11 17:02:38 161浏览 收藏

Go 的错误链解决的是一个很具体的问题:函数需要给错误增加上下文,但上层仍要知道底层到底是“未找到”、权限失败,还是某个自定义类型。最小规则是:用 fmt.Errorf 的 %w 包装错误,用 errors.Is 判断固定错误,用 errors.As 提取类型。如果只是用 %v 拼进错误文本,日志看起来一样,程序却无法继续沿链判断。

官方参考:https://pkg.go.dev/errors

官方说明:https://go.dev/blog/go1.13-errors

速查
  • %w 保留被包装错误,形成可被遍历的错误链;%v 只保留文本。
  • errors.Is(err, target) 适合判断哨兵错误,不能用字符串相等替代。
  • errors.As(err, &target) 适合取回自定义错误类型,目标变量要传地址。
  • 是否用 %w 也是 API 设计决定:包装后,底层错误可能成为对调用方可见的契约。

先看触发信号:错误信息还在,类型判断却失效

值班时常见的现象是日志已经带上了足够的上下文,例如“读取用户配置失败:权限不足”,但代码中的 errors.Is 返回了 false,或者类型断言拿不到自定义错误。先不要改字符串比较,先沿着错误的创建位置检查包装动词。

下面两种写法打印出来都可能包含相同的错误信息,但它们对调用方暴露的关系不同:

package main

import (
    "errors"
    "fmt"
)

var ErrPermission = errors.New("permission denied")

func withTextOnly(err error) error {
    // %v 只把底层错误格式化进文本,不建立 Unwrap 关系。
    return fmt.Errorf("读取配置失败: %v", err)
}

func withChain(err error) error {
    // %w 保留底层错误,调用方可以继续用 errors.Is 判断。
    return fmt.Errorf("读取配置失败: %w", err)
}

func main() {
    textErr := withTextOnly(ErrPermission)
    chainErr := withChain(ErrPermission)

    // 两个错误都能打印出上下文,但只有 chainErr 仍可匹配哨兵错误。
    fmt.Println(errors.Is(textErr, ErrPermission))  // false
    fmt.Println(errors.Is(chainErr, ErrPermission)) // true
}

快速判断可以记成一句话:%v 解决“人怎么看”,%w 同时解决“程序怎么认”。如果某个上层分支必须按底层错误采取不同动作,包装处就不能只追求可读文本。

Go 中 fmt.Errorf 使用百分号 v 和百分号 w 时错误链可判断边界的对比说明图
图1:边界说明图,对比 %v 与 %w 对程序化错误判断的影响,不是运行证据。

用 %w 建立可继续判断的错误链

错误链的关键不是错误消息里出现了多少个冒号,而是每一层是否提供了 Unwrap() error 关系。标准库的 fmt.Errorf 在使用 %w 时会返回一个可以解包的错误;自定义错误类型也可以通过 Unwrap 把内部错误交给后续判断。

package main

import (
    "errors"
    "fmt"
)

var ErrPermission = errors.New("permission denied")

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    // Error 负责提供可读信息,不改变内部错误的身份。
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    // Unwrap 把下一层错误暴露给 errors.Is 和 errors.As。
    return e.Err
}

func loadUser() error {
    // 先保存底层哨兵错误,再附加当前函数的业务上下文。
    return &QueryError{Query: "select user", Err: ErrPermission}
}

func serviceCall() error {
    err := loadUser()
    if err != nil {
        // %w 继续保留 QueryError 以及它内部的 ErrPermission。
        return fmt.Errorf("用户服务调用失败: %w", err)
    }
    return nil
}

这里的链可以理解成“服务层包装 -> QueryError -> ErrPermission”。上层不需要手写循环调用 Unwrap;errors.Is 和 errors.As 会沿着这条关系检查。

Go 错误链中底层错误、百分号 w 包装、Unwrap、errors.Is 和 errors.As 的静态结构说明图
图2:结构说明图,展示多层包装仍保留原始错误关系,不是运行截图。

固定错误用 errors.Is 判断,不要比较最终文本

哨兵错误适合代表一个稳定的程序分支,例如资源不存在、权限拒绝或输入结束。判断时把当前错误放在第一个参数,把目标哨兵放在第二个参数:

func isPermissionFailure(err error) bool {
    // Is 会检查当前错误以及它通过 Unwrap 连接的每一层。
    return errors.Is(err, ErrPermission)
}

func handle(err error) string {
    if err == nil {
        // nil 表示没有错误,先返回成功路径。
        return "ok"
    }
    if errors.Is(err, ErrPermission) {
        // 匹配成功后可以进入固定的权限处理分支。
        return "请检查访问权限"
    }
    // 未识别的错误继续向上返回,保留原始上下文。
    return "稍后重试"
}

不要写成 err.Error() == "permission denied"。一旦中间层增加了文件名、用户 ID 或操作名,文本就会变化;而错误链判断关注的是值或类型关系。也不要把 errors.Unwrap 当成主要业务判断工具,它只取一层,直接用 errors.Is 更能表达“链上是否存在该错误”。

自定义错误用 errors.As 取回结构化信息

如果上层需要读取 QueryError.Query 这样的字段,就用 errors.As。它不是把错误转换成字符串,而是在错误链中寻找目标类型,并把找到的值写入目标变量。

func queryName(err error) string {
    var queryErr *QueryError
    if errors.As(err, &queryErr) {
        // As 成功后,queryErr 指向链中匹配到的 QueryError。
        return queryErr.Query
    }
    // 没有该类型时不要假设字段存在,回到通用错误处理。
    return "unknown query"
}

func classify(err error) string {
    var queryErr *QueryError
    if !errors.As(err, &queryErr) {
        // 目标类型不存在时,保留统一的未知类别。
        return "other"
    }
    if errors.Is(err, ErrPermission) {
        // 同一个错误可以同时拥有类型信息和哨兵语义。
        return "query-permission"
    }
    return "query-other"
}

传参时容易写错的是目标变量:目标是 *QueryError,因此要声明一个 *QueryError 变量,再把它的地址传给 errors.As。如果目标类型本身是值类型,则按实际类型声明,不要机械地再加一层指针。

回滚与 API 边界:包装不是越多越好

如果一次发布后发现调用方开始依赖某个底层错误,回滚时不能只把 %w 改回 %v。这会让已有的 errors.Is 或 errors.As 判断突然失效,属于行为变化。更稳妥的处理是先确认调用方是否已经把该错误当作契约,再决定是否调整错误类型或提供自己的稳定哨兵。

场景推荐做法需要注意
本包内部记录上下文使用 %w 便于内部定位检查是否把依赖错误泄露给公共 API
公共函数返回稳定业务状态暴露自有哨兵或自有类型底层库替换时保持上层判断不变
只需要日志文本可以使用 %v调用方不能再依赖该底层错误
需要字段处理定义 Unwrap 并用 errors.As目标类型要传地址

官方错误处理说明特别强调:一旦包装某个底层错误,调用方就可能把它视为 API 的一部分。因此“是否用 %w”不仅是写法选择,也是抽象边界选择。

上线前的错误链检查清单

  • 出现固定错误分支时,是否使用 errors.Is,而不是比较字符串或只比较最外层值?
  • 需要读取字段时,是否用 errors.As,并按实际类型正确传入目标地址?
  • 每个自定义包装类型是否实现了返回内部错误的 Unwrap() error?
  • 包装公共错误前,是否确认底层类型不会无意中变成长期 API 承诺?
  • 回滚包装方式时,是否把 errors.Is / errors.As 的调用方一起纳入兼容检查?

常见问题

错误信息一样,为什么 errors.Is 结果不同?

错误文本只负责展示;只有通过 %w 或自定义 Unwrap 建立关系,errors.Is 才能沿链查找目标。

errors.As 为什么要传一个指针的地址?

As 需要把匹配到的错误值写入目标变量。目标如果是 *QueryError,就要传 &queryErr,这样调用成功后变量才会指向链中的具体错误。

所有错误都应该用 %w 吗?

不应该。若不希望调用方依赖某个底层实现,可以只保留文本,或返回由本包定义的稳定错误类型;关键是先明确 API 边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>