错误包装

Go 错误包装用上下文串起失败原因,同时保留 errors.Is 和 errors.As 可检查的错误契约。

难度 进阶 时长 标准深度约 11分钟
版本 Go 1.27
what

错误包装(error wrapping)在保留原始错误的同时添加操作上下文,让消息适合排查,也让程序仍能识别底层原因。

trap

%v 代替 %w、直接比较最外层错误,或无意中包装依赖库错误,都会破坏或扩大调用方依赖的契约。

fix

先决定调用方应识别什么,再用 %w 建立错误树,并通过 errors.Iserrors.As 和边界测试验证该契约。

是什么,为什么存在

错误包装(error wrapping) 是把一个错误放入另一个错误中,同时为当前操作添加上下文。 格式化后的消息可以说明哪项操作失败,而被包装的值仍保留自己的身份、类型和字段。

如果每一层只原样返回错误,最终日志可能只剩下 permission deniedrecord not found,无法指出失败发生在哪次业务操作中。 如果每一层都创建全新的文本错误,消息虽然变长,程序却无法再可靠地判断原始原因。 包装把这两个需求放在同一条路径上:文本供人阅读,结构供程序检查。

Go 代码在文件访问、数据库适配器、HTTP 客户端、解析器和服务边界中经常包装错误。 当一层知道当前操作,下一层知道更具体的失败原因时,就适合增加一层上下文。 没有新信息可补充时,直接返回错误通常更清楚。

错误结构也是 API 契约。 公开函数用 %w 包装某个值后,调用方就可能通过 errors.Iserrors.As 依赖它。 因此,选择 %w 不是单纯的格式化决定;它决定了哪些原因会穿过包边界。

调用方需要适合公开的结构
只需知道成功或失败非 nil 错误
识别稳定的失败类别 哨兵错误(sentinel error) errors.Is
读取路径、字段或状态码自定义错误类型与 errors.As
同时识别多个独立失败多错误包装与 errors.Join

工作原理

error 接口只有 Error() string 方法,但包装协议还约定了 Unwrap() errorUnwrap() []error 两种方法。 实现前者的值包装一个子错误,实现后者的值包装零个或多个子错误。 连续展开后得到的结构称为 错误链(error chain) ;存在多子节点时,它实际上是一棵树。

fmt.Errorf("load profile: %w", err) 会返回一个错误,其 Error 消息包含 err,其 Unwrap 方法则返回 err。 如果格式字符串含有多个 %w,结果会通过 Unwrap() []error 按参数出现顺序公开所有操作数。 %w 在可见文本上的格式与 %v 相同,差别只存在于可遍历结构中。

errors.Is(err, target) 会以前序深度优先方式检查错误树。 它先检查当前错误是否等于目标,或当前错误的 Is(error) bool 方法是否报告浅层匹配,然后再访问子错误。 经过多层 %w 包装后,稳定的哨兵错误仍然可以被找到。

errors.As(err, &target) 使用同样的遍历顺序寻找可赋给目标类型的错误。 调用方先声明目标槽位,再把该槽位的指针传给 As;匹配成功时,As 会写入找到的错误。 它适合读取自定义错误公开的结构化字段,而不是解析 Error() 返回的字符串。

errors.Join 会丢弃 nil 输入,并把剩余错误放在同一父节点下。 所有输入都是 nil 时,它返回 nil。 errors.Iserrors.As 会访问每个非 nil 子错误,但 errors.Unwrap 只识别 Unwrap() error,不会替你枚举合并节点。

一次典型的传播过程是:

  1. 底层操作返回具体错误或哨兵错误。
  2. 中间层用 %w 添加只有这一层知道的操作上下文。
  3. 边界层用 errors.Iserrors.As 分类失败。
  4. 拥有处理结果的边界记录一次日志、返回状态或决定重试。

这套机制不会自动定义错误契约。 包作者仍要决定保留底层原因、转换为领域错误,还是把原因隐藏在包内部。 调用方只能依赖文档与测试明确承诺的匹配行为。

