Go 基础体系 · 第 13/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 错误处理:包装、errors.Is/As、panic 与 recover 边界

本文所有规则与示例均以 Go 1.26.4 为基准。Go 把可预期失败表示为普通 error 返回值,调用方必须在控制流中处理它。错误链让上下文与机器可判定语义同时保留;panic/recover 则服务于无法在当前调用路径继续的不变量破坏和最外层故障隔离。两者不是同一种错误机制的不同写法。

1. error 是一个最小接口

预声明的 error 等价于只有 Error() string 方法的接口。任何类型只要实现它就能作为错误返回。惯例把 error 放在返回值最后,成功时为 nil,失败时其他结果只按函数文档定义是否可用。

type error interface { Error() string }

func Divide(a, b int) (int, error) {
    if b == 0 { return 0, errors.New("division by zero") }
    return a / b, nil
}

错误文本服务于人,程序分支不应比较字符串。错误也是 API 契约:调用方需要判断“不存在”“冲突”或“可重试”时,提供稳定哨兵值、结构化类型或谓词。只返回一句文本会迫使调用方解析不稳定的人类语言。

2. 处理、包装还是返回

收到错误后有三种主要选择:在当前层恢复并返回正常结果;增加本层操作上下文后继续返回;原样返回以保留已有上下文。不要既记录又返回,导致每一层重复日志。通常由拥有请求 ID、用户影响和最终状态的边界记录一次。

data, err := os.ReadFile(path)
if err != nil {
    return nil, fmt.Errorf("load config %q: %w", path, err)
}

上下文应描述失败操作和关键标识,形成从高层到根因的可读链;避免“failed”“error occurred”这类无信息包装。密码、token、完整请求体和隐私字段不可进入错误。对会离开进程的消息,还应将内部诊断与用户提示分开。

3. %w 建立错误链,%v 只生成文本

fmt.Errorf%w 会让结果实现 Unwrap,使 errors.Is/As 能遍历被包装错误。%v 只把文本格式化进去,底层身份丢失。是否 %w 是兼容性决定:暴露底层错误后,调用方可能依赖它。

var ErrNotFound = errors.New("not found")

func load(id string) error {
	return fmt.Errorf("load article %q: %w", id, ErrNotFound)
}

func exampleIs() {
	fmt.Println(errors.Is(load("a-1"), ErrNotFound)) // true
}

替换数据库或文件实现时,不希望泄露具体驱动错误,可在仓储边界把它翻译成领域错误,再包装领域错误。错误链应表达调用方有权依赖的语义,而不是无选择地暴露所有内部细节。

4. errors.Is 按语义匹配

errors.Is(err, target) 先检查当前错误是否等于 target,再考虑错误自定义的 Is(error) bool,然后沿 Unwrap() errorUnwrap() []error 深度优先遍历。target 通常是稳定哨兵值。

if errors.Is(err, fs.ErrNotExist) { /* 映射为 404 */ }
if errors.Is(err, context.DeadlineExceeded) { /* 记录超时 */ }

不要写 err == ErrNotFound,它无法穿过包装。自定义 Is 应做浅层匹配,不能反过来递归调用 errors.Is 造成复杂或循环逻辑。错误链理论上可由自定义实现造出环;标准工程代码应保持无环,避免遍历无法终止。

5. errors.As 提取结构化错误

errors.As(err, &target) 沿链查找可赋值给 target 指向类型的错误,并把结果写入 target。target 必须是非 nil 指针,通常是“指向错误类型变量的指针”;用错层级会 panic 或永远匹配不到。

type ValidationError struct {
	Field   string
	Problem string
}

func (e *ValidationError) Error() string { return e.Field + ": " + e.Problem }

func inspectValidation(err error) {
	var validation *ValidationError
	if errors.As(err, &validation) {
		fmt.Println(validation.Field)
	}
}

若错误实现是指针接收者,变量类型是 *ValidationError,传入的是 **ValidationError,这是正确用法。结构化字段只承载调用方需要的信息,不要把整个数据库对象或请求塞进错误。字段是否公开意味着稳定契约,演进时同样需要兼容性考虑。

6. 自定义错误的值/指针语义与 typed nil

自定义错误通常用指针接收者,避免复制并让 errors.As 目标明确。但绝不能返回 typed nil:

type ParseError struct{ Input string }
func (e *ParseError) Error() string { return "invalid " + e.Input }

func bad() error {
    var err *ParseError
    return err // 非 nil error,动态类型是 *ParseError
}

成功路径显式 return nilError 方法是否支持 nil 接收者不能修复接口非 nil 的语义。测试错误返回函数时,覆盖成功路径 err == nil,并用编译器或静态检查配合代码评审定位潜在 typed nil。

自定义错误若包装根因,实现 Unwrap() errorError() 应在零值或缺省字段下仍安全,避免记录错误时再次 panic。不要让 Error() 做 I/O、加可能死锁的锁或执行昂贵计算。

