原创

Go errors.Join 保住多项失败:错误树与 errors.Unwrap 的边界

写入失败,关闭资源又失败,最后日志只剩关闭错误,最初的写入原因被覆盖了。保留主错误、丢掉清理错误也不理想,因为资源没有正常收尾本身可能值得追查。

Go的errors.Join可以把多项失败组成一棵可遍历错误树。它不是把几段文本拼成一个普通errors.New;errors.Is和errors.As仍能沿着各分支查找原因。但不是每个老式“拆一层”的工具都理解多分支,尤其errors.Unwrap只处理单错误展开接口。

以下使用Go 1.24.1。errors.Join从Go 1.20提供,旧版本不能直接编译这份程序。例子以布尔开关模拟写入和关闭失败,重点验证传播合同,不声称真实文件系统出现过这些故障。

主操作与清理各保留自己的原因

保存main.go,在独立模块执行go run .。

package main

import (
    "errors"
    "fmt"
)

var ErrWrite = errors.New("write failed")
var ErrClose = errors.New("close failed")

type OperationError struct { Operation string; Cause error }
func (e *OperationError) Error() string { return e.Operation + ": " + e.Cause.Error() }
func (e *OperationError) Unwrap() error { return e.Cause }

func simulatedSave(writeFails, closeFails bool) (err error) {
    defer func() {
        var closing error
        if closeFails { closing = &OperationError{"close", ErrClose} }
        err = errors.Join(err, closing)
    }()
    if writeFails { return &OperationError{"write", ErrWrite} }
    return nil
}

func main() {
    for _, sample := range []struct { write, close bool }{
        {false, false}, {true, false}, {false, true}, {true, true},
    } {
        err := simulatedSave(sample.write, sample.close)
        fmt.Printf("write=%t close=%t -> nil=%t hasWrite=%t hasClose=%t\n",
            sample.write, sample.close, err == nil,
            errors.Is(err, ErrWrite), errors.Is(err, ErrClose))
        if (err == nil) != (!sample.write && !sample.close) { panic("result status") }
    }
    both := simulatedSave(true, true)
    if !errors.Is(both, ErrWrite) || !errors.Is(both, ErrClose) { panic("lost cause") }
    var operation *OperationError
    if !errors.As(both, &operation) || operation.Operation != "write" { panic("first matching branch") }
    fmt.Println("first operation:", operation.Operation)
    fmt.Println("single Unwrap is nil:", errors.Unwrap(both) == nil)
    branches, ok := both.(interface { Unwrap() []error })
    if !ok || len(branches.Unwrap()) != 2 { panic("expected two branches") }
    fmt.Println("direct branches:", len(branches.Unwrap()))
    if errors.Join(nil, nil) != nil { panic("all nil should be nil") }
    wrapped := fmt.Errorf("save document: %w", both)
    if !errors.Is(wrapped, ErrClose) { panic("single wrapper cannot hide tree") }
    fmt.Println("outer wrapper still finds close:", errors.Is(wrapped, ErrClose))
}

四组状态分别得到成功、仅写入失败、仅关闭失败、两者失败。errors.Is能同时识别两份原因,最外面再用一次%w包装也不会丢失关闭分支。

defer使用具名返回值err,在主函数确定结果后合并清理失败。这里没有把关闭错误无条件赋给err,也没有在每条return前复制一份清理逻辑,因此正常与失败路径遵守相同传播规则。

Join 组成树,不意味着所有分支地位相同

示例把主操作放在第一项,清理放在第二项。errors.As按相应遍历顺序找到第一份可赋值结果,因此拿到write,而不是返回全部OperationError。

若调用方需要完整错误清单,就不能只调用一次As再以为其余失败不存在。可以在产生错误时另外保留结构化结果,或者设计明确遍历器处理单分支与多分支接口。业务需要按文件统计结果时,往往直接返回逐项结果更清楚,不必从最终日志文本倒推每一项。

优先级也应由接口定义。比如主写入失败决定整次保存失败,清理错误用于附加诊断;如果写入成功、关闭失败则不能继续显示“保存成功”。Join帮忙保存信息,不替业务选择重试、补偿或对外状态。

重新执行主操作也不一定能修复关闭错误。尤其已产生外部副作用时,重试可能重复写入,需另行保证幂等。不能把“树里某个错误可重试”解释为整棵树对应的业务动作都可无条件重来。

为什么 errors.Unwrap 看起来找不到任何东西

errors.Unwrap识别Unwrap()error,不调用Unwrap()[]error。Join结果拥有多分支展开形式,前者返回nil是接口范围的结果,不证明里面没有原因。

老代码若一直写for err!=nil再逐层Unwrap,会在Join节点停下。迁移时应该检查这些自定义工具,尤其是分类、监控标签和错误脱敏组件。只把产生错误的一侧改成Join,消费侧仍按链条假设工作,就可能漏掉实际原因。

errors.Is和As已经理解错误树,适合寻找某个原因或类型。对同一节点既可能有单分支也可能有多分支的处理,应该根据接口类型分别访问,别通过Error字符串里的换行判断。

组合查询还应避免把不同分支的事实拼成同一原因。一次As拿到write操作,同时一次Is在整棵树里找到ErrClose,并不表示write的底层原因就是关闭失败。需要判断某个OperationError对应什么原因时,应继续检查那一节点自己的Cause,而不是把全树存在性当成同一分支关联。

错误顺序也不适合依赖并发任务完成先后。若多个任务结果进入切片的顺序不稳定,As第一项就可能变化;业务需要稳定主因时,应在合并前按明确策略排列,或者用单独字段表达主错误。仅仅让日志输出排序好看,不等于建立了稳定分类合同。

忽略 nil,不等于把任何空指针都变成成功

Join会忽略真正的nil错误,全部为nil时返回nil,这正好支持示例的成功路径。但Go接口中装着某个具体指针类型的空指针,不等于nil接口;这种值仍可能被当作错误分支保留。

因此错误产生函数应在成功时返回真正nil,不能返回一个没有实例的自定义错误指针,让调用方替它猜。Error方法是否能处理空接收者,也不能改变接口判空规则。这是独立于Join的Go接口合同,合并错误以后仍然存在。

也不要依赖Join返回对象与某个原错误指针相等。需要原因匹配时使用errors.Is,需要类型匹配时用errors.As,让代码表达它究竟在判断身份、原因还是具体结构。

文本适合诊断,协议字段还需单独设计

Join的Error表示可以包含多行原因,直接拿来当HTTP响应或日志单行字段,可能破坏格式,也可能暴露不适合公开的底层信息。对外返回稳定错误码和必要说明,内部保留结构化原因,更容易控制使用范围。

错误树本身没有并发等待能力。多个goroutine的失败要先由合适的同步流程收集,再决定怎样合并;把err列表交给Join,不会让仍在运行的任务自动结束,也不提供取消信号。

重新核对最开始的写入与关闭场景,关键证据是两份errors.Is都为true,而不是最终字符串里恰好出现了两个短语。这样以后再加外层上下文、调整日志语言,错误分类仍能沿着原因继续工作。

正文到此结束
评论插件初始化中...
Loading...