目录

Go-13 错误处理最佳实践

1. error 接口

Go 没有 try/catch/finally 这类异常机制,而是把错误当作普通的返回值来处理。函数把错误作为最后一个返回值交给调用方,调用方显式判断并处理。这是 Go 错误处理哲学的基石。

1.1 error 的本质是一个接口

标准库对 error 的定义极其简单,它就是一个只有一个方法的接口:

// 内置类型,定义在 builtin 包中
type error interface {
    Error() string // 返回错误的文字描述
}

任何实现了 Error() string 方法的类型,都可以作为一个 error。这带来了巨大的灵活性:错误不仅是一段文字,还可以是携带任意上下文字段的结构体。

package main

import (
    "errors"
    "fmt"
)

func divide(a, b int) (int, error) {
    if b == 0 {
        // 返回一个 error 值,调用方必须自己判断
        return 0, errors.New("除数不能为 0")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil { // 显式判断,这是 Go 最常见的一行
        fmt.Println("出错了:", err) // 出错了: 除数不能为 0
        return
    }
    fmt.Println("结果:", result)
}

1.2 显式错误处理 vs 异常

维度 Go 的 error 返回值 传统异常(Java / Python)
控制流 线性、显式,错误随返回值传递 隐式跳转,抛出后沿调用栈上溯
可见性 函数签名即声明可能失败 需查文档/注解才知道会抛什么
强制性 编译器不强制,但惯例强制检查 检查型异常需捕获,非检查型可漏
性能 无栈展开开销 抛异常需构造栈,代价高
代码风格 if err != nil 较啰嗦 主流程干净,错误集中处理

Go 的取舍:用少量样板代码换取错误路径的清晰可见。看一个函数的签名,你就知道它会不会失败。

调用方视角:

result, err := doSomething()
        │       │
     正常值   错误值(nil 表示成功)
        │       │
        └───────┴──► 两者通常互斥,err != nil 时 result 应视为无效

2. 创建错误

错误是(value),这是理解 Go 错误处理的关键。既然是值,就可以被存储、比较、传递、包装。

2.1 errors.New

最基础的方式,返回一个只包含字符串的错误:

package main

import (
    "errors"
    "fmt"
)

func main() {
    err := errors.New("文件读取失败")
    fmt.Println(err)         // 文件读取失败
    fmt.Printf("%T\n", err)  // *errors.errorString
}

errors.New 底层返回的是 *errorString 指针,源码非常朴素:

// src/errors/errors.go
func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

注意返回的是指针。这意味着两次 errors.New("同样的文字") 得到的是两个不同的错误值,== 比较为 false。这正是哨兵错误要用包级变量的原因(见第 3 节)。

2.2 fmt.Errorf 格式化错误

需要动态拼接信息时用 fmt.Errorf,它支持格式化占位符:

package main

import "fmt"

func openFile(name string) error {
    return fmt.Errorf("打开文件 %q 失败,错误码 %d", name, 2)
}

func main() {
    err := openFile("config.yaml")
    fmt.Println(err) // 打开文件 "config.yaml" 失败,错误码 2
}

2.3 错误是值

因为错误是普通值,我们可以写出很地道的代码,例如把错误累计起来,或用变量表示某种固定错误:

// 错误可以作为包级变量存在(哨兵错误)
var ErrNotFound = errors.New("记录不存在")

// 错误可以被赋值、传递、放进切片
errs := []error{
    errors.New("字段 name 不能为空"),
    errors.New("字段 age 必须大于 0"),
}

3. 哨兵错误 Sentinel Error

哨兵错误指预先定义好的、可导出的错误变量,调用方用它作为"哨兵"来判断具体是哪种错误。命名惯例是 Err 前缀。

3.1 标准库中的哨兵错误

最经典的例子是 io.EOF

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"
)

func main() {
    r := bufio.NewReader(strings.NewReader("line1\nline2"))
    for {
        line, err := r.ReadString('\n')
        fmt.Print(line)
        if err == io.EOF { // 用哨兵错误判断读到了文件末尾
            fmt.Println("\n[读取完毕]")
            break
        }
        if err != nil {
            fmt.Println("读取出错:", err)
            break
        }
    }
}