7. errors.Join 与多错误树

errors.Join 把多个非 nil 错误组合成一个错误;全部参数为 nil 时返回 nil。结果通过 Unwrap() []error 暴露分支,errors.Is/As 可匹配任一分支。它适合彼此独立的清理失败、批量校验或并行子任务。

err := errors.Join(
    fmt.Errorf("close input: %w", input.Close()),
    fmt.Errorf("close output: %w", output.Close()),
)

实际代码要先过滤 nil 或使用能正确处理 nil 的包装方式;fmt.Errorf("close: %w", nil) 自身仍可能得到非 nil 错误,不应这样包装成功。多错误必须明确部分成功语义、输出顺序和最大数量,防止批量输入制造巨型错误文本。errors.Unwrap 只处理单个 Unwrap() error,不会替你返回 Join 的全部子项;一般用 Is/As,需要展示树时自行受限遍历。

8. defer 如何修改命名返回错误

defer 在函数返回前按后进先出执行。需要同时保留主操作与关闭错误时,命名 error 返回值配合 Join 很实用,但要避免无条件覆盖原错误。

func writeFile(name string, data []byte) (err error) {
    file, err := os.Create(name)
    if err != nil { return fmt.Errorf("create: %w", err) }
    defer func() { err = errors.Join(err, file.Close()) }()
    if _, err = file.Write(data); err != nil { return fmt.Errorf("write: %w", err) }
    return nil
}

defer 的参数在注册时求值,闭包变量在执行时读取。资源必须在成功获取后立即注册释放;循环中长期 defer 会推迟到整个函数返回,可能耗尽文件描述符,应把单次迭代提取为函数。关闭错误是否重要取决于资源:写文件的 Close 可能报告刷盘失败,HTTP 响应体 Close 通常是另一种契约。

9. context 错误要保留取消原因

长耗时操作应尽早检查并传播 ctx.Err()context.Canceled 通常表示调用方离开或主动取消,context.DeadlineExceeded 表示预算耗尽;它们不应一律计为服务内部 500。

select {
case <-ctx.Done():
    return fmt.Errorf("wait result: %w", ctx.Err())
case result := <-results:
    return result, nil
}

包装后仍用 errors.Is 分类。Go 的 cause API 可记录更具体取消原因,但库函数一般仍应尊重 ctx.Err() 契约,并在适当层读取 context.Cause。不要把 context 存入长期结构体或用 nil context;把它作为首参数沿请求路径传递。

10. panic 的运行机制与适用范围

调用 panic(v) 会停止当前函数的正常执行,开始展开当前 goroutine 的调用栈。每一帧已注册的 defer 仍按后进先出执行;若没有 recover 截获,运行时打印 panic 与堆栈并终止进程。panic 不是跨 goroutine 抛出的异常。

适合 panic 的情况包括程序员破坏不可恢复的不变量、包初始化缺少必需静态配置、以及 MustX 明确约定失败即 panic 的启动辅助函数。用户输入无效、文件不存在、网络失败、数据库冲突都属于预期环境失败,应返回 error。

库若 panic,会夺走调用方的恢复策略。除非 API 明确记录或属于编程错误,公共库应优先返回错误。不要用 panic/recover 代替深层普通控制流,它使资源、类型和测试路径更难推理。

11. recover 只在当前 goroutine 的 defer 中有效

recover() 只有在被 panic 展开时直接执行的延迟函数中才能截获 panic;平常调用返回 nil。一个 goroutine 无法 recover 另一个 goroutine 的 panic,因此任务执行器必须在每个 worker 任务入口分别建立边界。

func protect(fn func()) (panicked any) {
    defer func() { panicked = recover() }()
    fn()
    return nil
}

恢复后栈已展开到边界,不能从 panic 点继续执行。recover 边界应记录 debug.Stack()、请求或任务 ID 和经过脱敏的 panic 值,然后返回明确错误或终止该任务。若进程状态可能已损坏,恢复后继续服务反而危险;隔离边界不是“吞掉所有 panic”。

一个细节是 panic(nil) 在现代 Go 中会被运行时包装,使有效 panic 通常可被 recover 观察;工程代码仍不应依靠 nil 作为 panic 协议。判断是否发生 panic,最好让边界显式设置状态而非把 nil 当特殊业务值。

12. HTTP、任务与 goroutine 边界

HTTP 中间件可在每个请求入口 recover,记录堆栈,并在响应尚未写出时返回 500。若 header 或部分 body 已发送,就无法可靠改写状态码;流式响应尤其要设计失败协议。不要把内部堆栈返回客户端。

任务池要在执行用户函数的同一个 goroutine 内 defer recover,并决定任务是重试、进入死信还是标记永久失败。启动裸 goroutine 时,父 goroutine 的 recover 无效。对关键后台 goroutine,panic 后继续还是让进程退出应由一致策略决定,避免悄悄失去核心循环。