示例

包装并匹配哨兵错误

第一层说明查找了哪个订单,第二层说明当时正在加载收据。 两层消息都保留下来,调用方仍能识别稳定的 ErrOrderMissing 类别。

Go
package main

import (
	"errors"
	"fmt"
)

var ErrOrderMissing = errors.New("order missing")

func lookupOrder(id string) error {
	if id != "ORD-42" {
		return fmt.Errorf("lookup order %q: %w", id, ErrOrderMissing)
	}
	return nil
}

func loadReceipt(id string) error {
	if err := lookupOrder(id); err != nil {
		return fmt.Errorf("load receipt: %w", err)
	}
	return nil
}

func main() {
	err := loadReceipt("ORD-9")
	fmt.Println(err)
	fmt.Println("is missing:", errors.Is(err, ErrOrderMissing))
	fmt.Println("equal:", err == ErrOrderMissing)
}
load receipt: lookup order "ORD-9": order missing
is missing: true
equal: false

直接比较看到的是最外层包装器,所以结果为 false。 errors.Is 会继续访问两个包装层,最终找到哨兵错误。 这正是调用方想问的语义问题,而不是两个接口值是否相等。

错误消息从外到内读起来像一条操作路径。 每一层只添加自己能说明的信息,避免出现「failed to handle error」这类没有诊断价值的填充文本。

errors.As 读取字段

类别判断不够用时,自定义错误可以携带字段。 它实现 Unwrap 后,具体类型与更宽泛的哨兵类别可以同时存在于一棵错误树中。

Go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var ErrInvalidField = errors.New("invalid field")

type FieldError struct {
	Field string
	Value string
	Err   error
}

func (e *FieldError) Error() string {
	return fmt.Sprintf("%s=%q: %v", e.Field, e.Value, e.Err)
}

func (e *FieldError) Unwrap() error { return e.Err }

func validateEmail(email string) error {
	if !strings.Contains(email, "@") {
		return &FieldError{"email", email, ErrInvalidField}
	}
	return nil
}

func main() {
	err := fmt.Errorf("create account: %w", validateEmail("alex.example.com"))
	var fieldErr *FieldError

	fmt.Println("invalid:", errors.Is(err, ErrInvalidField))
	if errors.As(err, &fieldErr) {
		fmt.Printf("field=%s value=%s\n", fieldErr.Field, fieldErr.Value)
	}
}
invalid: true
field=email value=alex.example.com

errors.Is 负责回答「是否属于无效字段」,errors.As 则取得 *FieldError 的字段。 两种检查关注不同的契约,因此可以在同一个调用位置组合使用。

这里的 Error 方法用 %v 格式化自身保存的原因是正确的。 建立包装关系的是 Unwrap 方法;在 Error() 内再次使用 %w 没有意义,因为 %w 只对 fmt.Errorf 的返回值建立结构。

定义浅层的自定义匹配

有些包希望用稳定代码分类错误,同时让每个实例保留不同的操作和原因。 自定义 Is 方法可以把模板错误映射到这种类别,但它只应比较当前接收者和目标。

Go
package main

import (
	"errors"
	"fmt"
)

var ErrRowAbsent = errors.New("row absent")

type CodeError struct {
	Code string
	Op   string
	Err  error
}

func (e *CodeError) Error() string {
	return fmt.Sprintf("%s [%s]: %v", e.Op, e.Code, e.Err)
}

func (e *CodeError) Unwrap() error { return e.Err }

func (e *CodeError) Is(target error) bool {
	want, ok := target.(*CodeError)
	return ok && e.Code == want.Code
}

var ErrNotFound = &CodeError{Code: "NOT_FOUND"}