3.2 自定义哨兵错误

package repo

import "errors"

// 预定义的哨兵错误,供调用方判断
var (
    ErrNotFound     = errors.New("记录不存在")
    ErrConflict     = errors.New("记录冲突")
    ErrPermission   = errors.New("无权限操作")
)

func FindUser(id int) (string, error) {
    if id <= 0 {
        return "", ErrNotFound
    }
    return "Alice", nil
}

调用方:

name, err := repo.FindUser(-1)
if err == repo.ErrNotFound { // 直接用 == 比较
    fmt.Println("用户不存在,返回默认值")
}

3.3 哨兵错误的缺点:耦合

哨兵错误虽然简单,但存在明显问题:

  • API 耦合:调用方必须 import 你的包才能引用 ErrNotFound,形成源码级依赖。
  • 一旦导出就是契约io.EOF 已成为公共约定,永远不能改,否则破坏所有下游。
  • 无法携带上下文:它只是一个固定文字,无法告诉你"哪条记录"不存在。
  • 直接 == 比较在包装后会失效:如果错误被 fmt.Errorf("%w") 包装,== 就判断不出来了(要用 errors.Is,见第 6 节)。

经验:哨兵错误适合表达少量、稳定、语义明确的"结果状态"(如 EOF、NotFound)。需要携带上下文时,改用自定义错误类型。


4. 自定义错误类型

当错误需要携带结构化上下文(字段、错误码、原始错误)时,定义一个实现了 error 接口的结构体。

4.1 实现 Error() 方法

package main

import "fmt"

// 自定义错误类型,携带上下文字段
type ValidationError struct {
    Field   string // 出错的字段名
    Value   any    // 非法的值
    Message string // 说明
}

// 实现 error 接口
func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %q 校验失败(值=%v): %s", e.Field, e.Value, e.Message)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Value: age, Message: "年龄不能为负"}
    }
    return nil
}

func main() {
    err := validateAge(-5)
    fmt.Println(err) // 字段 "age" 校验失败(值=-5): 年龄不能为负
}

4.2 类型断言获取详情

因为返回的是 error 接口,要读取结构体字段就需要类型断言(或 errors.As,见第 6 节):

err := validateAge(-5)

// 通过类型断言拿回具体类型,读取上下文字段
if ve, ok := err.(*ValidationError); ok {
    fmt.Println("出错字段:", ve.Field)   // 出错字段: age
    fmt.Println("非法值:", ve.Value)     // 非法值: -5
}

4.3 标准库范例:os.PathError

标准库大量使用自定义错误类型。os.PathError(现名 fs.PathError)就是典型:

// src/io/fs/fs.go
type PathError struct {
    Op   string // 操作,如 "open"
    Path string // 路径
    Err  error  // 底层错误
}

func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

// 它还实现了 Unwrap,能被 errors.Is/As 穿透(见第 5、6 节)
func (e *PathError) Unwrap() error { return e.Err }

实战中读取它的字段:

package main

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)

func main() {
    _, err := os.Open("/不存在的文件")
    var pe *fs.PathError
    if errors.As(err, &pe) { // 提取 *fs.PathError
        fmt.Println("操作:", pe.Op)   // 操作: open
        fmt.Println("路径:", pe.Path) // 路径: /不存在的文件
        fmt.Println("底层:", pe.Err)  // 底层: no such file or directory
    }
}

陷阱:自定义错误类型的方法通常定义在指针接收者上。返回时若把一个"值类型的 nil 指针"赋给 error 接口,会导致 err != nil 恒为真——即著名的"nil interface 陷阱"。务必让返回值声明为 error 类型,并在成功时直接 return nil

// 反例:函数返回具体指针类型,会踩 nil 陷阱
func bad() *ValidationError {
    return nil // 这个 nil 装进 error 接口后 != nil!
}

func caller() {
    var err error = bad() // err 的动态类型是 *ValidationError,非 nil
    fmt.Println(err == nil) // false —— 陷阱!
}