recover 得到的任意值不一定实现 error,可用 %v 展示;转换为 error 时保留 runtime/debug.Stack 作为日志字段而非塞进面向用户的错误字符串。

13. 错误分类、重试与日志

错误可按调用方行动分类:永久输入错误不重试;短暂依赖错误可能退避重试;取消与超时按请求生命周期统计;未知内部错误报警。类型名称本身不等于可重试,网络错误也可能是永久 DNS 配置错误。

重试必须有次数/时间预算、指数退避与抖动,并仅用于幂等或有去重保护的操作。保留最后错误,同时记录尝试次数;不要每次失败都高等级报警。错误在最外层记录一次,结构化字段包括操作、稳定错误码、耗时和 trace ID,敏感值脱敏。

对外 HTTP/gRPC 状态在传输边界映射,不让领域层导入协议状态码。错误文本可调整,稳定 code、哨兵或类型才是程序契约。

14. 测试与故障诊断

测试不要只比较完整错误字符串。用 errors.Is 验证分类,用 errors.As 验证结构化字段,再对必要上下文做子串检查。panic 契约可用延迟 recover 测试,但普通失败路径应断言 error。

gofmt -w .
go vet ./...
go test ./...
go test -race ./...
go test -run TestService -count=100 ./...

线上日志必须保留一次完整错误链和 panic 堆栈。%+v 是否显示更多信息取决于具体错误实现,标准错误链没有统一结构化格式;需要稳定观测时逐层提取已知字段。故障注入应覆盖超时、取消、部分写入、Close 失败和并发子任务部分失败。

15. 可运行综合示例:错误翻译、包装与 panic 隔离

下面程序模拟任务服务:仓库错误在边界翻译为领域哨兵,服务层增加操作上下文,runner 在同一 goroutine 隔离插件 panic 并保留堆栈摘要。正常错误仍走 error,不依赖 panic。

package main

import (
    "context"
    "errors"
    "fmt"
    "runtime/debug"
    "strings"
)

var ErrNotFound = errors.New("article not found")
var errStorageMissing = errors.New("missing row")

type ValidationError struct { Field, Problem string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Problem }

type Store struct { titles map[string]string }
func (s *Store) lookup(id string) (string, error) {
    title, ok := s.titles[id]
    if !ok { return "", errStorageMissing }
    return title, nil
}

func (s *Store) Find(id string) (string, error) {
    title, err := s.lookup(id)
    if errors.Is(err, errStorageMissing) { return "", fmt.Errorf("id %q: %w", id, ErrNotFound) }
    if err != nil { return "", fmt.Errorf("lookup %q: %w", id, err) }
    return title, nil
}

type Service struct { store *Store }
func (s *Service) Title(ctx context.Context, id string) (string, error) {
    if err := ctx.Err(); err != nil { return "", fmt.Errorf("title: %w", err) }
    id = strings.TrimSpace(id)
    if id == "" { return "", &ValidationError{Field: "id", Problem: "required"} }
    title, err := s.store.Find(id)
    if err != nil { return "", fmt.Errorf("get title: %w", err) }
    return title, nil
}

func runSafely(task func() error) (err error) {
    defer func() {
        if value := recover(); value != nil {
            stack := strings.SplitN(string(debug.Stack()), "\n", 3)
            err = fmt.Errorf("task panic: %v; stack: %s", value, strings.Join(stack[:2], " | "))
        }
    }()
    return task()
}

func main() {
    service := &Service{store: &Store{titles: map[string]string{"a-1": "Errors"}}}
    for _, id := range []string{"a-1", "missing", ""} {
        title, err := service.Title(context.Background(), id)
        var validation *ValidationError
        fmt.Printf("id=%q title=%q notFound=%t validation=%t err=%v\n",
            id, title, errors.Is(err, ErrNotFound), errors.As(err, &validation), err)
    }
    err := runSafely(func() error { panic("broken plugin invariant") })
    fmt.Println("recovered:", err)
}

输出能看到成功、包装后的 not-found、结构化校验错误和被 runner 隔离的 panic。runSafely 只用于插件任务边界;Title 的所有预期失败仍是 error。验证命令:

gofmt -w main.go
go run main.go
go test ./...

16. 工程实践清单

  • 可预期失败返回 error;panic 只用于不变量破坏或明确的 Must 契约。
  • 每层只增加有用上下文,使用 %w 有意识地公开语义,不比较错误字符串。
  • errors.Is/As 分类和提取;自定义错误字段保持最小、稳定且不含敏感数据。
  • 成功路径返回真正 nil error,避免 typed nil;多错误明确部分成功和数量上限。
  • context 取消与超时单独归类,重试受预算、幂等性和退避策略约束。
  • recover 只设在同 goroutine 的请求、任务或插件边界,记录堆栈且不泄露给客户端。
  • 错误只在拥有完整上下文的边界记录一次,用故障注入和竞态检测覆盖异常路径。

系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。