Skip to content

Go 错误处理与 panic:设计可判断、可追踪的失败

面向有 Java 经验的开发者,基于 Go 1.26。

Go 没有把所有失败都塞进异常控制流。可预期的失败通常以 error 值返回,由调用方在当前控制流中决定重试、降级、转换还是终止;panic 则留给程序不变量已经破坏、无法继续执行的情况,以及少数包内部的快速展开。

表面上看,这套机制只是不断写 if err != nil。真正难的是 API 设计:调用者究竟需要知道什么?哪些底层错误属于公共契约?日志应该在哪一层记录?重试会不会把一次操作执行两遍?后面的每一种写法,都应回到这些问题上判断。

目录

1. 错误是值,不是隐形跳转

预声明接口只有一个方法:

go
type error interface {
	Error() string
}

任何实现 Error() string 的类型都可以作为错误。函数签名把失败写进契约:

go
func LoadUser(ctx context.Context, id string) (User, error)

调用方从签名就能知道操作可能失败,错误在哪一行被处理也很清楚:

go
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

go
func ValidateName(name string) error {
	if strings.TrimSpace(name) == "" {
		return errors.New("name is empty")
	}
	return nil
}

若前面的结果在错误时没有意义,返回它的零值:

go
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:

go
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

go
func good() error {
	var err *ParseError
	if err == nil {
		return nil
	}
	return err
}

同样要小心返回 error 的泛型、回调和结构字段,不要把具体类型的 nil 指针装入接口。

3. 创建和返回错误

3.1 errors.New 与 fmt.Errorf

固定文本:

go
var errEmptyName = errors.New("empty name")

带运行时上下文:

go
return fmt.Errorf("user %q has invalid age %d", name, age)

错误消息通常:

  • 以小写开头;
  • 不加句号或换行,方便上层继续包装;
  • 描述失败动作和关键上下文;
  • 不放密码、token、完整隐私数据;
  • 不承担机器分类。

逐层包装后可以自然读成:

text
create invoice "inv-42": load customer "u-7": database unavailable

3.2 原样返回还是包装

若当前层没有新增语义,原样返回即可:

go
if err := encoder.Encode(v); err != nil {
	return err
}

跨越一个有意义的动作或资源边界时加上下文:

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

不要在每一层都机械地补一句“failed to”。一条有用的错误链应当像调用过程摘要,而不是重复的文字版堆栈。

3.3 只处理一次

一个常用原则是:要么处理,要么返回。如果当前层只能记录后继续返回,错误可能在多层重复打印:

go
// 不推荐:库层打印一次,HTTP 层又打印一次。
if err != nil {
	log.Printf("query failed: %v", err)
	return err
}

库通常返回带上下文的错误;进程、任务、请求等所有权边界负责记录。

4. 添加上下文与错误包装

fmt.Errorf%w 会保留底层错误,使调用方可以沿错误树检查:

go
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 只把文本放进新错误,不建立包装关系:

go
fmt.Errorf("read profile: %v", err) // errors.Is/As 找不到 err

选择 %w 是 API 决策。包装一个底层错误,等于允许调用方依赖它的身份或类型。若以后可能更换存储实现,不希望公开数据库驱动错误,应在仓储边界转换成自己的领域错误。

现代 fmt.Errorf 可以包含多个 %w,结果形成多个子错误;大多数聚合场景用 errors.Join 意图更清楚。

5. errors.Is:按身份或语义判断

不要通过字符串判断:

go
if err.Error() == "file does not exist" { // 脆弱
}

正确方式:

go
if errors.Is(err, fs.ErrNotExist) {
	// 缺少文件
}

if errors.Is(err, context.Canceled) {
	// 调用方取消
}

errors.Is 会对错误树做前序深度优先遍历,检查当前错误及其由 Unwrap() errorUnwrap() []error 返回的子错误。默认用相等性比较,也允许错误类型实现:

go
type semanticMatcher interface {
	Is(target error) bool
}

自定义 Is 应是浅比较,不能再递归调用 errors.Is

go
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

go
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,写法是:

go
var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// 使用 validationErr.Fields
}

这看起来像二级指针,是因为 As 需要改写变量本身。

也可以按能力提取接口:

go
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

go
pathErr, ok := errors.AsType[*fs.PathError](err)
if ok {
	fmt.Println(pathErr.Op, pathErr.Path)
}

它不再要求准备一个“指向目标变量的指针”,也避免了传错目标形态造成的 panic;在大多数只想取得某个具体错误类型的场景中更简洁。需要兼容较早 Go 版本的库,或者目标本来就是一个接口能力时,继续使用 errors.As 也很自然。

7. errors.Join 与错误树

多个独立清理或并行任务都可能失败时,用 errors.Join

go
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.Iserrors.As 能搜索每个分支;
  • 默认文本以换行连接各错误。