5. 错误包装 Wrapping

真实调用链很深,底层错误往上抛时需要不断附加上下文,形成错误链。Go 1.13 引入的 %w 让包装成为一等公民。

5.1 用 %w 包装错误

fmt.Errorf%w(wrap)会把原错误"包"进新错误,同时保留可追溯的引用:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("记录不存在")

func queryDB(id int) error {
    return ErrNotFound // 假设底层返回哨兵错误
}

func getUser(id int) error {
    if err := queryDB(id); err != nil {
        // %w 包装,附加当前层的上下文
        return fmt.Errorf("getUser(id=%d): %w", id, err)
    }
    return nil
}

func main() {
    err := getUser(42)
    fmt.Println(err) // getUser(id=42): 记录不存在

    // 尽管被包装,仍能识别出底层的哨兵错误
    fmt.Println(errors.Is(err, ErrNotFound)) // true
}

5.2 %w 与 %v 的区别

占位符 行为 errors.Is/As 能否穿透
%w 包装,保留原错误引用,形成错误链
%v 仅格式化其文字,丢失原错误引用 不能
%s %v,仅取字符串 不能
wrapped := fmt.Errorf("上下文: %w", ErrNotFound) // 保留链
flat := fmt.Errorf("上下文: %v", ErrNotFound)    // 只剩文字

fmt.Println(errors.Is(wrapped, ErrNotFound)) // true
fmt.Println(errors.Is(flat, ErrNotFound))    // false

5.3 Unwrap 方法与错误链

%w 生成的错误内部实现了 Unwrap() error,调用它就能剥掉一层,拿到被包装的错误。errors.Unwrap 是对外的入口:

err := fmt.Errorf("外层: %w", fmt.Errorf("中层: %w", ErrNotFound))

fmt.Println(err)                              // 外层: 中层: 记录不存在
fmt.Println(errors.Unwrap(err))               // 中层: 记录不存在
fmt.Println(errors.Unwrap(errors.Unwrap(err)))// 记录不存在

错误链结构可视化:

外层 err
  │ Unwrap()
  ▼
中层 err
  │ Unwrap()
  ▼
ErrNotFound(链尾,Unwrap 返回 nil)

5.4 Go 1.20 的多重 %w

Go 1.20 起,一个 fmt.Errorf 可以包含多个 %w,形成"树状"错误链(配合 errors.Join,见第 8 节):

err1 := errors.New("磁盘满")
err2 := errors.New("网络超时")

// Go 1.20+:一次包装两个错误
err := fmt.Errorf("批处理失败: %w; %w", err1, err2)

fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true

此时该错误的 Unwrap 方法签名是 Unwrap() []error(返回切片而非单个),errors.Is/As 会自动遍历所有分支。


6. errors.Is / errors.As

包装普及后,直接用 == 或类型断言都会因为"错误被包了几层"而失效。标准库因此提供了会遍历整条错误链errors.Iserrors.As

6.1 errors.Is:链上是否存在目标错误

errors.Is(err, target) 沿错误链逐层 Unwrap,判断链上是否有某个错误等于 target(或实现了自定义 Is 方法并返回 true)。

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("记录不存在")

func main() {
    err := fmt.Errorf("service层: %w",
        fmt.Errorf("dao层: %w", ErrNotFound))

    // == 只能比对最外层,包装后必然 false
    fmt.Println(err == ErrNotFound)          // false
    // errors.Is 会遍历整条链
    fmt.Println(errors.Is(err, ErrNotFound)) // true
}

用它替代对哨兵错误的 == 比较:

// 推荐写法
if errors.Is(err, io.EOF) { /* ... */ }

6.2 errors.As:提取特定类型的错误

errors.As(err, &target) 沿链查找第一个能赋值给 target 类型的错误,找到则赋值并返回 true。用于取回自定义错误类型的字段。

package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "字段错误: " + e.Field
}

func main() {
    // 把自定义错误包装两层
    err := fmt.Errorf("请求处理失败: %w",
        &ValidationError{Field: "email"})

    var ve *ValidationError
    if errors.As(err, &ve) { // 从链中提取出 *ValidationError
        fmt.Println("提取到字段:", ve.Field) // 提取到字段: email
    }
}

