Go 错误包装与链式错误

Go 1.13+ 错误包装:%w、Unwrap、errors.Is/As、Is/As 方法约定、errors.Join 与跨层错误身份设计。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 错误包装与链式错误

这个概念为什么会出现

Go 把错误当普通值返回(见 错误处理)。这带来清晰控制流,也带来两难:

  1. 向上返回时需要上下文
    底层 sql: no rows 对 HTTP 层几乎无用;需要知道“查用户失败”“订单 ID=…”等路径信息。
  2. 调用方仍需精确判断原因
    若只把错误格式化成字符串,上层只能字符串匹配,脆弱且不可组合。
  3. 跨层既要可读,又要可编程
    人读完整链条;程序用稳定哨兵错误或类型做分支。

Go 1.13 起用 包装(wrapping) 解决:新错误携带文案上下文,同时通过 Unwrap 链保留底层错误身份。errors.Is / errors.As 沿链检查或提取。Go 1.20 再引入 errors.Join,表达“多个独立错误同时发生”。

[!abstract] 一句话理解 用 fmt.Errorf("…: %w", err) 建立可遍历的错误链;用 errors.Is 匹配哨兵/相等语义,用 errors.As 提取类型化错误;%v 只拼字符串,不保留身份。

最小可运行示例

先把示例放进跨层调用场景,再看代码:

场景:下单前校验“用户是否存在”

一次下单请求会穿过多层:

  1. repo:只知道“库里有没有这个 user_id”
  2. service:知道“我在为订单预检用户”
  3. handler:要把 ErrNotFound 映射成 404,其它错误映射成 500

若每一层只 fmt.Errorf("%v", err) 拼字符串,上层就只能做脆弱的字符串匹配。
正确做法:每层用 %w 包一层本层在做什么,根因身份仍可被 errors.Is 认出。

package main

import (
	"errors"
	"fmt"
)

// ErrNotFound:领域哨兵,表示“资源不存在”。
// 稳定身份供 errors.Is 使用;文案本身可以很短。
var ErrNotFound = errors.New("not found")

// repoFindUser 模拟数据访问层。
// 业务意图:只回答“这个 id 在不在库里”。
// 教学点:最底层可以直接返回哨兵(或驱动错误),由上层决定如何包装。
func repoFindUser(id int) error {
	if id != 1 {
		// 简化:只有 id=1 存在。真实项目可能是 sql.ErrNoRows 等。
		return ErrNotFound
	}
	return nil
}

// servicePrepareOrder 模拟应用服务层。
// 业务意图:下单预检——用户必须存在。
// 教学点:%w 包上“本层动作 + 关键参数”,不丢掉底层 ErrNotFound。
func servicePrepareOrder(userID int) error {
	if err := repoFindUser(userID); err != nil {
		// 文案写本层在做什么,而不是复读 "not found"。
		return fmt.Errorf("prepare order for user %d: %w", userID, err)
	}
	return nil
}

// handlerCreateOrder 模拟 HTTP 边界。
// 业务意图:把错误链映射成对外状态。
func handlerCreateOrder(userID int) {
	err := servicePrepareOrder(userID)
	if err == nil {
		fmt.Println("HTTP 201 created for user", userID)
		return
	}

	// 人读:完整链条,方便排障。
	fmt.Println("error text:", err)

	// 程序读:沿链识别哨兵,决定 404 还是 500。
	if errors.Is(err, ErrNotFound) {
		fmt.Println("HTTP 404: user missing (matched via chain)")
		return
	}
	fmt.Println("HTTP 500: unexpected")
}

func main() {
	// --- 正确包装:%w 建链 ---
	// 用户 42 不存在 → 文案有 service 上下文,Is 仍能命中 ErrNotFound。
	handlerCreateOrder(42)

	// 用户 1 存在 → 成功路径。
	handlerCreateOrder(1)

	// --- 对比:%v 只拼字符串,不建链 ---
	// 看起来文案相似,但 errors.Is 会失败,上层无法稳定分支。
	loose := fmt.Errorf("prepare order for user 42: %v", ErrNotFound)
	fmt.Println("loose text:", loose)
	fmt.Println("loose Is NotFound?", errors.Is(loose, ErrNotFound)) // false
}

