《Go 语言编程入门》6.2 fmt.Errorf 与 %w 包装

错误需要上下文,也需要保留原始原因。本节给 TaskAPI 的错误加上「操作 + ID」上下文,讲清 %w 与 %v 的差别、错误链与 Unwrap、多层包装的可读性、以及 errors.Join 与多个 %w 的用法,让错误既能读懂又能判别。

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 做了两件事:

  1. 把 ErrNotFound 的字符串插入消息(和 %v 一样),所以错误文本变成 rename task 99: task not found。
  2. 保留对原始错误的引用,使这个新错误「包装」了 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 found
  • errors.Unwrap(wrapped) → base
  • errors.Unwrap(base) → ErrNotFound
  • errors.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 与自定义错误 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练