2466 字
约 8 分钟
14
S09-A17 · 错误的包装与判定:%w、errors.Is 与 unwrap 的边界
2024-12-31
2026-11-10

一段代码用 fmt.Errorf("load config: %v", err) 包装错误,然后在上层用 errors.Is(err, os.ErrNotExist) 判断——永远不成立。因为 %v 把错误变成了字符串,链断了。改成 %w 之后判断正常,但另一个地方用 err == os.ErrNotExist 仍然失败——因为包装后的错误不是那个值本身。

Go 1.13 之后,"错误是值"这件事有了新的层次:包装让错误携带上下文,同时保留了"它是什么"的能力。这个设计解决了 Go 早期的一个两难:要么在每层用同一个错误值(丢失上下文),要么每层新建错误(丢失可判定性)。包装让两者共存——上下文加在链上,判定能力保留在链里。
而能力来自两个不同的函数:errors.Is(判定链中是否有某个值)与 errors.As(提取链中某个类型的值)。


1. 三个函数的职责

函数 判断什么
errors.Is(err, target) 链中是否有 error 等于 target
errors.As(err, &t) 链中是否有 error 可赋值给 t 的类型
errors.Unwrap(err) 只解一层

第一项用于"哨兵错误"(os.ErrNotExist、sql.ErrNoRows),第二项用于"自定义错误类型"(需要读字段)。

第三项很少直接用:它只解一层,而链可能有多层——需要遍历时用 errors.Is/As。第三项还有一个正当用途:自己实现判定逻辑(比如遍历整条链收集所有错误)。此时用手写的 Unwrap 循环比 Is/As 更灵活。

// 反例:%v 断链,Is 永远不成立
if err := load(); err != nil {
    return fmt.Errorf("load config: %v", err)   // 变成字符串
}
// 上层
if errors.Is(err, os.ErrNotExist) { ... }        // 永远 false

// 正例:%w 保留链
return fmt.Errorf("load config: %w", err)

2. 什么时候用 %w、什么时候用 %v

场景 选择
上层需要判定类型/哨兵 %w
只是记录上下文,上层不判定 %v 也可
包装后需要隐藏细节(如对外) %v + 内部日志

第二项的判断有个实用规则:"这段错误的消费者会不会看它的类型"。不会(只打日志),%v 也能接受;会,就必须 %w。
第一项最常见:只要上层会 errors.Is/As,就必须 %w。而"上层会不会"这件事在写代码时往往不知道,所以默认应当用 %w。

第三项是"错误边界"的实践:对外的错误消息应当干净(%v),对内的日志保留完整链。第三项的落地方式是"同一次错误,两处输出":日志里 %+v 或完整链,响应体里只有一句用户能懂的话。而这两处应当指向同一个 request id,便于关联。
所以同一个错误在不同层可能用不同方式包装。

// 分层:内部保留链,对外输出干净
func (s *Service) Get(id string) (*Item, error) {
    it, err := s.repo.Find(id)
    if err != nil {
        return nil, fmt.Errorf("repo find %s: %w", id, err)   // 内部:保留链
    }
    return it, nil
}
// handler 层
if errors.Is(err, ErrNotFound) {
    writeJSON(w, 404, map[string]string{"error": "not found"})  // 对外:干净
}

3. 哨兵错误 vs 自定义类型

方式 优点 缺点
哨兵错误(var ErrX = errors.New(...)) 简单、可 Is 无法携带数据
自定义类型 可带字段 需要 As
两者的组合 兼顾 略繁琐

第二项还有一层"可扩展"优势:给自定义类型加字段不需要改判定代码(因为判定看类型),而给哨兵错误加信息只能靠再定义一个哨兵——后者很快会失控。
第二项在需要"错误里带 ID/字段名/重试建议"时必要:errors.As 取出结构体,读字段。

第三项的常见形态是"哨兵 + 类型":ErrNotFound 作为哨兵用于判定,而具体错误类型带数据用于展示。

type ValidationError struct {
    Field string
    Code  string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Code }

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

// 判定:
var ve *ValidationError
if errors.As(err, &ve) {
    respond(400, ve.Field, ve.Code)      // 读字段
}
if errors.Is(err, ErrNotFound) {
    respond(404, "", "")
}

4. 包装的三种反模式

反模式 后果
每层都包一次 消息变成 a: b: c: d
包装时丢掉原始错误 无法判定
用 errors.New(err.Error()) 链断,且丢了类型

第一项在多层调用里很常见:最终消息有 6 层前缀,而其中一半没有信息量。第一项的判据很实用:如果某一层的前缀在"从这层开始看日志"时没有帮助,它就是噪声。而"哪层有帮助"取决于你的日志是从哪里开始看的。
合适的做法是"在边界处包装一次,中间层直接 return err"。

