Go 错误包装与链式错误
Go 1.13+ 错误包装:%w、Unwrap、errors.Is/As、Is/As 方法约定、errors.Join 与跨层错误身份设计。
[!info] 关联笔记
Go 错误包装与链式错误
这个概念为什么会出现
Go 把错误当普通值返回(见 错误处理)。这带来清晰控制流,也带来两难:
- 向上返回时需要上下文
底层sql: no rows对 HTTP 层几乎无用;需要知道“查用户失败”“订单 ID=…”等路径信息。 - 调用方仍需精确判断原因
若只把错误格式化成字符串,上层只能字符串匹配,脆弱且不可组合。 - 跨层既要可读,又要可编程
人读完整链条;程序用稳定哨兵错误或类型做分支。
Go 1.13 起用 包装(wrapping) 解决:新错误携带文案上下文,同时通过 Unwrap 链保留底层错误身份。errors.Is / errors.As 沿链检查或提取。Go 1.20 再引入 errors.Join,表达“多个独立错误同时发生”。
[!abstract] 一句话理解 用
fmt.Errorf("…: %w", err)建立可遍历的错误链;用errors.Is匹配哨兵/相等语义,用errors.As提取类型化错误;%v只拼字符串,不保留身份。
最小可运行示例
先把示例放进跨层调用场景,再看代码:
场景:下单前校验“用户是否存在”
一次下单请求会穿过多层:
- repo:只知道“库里有没有这个 user_id”
- service:知道“我在为订单预检用户”
- 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
结合场景再看三个关注点
-
%w建链,%v只留文案
两段字符串可能长得很像,但只有%w能让errors.Is穿过 service 层认出ErrNotFound。 -
每层只叙述本层动作
repo 说“not found”;service 说“prepare order for user 42”;不要在每层重复粘贴底层原文。 -
身份给程序,文案给人
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() 字符串。
包装:%w 与 Unwrap
err := fmt.Errorf("open config: %w", original)
语义要点(官方 Go 1.13 错误模型):
- 新错误的
Error()字符串包含格式化结果。 - 新错误可通过
Unwrap() error返回被包装错误(由fmt.Errorf与%w生成的实现提供)。 - 一次格式化中通常只应使用一个
%w(多%w的行为/意图易混乱;多错误用errors.Join)。 - 自定义类型可自行实现:
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
规则直觉:
- 若
err == nil,仅当target == nil时为 true(实际调用中 target 几乎不为 nil)。 - 否则沿
Unwrap链(以及 Go 1.20+ 多错误展开)检查是否与target匹配。 - 默认匹配类似可比较值的相等;若某层错误实现了
Is(target error) bool,则调用该方法决定是否匹配。 - 不要用
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
规则:
target必须是非 nil 指针,指向可赋值为链中某错误的类型(接口或错误类型),否则 panic。- 找到第一个可赋值错误后写入
*target并返回 true。 - 类型可实现
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)
要点:
- 合并多个错误;
nil入参被忽略;全 nil 则返回 nil。 Error()文本通常多行拼接各错误。- 对
errors.Is/As,每个被 join 的错误都可被匹配/提取。 - 多错误展开通过
Unwrap() []error(与单错误Unwrap() error不同)。 - 适用:关闭多个资源、批量校验、多阶段清理中收集全部失败。
%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
}
设计动机
- 显式错误值 + 可组合上下文
不引入异常栈隐式传播,但仍能保留路径。 - 检查协议化
Is/As/Unwrap成为标准库与生态的共同约定。 - 区分“装饰”与“替换”
装饰用%w;替换身份用新哨兵或%v切断。 - 多错误现实
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 | 异常展开与边界恢复;非常规业务失败路径 |
| 日志 | 记录错误;包装决定返回给调用方的可编程结构 |
| 多返回值 | 错误常作最后返回值;包装不改变这一惯例 |
工程实践
-
包级哨兵
var ErrNotFound = errors.New("…"),导出需慎重(成为 API)。 -
包装格式
推荐:fmt.Errorf("OpName(arg=%v): %w", arg, err),便于检索。 -
何时不包装
已是本包错误且无新信息 → 直接返回。 -
何时切断
安全/抽象边界:映射为稳定错误,必要时%v或新错误 + 日志记录原链。 -
库作者
- 文档写明哪些错误可
Is/As。 - 避免每个版本变换错误身份。
- 文档写明哪些错误可
-
服务作者
- 中间件/handler 用
Is/As映射 HTTP 状态。 - 日志打印
%+v或结构化字段时保留链(按项目日志库能力)。
- 中间件/handler 用
-
测试
if !errors.Is(err, ErrNotFound) { t.Fatalf(...) } var qe *QueryError if !errors.As(err, &qe) { t.Fatalf(...) } -
Go 版本
- 1.13:
%w/Is/As - 1.20:
Join、多错误 Unwrap
模块go行决定可用语法与标准库行为假设。
- 1.13:
可验证实验
实验 1:%w vs %v
对同一底层哨兵分别用 %w/%v 包装,打印 errors.Is。
实验 2:多层链
a 包装 b 包装 ErrX,确认 Is 仍为 true,并手动 Unwrap 两层观察。
实验 3:As 提取
返回 *os.PathError(如 os.Open 不存在路径),经 %w 后 As 取出 Path。
实验 4:Join
errors.Join(ErrA, ErrB) 上分别 Is 两者;再与单链 %w 对比心智模型。
实验 5:自定义 Unwrap
实现 QueryError,确认无 Unwrap 时 Is 失败,补上后成功。
本节总结
- 本质:包装 = 上下文文案 + 可遍历错误身份链。
- 关键 API:
%w、Unwrap、errors.Is、errors.As、errors.Join。 - 最易错:
%v当包装、==代替Is、As参数形态、对外泄漏底层错误契约。 - 下一步:defer / panic / recover 分清错误与异常边界;在服务层用 Is/As 映射状态码。
自测题
概念题
- 为什么
fmt.Errorf("x: %v", ErrX)后errors.Is通常为 false? errors.Is与errors.As分别解决什么问题?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?如何设计更稳?
参考答案
展开
%v只把错误当字符串格式化进新错误,不提供Unwrap链,身份断开。Is:是否匹配某哨兵/相等语义;As:是否存在某类型并提取出来。%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 边界)