6.3 Is vs As 对比

函数 用途 第二参数 返回
errors.Is(err, target) 判断链上是否有"某个具体错误值" 一个 error 值 bool
errors.As(err, &target) 提取链上"某个类型的错误" 指向变量的指针 bool,成功时赋值

一句话记忆:Is 比对"值",As 提取"类型"

6.4 原理:遍历错误链

两者的核心逻辑都是循环 Unwrap,简化版实现如下:

// errors.Is 简化逻辑
func Is(err, target error) bool {
    for {
        if err == target {
            return true
        }
        // 若错误自定义了 Is 方法,优先用它
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        // 剥一层,继续
        if err = errors.Unwrap(err); err == nil {
            return false
        }
    }
}

errors.As 逻辑类似,只是把"值相等判断"换成"类型可赋值判断"(通过反射 reflect.TypeOf(target).AssignableTo)。Go 1.20 后两者都能处理 Unwrap() []error 的多分支链,用 DFS 遍历整棵树。

自定义错误可以实现 Is(target error) boolAs(target any) bool 方法,来定制匹配逻辑。例如允许"同一错误码"的两个错误被 Is 视为相等。


7. panic vs error 的边界

Go 有 panic/recover,但它不是用来做常规错误处理的。分清边界是工程素养。

7.1 何时用 error(绝大多数情况)

可预期的失败——调用方能合理应对的失败,一律用 error 返回:

  • 文件不存在、网络超时、参数校验不通过
  • 数据库查不到记录、JSON 解析失败
  • 任何"业务上可能发生"的情况

7.2 何时用 panic(极少数情况)

不可恢复的、程序员的错误,即程序进入了"本不该发生"的状态:

  • 数组越界、空指针解引用(运行时自动 panic)
  • 违反了不变量(invariant),如初始化必需的配置缺失导致无法启动
  • map 并发写、类型断言失败(无 ,ok 形式时)
package main

import "fmt"

// 程序启动时必须成功,失败无意义继续运行 —— 可以 panic
func mustLoadConfig(path string) map[string]string {
    cfg := loadConfig(path)
    if cfg == nil {
        panic(fmt.Sprintf("配置文件 %s 加载失败,无法启动", path))
    }
    return cfg
}

func loadConfig(path string) map[string]string { return nil }

标准库惯例:以 Must 前缀命名的函数(regexp.MustCompiletemplate.Must)在失败时 panic,专用于包初始化等失败即等于程序 bug 的场景。

7.3 库不应 panic 越过边界

一条重要原则:库(package)不应让 panic 逃逸到调用方。库的公共 API 应返回 error。若库内部使用了 panic(如递归下降解析器),应在 API 边界用 recover 转换回 error:

package parser

import "fmt"

func Parse(input string) (result string, err error) {
    // 在边界捕获内部 panic,转成 error 返回
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("解析失败: %v", r)
        }
    }()
    return doParse(input), nil // doParse 内部可能 panic
}

func doParse(s string) string { /* ... */ return s }
场景 用 error 用 panic
用户输入非法
网络/IO 失败
初始化必需资源缺失 视情况 ✅(Must 系列)
不可能到达的分支(default) ✅(防御性)
库对外 API ❌(内部用完须 recover)
panic 传播路径:

f() panic ──► 逐层向上,执行沿途 defer ──► recover 拦截?
                                            ├─ 是:恢复,程序继续
                                            └─ 否:到达 goroutine 顶部 ──► 程序崩溃

注意:recover 只在 defer 函数中直接调用才有效。且 panic 只能恢复当前 goroutine 的,跨 goroutine 的 panic 无法被别处 recover,会直接使整个程序崩溃。


8. 常见错误处理模式

8.1 尽早返回(Guard Clause)

出错立刻返回,避免深层嵌套,让正常流程留在最左侧缩进:

// 推荐:尽早返回,主流程平铺
func process(id int) error {
    user, err := fetchUser(id)
    if err != nil {
        return err
    }
    order, err := fetchOrder(user)
    if err != nil {
        return err
    }
    return saveResult(order)
}

