2. Go 错误处理基础:error 接口、errors.New、fmt.Errorf、%w 动词与错误链

Go 语言的错误处理哲学与多数主流语言不同:它没有异常抛出机制,而是通过函数返回 error 接口值来传递错误。这种显式处理方式让错误传播路径清晰可追踪,但也要求开发者掌握一套严谨的构建与断言方法。本章从最底层的 error 接口开始,逐步深入到错误链的构建与解析,为后续安全错误码断言打下基础。

2.1 error 接口:一切错误的契约

error 是 Go 语言内置的预声明接口,定义在 builtin 包中,其契约极其简洁:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都自动满足 error 接口。这意味着你可以为自定义错误类型附加额外字段(如错误码、上下文信息),同时保持与标准错误处理流程的兼容。

特性说明
接口方法数1 个:Error() string
返回值用途人类可读的错误描述,不用于程序逻辑判断
零值语义nil 表示无错误,非 nil 表示有错误
可比较性接口值可比较,但依赖动态类型和动态值
关键认知: error 是一个接口,不是字符串。两个 error 值相等当且仅当它们的动态类型相同且动态值相等。直接比较 err == nil 是安全的,但比较两个非 nil 错误时需谨慎。

2.2 errors.New:最简错误构造器

errors.New 接受一个字符串常量,返回一个包含该字符串的 error 值。其内部实现是一个未导出的 errorString 结构体:

// 标准库实现示意
package errors

type errorString struct {
    s string
}

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

func New(text string) error {
    return &errorString{text}
}

使用示例:

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

func GetUser(id int) (*User, error) {
    // ...
    return nil, ErrNotFound
}
避坑指南: errors.New 返回的是指针类型 *errorString。每次调用都会分配一个新指针,因此 errors.New("x") == errors.New("x") 结果为 false。正确的做法是将错误值定义为包级变量(如 var ErrX = errors.New("x")),通过指针相等性做 sentinel 错误判断。

2.3 fmt.Errorf:带格式的错误构造

fmt.Errorf 在 errors.New 的基础上增加了格式化能力,支持 %s、%d、%v 等占位符:

func OpenFile(path string) error {
    return fmt.Errorf("cannot open file %s: permission denied", path)
}

在 Go 1.13 之前,fmt.Errorf 只能生成一个扁平字符串,无法保留原始错误信息。调用方只能通过字符串匹配(如 strings.Contains)来识别错误类型,这种做法脆弱且不安全。

2.4 %w 动词与错误链

Go 1.13 引入了 %w 动词,允许 fmt.Errorf 将已有错误包装为新的错误,同时保留对原始错误的引用。被包装的错误形成一条“错误链”,可以通过 errors.Is 和 errors.As 沿链遍历。

import (
    "errors"
    "fmt"
)

var ErrPermission = errors.New("permission denied")

func ReadConfig(path string) error {
    // 模拟权限错误
    return fmt.Errorf("read config %s: %w", path, ErrPermission)
}

func main() {
    err := ReadConfig("/etc/app.conf")
    
    // errors.Is 沿错误链查找匹配的 sentinel 错误
    if errors.Is(err, ErrPermission) {
        fmt.Println("权限错误,需要提升权限")
    }
    
    // err.Error() 输出包含完整上下文
    fmt.Println(err)
    // 输出: read config /etc/app.conf: permission denied
}

%w 的行为规则:

  • 只能用于 fmt.Errorf,且只能出现一次 %w。
  • 被包装的错误必须是非 nil 的 error 接口值。
  • 包装后的错误实现了 Unwrap() error 方法,返回被包装的错误。
  • 多次包装形成链式结构,errors.Is 和 errors.As 会递归遍历整条链。
实战要点: 使用 %w 时,错误消息的格式建议采用 "外层上下文: %w" 风格,冒号后加空格。这样既保留了原始错误信息,又增加了调用栈上下文,便于调试和日志分析。