第三项最糟:它看起来"保留了消息",实际丢了全部能力——Is/As 都不成立,且堆栈也丢了。

// 反例:三层都包
return fmt.Errorf("handler: %w", fmt.Errorf("service: %w", fmt.Errorf("repo: %w", err)))
// 消息:handler: service: repo: disk full —— 三层前缀,只有最后一层有用

// 正例:中间层直接返回,只在边界包装
func (s *Service) Get(id string) (*Item, error) {
    return s.repo.Find(id)          // 不包装
}
// handler 边界:
return fmt.Errorf("get item %s: %w", id, err)   // 一处包装,带上下文

还有一个"错误值"的细节容易被忽略:哨兵错误应当是不可变的全局变量(var ErrX = errors.New(...)),而不是函数返回的。否则每次返回新实例,Is 就永远不成立。

// 反例:每次返回新实例,Is 永远 false
func errNotFound() error { return errors.New("not found") }

// 正例:全局哨兵
var ErrNotFound = errors.New("not found")
// 需要类型时:定义结构体 + 一个哨兵用于 Is 判定

5. 判定的顺序

顺序 理由
先 Is 哨兵 简单、常见
再 As 类型 需要读字段时
最后取字符串 兜底展示

第一项与第二项的顺序在语义上不能反:同一个错误可能既是某哨兵又是某类型,此时应当先处理"最具体"的那个判断(通常是类型,因为它带数据)。

实践中更稳的做法是"先 As 具体类型,再 Is 哨兵,最后兜底"。

测试也要跟上:每个"上层会判定"的错误都应当有一个断言 Is 或 As 的测试。否则"包装方式被改成 %v"这类回归不会有人发现——因为它不影响编译,也不影响正常路径。

// 测试:把"可判定性"锁住
func TestRepoFind_NotFound(t *testing.T) {
    _, err := repo.Find("nope")
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf("want ErrNotFound in chain, got %v", err)
    }
}
// 若有人把 %w 改成 %v,这个测试会红

6. 排错:五种"错误判定不生效"

现象 成因 处理方向
Is 永远 false 用了 %v 改 %w
Is 有时 true 某层断了链 逐层检查包装
As 拿不到字段 用了 errors.New(err.Error()) 保留原错误
消息层级过多 每层都包 只在边界包
对外的消息含内部细节 直接 err.Error() 分层输出

第四项(消息含内部细节)在对外 API 上很常见:把 err.Error() 直接写进响应,于是"连接 172.19.0.2:3306 被拒绝"泄漏到了外部。而这类泄漏通常在上线后的安全扫描里才被发现。
第三项很隐蔽:"重新构造错误"在 code review 里看起来是"整理错误信息",实际是破坏类型。

还有一类"跨进程"的边界:错误经 HTTP/gRPC 传出去后,链就断了。所以跨服务的错误判定不能靠 Is,只能靠"错误码 + 结构化体"。这意味着"内部用链、外部用码"是必然的分工。

// 内外的分工
内部:errors.Is/As + %w 链(同进程内判定)
边界:转换为 {code, message, retryable}(跨进程只能传结构化字段)
// 反例:把 err.Error() 传给下游,下游做字符串匹配 → 一改文案就失效

7. 边界:什么情况不必包装

场景 为什么 做法
只是向上传递 上层不需要上下文 直接 return err
一次性的 main 无人判定 直接打印
已经带了上下文 再包只增噪声 不包
测试代码 不长期 随意

判断口径一句话:上层会不会需要"判断这是什么错"或"读出错误里的字段"。最后一条与"日志"有关的边界:包装链的价值 = 能否回答"失败发生在哪一步"。如果一条错误链从头到尾只有"失败"两个字,那它虽然"包了很多层",实际信息量为零——包装要带"动作",而不是带"层级名"。
会,就必须 %w 并保留类型;只是记录日志,%v 就够。


8. 自测

  1. errors.Is 与 errors.As 分别判断什么?为什么 Unwrap 很少直接用?
  2. %v 与 %w 的区别是什么?为什么 Is 会永远不成立?
  3. "哨兵错误"与"自定义类型"各自适合什么?为什么实践中常组合使用?
  4. "每层都包一次"会造成什么?为什么中间层应当直接 return err?
  5. errors.New(err.Error()) 为什么是最糟的包装方式?
  6. 判断"是否需要包装"的那个问题是什么?
S09-A17 · 错误的包装与判定:%w、errors.Is 与 unwrap 的边界
https://www.clxhxhhr.top/posts/6513/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。