建议运行:

go run .

期望输出:

error text: prepare order for user 42: not found
HTTP 404: user missing (matched via chain)
HTTP 201 created for user 1
loose text: prepare order for user 42: not found
loose Is NotFound? false

结合场景再看三个关注点

  1. %w 建链,%v 只留文案
    两段字符串可能长得很像,但只有 %w 能让 errors.Is 穿过 service 层认出 ErrNotFound

  2. 每层只叙述本层动作
    repo 说“not found”;service 说“prepare order for user 42”;不要在每层重复粘贴底层原文。

  3. 身份给程序,文案给人
    handler 用 errors.Is 做 404/500;日志打印整条 err 给人看。

核心概念与准确模型

error 接口与哨兵

type error interface {
	Error() string
}

常见构造:

err1 := errors.New("boom")
err2 := fmt.Errorf("code=%d", 404)
var ErrX = errors.New("x") // 包级哨兵,供 Is 比较

哨兵错误应是包内稳定身份(通常 var ErrXxx = errors.New(...)),调用方用 errors.Is 判断,不要比较 Error() 字符串。

包装:%wUnwrap

err := fmt.Errorf("open config: %w", original)

语义要点(官方 Go 1.13 错误模型):

  1. 新错误的 Error() 字符串包含格式化结果。
  2. 新错误可通过 Unwrap() error 返回被包装错误(由 fmt.Errorf%w 生成的实现提供)。
  3. 一次格式化中通常只应使用一个 %w(多 %w 的行为/意图易混乱;多错误用 errors.Join)。
  4. 自定义类型可自行实现:
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:沿链匹配

func Is(err, target error) bool

规则直觉:

  1. err == nil,仅当 target == nil 时为 true(实际调用中 target 几乎不为 nil)。
  2. 否则沿 Unwrap 链(以及 Go 1.20+ 多错误展开)检查是否与 target 匹配。
  3. 默认匹配类似可比较值的相等;若某层错误实现了 Is(target error) bool,则调用该方法决定是否匹配。
  4. 不要err == ErrX 处理可能被包装的错误;统一 errors.Is
type temporary interface {
	Temporary() bool
}

// 自定义 Is 示例:按语义而非指针相等
type MyError struct{ code int }

func (e *MyError) Error() string { return fmt.Sprintf("code=%d", e.code) }

func (e *MyError) Is(target error) bool {
	t, ok := target.(*MyError)
	return ok && e.code == t.code
}

errors.As:沿链提取类型

func As(err error, target any) bool

规则:

  1. target 必须是非 nil 指针,指向可赋值为链中某错误的类型(接口或错误类型),否则 panic
  2. 找到第一个可赋值错误后写入 *target 并返回 true。
  3. 类型可实现 As(any) bool 自定义提取逻辑。
var pathErr *os.PathError
if errors.As(err, &pathErr) {
	fmt.Println(pathErr.Path, pathErr.Op)
}

errors.Unwrap

func Unwrap(err error) error

仅展开一层。日常优先 Is/As;手动循环 Unwrap 容易漏掉 Join 的多错误分支与 Is/As 方法约定。

errors.Join(Go 1.20+)

err := errors.Join(err1, err2, err3)

要点:

  1. 合并多个错误;nil 入参被忽略;全 nil 则返回 nil。
  2. Error() 文本通常多行拼接各错误。
  3. errors.Is/As每个被 join 的错误都可被匹配/提取。
  4. 多错误展开通过 Unwrap() []error(与单错误 Unwrap() error 不同)。
  5. 适用:关闭多个资源、批量校验、多阶段清理中收集全部失败。

%w vs %v vs Join