2.5 错误链的结构与遍历

下图展示了三次 %w 包装形成的错误链结构,以及 errors.Is 的遍历路径:

错误链结构与 errors.Is 遍历路径 ErrPermission read config: %w load app: %w startup failed: %w errors.Is 遍历顺序 startup failed → load app → read config → ErrPermission 匹配成功,返回 true

2.6 errors.Is 与 errors.As:链式断言

Go 1.13 标准库提供了两个核心函数用于错误链断言:

函数签名用途匹配依据
errors.IsIs(err, target error) bool判断错误链中是否存在与 target 相等的错误接口值相等(类型+值)
errors.AsAs(err error, target interface{}) bool查找错误链中第一个可赋值给 target 类型的错误类型匹配(可赋值)

errors.Is 示例:

var ErrTimeout = errors.New("operation timed out")

func fetchData() error {
    return fmt.Errorf("fetch: %w", ErrTimeout)
}

func handle() {
    err := fetchData()
    if errors.Is(err, ErrTimeout) {
        // 触发重试逻辑
        fmt.Println("超时,准备重试")
    }
}

errors.As 示例:

type HTTPError struct {
    Code    int
    Message string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}

func callAPI() error {
    return fmt.Errorf("api call failed: %w", &HTTPError{Code: 503, Message: "Service Unavailable"})
}

func handleAPI() {
    err := callAPI()
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        // 提取 HTTP 状态码做针对性处理
        fmt.Printf("HTTP 错误码: %d\n", httpErr.Code)
    }
}
最佳实践:
  • 使用 errors.Is 判断 sentinel 错误(包级变量定义的错误)。
  • 使用 errors.As 提取自定义错误类型中的结构化信息。
  • 避免在业务逻辑中依赖 err.Error() 字符串匹配,那会引入脆弱性。

2.7 自定义 Unwrap 方法

除了 fmt.Errorf 的 %w,你也可以在自定义错误类型上实现 Unwrap() error 方法,使其参与错误链:

type WrappedError struct {
    Msg string
    Err error
}

func (w *WrappedError) Error() string {
    return fmt.Sprintf("%s: %v", w.Msg, w.Err)
}

func (w *WrappedError) Unwrap() error {
    return w.Err
}

func process() error {
    return &WrappedError{
        Msg: "process failed",
        Err: ErrPermission,
    }
}

func main() {
    err := process()
    fmt.Println(errors.Is(err, ErrPermission)) // true
}

多个包装层可以形成更复杂的链,errors.Is 会递归调用每个节点的 Unwrap 方法直到链尾或匹配成功。

2.8 错误链与安全编码

从安全编码角度看,错误链设计需要遵循以下原则:

  • 不泄露敏感信息: 外层错误消息应使用通用描述,避免将内部路径、SQL 语句、堆栈细节暴露给用户。敏感信息应放在 Unwrap 的内层错误中,仅用于日志记录。
  • 不破坏断言契约: 使用 %w 包装时,确保被包装的错误是包级导出的 sentinel 错误或公开的自定义错误类型,以便调用方通过 errors.Is/errors.As 可靠断言。
  • 避免循环包装: 不要将同一个错误实例反复包装到自身,会导致 errors.Is 陷入无限循环。
安全红线: 永远不要在 Error() 方法中返回包含密码、令牌、密钥或用户个人身份信息的字符串。错误消息可能被记录到日志系统、返回给 API 客户端或显示在用户界面上,信息泄露将直接导致安全漏洞。

2.9 本章小结

Go 的错误处理基础围绕 error 接口展开:errors.New 构造不可变的 sentinel 错误,fmt.Errorf 配合 %w 动词构建可遍历的错误链,errors.Is 和 errors.As 提供链式断言能力。掌握这些基础是后续实现安全错误码断言的前提——错误链的 Unwrap 机制正是自定义错误码断言器能够沿链查找匹配码的底层支撑。