func main() {
	err := &CodeError{
		Code: "NOT_FOUND",
		Op:   `find user "U-9"`,
		Err:  ErrRowAbsent,
	}

	fmt.Println(err)
	fmt.Println("category:", errors.Is(err, ErrNotFound))
	fmt.Println("cause:", errors.Is(err, ErrRowAbsent))
}
find user "U-9" [NOT_FOUND]: row absent
category: true
cause: true

第一次匹配由 CodeError.Is 根据代码完成。 第二次匹配不需要 Is 方法递归搜索;标准库在检查当前节点后会调用 Unwrap,再找到 ErrRowAbsent

这种模板匹配会扩大公开契约。 一旦调用方依赖 NOT_FOUND 的匹配语义,修改代码值或 Is 规则就可能成为破坏性变更。

合并多个错误分支

验证器可以一次返回多个独立失败。 errors.Join 保留每个分支的包装上下文,调用方无需解析换行后的消息。

Go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var (
	ErrNameRequired = errors.New("name required")
	ErrEmailInvalid = errors.New("email invalid")
)

func validateProfile(name, email string) error {
	var failures []error
	if strings.TrimSpace(name) == "" {
		failures = append(failures,
			fmt.Errorf("name: %w", ErrNameRequired))
	}
	if !strings.Contains(email, "@") {
		failures = append(failures,
			fmt.Errorf("email %q: %w", email, ErrEmailInvalid))
	}
	return errors.Join(failures...)
}

func main() {
	err := validateProfile("", "alex.example.com")
	if err == nil {
		fmt.Println("valid")
		return
	}

	fmt.Println(err)
	fmt.Println("name:", errors.Is(err, ErrNameRequired))
	fmt.Println("email:", errors.Is(err, ErrEmailInvalid))
	fmt.Println("single unwrap is nil:", errors.Unwrap(err) == nil)
}
name: name required
email "alex.example.com": email invalid
name: true
email: true
single unwrap is nil: true

合并值的文本把两个子错误放在不同行,但格式不是分类 API。 两个 errors.Is 调用分别沿对应分支找到目标。

最后一行展示了一个容易误判的细节:errors.Unwrap 不处理 Unwrap() []error。 需要分类时直接使用 IsAs;需要枚举整棵树时,诊断代码必须明确处理两种 Unwrap 签名。

陷阱

%v 截断错误树

修复方法: 如果底层原因属于公开契约,就改用 %w,并添加经过至少一层包装的测试。 如果原因属于实现细节,则有意转换为领域错误,不要让 %v 假装成仍可遍历的包装。

只检查最外层值

修复方法: 对稳定值使用 errors.Is,对结构化类型使用 errors.As。 只有明确需要判断最外层身份时才直接比较,而且测试应说明这种限制。

包装 nil 错误

修复方法: 在包装前写出 if err != nil 分支,成功路径直接返回 nil。 特别检查短函数末尾的 return fmt.Errorf("operation: %w", err):缺少前置判断时,操作成功也会返回非 nil 错误。

errors.As 错误的目标

修复方法: 先声明 var target *PathError,再调用 errors.As(err, &target)。 运行 go vet,并用包含该类型和不包含该类型的错误树覆盖这条分支。

无意公开依赖错误

修复方法: 在包边界决定调用方是否应该依赖该原因。 如果不应该,就转换为自己的哨兵错误或类型;如果应该,就写入文档,并用包边界测试固定行为。

把错误链当作线性列表

修复方法: 分类时让 errors.Iserrors.As 完成标准遍历。 只有诊断工具确实需要访问所有节点时才自行遍历,并分别处理单子节点和多子节点接口。

深入 错误树的遍历

错误树的遍历

标准库把根错误本身也视为树的一部分。 errors.Iserrors.As 先检查当前节点,再以前序深度优先顺序访问每个子节点。 因此,最外层自定义匹配或可赋值类型会先于更深层的同类节点生效。

单个 %w 通常产生实现 Unwrap() error 的包装器。 多个 %w 产生实现 Unwrap() []error 的包装器,子节点顺序与操作数顺序相同。 errors.Join 也使用多子节点形式,并忽略传入的 nil 错误。