8.2 包装以添加上下文

每一层附加自己知道的上下文,让最终错误信息形成一条清晰的"路径":

func fetchUser(id int) (string, error) {
    row, err := db.Query(id)
    if err != nil {
        // 附加本层上下文,用 %w 保留可追溯性
        return "", fmt.Errorf("fetchUser(id=%d): %w", id, err)
    }
    return row, nil
}
// 最终输出可能是:
// process: fetchUser(id=42): db query: connection refused

惯例:包装信息不要以大写开头、不要以标点结尾,因为它们会被拼接。写 "fetchUser: %w" 而非 "FetchUser failed. %w"

8.3 反模式:既记日志又返回错误

同一个错误在每一层都 log.Printlnreturn,会导致日志里同一错误重复出现十几遍,难以定位真正源头。

// 反例:重复日志
func bad() error {
    if err := doWork(); err != nil {
        log.Println("doWork 失败:", err) // ❌ 每层都打
        return err
    }
    return nil
}

原则:要么处理(含记日志),要么返回(交给上层处理),不要两者都做。 通常在最顶层(如 HTTP handler、main)统一记录一次。

// 正确:底层只包装返回,顶层统一记日志
func handler(w http.ResponseWriter, r *http.Request) {
    if err := process(getID(r)); err != nil {
        log.Printf("请求失败: %+v", err) // ✅ 只在边界记一次
        http.Error(w, "内部错误", 500)
    }
}

8.4 errors.Join 聚合多个错误(Go 1.20)

需要收集多个独立错误(如批量校验、并发任务)时,用 errors.Join

package main

import (
    "errors"
    "fmt"
)

func validate(name string, age int) error {
    var errs []error
    if name == "" {
        errs = append(errs, errors.New("name 不能为空"))
    }
    if age < 0 {
        errs = append(errs, errors.New("age 不能为负"))
    }
    // Join 会忽略 nil,全 nil 时返回 nil
    return errors.Join(errs...)
}

func main() {
    err := validate("", -1)
    fmt.Println(err)
    // name 不能为空
    // age 不能为负

    // Join 的结果仍支持 errors.Is 遍历每个子错误
    target := errors.New("x")
    _ = target
}

errors.Join 返回的错误其 Error() 会把各子错误用换行拼接,且实现了 Unwrap() []error,因此 errors.Is/As 能匹配到任意一个子错误。

8.5 在 defer 中处理错误

defer 结合命名返回值可以统一收尾(如关闭资源并把关闭错误合并进返回值):

package main

import (
    "errors"
    "fmt"
)

func writeFile() (err error) { // 命名返回值 err
    f, e := openWriter()
    if e != nil {
        return e
    }
    // defer 中处理 Close 的错误,避免遗漏
    defer func() {
        if cerr := f.Close(); cerr != nil {
            // 用 Join 把主错误和关闭错误合并
            err = errors.Join(err, fmt.Errorf("关闭失败: %w", cerr))
        }
    }()

    return f.Write("data")
}

type writer struct{}
func openWriter() (*writer, error)      { return &writer{}, nil }
func (w *writer) Write(s string) error  { return nil }
func (w *writer) Close() error          { return nil }

关键点:defer修改返回的 error,必须使用命名返回值 (err error),否则闭包里改的只是局部副本。


9. 优化与工程实践

9.1 三种错误处理策略

Dave Cheney 总结的经典分类,工程中按需选择:

策略 定义 优点 缺点
Sentinel(哨兵) 预定义 var ErrXxx 简单直观 API 耦合、无上下文
Typed(类型) 自定义 error 结构体 可携带字段、可断言 暴露类型、增加 API 面
Opaque(不透明) 只返回 error,调用方仅判断"有没有错" 解耦最彻底 无法区分错误种类

优先级建议:能不透明就不透明(大多数情况调用方只关心成功与否);需要分类时用 errors.Is 配哨兵,或用 errors.As 配类型;避免让调用方直接类型断言你的内部类型。

