一个操作可能不只产生一个错误
Go 代码里最常见的错误处理方式是返回一个 error。这很清楚:函数成功返回 nil,失败返回错误。可是有些场景并不只有一个失败点。比如导入 CSV 时,100 行里有 5 行格式错误;关闭多个资源时,文件关闭失败,网络连接关闭也失败;并发执行多个任务时,几个任务都返回了错误。过去很多代码会只返回第一个错误,或者手写一个 []error,再把它格式化成字符串。
Go 1.20 引入了 errors.Join,它允许你把多个错误合并成一个 error。合并后的错误仍然能被 errors.Is 和 errors.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.Is 和 errors.As 的判断能力。
入门阶段记住一个标准:多个错误都对调用方或排查有价值时,用 Join;只关心第一个失败,或者需要结构化展示给用户时,不必强行使用。错误处理的目标始终是让失败更清楚,而不是使用最新 API。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。