编程 现代 Go 错误处理:errors.Join 聚合、Go 1.26 errors.AsType 泛型断言与工程取舍

2026-09-07 21:32:05

现代 Go 错误处理:从 %w 包装到 errors.AsType 的工程取舍

Go 把错误当普通值处理:错误不是隐式异常,而是正常控制流的一部分。函数遇到错误时,把错误连同其他返回值一起返回,由调用方检查并处理。

error 接口只有一个方法:

type error interface {
    Error() string
}

任何实现该方法的类型都能作为自定义错误类型。

返回错误:惯例与边界

错误惯例放在返回值最后。多数函数遇到错误时自身不具备完整上下文,应当把错误返回上层处理:

if path == "" {
    return errors.New("path is empty")
}

如果 os.Open 失败,用 %w 包装底层错误:

return fmt.Errorf("open failed: %w", err)

读完数据后还要检查 io.ReadAll 的返回值。

panic/recover:不是 try/catch

panic 会脱离常规调用流程强行展开栈,中间函数的错误处理全部失效;Go 没有 throws 语法,签名上无法看出谁会 panic。可预见的失败(用户输入非法、文件缺失、网络超时)一律走 return error

panic 只保留给两类情况:

  • 写死且必然合法的代码失效,例如硬编码正则的 regexp.MustCompile 编译失败;
  • 完全无法挽回的底层灾难,例如内存耗尽。

recover 的正确用途是挡住单次请求边界,例如 HTTP server 中某个 handler panic 不拖垮整个进程:

defer func() {
    if r := recover(); r != nil {
        // 记录日志并恢复
    }
}()

注意 defer 调用要加括号,不能漏。

错误日志:库内不要打印

标准库 log 提供 log.Printf,Go 1.21 起结构化日志用 slog。通用类库内部不要打印日志,错误应返回上层,由使用方决定日志框架和输出目标。

错误包装:无脑拼接是错的

错误沿调用链冒泡时,每层可以附加上下文,同时保留原始错误,方便后续解包检查。只有当你无法添加任何有价值的信息时,才原样 return err

字符串拼接会压平底层类型:

errors.New("open failed: " + err.Error())

这会丢失类型和结构化信息。正确做法是 fmt.Errorf + %w

解包与检查:Is/As/AsType

%w 包装的错误实现了 Unwrap() erroros.Open 返回的底层错误是 *fs.PathError,包含 Op(操作)、Path(路径)、Err 字段。

errors.Is

判断错误是否同种或包装了某个哨兵值,例如 fs.ErrNotExist

if errors.Is(err, fs.ErrNotExist) {
    // 文件不存在
}

errors.As

判断错误链中是否存在某个类型,并解出实例。需要先声明指针变量再传指针,走反射:

target := &fs.PathError{}
if errors.As(err, &target) {
    // 可访问 target.Path / target.Op / target.Err
}

errors.AsType(Go 1.26)

泛型版本,类型写进尖括号,返回匹配实例和布尔值:

func AsType[E error](err error) (E, bool)

它免去反射,类型错误在编译期拦截——例如本该传指针却传值会直接编译失败。errors.As 没有废弃,新代码优先使用 AsType。连续检查多种类型时,每个匹配变量的作用域限定在各自分支:

if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
    // ...
} else if linkErr, ok := errors.AsType[*os.LinkError](err); ok {
    // ...
}

errors.Join:合并多个错误(Go 1.20)

批处理读取多个文件时,希望任一失败不中断其余文件,最后收集全部错误合并为一个:

func ReadFiles(paths []string) ([][]byte, error) {
    var contents [][]byte
    var errs error
    for _, path := range paths {
        data, err := os.ReadFile(path)
        if err != nil {
            errs = errors.Join(errs, fmt.Errorf("reading %s failed: %w", path, err))
            continue
        }
        contents = append(contents, data)
    }
    return contents, errs
}

注意,合并后的错误调用 errors.Unwrap() 返回 nil,因为 join 错误底层是 []error,而 Unwrap 只能返回单个 error。要拿到底层切片,需要类型断言:

e, ok := err.(interface{ Unwrap() []error })
if ok {
    for _, subErr := range e.Unwrap() {
        // ...
    }
}

Context 错误:WithCancelCause 追溯根因

Go 1.20 起,context.WithCancelCause 允许在 cancel 时附带自定义错误:

ctx, cancel := context.WithCancelCause(parent)

// 正常结束:原因默认为 context.Canceled
defer cancel(nil)

// 出错时取消:原因带上自定义错误
cancel(fmt.Errorf("my error: %w", myError))

之后包括该 ctx 在内的相关方调用 context.Cause(ctx) 可以取回自定义错误。ctx.Err() 仍返回 context.Canceled,但 Cause 能返回真正出错的原因,适用于级联取消时追溯根因。

工程实践

  1. 善用 defer 清理资源:函数有多个退出路径时,用 defer f.Close() 统一释放资源。defer 必须放在错误检查之后:如果 os.Open 失败返回的是 nil 句柄,先 defer 会在函数退出时空指针解引用。
  2. 提供明确具体的错误上下文:不要写 "EPIC FAIL" 这类无线索日志。函数遇到错误时不应原样直传,应当包装附加排查所需的上下文。
  3. 只在必要时 panic/recover:不要为了省事让函数 panic、由顶层 recover 兜底。
  4. 慎选第三方错误处理实现:会吞错误、不透传错误、错误缺少上下文的库会让排障碰运气。引入前翻源码确认其错误处理策略。
  5. 在合适场景定义自定义错误类型:如 PathError 实现 Error/Unwrap/Timeout,并携带 Op/Path/Err 结构化字段。
  6. 对网络错误、I/O 错误做针对性处理,而不是统一走一条模糊分支。

常见陷阱

  • 忽略错误:用 _ 丢弃返回值;
  • 传递错误时不包装上下文;
  • 错误信息过于宽泛笼统;
  • 使用了不恰当的错误类型;
  • 遗漏错误日志:
  • log.Fatal 记录错误——它会直接 os.Exit,跳过 defer 清理;
  • 没有充分考虑错误恢复边界。

推荐文章

程序员茄子在线接单