错误不再总是一条链,而可能是一棵树。errors.Unwrap 只识别 Unwrap() error,不会展开 errors.Join[]error。若要自定义遍历,应同时识别两种方法:

go
type singleUnwrapper interface {
	Unwrap() error
}

type multiUnwrapper interface {
	Unwrap() []error
}

不要为了收集错误而掩盖主错误。事务提交失败后,回滚也失败时,二者都重要:

go
return errors.Join(
	fmt.Errorf("commit: %w", commitErr),
	fmt.Errorf("rollback after commit failure: %w", rollbackErr),
)

但如果后续清理没有执行意义,应该停止,而不是勉强制造一堆衍生错误。

8. 哨兵错误怎么设计

哨兵错误是包级、可比较的固定值:

go
var ErrNotFound = errors.New("catalog: not found")

调用方:

go
if errors.Is(err, catalog.ErrNotFound) {
	// 映射成 HTTP 404
}

适合稳定、无额外数据的状态,例如“不存在”“已关闭”“无结果”。一旦导出,它就是兼容性契约,删除或改成无法 Is 匹配会破坏调用方。

当调用方需要字段时,用自定义类型;当只需要分类时,也可以不导出哨兵,而让自定义错误通过 Is 匹配导出的类别。

不要为每段错误文字都创建哨兵。哨兵表示程序要分支处理的语义,不是消除 errors.New 的代码风格。

9. 自定义错误类型

9.1 携带稳定字段

go
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 不要把底层实现泄露到领域层

仓储接口可以定义:

go
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 边界与错误转换

不同层需要不同表达:

text
驱动错误 -> 基础设施错误 -> 领域错误 -> 传输协议状态

一个 HTTP 边界:

go
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:

go
return fmt.Errorf("%w: customer %q", ErrNotFound, id)

这保留领域类别,但没有暴露驱动错误。若诊断必须保留底层原因,可以定义同时包装类别与 cause 的类型,或用 errors.Join;注意这会把两个分支都纳入 Is/As 契约。

11. defer 的完整语义

defer 把调用安排在当前函数返回前执行。

11.1 三条核心规则

  1. defer 语句执行时,函数值和实参立即求值;
  2. 延迟调用按后进先出执行;
  3. 延迟函数可以读取和修改命名返回值。
go
func example() (n int) {
	n = 1
	defer func() { n++ }()
	return n // 最终返回 2
}

11.2 关闭资源时别漏掉错误

只读文件通常可以:

go
f, err := os.Open(path)
if err != nil {
	return err
}
defer f.Close()

写文件的 Close、缓冲区的 Flush 可能报告真正的数据落盘错误,不能总是忽略:

go
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 会让资源一直积累到函数返回:

go
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 只能在正确位置生效

go
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
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 堆栈。服务边界通常同时记录:

go
defer func() {
	if v := recover(); v != nil {
		logger.Error("panic",
			"value", v,
			"stack", string(debug.Stack()),
		)
	}
}()

恢复的目标是隔离请求或任务,不是装作没有缺陷。要记录、计数、告警,并根据状态是否可能损坏决定是否继续进程。内存结构已经处于未知状态时,盲目恢复可能比崩溃更危险。

12.4 Must 函数

go
var pageTemplate = template.Must(template.ParseFS(assets, "page.html"))

Must 适合包初始化阶段的静态资源:错误意味着构建或部署有问题,程序无法合理运行。不要在处理用户输入的请求路径上用 Must

13. HTTP、goroutine 与进程边界

13.1 HTTP handler 适配器

把业务处理函数写成返回 error,统一在适配器里转换和记录:

go
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.Fatalos.Exit 会直接终止进程,延迟函数不会执行。库和深层函数不应调用它们。让 run 返回 error,在 main 最外层决定退出:

go
func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

需要清理的资源放在 run 内,run 返回前 defer 仍会执行。

14. 日志、指标与链路追踪

错误对象和日志记录职责不同:

  • 错误携带语义和调用上下文;
  • 日志记录某次事件、时间、请求 ID 和运行环境;
  • 指标统计类别、频率和延迟;
  • trace 连接跨服务调用。

推荐在少数所有权边界记录一次完整错误:

go
slog.ErrorContext(ctx, "invoice generation failed",
	"error", err,
	"invoice_id", invoiceID,
	"request_id", requestID,
)

避免:

  • 把错误文本用作指标 label,导致高基数;
  • 每层重复记录同一错误;
  • 同时在消息和字段重复完整错误;
  • 将秘密、SQL 参数、文件内容写入日志;
  • 因为已经记录就返回 nil。

可用稳定分类生成指标:

