错误包装(error wrapping)在保留原始错误的同时添加操作上下文,让消息适合排查,也让程序仍能识别底层原因。
用 %v 代替 %w、直接比较最外层错误,或无意中包装依赖库错误,都会破坏或扩大调用方依赖的契约。
先决定调用方应识别什么,再用 %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,不会替你枚举合并节点。
一次典型的传播过程是:
- 底层操作返回具体错误或哨兵错误。
- 中间层用
%w添加只有这一层知道的操作上下文。 - 边界层用
errors.Is或errors.As分类失败。 - 拥有处理结果的边界记录一次日志、返回状态或决定重试。
这套机制不会自动定义错误契约。 包作者仍要决定保留底层原因、转换为领域错误,还是把原因隐藏在包内部。 调用方只能依赖文档与测试明确承诺的匹配行为。
示例
包装并匹配哨兵错误
第一层说明查找了哪个订单,第二层说明当时正在加载收据。
两层消息都保留下来,调用方仍能识别稳定的 ErrOrderMissing 类别。
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 后,具体类型与更宽泛的哨兵类别可以同时存在于一棵错误树中。
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.comerrors.Is 负责回答「是否属于无效字段」,errors.As 则取得 *FieldError 的字段。
两种检查关注不同的契约,因此可以在同一个调用位置组合使用。
这里的 Error 方法用 %v 格式化自身保存的原因是正确的。
建立包装关系的是 Unwrap 方法;在 Error() 内再次使用 %w 没有意义,因为 %w 只对 fmt.Errorf 的返回值建立结构。
定义浅层的自定义匹配
有些包希望用稳定代码分类错误,同时让每个实例保留不同的操作和原因。
自定义 Is 方法可以把模板错误映射到这种类别,但它只应比较当前接收者和目标。
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 保留每个分支的包装上下文,调用方无需解析换行后的消息。
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 道找错题