Go 1.20 多错误处理入门:errors.Join 怎么用才自然

本文用批量校验、资源关闭和并发任务三个例子讲解 Go 1.20 errors.Join 的基本用法,帮助初学者理解多错误处理的边界。

一个操作可能不只产生一个错误

Go 代码里最常见的错误处理方式是返回一个 error。这很清楚:函数成功返回 nil,失败返回错误。可是有些场景并不只有一个失败点。比如导入 CSV 时,100 行里有 5 行格式错误;关闭多个资源时,文件关闭失败,网络连接关闭也失败;并发执行多个任务时,几个任务都返回了错误。过去很多代码会只返回第一个错误,或者手写一个 []error,再把它格式化成字符串。

Go 1.20 引入了 errors.Join,它允许你把多个错误合并成一个 error。合并后的错误仍然能被 errors.Iserrors.As 判断。这一点很重要:它不是简单地把字符串拼起来,而是保留了错误链的结构。

这篇文章用几个入门场景讲 errors.Join。重点不是“所有地方都要 Join”,而是知道什么时候多个错误都值得保留。

最小示例

err := errors.Join(
	fmt.Errorf("email is required"),
	fmt.Errorf("password is too short"),
)

fmt.Println(err)

输出会包含两条错误信息。errors.Join 会忽略 nil:

err := errors.Join(nil, fmt.Errorf("failed"), nil)
fmt.Println(err)

如果全部是 nil,结果就是 nil:

err := errors.Join(nil, nil)
fmt.Println(err == nil) // true

这个特性让收集错误很方便。你可以在循环里 append 错误,最后统一 Join。

批量校验

假设导入用户行:

type UserRow struct {
	Line  int
	Email string
	Age   int
}

定义可判断错误:

var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidAge = errors.New("invalid age")

校验一行:

func ValidateRow(row UserRow) error {
	var errs []error

	if !strings.Contains(row.Email, "@") {
		errs = append(errs, fmt.Errorf("line %d: %w", row.Line, ErrInvalidEmail))
	}
	if row.Age < 0 {
		errs = append(errs, fmt.Errorf("line %d: %w", row.Line, ErrInvalidAge))
	}

	return errors.Join(errs...)
}

批量校验:

func ValidateRows(rows []UserRow) error {
	var errs []error
	for _, row := range rows {
		if err := ValidateRow(row); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

调用:

if err := ValidateRows(rows); err != nil {
	if errors.Is(err, ErrInvalidEmail) {
		fmt.Println("some rows contain invalid email")
	}
	return err
}

即使错误被包装并合并,errors.Is 仍然能判断出其中包含 ErrInvalidEmail。这比单纯拼字符串可靠。

资源关闭时保留多个错误

关闭多个资源时,以前常常只返回第一个错误:

func CloseAll(closers ...io.Closer) error {
	for _, closer := range closers {
		if closer == nil {
			continue
		}
		if err := closer.Close(); err != nil {
			return err
		}
	}
	return nil
}

这样第二个、第三个关闭错误都会丢失。改成:

func CloseAll(closers ...io.Closer) error {
	var errs []error
	for _, closer := range closers {
		if closer == nil {
			continue
		}
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

清理流程里,多个错误都可能有排查价值。比如日志文件 flush 失败,同时网络连接关闭失败,保留完整信息比只知道第一个失败更好。

什么时候不用 Join

如果业务只需要第一个错误,直接返回第一个更清楚。比如登录接口,邮箱和密码都错时,你未必想把所有细节都返回给用户。为了安全,很多登录接口只返回统一的“账号或密码错误”。

如果前端需要逐字段展示错误,结构化列表可能比 errors.Join 更合适:

type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

然后返回:

type ValidationResult struct {
	Errors []FieldError `json:"errors"`
}

errors.Join 适合在 Go 调用链里保留多个错误,不一定适合作为用户界面数据结构。

给多错误写测试

多错误处理最怕“看起来有错误,实际上上层无法判断”。所以测试不要只判断 err != nil,还要验证 errors.Is 是否能识别内部错误。

func TestValidateRows(t *testing.T) {
	rows := []UserRow{
		{Line: 2, Email: "bad-email", Age: 18},
		{Line: 3, Email: "ok@example.com", Age: -1},
	}

	err := ValidateRows(rows)
	if err == nil {
		t.Fatal("expected error")
	}
	if !errors.Is(err, ErrInvalidEmail) {
		t.Fatalf("expected ErrInvalidEmail, got %v", err)
	}
	if !errors.Is(err, ErrInvalidAge) {
		t.Fatalf("expected ErrInvalidAge, got %v", err)
	}
}

还可以测试全成功时返回 nil:

func TestValidateRowsOK(t *testing.T) {
	rows := []UserRow{
		{Line: 2, Email: "ok@example.com", Age: 18},
	}
	if err := ValidateRows(rows); err != nil {
		t.Fatalf("ValidateRows() error = %v", err)
	}
}

这类测试能防止后来有人把 %w 改成 %v,导致错误链断掉。错误处理代码也需要回归测试,因为它往往只在异常场景触发,手工测试最容易漏。

小结

errors.Join 适合批量处理、资源清理、并发任务收集错误这类场景。它能把多个错误合并成一个 error,同时保留 errors.Iserrors.As 的判断能力。

入门阶段记住一个标准:多个错误都对调用方或排查有价值时,用 Join;只关心第一个失败,或者需要结构化展示给用户时,不必强行使用。错误处理的目标始终是让失败更清楚,而不是使用最新 API。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页