如果多个分支都含有可赋给同一目标的类型,errors.As 只写入遍历时遇到的第一个。 不要把这个顺序当成面向用户的优先级模型。 需要展示有序验证结果时,应另外保留有序数据,而不是从错误树反推界面顺序。

errors.Unwrap 是为单子节点便利函数设计的。 它只调用 Unwrap() error,遇到多子节点包装器时返回 nil。 这不是数据丢失;子节点仍然存在,IsAs 仍会遍历它们。

自定义 Unwrap() []error 不应返回含 nil 的切片。 包装器还应形成有限结构;让节点直接或间接解包到自身,会使遍历无法正常结束。 除非你在编写聚合库,否则优先使用 fmt.Errorferrors.Join 提供的实现。

自定义包装器的契约

自定义错误通常在需要结构化字段时才值得创建。 Error() 负责可读消息,Unwrap 公开原因,导出的字段或方法则公开调用方可以读取的数据。 三个部分应描述同一个失败,而不是维护互相矛盾的副本。

实现 Is(target error) bool 可以提供超越接口相等性的匹配。 该方法应只浅层比较接收者与目标,不应调用 Unwrap 或递归调用 errors.Is。 标准库会负责遍历子树;在自定义方法中再次遍历会重复工作,并让复杂树的成本迅速增加。

自定义 Is 还要避免意外放宽匹配。 常见做法是把目标值中的零值字段解释为通配符,但这必须有文档说明。 匹配规则一旦公开,调用方就可能在控制流中依赖它。

实现 As(target any) bool 的门槛更高,因为方法需要验证目标形状并完成赋值。 大多数自定义错误无需实现它;正常的类型可赋值检查已经能处理指针错误和接口。 只有包装器需要呈现不同抽象类型,而且该行为能形成稳定契约时,才考虑自定义 As

一个错误可以同时提供类别、详情和原因。 例如,FieldError 的具体类型提供字段,ErrInvalidField 提供稳定类别,底层原因还可以提供更具体诊断。 公开多少层取决于调用方需要作出的决定,而不是可以包装多少层。

边界转换可能有意结束一条链。 如果存储层细节不应离开包,服务层可以返回自己的哨兵错误,并把原始错误交给内部遥测边界。 不要通过解析原始消息来复制细节,也不要在每一层记录后继续返回同一个错误。

错误契约的测试

测试应从调用方看到的包边界开始,而不是只测试创建哨兵错误的底层函数。 这样,某一层把 %w 改成 %v 时,测试才会失败。 至少经过一层真实包装,再断言 errors.Iserrors.As 的结果。

对于哨兵错误,断言 errors.Is(got, want)。 对于自定义类型,先用 errors.As 取得目标,再检查文档承诺的字段。 不要比较整个结构体,因为未公开的诊断字段以后可能合理变化。

失败测试也应包含不匹配目标。 过于宽松的自定义 Is 可能让所有同类型错误都匹配,而只有正向用例无法发现这个问题。 多错误测试则应分别检查每个预期分支,并验证无失败输入时返回真正的 nil。

成功路径必须单独测试。 无条件调用 fmt.Errorf,或返回保存 nil 指针的 error 接口,都会产生非 nil 错误。 先断言 err == nil,再使用结果,可以更快暴露这类生成代码缺陷。

一组边界测试至少覆盖:

  • 一层和多层 %w 包装后仍能匹配的目标。
  • 使用 %v 或有意转换后不应再匹配的内部原因。
  • errors.As 成功时得到的具体类型和公开字段。
  • errors.Join 中的每个分支,以及全部输入为 nil 的情况。

错误消息只有在本身属于公开输出时才做精确比较。 命令行诊断可以使用 golden test,库的分类测试则应依赖 IsAs 和公开字段。 这样既保留重写消息的自由,也固定真正的程序契约。

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

下一篇 错误处理 接口 Testing 即将上线 Context 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?