错误包装

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

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

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

trap

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

fix

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

是什么,为什么存在

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

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

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

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

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

工作原理

error接口只有Error() string方法,但包装协议还约定了Unwrap() error和Unwrap() []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.Is和errors.As会访问每个非nil子错误,但errors.Unwrap只识别Unwrap() error,不会替你枚举合并节点。

一次典型的传播过程是:

  1. 底层操作返回具体错误或哨兵错误。
  2. 中间层用%w添加只有这一层知道的操作上下文。
  3. 边界层用errors.Is或errors.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。 需要分类时直接使用Is或As;需要枚举整棵树时,诊断代码必须明确处理两种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.Is和errors.As完成标准遍历。 只有诊断工具确实需要访问所有节点时才自行遍历,并分别处理单子节点和多子节点接口。

深入 错误树的遍历

错误树的遍历

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

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

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

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

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

自定义包装器的契约

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

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

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

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

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

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

错误契约的测试

测试应从调用方看到的包边界开始,而不是只测试创建哨兵错误的底层函数。 这样,某一层把%w改成%v时,测试才会失败。 至少经过一层真实包装,再断言errors.Is或errors.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,库的分类测试则应依赖Is、As和公开字段。 这样既保留重写消息的自由,也固定真正的程序契约。

延伸阅读

检查点

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

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