Go 错误处理与 panic:设计可判断、可追踪的失败
面向有 Java 经验的开发者,基于 Go 1.26。
Go 没有把所有失败都塞进异常控制流。可预期的失败通常以 error 值返回,由调用方在当前控制流中决定重试、降级、转换还是终止;panic 则留给程序不变量已经破坏、无法继续执行的情况,以及少数包内部的快速展开。
表面上看,这套机制只是不断写 if err != nil。真正难的是 API 设计:调用者究竟需要知道什么?哪些底层错误属于公共契约?日志应该在哪一层记录?重试会不会把一次操作执行两遍?后面的每一种写法,都应回到这些问题上判断。
目录
1. 错误是值,不是隐形跳转
预声明接口只有一个方法:
type error interface {
Error() string
}任何实现 Error() string 的类型都可以作为错误。函数签名把失败写进契约:
func LoadUser(ctx context.Context, id string) (User, error)调用方从签名就能知道操作可能失败,错误在哪一行被处理也很清楚:
user, err := LoadUser(ctx, id)
if err != nil {
return fmt.Errorf("load user %q: %w", id, err)
}Go 错误处理并不追求消灭每一行判断,而是把错误保留为普通值。它可以被包装、比较、聚合、存储、通过 channel 传递,也可以在边界层统一转换。
1.1 先给失败分类
写 API 前可以先问:
- 调用方输入不合法:应返回可识别的验证错误;
- 目标不存在或已存在:可能是公共业务状态;
- 依赖暂时不可用:可能允许有界重试;
- 权限或认证失败:要避免泄露敏感细节;
- 请求被取消或超时:保留
context语义; - 内部不变量破坏:通常记录为缺陷,必要时 panic;
- 进程无法启动:在最外层返回并退出。
分类通常比错误文本更重要。程序根据类型、哨兵值或方法判断,文本则留给人阅读。
2. error 接口与 nil 的语义
2.1 成功返回 nil
Go 的惯例是最后一个返回值为 error,成功时为 nil:
func ValidateName(name string) error {
if strings.TrimSpace(name) == "" {
return errors.New("name is empty")
}
return nil
}若前面的结果在错误时没有意义,返回它的零值:
func ParsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse port %q: %w", s, err)
}
return n, nil
}2.2 typed nil 陷阱
接口值由动态类型和动态值组成。动态值是 nil,但动态类型非 nil 时,接口不等于 nil:
type ParseError struct {
Input string
}
func (e *ParseError) Error() string {
return "invalid input: " + e.Input
}
func bad() error {
var err *ParseError
return err // 返回的 error 非 nil
}应直接返回 nil:
func good() error {
var err *ParseError
if err == nil {
return nil
}
return err
}同样要小心返回 error 的泛型、回调和结构字段,不要把具体类型的 nil 指针装入接口。
3. 创建和返回错误
3.1 errors.New 与 fmt.Errorf
固定文本:
var errEmptyName = errors.New("empty name")带运行时上下文:
return fmt.Errorf("user %q has invalid age %d", name, age)错误消息通常:
- 以小写开头;
- 不加句号或换行,方便上层继续包装;
- 描述失败动作和关键上下文;
- 不放密码、token、完整隐私数据;
- 不承担机器分类。
逐层包装后可以自然读成:
create invoice "inv-42": load customer "u-7": database unavailable3.2 原样返回还是包装
若当前层没有新增语义,原样返回即可:
if err := encoder.Encode(v); err != nil {
return err
}跨越一个有意义的动作或资源边界时加上下文:
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}不要在每一层都机械地补一句“failed to”。一条有用的错误链应当像调用过程摘要,而不是重复的文字版堆栈。
3.3 只处理一次
一个常用原则是:要么处理,要么返回。如果当前层只能记录后继续返回,错误可能在多层重复打印:
// 不推荐:库层打印一次,HTTP 层又打印一次。
if err != nil {
log.Printf("query failed: %v", err)
return err
}库通常返回带上下文的错误;进程、任务、请求等所有权边界负责记录。
4. 添加上下文与错误包装
fmt.Errorf 中 %w 会保留底层错误,使调用方可以沿错误树检查:
func ReadProfile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read profile %q: %w", path, err)
}
return data, nil
}%v 只把文本放进新错误,不建立包装关系:
fmt.Errorf("read profile: %v", err) // errors.Is/As 找不到 err选择 %w 是 API 决策。包装一个底层错误,等于允许调用方依赖它的身份或类型。若以后可能更换存储实现,不希望公开数据库驱动错误,应在仓储边界转换成自己的领域错误。
现代 fmt.Errorf 可以包含多个 %w,结果形成多个子错误;大多数聚合场景用 errors.Join 意图更清楚。
5. errors.Is:按身份或语义判断
不要通过字符串判断:
if err.Error() == "file does not exist" { // 脆弱
}正确方式:
if errors.Is(err, fs.ErrNotExist) {
// 缺少文件
}
if errors.Is(err, context.Canceled) {
// 调用方取消
}errors.Is 会对错误树做前序深度优先遍历,检查当前错误及其由 Unwrap() error 或 Unwrap() []error 返回的子错误。默认用相等性比较,也允许错误类型实现:
type semanticMatcher interface {
Is(target error) bool
}自定义 Is 应是浅比较,不能再递归调用 errors.Is:
type CodeError struct {
Code string
Msg string
}
func (e *CodeError) Error() string { return e.Msg }
func (e *CodeError) Is(target error) bool {
t, ok := target.(*CodeError)
return ok && t.Code != "" && e.Code == t.Code
}err == target 只适用于能保证没有包装且身份就是契约的情况。公共调用代码通常优先 errors.Is。
6. errors.As:提取结构化错误
需要读取错误字段或附加能力时使用 errors.As:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Printf("operation=%s path=%s cause=%v\n",
pathErr.Op, pathErr.Path, pathErr.Err)
}目标通常是“指向目标类型变量的指针”。错误具体类型若为 *ValidationError,写法是:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// 使用 validationErr.Fields
}这看起来像二级指针,是因为 As 需要改写变量本身。
也可以按能力提取接口:
type timeout interface {
Timeout() bool
}
var te timeout
if errors.As(err, &te) && te.Timeout() {
// timeout
}类型断言 err.(*ValidationError) 只看最外层,包装后会失败;errors.As 会遍历整个树。
Go 1.26 还新增了类型安全的泛型版本 errors.AsType:
pathErr, ok := errors.AsType[*fs.PathError](err)
if ok {
fmt.Println(pathErr.Op, pathErr.Path)
}它不再要求准备一个“指向目标变量的指针”,也避免了传错目标形态造成的 panic;在大多数只想取得某个具体错误类型的场景中更简洁。需要兼容较早 Go 版本的库,或者目标本来就是一个接口能力时,继续使用 errors.As 也很自然。
7. errors.Join 与错误树
多个独立清理或并行任务都可能失败时,用 errors.Join:
func closeAll(closers ...io.Closer) error {
var errs []error
for _, c := range closers {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}规则:
- nil 元素被丢弃;
- 全部是 nil 时返回 nil;
- 非 nil 结果实现
Unwrap() []error; errors.Is和errors.As能搜索每个分支;- 默认文本以换行连接各错误。
错误不再总是一条链,而可能是一棵树。errors.Unwrap 只识别 Unwrap() error,不会展开 errors.Join 的 []error。若要自定义遍历,应同时识别两种方法:
type singleUnwrapper interface {
Unwrap() error
}
type multiUnwrapper interface {
Unwrap() []error
}不要为了收集错误而掩盖主错误。事务提交失败后,回滚也失败时,二者都重要:
return errors.Join(
fmt.Errorf("commit: %w", commitErr),
fmt.Errorf("rollback after commit failure: %w", rollbackErr),
)但如果后续清理没有执行意义,应该停止,而不是勉强制造一堆衍生错误。
8. 哨兵错误怎么设计
哨兵错误是包级、可比较的固定值:
var ErrNotFound = errors.New("catalog: not found")调用方:
if errors.Is(err, catalog.ErrNotFound) {
// 映射成 HTTP 404
}适合稳定、无额外数据的状态,例如“不存在”“已关闭”“无结果”。一旦导出,它就是兼容性契约,删除或改成无法 Is 匹配会破坏调用方。
当调用方需要字段时,用自定义类型;当只需要分类时,也可以不导出哨兵,而让自定义错误通过 Is 匹配导出的类别。
不要为每段错误文字都创建哨兵。哨兵表示程序要分支处理的语义,不是消除 errors.New 的代码风格。
9. 自定义错误类型
9.1 携带稳定字段
type ValidationError struct {
Field string
Value string
Err error
}
func (e *ValidationError) Error() string {
if e.Err == nil {
return fmt.Sprintf("invalid field %q", e.Field)
}
return fmt.Sprintf("invalid field %q: %v", e.Field, e.Err)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}字段应服务于调用方决策,并谨慎考虑导出后的兼容性。Error() 给人读,字段给机器读。
9.2 指针还是值接收者
错误结构通常用指针:
- 避免复制较大字段;
errors.As的目标形式一致;- 保持单一身份;
- 后续增加字段时成本较低。
但必须避免 typed nil。值类型错误也完全合法,关键是 API 文档和 As 用法保持一致。
9.3 不要把底层实现泄露到领域层
仓储接口可以定义:
var ErrCustomerNotFound = errors.New("customer not found")
func (r *Repository) Find(ctx context.Context, id string) (Customer, error) {
row := r.db.QueryRowContext(ctx, query, id)
var c Customer
if err := row.Scan(&c.ID, &c.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Customer{}, ErrCustomerNotFound
}
return Customer{}, fmt.Errorf("scan customer %q: %w", id, err)
}
return c, nil
}调用方不必知道实现用了 SQL;真正诊断需要的底层错误仍可在非分类分支中保留。
10. API 边界与错误转换
不同层需要不同表达:
驱动错误 -> 基础设施错误 -> 领域错误 -> 传输协议状态一个 HTTP 边界:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
return
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, ErrConflict):
http.Error(w, "conflict", http.StatusConflict)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}不要把 err.Error() 原样发给外部用户。它可能含路径、SQL、主机名或内部标识。对外返回稳定代码和安全消息,对内日志保留完整错误链与关联 ID。
边界转换时要决定是否保留 cause:
return fmt.Errorf("%w: customer %q", ErrNotFound, id)这保留领域类别,但没有暴露驱动错误。若诊断必须保留底层原因,可以定义同时包装类别与 cause 的类型,或用 errors.Join;注意这会把两个分支都纳入 Is/As 契约。
11. defer 的完整语义
defer 把调用安排在当前函数返回前执行。
11.1 三条核心规则
- defer 语句执行时,函数值和实参立即求值;
- 延迟调用按后进先出执行;
- 延迟函数可以读取和修改命名返回值。
func example() (n int) {
n = 1
defer func() { n++ }()
return n // 最终返回 2
}11.2 关闭资源时别漏掉错误
只读文件通常可以:
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()写文件的 Close、缓冲区的 Flush 可能报告真正的数据落盘错误,不能总是忽略:
func writeReport(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report: %w", err)
}
defer func() {
err = errors.Join(err, f.Close())
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write report: %w", err)
}
return nil
}是否把 Close 错误加进返回值取决于资源语义。不要用 defer 中的清理错误无条件覆盖更重要的主错误。
11.3 循环中的 defer
defer 绑定当前函数,不是当前循环迭代。大量循环里直接 defer 会让资源一直积累到函数返回:
for _, path := range paths {
if err := func() error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return process(f)
}(); err != nil {
return err
}
}用小函数给每次迭代建立独立作用域,比手写所有早退分支更安全。
12. panic 与 recover
12.1 panic 的语义
panic(v) 停止当前函数的正常执行,按栈展开并执行每层 defer。如果一直没有恢复到该 goroutine 顶层,程序通常打印堆栈并退出。
适合 panic 的情况:
- 包内部不可恢复的不变量被破坏;
- 程序员明显误用 API,且惯例已约定 panic;
- 初始化期的
MustXxx辅助函数; - 包内部深层递归用 panic 快速展开,并在导出边界转换为 error。
不适合:
- 文件不存在;
- 用户输入错误;
- 网络失败;
- 业务冲突;
- 可以合理预期并恢复的情况。
12.2 recover 只能在正确位置生效
func safeRun(fn func()) (err error) {
defer func() {
if v := recover(); v != nil {
err = fmt.Errorf("callback panic: %v", v)
}
}()
fn()
return nil
}recover 只有在同一 goroutine 的延迟函数中、且当前正在展开 panic 时才有用。另一个 goroutine 无法恢复这里的 panic:
go func() {
defer recoverAndReport()
runTask()
}()每个 goroutine 所有权边界都要自行决定 panic 策略。
Go 1.21 以后,默认 panic(nil) 会产生非 nil 的 *runtime.PanicNilError,因此 recover() != nil 可以可靠判断是否正在 panic;旧 go 版本语义可能受 GODEBUG=panicnil 影响。
12.3 保留堆栈
recover 后若只格式化值,会丢失原 panic 堆栈。服务边界通常同时记录:
defer func() {
if v := recover(); v != nil {
logger.Error("panic",
"value", v,
"stack", string(debug.Stack()),
)
}
}()恢复的目标是隔离请求或任务,不是装作没有缺陷。要记录、计数、告警,并根据状态是否可能损坏决定是否继续进程。内存结构已经处于未知状态时,盲目恢复可能比崩溃更危险。
12.4 Must 函数
var pageTemplate = template.Must(template.ParseFS(assets, "page.html"))Must 适合包初始化阶段的静态资源:错误意味着构建或部署有问题,程序无法合理运行。不要在处理用户输入的请求路径上用 Must。
13. HTTP、goroutine 与进程边界
13.1 HTTP handler 适配器
把业务处理函数写成返回 error,统一在适配器里转换和记录:
type appHandler func(http.ResponseWriter, *http.Request) error
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
slog.ErrorContext(r.Context(), "handler panic",
"panic", v,
"stack", string(debug.Stack()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
if err := h(w, r); err != nil {
slog.ErrorContext(r.Context(), "request failed", "error", err)
writeError(w, err)
}
}实际服务要处理“响应头已经发送”的情况,不能在部分响应后再可靠改成 500。
13.2 后台任务
启动 goroutine 就接管了它的生命周期和错误。至少要选择一种策略:
- 通过结果 channel 返回;
- 由任务组统一收集;
- 在 goroutine 边界记录并更新指标;
- 错误触发全局取消;
- 明确任务是 best-effort,可以丢弃,但仍要观测。
sync.WaitGroup 不收集错误。需要“首个错误取消其他任务”时,应实现结果协调或采用项目选定的任务组工具。
13.3 main 只退出一次
log.Fatal 和 os.Exit 会直接终止进程,延迟函数不会执行。库和深层函数不应调用它们。让 run 返回 error,在 main 最外层决定退出:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}需要清理的资源放在 run 内,run 返回前 defer 仍会执行。
14. 日志、指标与链路追踪
错误对象和日志记录职责不同:
- 错误携带语义和调用上下文;
- 日志记录某次事件、时间、请求 ID 和运行环境;
- 指标统计类别、频率和延迟;
- trace 连接跨服务调用。
推荐在少数所有权边界记录一次完整错误:
slog.ErrorContext(ctx, "invoice generation failed",
"error", err,
"invoice_id", invoiceID,
"request_id", requestID,
)避免:
- 把错误文本用作指标 label,导致高基数;
- 每层重复记录同一错误;
- 同时在消息和字段重复完整错误;
- 将秘密、SQL 参数、文件内容写入日志;
- 因为已经记录就返回 nil。
可用稳定分类生成指标:
func errorCode(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, context.Canceled):
return "canceled"
case errors.Is(err, ErrNotFound):
return "not_found"
default:
return "internal"
}
}15. 重试、退避与幂等性
重试不是“遇到 error 再调用一次”。先回答:
- 错误是暂时的吗?
- 操作是否幂等?
- 前一次是否可能已经成功,只是响应丢了?
- 重试预算由哪一层拥有?
- 是否尊重 context 截止时间和取消?
一个简化的有界退避:
func Retry(
ctx context.Context,
attempts int,
base time.Duration,
fn func(context.Context) error,
) error {
var errs []error
for i := 0; i < attempts; i++ {
err := fn(ctx)
if err == nil {
return nil
}
errs = append(errs, fmt.Errorf("attempt %d: %w", i+1, err))
if !isRetryable(err) {
break
}
delay := base << i
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return errors.Join(errors.Join(errs...), context.Cause(ctx))
case <-timer.C:
}
}
return errors.Join(errs...)
}生产实现还要加入抖动、最大退避、服务端 Retry-After、总预算和可观测性。不要在 HTTP 客户端、SDK、业务层和网关同时无意识重试,重试次数会乘法膨胀。
写操作最好使用幂等键、条件更新或事务。超时不代表服务端没有成功;在不清楚结果的情况下重试扣款可能造成双扣。
context.DeadlineExceeded 与 context.Canceled 通常不应被层层改写成模糊的“internal error”。使用 errors.Is 保留其可识别性,context.Cause 可读取带原因的取消。
16. 清理失败与事务式回滚
多个阶段的函数要明确每一步失败后的补偿:
func createAndPublish(ctx context.Context, item Item) (err error) {
id, err := repository.Create(ctx, item)
if err != nil {
return fmt.Errorf("create item: %w", err)
}
committed := false
defer func() {
if committed {
return
}
if cleanupErr := repository.Delete(ctx, id); cleanupErr != nil {
err = errors.Join(err,
fmt.Errorf("delete uncommitted item %q: %w", id, cleanupErr))
}
}()
if err := publisher.Publish(ctx, id); err != nil {
return fmt.Errorf("publish item %q: %w", id, err)
}
committed = true
return nil
}真实分布式系统中补偿本身也可能失败,不能假装 defer 等于数据库事务。需要 outbox、状态机或可重试任务时,应提升到架构层解决。
17. 测试错误契约
测试机器可见语义,不要把完整错误文字锁死:
func TestFindMissing(t *testing.T) {
_, err := repo.Find(context.Background(), "missing")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}自定义类型:
var got *ValidationError
if !errors.As(err, &got) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if got.Field != "email" {
t.Fatalf("field = %q, want email", got.Field)
}错误消息测试只检查确有价值的稳定片段,或者测试面向用户的独立响应模型。还应覆盖:
- 错误被包装后
Is/As仍生效; errors.Join的每个分支可找到;- 清理失败是否保留主错误;
- context 取消是否及时返回;
- panic 边界是否记录并保留堆栈;
- 重试是否遵守次数、截止时间和幂等协议。
18. 性能与错误路径
错误通常不是热路径,不要为了少一次分配牺牲语义。但高频解析器、协议栈确实可能把“预期不匹配”设计成布尔值而不是 error:
value, ok := cache.Lookup(key)若“不存在”属于正常分支,ok 往往比每次构造错误更合适;若调用方必须解释失败,则 error 更清晰。
性能注意:
fmt.Errorf格式化和包装会分配;- 堆栈采集昂贵,只在合适边界采集;
- 不要每层重复创建同义错误和日志;
- 重试放大负载,比 error 分配更值得关注;
- 基准必须覆盖失败比例,不能只测成功路径。
19. 与 Java 异常的对照
| Go | Java | 设计含义 |
|---|---|---|
error 返回值 | checked/unchecked exception | Go 在普通控制流中显式处理 |
fmt.Errorf("%w") | exception cause | 是否暴露 cause 是 API 契约 |
errors.Is | 按类别/cause 判断 | 支持自定义语义匹配与错误树 |
errors.As | catch / instanceof | 提取包装树里的具体类型或能力 |
errors.Join | suppressed/aggregate exception | 多个并列原因组成树 |
defer | finally / try-with-resources | 函数作用域、后进先出 |
panic/recover | unchecked exception/catch | 不用于普通业务失败,且不能跨 goroutine recover |
Java 开发者常把 Go 的 panic 当 throw 使用,这是最需要纠正的迁移习惯。Go 的公共 API 通常用 error 表示预期失败;panic 穿过包边界一般意味着程序缺陷或 API 明确声明的误用。
20. 常见误区
误区 1:错误消息相同就是同一类错误
文本会变,也可能由多个类型产生。用 errors.Is/As。
误区 2:任何错误都应该 %w
包装会暴露底层契约。实现边界可能应该转换而不是透传。
误区 3:记录日志后返回 nil 就算处理了
除非明确降级成功,否则这会让上层误以为操作完成。
误区 4:每层都记录更便于排查
同一失败会产生一串重复日志,掩盖真正根因。所有权边界记录一次。
误区 5:recover 可以捕获所有 goroutine 的 panic
它只能恢复同一 goroutine 上正在展开的 panic。
误区 6:recover 后继续一定安全
如果 panic 发生在修改共享状态中途,不变量可能已经损坏。
误区 7:defer f.Close() 能报告写入完成
忽略的 Close/Flush 错误可能正是最终写入失败。
误区 8:超时错误可以直接重试
前一次可能已经成功。必须考虑幂等性和总预算。
误区 9:log.Fatal 会执行 defer
它最终调用 os.Exit,不会展开当前 goroutine 的 defer。
误区 10:返回具体 nil 指针等于返回 nil error
装入接口后可能成为非 nil 的 typed nil。
21. 速查表
| 需求 | 做法 |
|---|---|
| 固定、无需分类的错误 | errors.New |
| 带上下文但不需要 cause | fmt.Errorf("...: %v", err) 或新错误 |
| 保留可判断的 cause | fmt.Errorf("...: %w", err) |
| 判断类别/身份 | errors.Is |
| 读取具体字段或能力 | errors.As |
| 聚合并列失败 | errors.Join |
| 稳定无字段的公共状态 | 导出哨兵错误 |
| 需要结构化详情 | 自定义错误类型 |
| 普通可恢复失败 | 返回 error |
| 不变量破坏或 Must 初始化 | panic |
| 请求/任务隔离 | 边界 defer + recover + 堆栈和告警 |
| 进程退出 | 让 run 返回,main 最后 os.Exit |
错误设计检查清单:
- 调用方需要根据什么分支?
- 错误链是否泄露实现细节?
- 文本是否包含秘密?
- 谁负责记录?
- 能否重试,是否幂等?
- 清理错误会不会覆盖主错误?
- 包装后
Is/As测试是否通过?
可运行示例
错误处理不能停在“把文本打印出来”。调用者需要在保留上下文的同时,可靠地识别错误类别、提取结构化信息,并且只在合适的边界处理 panic。
示例一:用 wrapping 增加上下文,用 Is 识别身份
上下文和错误身份都要保留。 直接返回 ErrNotFound 会缺少用户 ID,重新创建一条错误文本又会丢失错误身份。fmt.Errorf 的 %w 可以同时保留两者。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findUser(id string) error {
// %w 保留 ErrNotFound 的身份,同时补上排查所需的业务上下文。
return fmt.Errorf("查询用户 %s: %w", id, ErrNotFound)
}
func main() {
err := findUser("u-42")
fmt.Println("error:", err)
// Is 检查错误链中的身份,不依赖展示文本,因此外层继续包装也不会破坏判断。
fmt.Println("is not found:", errors.Is(err, ErrNotFound))
}运行:
go run ./examples/ch14/wrap-is预期输出:
error: 查询用户 u-42: not found
is not found: true错误链在这里建立。
- sentinel error 表示稳定、可判断的失败类别。
%w建立错误链,外围文本提供当前操作与参数上下文。errors.Is遍历错误链,不依赖可能变化或本地化的错误字符串。
进一步验证。 再包一层 fmt.Errorf("HTTP handler: %w", err),errors.Is 仍应为 true;随后把 %w 改成 %v,观察错误链为什么会被截断。
示例二:Join 保留并行失败,As 提取结构化错误
并列失败不能只留一个。 表单的姓名和年龄可以同时非法,只返回一个错误会迫使用户反复提交。errors.Join 保留多个分支,每个自定义错误又能通过 As 提取字段。
package main
import (
"errors"
"fmt"
)
var (
ErrNameRequired = errors.New("name required")
ErrAgeInvalid = errors.New("age invalid")
)
type ValidationError struct {
Field string
Err error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s: %v", e.Field, e.Err)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}
func validate() error {
// Join 表示多个错误同时成立;每个分支仍可被 Is/As 遍历。
return errors.Join(
&ValidationError{Field: "name", Err: ErrNameRequired},
&ValidationError{Field: "age", Err: ErrAgeInvalid},
)
}
func main() {
err := validate()
var validation *ValidationError
fmt.Println("name missing:", errors.Is(err, ErrNameRequired))
fmt.Println("age invalid:", errors.Is(err, ErrAgeInvalid))
if errors.As(err, &validation) {
// 多个目标都匹配时,As 返回深度优先遍历遇到的第一个。
fmt.Println("first field:", validation.Field)
}
}运行:
go run ./examples/ch14/as-join预期输出:
name missing: true
age invalid: true
first field: name错误树由两层组成。
ValidationError.Unwrap让底层 sentinel 继续参与errors.Is。errors.Join形成多分支错误树,任一分支匹配即可返回 true。errors.As把错误赋给目标指针;多个分支都匹配时只返回遍历遇到的第一个。
交换分支顺序。 交换 Join 的两个参数,观察 As 首次得到的字段变化,而两个 Is 判断仍为 true。若业务需要展示全部字段错误,应显式遍历或直接返回结构化集合,不能只调用一次 As。
示例三:在明确的插件边界 recover
恢复只能放在明确边界。 panic 不应作为普通错误返回,但受控的同进程扩展点发生普通 panic 时,也未必需要终止整个服务。runPlugin 在调用边界恢复,并转换成带插件名的错误;它不是隔离恶意代码的安全沙箱。
package main
import "fmt"
func runPlugin(name string, plugin func() error) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
// recover 必须在当前 goroutine 的 deferred function 中直接调用。
// 边界把同进程扩展点的普通 panic 转为错误,但真实服务还应记录调用栈。
// recover 不是安全沙箱;若共享状态可能已损坏,进程不应盲目继续。
err = fmt.Errorf("插件 %s panic: %v", name, recovered)
}
}()
return plugin()
}
func main() {
err := runPlugin("report", func() error {
panic("模板损坏")
})
fmt.Println(err)
err = runPlugin("healthy", func() error {
return nil
})
fmt.Println("healthy error:", err)
}运行:
go run ./examples/ch14/recover-boundary预期输出:
插件 report panic: 模板损坏
healthy error: <nil>这个边界只承诺三件事。
- recover 位于同一 goroutine 的 deferred closure 中,能拦截该调用栈向外传播的 panic。
- 命名返回值让 defer 把 panic 转换为正常错误结果。
- 边界仅处理当前插件的 panic;真实服务还应记录
debug.Stack(),并确认共享状态的不变量没有遭到破坏,不能静默吞掉证据后盲目继续。
验证 goroutine 边界。 在插件中启动新 goroutine 并让它 panic,验证外层 runPlugin 无法恢复另一个 goroutine;正确做法是在新 goroutine 自己的入口设置边界。不要让发生内存损坏或全局不变量破坏的进程盲目继续。
22. 练习
- 为用户注册定义验证错误、冲突错误和基础设施错误,并在 HTTP 边界映射为稳定状态码。
- 写一个返回 typed nil 的最小示例,再修复并添加测试。
- 实现批量删除:返回所有失败,用
errors.Join保证每个底层错误都能通过errors.Is找到。 - 为配置加载器增加文件路径上下文,同时保留
fs.ErrNotExist的可判断性。 - 实现一个
Retry,支持指数退避、抖动、context 取消、最大总时长和Retry-After。 - 给 HTTP handler 增加 panic 边界,测试正常 error、panic 和已经写出响应三种路径。
- 审查一个现有服务的日志:找出重复记录、错误文本高基数 label 和敏感字段。
- 设计一个写临时文件后原子替换目标文件的函数,正确组合写入、
Sync、Close、Rename和清理错误。