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,而不是最终字符串里恰好出现了两个短语。这样以后再加外层上下文、调整日志语言,错误分类仍能沿着原因继续工作。