9.2 pkg/errors 的历史地位

在 Go 1.13 之前,标准库没有包装和堆栈能力,社区广泛使用 github.com/pkg/errors

// 历史写法(Go 1.13 之前)
import "github.com/pkg/errors"

err := errors.Wrap(originalErr, "上下文")   // 包装并记录堆栈
errors.Cause(err)                          // 取最底层原因
fmt.Printf("%+v", err)                     // 打印含堆栈

Go 1.13 把 %w/Is/As/Unwrap 吸收进标准库后,pkg/errors 基本退役。但它的一个能力标准库至今没有:自动记录堆栈信息

9.3 给错误加堆栈信息

标准库 %w 只保留错误链,不含调用堆栈。生产环境常需堆栈来定位。方案:

  • 使用第三方库如 github.com/pkg/errorserrors.WithStack)或较新的 cockroachdb/errors
  • 或自己在自定义错误类型里用 runtime.Callers 记录:
package main

import (
    "fmt"
    "runtime"
)

type stackError struct {
    msg   string
    stack []uintptr
}

func newStackError(msg string) *stackError {
    pcs := make([]uintptr, 32)
    // 跳过 runtime.Callers 和 newStackError 自身两帧
    n := runtime.Callers(2, pcs)
    return &stackError{msg: msg, stack: pcs[:n]}
}

func (e *stackError) Error() string { return e.msg }

func (e *stackError) StackTrace() string {
    frames := runtime.CallersFrames(e.stack)
    var sb []byte
    for {
        f, more := frames.Next()
        sb = append(sb, fmt.Sprintf("%s\n\t%s:%d\n", f.Function, f.File, f.Line)...)
        if !more {
            break
        }
    }
    return string(sb)
}

9.4 错误码设计(面向前端/微服务)

对外 API 常需稳定的错误码(而非文字)供客户端判断。推荐用一个统一的错误类型承载:

package apperr

import "fmt"

type Code int

const (
    OK           Code = 0
    NotFound     Code = 40400
    Unauthorized Code = 40100
    Internal     Code = 50000
)

type APIError struct {
    Code    Code   // 稳定的机器可读码
    Message string // 面向用户的提示
    Err     error  // 内部原始错误(不返回给前端)
}

func (e *APIError) Error() string {
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

func (e *APIError) Unwrap() error { return e.Err } // 支持 Is/As 穿透

// 构造器
func NotFoundErr(msg string, cause error) *APIError {
    return &APIError{Code: NotFound, Message: msg, Err: cause}
}

9.5 错误分层

清晰的分层能让错误在正确的地方被"翻译":

┌────────────────────────────────────────────────┐
│ Handler: catch err, map to APIError/HTTP       │ ← Handler 层:捕获错误,翻译成 APIError/HTTP 状态码,统一记日志
├────────────────────────────────────────────────┤
│ Service: wrap biz context (%w), decide         │ ← Service 层:包装业务上下文(%w),做业务判断
├────────────────────────────────────────────────┤
│ DAO: map sql.ErrNoRows -> repo.ErrNotFound     │ ← DAO 层:把 sql.ErrNoRows 翻译为 repo.ErrNotFound,屏蔽底层细节
└────────────────────────────────────────────────┘

关键实践:底层错误不应直接泄漏到上层甚至前端。DAO 层应把 sql.ErrNoRows 转换为领域错误 ErrNotFound,避免上层依赖数据库实现细节。


10. 高频面试题

Q1:Go 的 error 是什么?

error 是一个内置接口,只有一个方法 Error() string。任何实现该方法的类型都是一个错误。错误在 Go 中是普通的,作为函数返回值显式传递和处理,而不是像 Java/Python 那样用异常抛出。

Q2:%w%v 在 fmt.Errorf 里有什么区别?

  • %w(wrap):包装原错误,保留其引用形成错误链,之后可被 errors.Is/As/Unwrap 穿透识别;一个 Errorf 中 Go 1.20 起可有多个 %w
  • %v:仅取原错误的文字表示,丢失引用,无法再被 errors.Is/As 追溯。
  • 结论:需要保留可追溯性用 %w,只想拼文字用 %v

Q3:errors.Is 和 errors.As 的区别?

  • errors.Is(err, target):沿错误链遍历,判断链上是否存在等于 target 的错误值,常用于比对哨兵错误(替代 ==)。
  • errors.As(err, &target):沿链查找第一个能赋给 target 类型的错误并赋值,用于提取自定义错误类型以读取其字段。
  • 记忆:Is 比"值",As 取"类型"。两者都会遍历整条链(含 Go 1.20 的多分支 []error)。

Q4:哨兵错误有什么缺点?

  • 造成 API 耦合:调用方必须导入你的包才能引用错误变量;
  • 一旦导出即成永久契约,难以修改;
  • 无法携带上下文(只是固定文字);
  • %w 包装后,直接用 == 判断会失效,必须改用 errors.Is。 替代方案:用不透明错误(opaque)或自定义类型 + errors.As

Q5:什么时候用 panic,什么时候用 error?

  • error:可预期的、调用方能应对的失败(IO、网络、参数校验、查无数据)。这是 99% 的情况。
  • panic:不可恢复的程序员错误或违反不变量的状态(越界、空指针、初始化必需资源缺失)。
  • 原则:库不应让 panic 逃逸,内部若用 panic 必须在公共 API 边界用 recover 转成 error。Must 前缀函数是允许 panic 的约定(用于初始化)。

Q6:如何给错误加上调用堆栈?

标准库 %w 只保留错误链,不含堆栈。方案:

  • 用第三方库 github.com/pkg/errorserrors.WithStack / errors.Wrap)或 cockroachdb/errors
  • 自定义错误类型中用 runtime.Callers 抓取 PC,再用 runtime.CallersFrames 还原文件/行号。 注意堆栈应在错误产生的最底层记录一次,包装时不必重复抓取。