go
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 再调用一次”。先回答:

  1. 错误是暂时的吗?
  2. 操作是否幂等?
  3. 前一次是否可能已经成功,只是响应丢了?
  4. 重试预算由哪一层拥有?
  5. 是否尊重 context 截止时间和取消?

一个简化的有界退避:

go
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.DeadlineExceededcontext.Canceled 通常不应被层层改写成模糊的“internal error”。使用 errors.Is 保留其可识别性,context.Cause 可读取带原因的取消。

16. 清理失败与事务式回滚

多个阶段的函数要明确每一步失败后的补偿:

go
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. 测试错误契约

测试机器可见语义,不要把完整错误文字锁死:

go
func TestFindMissing(t *testing.T) {
	_, err := repo.Find(context.Background(), "missing")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

自定义类型:

go
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:

go
value, ok := cache.Lookup(key)

若“不存在”属于正常分支,ok 往往比每次构造错误更合适;若调用方必须解释失败,则 error 更清晰。

性能注意:

  • fmt.Errorf 格式化和包装会分配;
  • 堆栈采集昂贵,只在合适边界采集;
  • 不要每层重复创建同义错误和日志;
  • 重试放大负载,比 error 分配更值得关注;
  • 基准必须覆盖失败比例,不能只测成功路径。

19. 与 Java 异常的对照

GoJava设计含义
error 返回值checked/unchecked exceptionGo 在普通控制流中显式处理
fmt.Errorf("%w")exception cause是否暴露 cause 是 API 契约
errors.Is按类别/cause 判断支持自定义语义匹配与错误树
errors.Ascatch / instanceof提取包装树里的具体类型或能力
errors.Joinsuppressed/aggregate exception多个并列原因组成树
deferfinally / try-with-resources函数作用域、后进先出
panic/recoverunchecked exception/catch不用于普通业务失败,且不能跨 goroutine recover

Java 开发者常把 Go 的 panicthrow 使用,这是最需要纠正的迁移习惯。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
带上下文但不需要 causefmt.Errorf("...: %v", err) 或新错误
保留可判断的 causefmt.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 可以同时保留两者。

go
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))
}

运行:

bash
go run ./examples/ch14/wrap-is

预期输出:

text
error: 查询用户 u-42: not found
is not found: true

错误链在这里建立。

  1. sentinel error 表示稳定、可判断的失败类别。
  2. %w 建立错误链,外围文本提供当前操作与参数上下文。
  3. errors.Is 遍历错误链,不依赖可能变化或本地化的错误字符串。

进一步验证。 再包一层 fmt.Errorf("HTTP handler: %w", err)errors.Is 仍应为 true;随后把 %w 改成 %v,观察错误链为什么会被截断。

示例二:Join 保留并行失败,As 提取结构化错误

并列失败不能只留一个。 表单的姓名和年龄可以同时非法,只返回一个错误会迫使用户反复提交。errors.Join 保留多个分支,每个自定义错误又能通过 As 提取字段。

go
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)
	}
}

运行:

bash
go run ./examples/ch14/as-join

预期输出:

text
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 在调用边界恢复,并转换成带插件名的错误;它不是隔离恶意代码的安全沙箱。

go
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)
}

运行:

bash
go run ./examples/ch14/recover-boundary

预期输出:

text
插件 report panic: 模板损坏
healthy error: <nil>

这个边界只承诺三件事。

  1. recover 位于同一 goroutine 的 deferred closure 中,能拦截该调用栈向外传播的 panic。
  2. 命名返回值让 defer 把 panic 转换为正常错误结果。
  3. 边界仅处理当前插件的 panic;真实服务还应记录 debug.Stack(),并确认共享状态的不变量没有遭到破坏,不能静默吞掉证据后盲目继续。

验证 goroutine 边界。 在插件中启动新 goroutine 并让它 panic,验证外层 runPlugin 无法恢复另一个 goroutine;正确做法是在新 goroutine 自己的入口设置边界。不要让发生内存损坏或全局不变量破坏的进程盲目继续。

22. 练习

  1. 为用户注册定义验证错误、冲突错误和基础设施错误,并在 HTTP 边界映射为稳定状态码。
  2. 写一个返回 typed nil 的最小示例,再修复并添加测试。
  3. 实现批量删除:返回所有失败,用 errors.Join 保证每个底层错误都能通过 errors.Is 找到。
  4. 为配置加载器增加文件路径上下文,同时保留 fs.ErrNotExist 的可判断性。
  5. 实现一个 Retry,支持指数退避、抖动、context 取消、最大总时长和 Retry-After
  6. 给 HTTP handler 增加 panic 边界,测试正常 error、panic 和已经写出响应三种路径。
  7. 审查一个现有服务的日志:找出重复记录、错误文本高基数 label 和敏感字段。
  8. 设计一个写临时文件后原子替换目标文件的函数,正确组合写入、SyncCloseRename 和清理错误。

23. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。