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

Go fmt.Errorf %w 链为什么被 errors.Is 找不到

来源:17golang原创

时间:2026-10-14 22:28:11 453浏览 收藏

如果你看到日志里明明有“记录不存在”,却发现 errors.Is(err, ErrNotFound) 返回了 false,最常见的原因不是 errors.Is 失效,而是包装错误时用了 %v。fmt.Errorf 中,%v 只把原错误格式化进新消息;%w 才会把原错误保留在可遍历的错误链里。

官方资料:https://pkg.go.dev/errors

需要让上层继续判断底层错误时使用 %w;只需要把错误读给人看、并不打算让调用方识别其类型时,才考虑 %v。

为什么 %v 会让 errors.Is 找不到原始错误

先定义一个业务层要识别的哨兵错误。下面两个函数看起来只差一个格式化动词,但它们返回的结构不同。

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("record not found")

func loadWithMessage() error {
    // %v 只把底层错误转成文字,新错误不会保留可展开的链路。
    return fmt.Errorf("load user: %v", ErrNotFound)
}

func loadWithChain() error {
    // %w 在增加上下文的同时保留 ErrNotFound,供 errors.Is 继续判断。
    return fmt.Errorf("load user: %w", ErrNotFound)
}

func main() {
    fmt.Println(errors.Is(loadWithMessage(), ErrNotFound)) // false
    fmt.Println(errors.Is(loadWithChain(), ErrNotFound))   // true
}

两次输出的文字都可能包含 record not found,但文字相同不代表错误对象相同。%v 生成的是新的消息错误,原来的 ErrNotFound 已经不再是它的子错误;%w 则相当于在外层消息与原始错误之间建立了关系。

Go fmt.Errorf 使用百分号 w 和百分号 v 包装错误的结构对比说明图
图1:%w 与 %v 包装结果的结构示意图,不是运行截图或运行证据。

用 errors.Is 沿错误链判断固定错误

errors.Is 适合判断“这次失败是否属于某个已知错误”。它会先检查当前错误,再沿着错误的 Unwrap 关系继续向内查找。因此,业务函数可以增加上下文,而调用方仍然能够做稳定分支。

func findUser(id int64) error {
    if id <= 0 {
        // 参数错误直接返回,调用方可以按哨兵错误处理。
        return ErrNotFound
    }

    // 这里模拟存储层没有找到记录。
    return fmt.Errorf("find user %d: %w", id, ErrNotFound)
}

func handle(id int64) error {
    err := findUser(id)
    if err == nil {
        return nil
    }

    // 不比较错误字符串,避免消息改动导致分支失效。
    if errors.Is(err, ErrNotFound) {
        return fmt.Errorf("user %d is unavailable: %w", id, err)
    }
    return err
}

这里即使 handle 再包一层,仍然可以通过 errors.Is 找到最底层的 ErrNotFound。如果把其中任意一个 %w 改成 %v,链会在那一层断开,后续调用方就只能看到一段文本。

Go errors.Is 沿多层错误链查找底层错误的结构示意图
图2:errors.Is 沿多层 error 链向内匹配的结构示意图,不是运行截图或运行证据。

排查 errors.Is 返回 false 的三个位置

遇到错误链匹配失败时,可以从返回值向下回看,而不是先怀疑目标错误的文字是否相同。

  1. 检查每一层包装动词。搜索错误传播路径中的 fmt.Errorf,凡是还需要被上层识别的底层错误,都应使用 %w 并把 error 作为对应参数传入。
  2. 检查是否比较了另一个实例。两个分别通过 errors.New("record not found") 创建的错误,文字相同但不是同一个哨兵;应在包级别复用同一个错误变量,或使用自定义类型配合 errors.As。
  3. 检查包装函数是否真的返回 error。有些日志辅助函数把错误转成字符串后再返回,调用方拿到的已不是原来的错误值,后续自然无法展开。
func wrap(err error) error {
    if err == nil {
        // nil 不应被包装成一个看似失败的新错误。
        return nil
    }
    // 这里保留原错误,让上层仍可使用 errors.Is 或 errors.As。
    return fmt.Errorf("repository operation: %w", err)
}

func inspect(err error) {
    for current := err; current != nil; current = errors.Unwrap(current) {
        // 逐层打印类型,定位到底是哪一次包装切断了链路。
        fmt.Printf("%T: %v\n", current, current)
    }
}

errors.Unwrap 只能向下取一层,适合定位链路;实际业务判断通常直接使用 errors.Is 或 errors.As,不必手写循环。

自定义错误也要提供 Unwrap

如果项目使用自己的包装类型,就不能只保存一个 Err error 字段,还要实现约定的方法。否则字段虽然存在,标准库的错误链函数并不知道应该怎样继续。

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    // 返回内部错误,errors.Is 和 errors.As 才能继续向下检查。
    return e.Err
}

func query() error {
    // 自定义类型增加查询上下文,同时保留底层原因。
    return &QueryError{Query: "select user", Err: ErrNotFound}
}

自定义类型实现了 Unwrap() error 后,errors.Is(query(), ErrNotFound) 就能匹配到底层哨兵。若要提取某种具体错误类型,可以用 errors.As,它和 errors.Is 一样依赖错误链完整存在。

什么时候可以使用 %v

%v 并不是错误动词,它仍然适合日志、一次性展示或明确不希望暴露底层错误关系的边界。例如记录一条不会再被上层判断的诊断文本时,使用 %v 可以只关注可读消息。

但在这些位置通常应该使用 %w:

  • repository、HTTP client、文件访问等底层函数向上返回错误;
  • 上层需要区分不存在、超时、权限不足等已知错误;
  • 需要用 errors.As 取得 *os.PathError 等具体类型;
  • 添加上下文后,仍希望保留原错误用于重试、告警或响应映射。

可以记成一句话:面向人读的文本用格式化,面向程序判断的错误用包装。当你不确定调用方是否需要识别底层错误时,保留链路通常更稳妥;真正对外隐藏实现细节时,再把边界设计成只返回稳定的业务错误。

相关问题

errors.Is 能比较错误字符串吗?

不能。它比较错误值、错误链中的匹配关系以及错误类型提供的匹配逻辑,不应把字符串相等当成错误身份。

fmt.Errorf 可以只写 %w 不加上下文吗?

技术上可以,但通常应增加简短上下文,让日志更容易定位来源。上下文不要替代底层错误,底层错误仍通过 %w 保留。

errors.Unwrap 为什么返回 nil?

通常表示当前错误没有实现可用的 Unwrap 方法,或者链已经走到了最底层。先检查上一层是否误用了 %v。

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