2. Go 错误处理基础:error 接口、errors.New 与 fmt.Errorf 的使用、错误链与 Unwrap

在 Go 语言中,错误处理不是异常抛出机制,而是通过返回 error 接口值来传递失败信息。这种设计迫使开发者显式处理每一个可能出错的调用,是 Go 代码健壮性的基石。本章从接口定义出发,逐步深入到错误构建、格式化与错误链拆解,为后续慢查询断言与错误码映射打下基础。

2.1 error 接口:一切错误的根类型

Go 内置的 error 是一个极其简洁的接口,只包含一个方法:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为错误值传递。这意味着你可以自定义错误结构体,携带额外的上下文信息(如错误码、SQL 语句、耗时等)。

特性 说明
接口方法数 1 个(Error() string)
零值语义 nil 表示无错误
传递方式 作为函数返回值,通常放在最后一个返回值
比较方式 与 nil 比较,或使用类型断言/switch 判断具体类型
关键认知: Go 的 error 不是异常,不会中断控制流。你必须手动检查每个返回 error 的调用,否则程序会继续执行,可能导致更隐蔽的故障。

2.2 errors.New:快速创建简单错误

errors.New 接收一个字符串,返回一个包含该字符串的 error 值。这是最基础的错误构造方式:

import "errors"

var ErrNotFound = errors.New("record not found")

func GetUser(id int) (*User, error) {
    // 模拟查询失败
    return nil, ErrNotFound
}

使用 errors.New 创建的错误值,其 Error() 方法直接返回传入的字符串。注意:每次调用 errors.New 都会分配新的错误实例,但相同字符串的错误值通过 == 比较时并不相等(除非使用包级别变量复用同一个实例)。

最佳实践: 将错误定义为包级别的 var 变量,便于调用方使用 == 或 errors.Is 进行断言,避免字符串硬编码。

2.3 fmt.Errorf:带格式化上下文的错误

当需要将动态值嵌入错误消息时,fmt.Errorf 比 errors.New 更灵活:

import "fmt"

func QueryDB(sql string, args ...interface{}) error {
    // 模拟数据库超时
    return fmt.Errorf("query timeout: sql=%s, args=%v", sql, args)
}

fmt.Errorf 返回的也是 error 接口,其内部实现与 errors.New 类似,但支持 %s、%d、%v 等格式化动词。在 Go 1.13 之后,fmt.Errorf 还支持 %w 动词用于包装错误(见 2.4 节)。

构造方式 适用场景 是否支持包装
errors.New("msg") 静态错误消息,无动态参数 否
fmt.Errorf("msg %v", val) 需要嵌入动态值的错误消息 否(使用 %v 时)
fmt.Errorf("msg: %w", err) 需要包装原始错误,保留错误链 是(使用 %w 时)

2.4 错误链与 Unwrap:从包装中还原根因

在实际系统中,错误往往经过多层函数传递,每一层都可能添加上下文。Go 1.13 引入了错误链机制,通过 %w 动词和 Unwrap 接口实现错误包装与拆解。

2.4.1 使用 %w 包装错误

import "fmt"

func LoadConfig(path string) error {
    return fmt.Errorf("load config: %w", ErrNotFound)
}

func InitApp() error {
    if err := LoadConfig("/etc/app.yaml"); err != nil {
        return fmt.Errorf("init app failed: %w", err)
    }
    return nil
}

通过 %w 包装后,原始错误 ErrNotFound 被嵌入到新错误中,形成一条错误链:init app failed → load config → record not found。

2.4.2 Unwrap 接口与 errors.Is/errors.As

被包装的错误实现了 Unwrap() error 接口,标准库提供了两个函数来遍历错误链:

  • errors.Is(err, target):沿着错误链查找,判断是否存在与 target 相等的错误。
  • errors.As(err, target):沿着错误链查找,判断是否存在可以赋值给 target 类型的错误。
import "errors"