Q7:如何判断一个错误链里是否包含某个错误?为什么不能用 ==

errors.Is(err, target)。因为错误在向上传递时常被 fmt.Errorf("%w") 包装了多层,最外层的错误值已不再等于 target,直接 == 只比对最外层必然失败;errors.Is 会循环调用 Unwrap() 遍历整条链逐个比对(并尊重自定义的 Is 方法),因此能穿透包装。

Q8:nil interface 陷阱是什么?

当函数返回具体的错误指针类型(如 *MyError)而非 error 接口,且返回了 nil 指针时,把它赋给 error 接口变量后,接口的动态类型非 nil、动态值为 nil,导致 err != nil 恒成立。规避方法:函数返回值直接声明为 error 类型,成功时 return nil,不要返回具体类型的 nil。

Q9:errors.Join 的作用?

Go 1.20 引入,用于把多个错误聚合成一个(自动忽略 nil,全 nil 返回 nil)。其 Error() 用换行拼接各子错误,并实现 Unwrap() []error,使 errors.Is/As 能匹配到任意子错误。适合批量校验、并发任务收集错误、defer 中合并关闭错误等场景。


小结

  • error 是接口:只含 Error() string,错误是,通过返回值显式传递,这是 Go 区别于异常机制的核心哲学。
  • 创建errors.New 造固定错误,fmt.Errorf 造格式化错误。
  • 哨兵错误 var ErrXxx:简单但耦合、无上下文;自定义错误类型可携带字段,用类型断言或 errors.As 读取。
  • 包装fmt.Errorf("%w") 形成错误链,%v 会丢链;Go 1.20 支持多重 %werrors.Join
  • 判断链errors.Is 比对值(替代 ==),errors.As 提取类型;两者都遍历整条链,靠 Unwrap 驱动。
  • panic 边界:仅用于不可恢复的程序员错误,库对外 API 必须用 error,内部 panic 需在边界 recover
  • 工程实践:尽早返回、逐层包装上下文、不要重复记日志(只在边界记一次)、错误分层翻译、按需选择 sentinel/typed/opaque 策略、生产环境考虑堆栈与错误码设计。

掌握"错误即值 + 显式处理 + 错误链"这三点,就抓住了 Go 错误处理的精髓。