写法人读上下文可编程身份典型用途
%w单链保留跨层附加上下文
%v / %s丢失刻意切断身份、或非 error 值
errors.Join多错误均可匹配同时发生的多个失败
直接 return err无新文案完全保留本层无额外信息可加

包装与 API 表面

包装会把底层错误身份暴露为可观测契约

  • 若上层可用 errors.Is(err, sql.ErrNoRows),则 DAO 换实现可能破坏调用方。
  • 稳定对外 API 应在边界转换为本包哨兵或类型,而不是永远透传驱动错误。
var ErrUserNotFound = errors.New("user not found")

func (s *Service) User(id string) (*User, error) {
	u, err := s.repo.Find(id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("%w: %s", ErrUserNotFound, id)
	}
	if err != nil {
		return nil, fmt.Errorf("User(%s): %w", id, err) // 内部错误可保留链
	}
	return u, nil
}

设计动机

  1. 显式错误值 + 可组合上下文
    不引入异常栈隐式传播,但仍能保留路径。
  2. 检查协议化
    Is/As/Unwrap 成为标准库与生态的共同约定。
  3. 区分“装饰”与“替换”
    装饰用 %w;替换身份用新哨兵或 %v 切断。
  4. 多错误现实
    Join 承认“清理阶段多个失败”等真实场景。

边界情况与反直觉行为

1. %v 看起来像包装,其实不是

err := fmt.Errorf("load: %v", ErrNotFound)
errors.Is(err, ErrNotFound) // false

2. errors.As 参数形态错误会 panic

var e error
// errors.As(err, e)   // panic:不是非 nil 指针
// errors.As(err, nil) // panic
var pe *os.PathError
errors.As(err, &pe) // 正确

3. 指针 vs 值接收者的错误类型

As 的目标类型必须与链中动态类型可赋值匹配。常见模式是 *MyError;若链中是值类型 MyError,目标要匹配该形态。

4. Join 与单 %w 语义不同

  • %w:一条因果链(“因为 A 所以 B”)。
  • Join:并列失败(“A 和 B 都出错”)。
    不要用多层 %w 假装并列。

5. 包装过深与重复文案

每层都写 failed to …: failed to … 会噪声爆炸。约定:

  • 只在跨越有意义边界时包装(仓储 → 服务 → 传输)。
  • 文案写操作与关键输入,不重复底层整句。

6. 比较接口值的陷阱

两个 errors.New("x") 不相等。必须共享同一哨兵变量,或实现自定义 Is

7. 与 panic 的关系

包装是 error 值技术;不可恢复的不变量破坏仍用 panic(见 defer / panic / recover)。不要用错误包装替代 panic 边界设计。

常见误区

[!warning] 常见误区:用字符串判断错误 错误:strings.Contains(err.Error(), "not found")
正确:哨兵 + errors.Is,或类型 + errors.As

[!warning] 常见误区:一律 %v 或一律裸传 错误:要么丢身份,要么上层只看到驱动原文。
正确:本层有上下文用 %w;对外稳定契约用转换。

[!warning] 常见误区:err == ErrX 错误:包装后失败。
正确:errors.Is(err, ErrX)

[!warning] 常见误区:把所有底层错误都变成 API 契约 错误:HTTP 层依赖 *pq.Error 细节。
正确:边界映射为本域错误;日志/追踪保留完整链。

[!warning] 常见误区:自定义错误只写 Error() 不写 Unwrap 错误:链在自定义类型处断开。
正确:持有 Err error 字段时实现 Unwrap(及必要的 Is/As)。

与相邻概念对比

概念差异
基础错误处理关注 if err != nil 控制流;包装关注身份与上下文
panic/recover异常展开与边界恢复;非常规业务失败路径
日志记录错误;包装决定返回给调用方的可编程结构
多返回值错误常作最后返回值;包装不改变这一惯例