func main() {
    err := InitApp()

    // 使用 errors.Is 检查链中是否包含 ErrNotFound
    if errors.Is(err, ErrNotFound) {
        fmt.Println("根因是记录未找到")
    }

    // 使用 errors.As 提取特定类型的错误
    var dbErr *DBError
    if errors.As(err, &dbErr) {
        fmt.Printf("数据库错误码: %d\n", dbErr.Code)
    }
}
避坑提示: 不要使用 == 直接比较包装后的错误,因为包装后的错误是一个新实例。始终使用 errors.Is 或 errors.As 进行错误链断言。

2.5 错误链结构图

下图展示了错误从底层到顶层的包装过程,以及 errors.Is 如何沿着 Unwrap 链回溯查找根因:

错误链包装与 Unwrap 回溯 底层错误 ErrNotFound 中间层包装 LoadConfig: %w 顶层包装 InitApp: %w errors.Is(err, ErrNotFound) 回溯过程: 顶层错误 → 调用 Unwrap() → 中间层错误 → 调用 Unwrap() → 底层错误 → 匹配 ErrNotFound → 返回 true 包装方向与 Unwrap 方向相反,errors.Is/As 自动遍历整个链。

2.6 自定义错误类型与错误码

在搜索错误码断言场景中,仅靠字符串消息远远不够。我们需要携带错误码、SQL 语句、耗时等结构化信息。自定义错误类型是实现这一目标的标准方式:

type SQLError struct {
    Code    int    // 业务错误码,如 1001 表示超时
    Message string // 人类可读描述
    SQL     string // 触发错误的 SQL 语句
    Err     error  // 原始底层错误(可选)
}

func (e *SQLError) Error() string {
    return fmt.Sprintf("[%d] %s | sql: %s", e.Code, e.Message, e.SQL)
}

func (e *SQLError) Unwrap() error {
    return e.Err
}

通过实现 Unwrap() error 方法,自定义错误类型也能融入标准错误链,被 errors.Is 和 errors.As 识别。

2.7 错误处理模式对比

模式 代码示例 适用场景 错误链支持
哨兵错误 var ErrNotFound = errors.New("not found") 预定义的已知错误,调用方需要精确匹配 否(本身是叶子节点)
包装错误 fmt.Errorf("context: %w", err) 在传递过程中添加上下文,保留根因 是(通过 %w)
自定义类型 type SQLError struct { Code int; ... } 需要携带结构化字段(错误码、SQL、耗时等) 是(实现 Unwrap)
透明错误 return fmt.Errorf("something went wrong") 对外隐藏内部细节,仅记录日志 否(不包装)
实战要点: 在慢查询复盘场景中,推荐使用自定义错误类型携带错误码和 SQL 语句,同时通过 %w 包装底层数据库驱动错误。这样上层断言函数可以通过 errors.As 提取错误码,通过 errors.Is 判断底层驱动错误类型,实现精准的故障分类。

2.8 错误链拆解实战示例

以下代码展示了如何从多层包装的错误中提取根因和结构化信息:

func handleError(err error) {
    // 1. 检查是否为自定义 SQLError
    var sqlErr *SQLError
    if errors.As(err, &sqlErr) {
        fmt.Printf("错误码: %d, SQL: %s\n", sqlErr.Code, sqlErr.SQL)
    }

    // 2. 检查链中是否包含特定哨兵错误
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("根因是上下文超时")
    }

    // 3. 手动遍历错误链(调试用)
    for e := err; e != nil; e = errors.Unwrap(e) {
        fmt.Printf("  → %s\n", e.Error())
    }
}

输出示例:

错误码: 1001, SQL: SELECT * FROM users WHERE id = ?
根因是上下文超时
  → query user failed: context deadline exceeded
  → [1001] query timeout | sql: SELECT * FROM users WHERE id = ?
  → context deadline exceeded

通过错误链机制,我们既保留了完整的调用上下文,又能精确提取底层根因和结构化错误码,这正是搜索错误码断言系统的核心数据基础。