4. 错误包装与解包:fmt.Errorf + %w、errors.Is 与 errors.As、错误链的遍历
在真实业务系统中,错误很少是孤立的。一个底层数据库查询失败,可能被上层业务逻辑包装为“订单查询失败”,再被 API 层包装为“请求处理异常”。Go 1.13 引入的错误链机制,允许我们通过 fmt.Errorf 配合 %w 动词将错误层层包装,同时保留对原始错误的断言能力。本章深入讲解错误包装与解包的核心 API、遍历机制及业务实践。
4.1 错误包装:fmt.Errorf + %w
fmt.Errorf 的 %w 动词是构建错误链的基石。它创建一个新的错误,该错误内部持有对原始错误的引用,形成单向链表结构。
核心语法:
err := fmt.Errorf("上下文信息: %w", originalErr)
包装后的错误可以通过 errors.Unwrap 解包,获取原始错误。
| 特性 | %w(包装) |
%v / %s(不包装) |
|---|---|---|
| 错误链 | 形成链,可被 errors.Is/errors.As 遍历 |
仅格式化字符串,丢失原始错误类型 |
| 解包能力 | 支持 errors.Unwrap |
不支持解包 |
| 适用场景 | 需要保留错误类型做断言 | 仅用于日志输出,无需断言 |
最佳实践:在业务代码中,对需要上层做错误码断言的关键错误,始终使用
%w 包装;对仅用于日志记录的辅助信息,使用 %v 避免链过长。
4.2 错误解包与断言:errors.Is 与 errors.As
Go 标准库提供了两个核心函数用于遍历错误链并做断言:
errors.Is(err, target):沿着错误链查找,判断是否存在某个错误与target相等(通过==比较或自定义Is方法)。errors.As(err, target):沿着错误链查找,将第一个匹配类型的错误赋值给target(通过类型断言或自定义As方法)。
典型用法:
var bizErr *BusinessError
if errors.As(err, &bizErr) {
// 成功提取业务错误,可访问 bizErr.Code
fmt.Println("错误码:", bizErr.Code)
}
if errors.Is(err, ErrNotFound) {
// 判断是否为“未找到”错误
fmt.Println("资源不存在")
}
| 函数 | 断言方式 | 返回值 | 典型用途 |
|---|---|---|---|
errors.Is |
值相等(==) |
bool |
判断错误是否为特定哨兵值 |
errors.As |
类型匹配 | bool,同时填充 target |
提取自定义错误类型中的字段 |
注意:
errors.As 的 target 必须是非 nil 的指针,且指向实现了 error 接口的类型。常见错误:传入 nil 或非指针类型会导致 panic。
4.3 错误链的遍历机制
错误链本质上是一个单向链表。每个实现了 Unwrap() error 接口的错误节点都可以指向下一个错误。Go 标准库的 errors.Is 和 errors.As 内部通过循环调用 Unwrap 实现链的遍历。
4.4 自定义错误类型的 Unwrap 方法
当自定义错误类型需要参与错误链时,必须实现 Unwrap() error 方法。Go 标准库的 errors.Is 和 errors.As 会通过反射检查该方法。
自定义业务错误示例:
type BusinessError struct {
Code int
Message string
Err error // 内部包装的原始错误
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
func (e *BusinessError) Unwrap() error {
return e.Err // 返回被包装的错误,形成链
}
| 方法 | 返回值 | 说明 |
|---|---|---|
Unwrap() error |
被包装的错误,或 nil |
链的下一节点;返回 nil 表示链尾 |
Is(target error) bool |
bool |
(可选)自定义相等比较逻辑,覆盖默认 == |
As(target interface{}) bool |
bool |
(可选)自定义类型匹配逻辑,覆盖默认类型断言 |
提示:实现
Is 和 As 方法可以精细控制错误链的匹配行为。例如,一个业务错误可以声明“我的 Code 为 404 时,等同于哨兵错误 ErrNotFound”。
4.5 业务实战:错误码断言与链遍历
在业务系统中,错误链的典型使用模式如下:
- 底层返回原始错误(如数据库驱动错误、网络错误)
- 中间层包装为业务错误,使用
%w保留原始错误 - 上层通过
errors.As提取业务错误码,决定返回给客户端的 HTTP 状态码 - 通过
errors.Is判断特定哨兵错误,执行重试或降级逻辑
完整示例:
// 定义哨兵错误
var ErrUserNotFound = errors.New("user not found")
// 业务层包装
func GetUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("获取用户失败: %w", ErrUserNotFound)
}
return user, nil
}
// API 层断言
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := GetUser(123)
if err != nil {
var bizErr *BusinessError
if errors.As(err, &bizErr) {
// 提取业务错误码
http.Error(w, bizErr.Message, bizErr.HTTPStatus())
return
}
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "用户不存在", http.StatusNotFound)
return
}
http.Error(w, "内部错误", http.StatusInternalServerError)
return
}
// 正常处理
}
4.6 常见陷阱与避坑指南
| 陷阱 | 错误示例 | 正确做法 |
|---|---|---|
| 多次包装同一错误 | fmt.Errorf("A: %w", fmt.Errorf("B: %w", err)) |
链式包装,每个节点只包装一次 |
使用 %v 代替 %w |
fmt.Errorf("msg: %v", err) |
需要断言时使用 %w |
对 nil 调用 errors.Is |
errors.Is(nil, target) |
先检查 err != nil |
errors.As 传入非指针 |
var e BusinessError; errors.As(err, e) |
传入 &e |
性能注意:错误链遍历是 O(n) 操作,n 为链长度。在热路径中,避免构造过长的错误链(建议不超过 5 层)。对于高频调用的断言,考虑在包装时直接携带错误码,减少遍历次数。