6.2 fmt.Errorf 与 %w 包装
6.1 定义的哨兵错误解决了「错误可判别」,但留下一个新问题:MemStore.Rename 直接返回 ErrNotFound,调用方只知道「没找到」,却不知道「哪个操作、哪个 ID 没找到」。真实排查问题时,一个只有 task not found 的错误几乎没有价值。本节给错误加上上下文,同时不破坏上层的判别能力。
本节把 TaskAPI 推进到「错误分层」:存储层返回包装后的错误,携带操作名与任务 ID,同时保留哨兵错误作为根因,让上层既能读懂上下文、又能用
errors.Is精确判别。
6.2.1 问题:上下文与判别不可兼得?
设想两种朴素写法,各有缺陷:
// 写法 A:直接返回哨兵错误,可判别但没上下文
return ErrNotFound
// 错误信息:task not found —— 不知道是哪个 ID
// 写法 B:用 %v 拼消息,有上下文但不可判别
return fmt.Errorf("rename task %d: %v", id, ErrNotFound)
// 错误信息:rename task 99: task not found —— 但 == ErrNotFound 为 false
写法 B 的致命问题:fmt.Errorf 用 %v 只是把错误的字符串拼进去,原始错误对象被丢弃。err == ErrNotFound 变成 false,errors.Is(err, ErrNotFound) 也是 false——上层再也无法程序化地识别根因。你得到了一段好看的文本,却失去了一个可用的错误。
6.2.2 %w:既包装又保留
Go 1.13 引入 %w 动词,专门解决这个矛盾:
return fmt.Errorf("rename task %d: %w", id, ErrNotFound)
%w 做了两件事:
- 把
ErrNotFound的字符串插入消息(和%v一样),所以错误文本变成rename task 99: task not found。 - 保留对原始错误的引用,使这个新错误「包装」了
ErrNotFound,可以被errors.Is/errors.As穿透识别。
区别总结成表:
| 动词 | 消息中 | 保留原因 | errors.Is 可判别 |
|---|---|---|---|
%v | 是 | 否 | 否 |
%w | 是 | 是 | 是 |
规则很简单:需要上层识别根因时用 %w,只是想把信息拼进文本时用 %v。绝大多数「向上传递」的场景都应该用 %w。
6.2.3 错误链与 Unwrap
%w 创建的包装错误,内部实现了 Unwrap() error 方法,返回被包装的原始错误。一串包装就构成错误链:
base := fmt.Errorf("db: %w", ErrNotFound)
wrapped := fmt.Errorf("rename task 99: %w", base)
从 wrapped 出发:
wrapped.Error()→rename task 99: db: task not founderrors.Unwrap(wrapped)→baseerrors.Unwrap(base)→ErrNotFounderrors.Unwrap(ErrNotFound)→nil(链的尽头)
errors.Is 会自动沿着这条链逐层 Unwrap,直到找到与目标相等的错误:
fmt.Println(errors.Is(wrapped, ErrNotFound)) // true
你不需要手动展开链——errors.Is 替你做了深度优先遍历。手动 Unwrap 只在你确实想拿到中间某一层时才有用。
6.2.4 %v 与 %w 的实测对比
前面说 %v 会丢失原因、%w 会保留,用一段实测来坐实这个结论:
sentinel := errors.New("not found")
ve := fmt.Errorf("op: %v", sentinel) // 只拼文本
we := fmt.Errorf("op: %w", sentinel) // 包装
fmt.Println(ve) // op: not found
fmt.Println(we) // op: not found
fmt.Println(errors.Is(ve, sentinel)) // false
fmt.Println(errors.Is(we, sentinel)) // true
实测输出:
op: not found
op: not found
false
true
两条错误的文本完全一样,但只有 %w 那条能被 errors.Is 识别。这是本节最重要的一课:看起来一样的错误,判别能力可能完全不同。所以选 %v 还是 %w 不是风格问题,而是功能问题——只要你希望上层能识别根因,就必须用 %w。
6.2.5 手动遍历错误链
errors.Is 会自动 Unwrap,但理解手动遍历有助于调试「错误链到底长什么样」:
for err := wrapped; err != nil; err = errors.Unwrap(err) {
fmt.Println(" ->", err)
}
对 rename task 99: db: task not found 这条链,实测输出:
-> rename task 99: db: task not found
-> db: task not found
-> task not found
每一层都是一个完整的错误,最后一层是没有被包装的哨兵错误。errors.Unwrap 返回 nil 时循环结束。这个循环在写自定义错误类型、排查「为什么 errors.Is 没匹配上」时非常有用——如果某层忘了用 %w,链会在这里断掉。
6.2.6 项目落地:给错误加上下文
把 MemStore 的错误返回值全部改成 %w 包装,携带操作名与 ID:
func (m *MemStore) Rename(id int64, title string) error {
if title == "" {
return fmt.Errorf("rename task %d: %w", id, ErrInvalidTitle)
}
t, ok := m.tasks[id]
if !ok {
return fmt.Errorf("rename task %d: %w", id, ErrNotFound)
}
t.Title = title
m.tasks[id] = t
return nil
}
实测输出:
err: rename task 1: invalid title
Is ErrInvalidTitle: true
Is ErrNotFound: false
err2: rename task 99: task not found
Is ErrNotFound: true
注意两点:
- 错误文本现在是「操作 + ID + 原因」,排查时一眼能定位。
errors.Is(err, ErrNotFound)依然为true——上下文没有破坏判别能力。
调用方从 switch err 改用 errors.Is:
err := store.Rename(99, "新")
if errors.Is(err, ErrNotFound) {
// 返回 404
} else if errors.Is(err, ErrInvalidTitle) {
// 返回 400
}
这就是「错误分层」的形态:底层产生哨兵错误,中间层逐级 %w 包装补充上下文,顶层用 errors.Is 判别根因。
6.2.7 包装的分寸
包装不是越多越好。一条规则:每层包装都应该提供上层不知道的新信息。
- 存储层:
rename task 99: ...(补上操作与 ID) - 服务层:
update task: ...(补上业务动作) - HTTP 层:通常不再包装,直接把错误映射成状态码
如果每层都机械地加 %w,错误文本会变成 handler: service: store: rename task 99: task not found 这种冗长的面包屑,可读性反而下降。判断标准是:这层知道的信息,上层是否真的需要。
另一条规则:包装时不要用大写的「Error:」或换行。Go 错误惯例是小写开头、单行、不带句号,因为错误会被层层拼接。fmt.Errorf("rename task %d: %w", ...) 就是标准形态。
6.2.8 多个 %w 与 errors.Join
有时一个操作会同时产生多个错误,比如批量创建任务时若干条失败。Go 1.20 起 fmt.Errorf 支持多个 %w,errors.Is 会匹配其中任意一个:
e1 := errors.New("e1")
e2 := errors.New("e2")
multi := fmt.Errorf("both: %w and %w", e1, e2)
fmt.Println(errors.Is(multi, e1)) // true
fmt.Println(errors.Is(multi, e2)) // true
实测两个都是 true。多 %w 适合「一个错误同时包装两个原因」的场景。
当错误数量不定时(比如一个 slice),用 errors.Join:
joined := errors.Join(e1, e2)
fmt.Println(joined) // e1\ne2(换行分隔)
fmt.Println(errors.Is(joined, e1)) // true
fmt.Println(errors.Is(joined, e2)) // true
errors.Join 返回的错误实现了 Unwrap() []error,errors.Is / errors.As 会遍历所有子错误。注意 errors.Join 会丢弃 nil,全部为 nil 时返回 nil——所以可以放心地把一批可能为 nil 的错误直接传进去。
6.2.9 包装 vs 自定义 Unwrap
%w 是最常用的包装方式,但有时你需要一个自定义错误类型,同时让它包装另一个错误。做法是实现 Unwrap() error:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return "query " + e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
这样 errors.Is(wrapped, ErrNotFound) 会穿透 QueryError 找到内层。6.3 会把这个模式和 errors.As 结合,实现「既包装、又能取回结构化字段」的错误类型。
6.2.10 小结与检查清单
- 需要上层识别根因时用
%w,只拼文本时用%v -
%w保留原因,errors.Is可穿透错误链判别 - 每层包装只补充上层不知道的新信息,避免面包屑式冗长
- 错误消息小写开头、单行、不带句号
- 多个
%w或errors.Join处理「一个错误多个原因」 - 自定义错误类型实现
Unwrap() error即可参与错误链 - 用
for err := e; err != nil; err = errors.Unwrap(err)调试错误链断点
下一节把「判别」和「提取」做完整:errors.Is 判断根因,errors.As 取回带字段的自定义错误类型,并给出 TaskAPI 里 ValidationError 的完整实现。
阅读导航:上一节:6.1 error 接口与哨兵错误 · 下一节:6.3 errors.Is/As 与自定义错误 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。