工程实践

  1. 包级哨兵
    var ErrNotFound = errors.New("…"),导出需慎重(成为 API)。

  2. 包装格式
    推荐:fmt.Errorf("OpName(arg=%v): %w", arg, err),便于检索。

  3. 何时不包装
    已是本包错误且无新信息 → 直接返回。

  4. 何时切断
    安全/抽象边界:映射为稳定错误,必要时 %v 或新错误 + 日志记录原链。

  5. 库作者

    • 文档写明哪些错误可 Is/As
    • 避免每个版本变换错误身份。
  6. 服务作者

    • 中间件/handler 用 Is/As 映射 HTTP 状态。
    • 日志打印 %+v 或结构化字段时保留链(按项目日志库能力)。
  7. 测试

    if !errors.Is(err, ErrNotFound) { t.Fatalf(...) }
    var qe *QueryError
    if !errors.As(err, &qe) { t.Fatalf(...) }
  8. Go 版本

    • 1.13:%w / Is / As
    • 1.20:Join、多错误 Unwrap
      模块 go 行决定可用语法与标准库行为假设。

可验证实验

实验 1:%w vs %v

对同一底层哨兵分别用 %w/%v 包装,打印 errors.Is

实验 2:多层链

a 包装 b 包装 ErrX,确认 Is 仍为 true,并手动 Unwrap 两层观察。

实验 3:As 提取

返回 *os.PathError(如 os.Open 不存在路径),经 %wAs 取出 Path

实验 4:Join

errors.Join(ErrA, ErrB) 上分别 Is 两者;再与单链 %w 对比心智模型。

实验 5:自定义 Unwrap

实现 QueryError,确认无 UnwrapIs 失败,补上后成功。

本节总结

  • 本质:包装 = 上下文文案 + 可遍历错误身份链。
  • 关键 API%wUnwraperrors.Iserrors.Aserrors.Join
  • 最易错%v 当包装、== 代替 IsAs 参数形态、对外泄漏底层错误契约。
  • 下一步defer / panic / recover 分清错误与异常边界;在服务层用 Is/As 映射状态码。

自测题

概念题

  1. 为什么 fmt.Errorf("x: %v", ErrX)errors.Is 通常为 false?
  2. errors.Iserrors.As 分别解决什么问题?
  3. errors.Join%w 在语义上有何不同?

代码推理题

var ErrA = errors.New("a")

type wrap struct{ err error }

func (w *wrap) Error() string { return "w: " + w.err.Error() }

func main() {
	e := error(&wrap{err: ErrA})
	fmt.Println(errors.Is(e, ErrA))
}

输出是什么?如何最小改动使 Is 为 true?

工程思考题

公共库 OpenConfig 内部调用 os.Open。是否应让调用方直接 errors.As*os.PathError?如何设计更稳?

参考答案

展开
  1. %v 只把错误当字符串格式化进新错误,不提供 Unwrap 链,身份断开。
  2. Is:是否匹配某哨兵/相等语义;As:是否存在某类型并提取出来。
  3. %w 表达单链因果包装;Join 表达多个独立错误并存且均可被匹配。
    代码题:false(wrap 未实现 Unwrap)。为 *wrap 添加 func (w *wrap) Unwrap() error { return w.err }
    工程题:不宜把 *os.PathError 当稳定公共契约;可定义 ErrConfigNotFound 等并在边界映射,同时日志记录原错误;若确需细节,文档明确并视为 API 的一部分。

延伸阅读与资料来源

资料类型支撑
Working with Errors in Go 1.13官方博客%w、Is、As 设计
Package errors标准库文档Is/As/Join/Unwrap 契约
fmt.Errorf标准库文档%w 动词
Go 1.20 Release Notes — errors发行说明errors.Join
Error handling and Go官方博客早期错误理念(包装前史)

笔记元信息

  • 建议文件名:go-error-wrapping.md
  • 所属阶段:阶段二(错误与控制边界)
  • 学习顺序:在 错误处理 之后
  • 建议下一篇:defer / panic / recover
  • 本篇状态:已深化(对齐 Is/As/Join 官方约定与 API 边界)
创建于 2026/6/25 更新于 2026/7/15