登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

errors.Join 聚合多个清理错误的返回方案

来源:17golang原创

时间:2026-11-12 16:37:31 302浏览 收藏

在一个函数里关闭文件、响应体、事务或临时资源时,清理动作往往不止一个。若每次都覆盖前一个错误,调用方最后只能看到最后一次关闭失败,前面的原因就丢了。Go 1.20 加入的 errors.Join 可以把多个非 nil 错误合并成一个返回值,并继续支持 errors.Is 和 errors.As 检查。

实用做法是:先把清理阶段产生的非 nil 错误收集到切片,再用 errors.Join(errs...) 返回。所有元素都是 nil 时它返回 nil;存在错误时返回一个实现 Unwrap() []error 的聚合错误。

官方资料:https://pkg.go.dev/errors#Join

为什么清理错误不能只保留最后一个

常见的旧写法是让多个 defer 依次给命名返回值赋值。由于 defer 按后进先出执行,最后一次赋值会覆盖前面的错误。这个问题在“主流程成功、清理阶段失败”时尤其容易被忽略:函数表面上返回了错误,但调用方无法知道还有哪些资源没有正确关闭。

Go 清理多个资源并用 errors.Join 聚合错误的关系说明图
图1:多个资源清理错误进入 errors.Join 的静态说明图,不是运行截图。

聚合的重点不是把错误文本拼成一段日志,而是让返回值保留错误树。这样既能让日志看到多个原因,又能让上层继续按具体错误类型做判断。

先收集清理阶段的非 nil 错误

可以把资源关闭逻辑放进一个小函数,由它负责收集错误。下面的示例模拟文件、响应体和事务三个清理动作;实际项目中把三个变量替换成真实资源即可。

package main

import (
    "errors"
    "fmt"
)

type closeResource func() error

func closeAll(resources ...closeResource) error {
    var cleanupErrs []error
    for _, close := range resources {
        if err := close(); err != nil {
            // 只保存失败的清理动作,成功关闭不应进入返回值。
            cleanupErrs = append(cleanupErrs, err)
        }
    }

    // Join 会再次忽略 nil;切片为空时返回 nil。
    return errors.Join(cleanupErrs...)
}

func main() {
    err := closeAll(
        func() error {
            // 模拟文件关闭失败。
            return errors.New("file close: flush failed")
        },
        func() error {
            // 模拟响应体已经正常关闭。
            return nil
        },
        func() error {
            // 模拟事务回滚失败。
            return errors.New("tx rollback: connection reset")
        },
    )
    fmt.Println(err)
}

这个函数有三个值得保留的细节:

  • 只在关闭动作返回非 nil 时追加,避免把“成功”伪装成错误。
  • 按资源关闭的调用顺序收集错误,输出顺序更容易和清理代码对应。
  • 统一由 errors.Join 处理空切片和多个成员,调用方仍然只接收一个 error。

用 errors.Join 组合主流程错误与清理错误

真实业务函数通常还有一个主流程错误。例如写入失败后需要回滚,回滚又可能失败。此时不要让清理错误覆盖主流程错误,而是把两者一起加入聚合列表。

func process() (err error) {
    file, err := openFile()
    if err != nil {
        // 文件都没有打开成功,不需要进入清理阶段。
        return err
    }

    defer func() {
        // 关闭文件失败时,把它和主流程错误一起返回。
        err = errors.Join(err, file.Close())
    }()

    if err = writeData(file); err != nil {
        // 命名返回值保留写入错误,defer 会在返回前追加关闭结果。
        return err
    }
    return nil
}

这里的关键是 errors.Join(nil, closeErr) 会得到关闭错误,而 errors.Join(mainErr, nil) 会保留主流程错误。两个都为 nil 时最终仍是 nil,因此不需要为“是否只有一个错误”再写一层分支。

如果清理动作不止一个,可以在 defer 中先调用 closeAll,再把结果与命名返回值合并:

defer func() {
    cleanupErr := closeAll(file.Close, response.Body.Close, tx.Rollback)
    // 主流程和清理阶段都可能失败,统一交给 Join 保留两边的信息。
    err = errors.Join(err, cleanupErr)
}()

聚合后仍然可以用 errors.Is 和 errors.As

errors.Join 返回的错误不是只能打印的字符串。官方实现让非 nil 结果提供 Unwrap() []error,因此 errors.Is 可以在聚合结果的成员中查找哨兵错误,errors.As 可以提取某一种具体错误类型。

var (
    errFileClosed = errors.New("file close failed")
    errRollback   = errors.New("rollback failed")
)

joined := errors.Join(errFileClosed, errRollback)

if errors.Is(joined, errRollback) {
    // 聚合错误中包含回滚失败,可以触发补偿或告警。
    fmt.Println("rollback needs attention")
}

type timeoutError struct{}

func (timeoutError) Error() string { return "cleanup timeout" }

var timeout timeoutError
joined = errors.Join(joined, timeoutError{})
if errors.As(joined, &timeout) {
    // As 能从错误树中取出匹配的具体类型。
    fmt.Println("cleanup timed out")
}

需要读取全部成员时,可以通过接口断言取得 Unwrap() []error。这适合记录结构化清理结果,但不要依赖标准库内部的具体类型名称;只依赖这个公开的解包方法即可。

type multiUnwrapper interface {
    Unwrap() []error
}

if multi, ok := joined.(multiUnwrapper); ok {
    for _, item := range multi.Unwrap() {
        // 逐个记录成员,保留原始 error 供后续判断。
        fmt.Println(item)
    }
}

和“只返回最后一个错误”的方案有什么区别

只返回最后一个清理错误与 errors.Join 保留多个错误的对比说明图
图2:覆盖式返回与 errors.Join 聚合式返回的边界对比图,不是运行截图。
处理方式调用方能看到什么适用情况
每次覆盖返回值通常只剩最后一次清理失败前序清理错误确实不需要保留时
手工拼接字符串文本完整,但失去类型检查仅用于展示日志,不作为程序错误返回值
errors.Join多个错误文本与错误树都保留多个清理动作都影响诊断或补偿时

errors.Join 也不是所有场景都该使用。如果错误之间存在明确的因果链,例如“读取配置失败,因为文件不存在”,应该使用 %w 包装来表达一条链;只有多个相对独立、需要同时呈现的失败,才适合聚合。另一个边界是错误数量:批量处理数万条记录时,不应无上限地把每一条原始错误都放入内存,应该在业务层先做数量限制或汇总。

落地时记住这三个判断

  1. 清理动作相互独立且可能同时失败时,先收集非 nil 错误,再调用 errors.Join。
  2. 主流程已经有错误时,不要用清理错误覆盖它,用 errors.Join(mainErr, cleanupErr) 保留两类原因。
  3. 需要判断具体原因时使用 errors.Is 或 errors.As,不要解析 Error() 返回的换行文本。

常见问题

errors.Join 传入 nil 会不会报错?

不会。nil 会被丢弃;当所有参数都是 nil 时,返回值就是 nil。

errors.Join 的输出为什么有多行?

多个成员的错误文本会按参数顺序用换行连接。这个格式适合日志展示,但程序判断仍应使用 errors.Is 或 errors.As。

只有一个错误时还需要 Join 吗?

可以统一调用。单个非 nil 成员会保留其错误文本和可检查性,统一入口能减少清理代码中的条件分支。

errors.Join 能替代 fmt.Errorf("%w") 吗?

不能完全替代。%w 表达一条带上下文的错误链,Join 表达多个并列成员;根据错误之间的关系选择对应方式。

参考资料:https://pkg.go.dev/errors#Join、https://go.dev/src/errors/join.go、https://go.dev/doc/go1.20

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>