2、Go 错误接口与自定义错误:error 接口详解、fmt.Errorf、errors.New、自定义错误结构体

Go 语言通过内置的 error 接口实现了简洁而强大的错误处理机制。理解该接口的设计哲学以及三种常见的错误构造方式,是编写健壮、可维护 Go 代码的基础。本章将深入剖析 error 接口、标准库提供的错误工厂函数,以及如何通过自定义结构体构建携带丰富上下文的领域错误。

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

Go 的 error 是一个预声明接口,定义在 builtin 包中。任何实现了该接口的类型都可以作为错误值传递。

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

该接口仅包含一个方法 Error() string,返回错误的描述字符串。这意味着:

  • 极简主义:接口只要求一个方法,任何类型只要实现了 Error() string 即满足契约。
  • 值语义:错误是值,可以赋值、比较、传递。零值 nil 表示没有错误。
  • 可组合性:基于该接口,可以构建错误链、包装、断言等高级模式。
安全提示:永远不要将 nil 错误值通过非 nil 接口变量返回。例如 return (*MyError)(nil) 会导致调用方判断 err != nil 为 true,引发逻辑错误。始终使用 return nil 返回无错误状态。

2.2 标准错误工厂函数

Go 标准库提供了两种最常用的错误构造方式:errors.New 和 fmt.Errorf。它们适用于不同场景,且内部实现均基于 error 接口。

函数 包 签名 特点 适用场景
errors.New errors func New(text string) error 创建仅包含静态文本的错误;每次调用返回不同实例(指针比较不相等) 预定义哨兵错误、简单错误常量
fmt.Errorf fmt func Errorf(format string, a ...any) error 支持格式化占位符;Go 1.13+ 支持 %w 包装错误 动态错误消息、错误链包装

2.2.1 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}
}

典型用法是定义包级别的哨兵错误,供调用方使用 == 或 errors.Is 判断:

var (
    ErrNotFound = errors.New("resource not found")
    ErrPermission = errors.New("permission denied")
)

func GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrNotFound
    }
    // ...
}

2.2.2 fmt.Errorf:动态格式化与错误包装

fmt.Errorf 在 errors.New 的基础上增加了格式化能力。Go 1.13 引入的 %w 动词使其能够包装已有错误,形成错误链。

// 简单格式化
err := fmt.Errorf("user %d not found in database", userID)

// 包装错误(Go 1.13+)
if err := readConfig(); err != nil {
    return fmt.Errorf("read config: %w", err)
}
关键区别:使用 %w 包装的错误可以通过 errors.Is 和 errors.As 解包和断言。使用 %v 或 %s 则仅保留消息文本,丢失原始错误类型信息。在需要保留错误链的上下文中,务必使用 %w。

2.3 自定义错误结构体:携带领域上下文

当错误需要携带结构化信息(如错误码、HTTP 状态码、内部原因、调用栈等)时,自定义结构体是最佳选择。通过实现 Error() string 方法,任何结构体都可以成为合法的错误类型。

2.3.1 基础自定义错误

type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field '%s' (value=%v): %s",
        e.Field, e.Value, e.Message)
}

// 使用
func ValidateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "must be between 0 and 150",
        }
    }
    return nil
}

2.3.2 带错误码的领域错误

在微服务或 API 开发中,错误码是客户端识别错误类型的标准方式。自定义结构体可以同时包含机器可读的错误码和人类可读的消息。

type DomainError struct {
    Code    int    // 业务错误码,如 1001
    Message string // 用户友好消息
    Err     error  // 内部根因(可选)
}

func (e *DomainError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// 解包支持:使 errors.As 能够找到内部错误
func (e *DomainError) Unwrap() error {
    return e.Err
}
最佳实践:自定义错误结构体应实现 Unwrap() error 方法(Go 1.13+),以便 errors.Is 和 errors.As 能够遍历错误链。这保持了与标准错误检查机制的兼容性。

2.4 三种错误构造方式对比

维度 errors.New fmt.Errorf 自定义结构体
消息灵活性 静态字符串 动态格式化 完全可控
携带结构化数据 否 否(仅文本) 是(字段、错误码等)
错误链支持 无 通过 %w 通过 Unwrap()
类型断言 仅 errors.Is 仅 errors.Is errors.As 可提取字段
适用层级 包级哨兵 中间层包装 领域边界/API 层
性能开销 低 中(格式化) 中(结构体分配)

2.5 错误构造与断言流程

下面的 SVG 图展示了从错误构造到调用方断言处理的完整流程,涵盖三种构造方式及其对应的断言方法。

错误构造与断言流程 errors.New("msg") fmt.Errorf("fmt %w", err) 自定义结构体{Code, Msg} 调用方接收 err := doSomething() err 类型: error 接口 零值 nil 表示无错误 非 nil 表示有错误 if err != nil { ... } errors.Is(err, ErrSentinel) errors.As(err, &target) 断言方式选择指南 • err != nil:仅判断是否有错误,不关心具体类型。适用于快速失败。 • errors.Is:遍历错误链,判断是否包含特定哨兵错误。适用于 errors.New 创建的静态错误。 • errors.As:遍历错误链,将错误断言为特定自定义类型并提取字段。适用于自定义结构体。 • 类型断言 e.(*CustomError):仅检查最外层错误类型,不遍历链。不推荐用于包装后的错误。 • 安全原则:优先使用 errors.Is/As 而非直接类型断言,确保错误链兼容性。

2.6 自定义错误结构体设计模式

在领域驱动设计(DDD)中,错误是领域语言的一部分。以下模式帮助你在不同层级中正确使用自定义错误。

2.6.1 分层错误设计

层级 错误类型 携带信息 示例
基础设施层 errors.New 或 fmt.Errorf 技术细节(SQL 错误、网络超时) fmt.Errorf("db query: %w", sql.ErrNoRows)
应用服务层 自定义结构体(含错误码) 业务错误码、用户消息、内部根因 &DomainError{Code: 1001, Message: "用户不存在"}
接口层(HTTP/gRPC) 自定义结构体(含状态码) HTTP 状态码、gRPC 状态、序列化字段 &HTTPError{Status: 404, Body: ...}

2.6.2 实现 Unwrap 保持链兼容

为了让自定义错误能够被标准库的 errors.Is 和 errors.As 遍历,必须实现 Unwrap() error 方法。如果错误包含多个根因(如验证错误列表),可以实现 Unwrap() []error(Go 1.20+)。

// 单根因
func (e *DomainError) Unwrap() error {
    return e.Err
}

// 多根因(Go 1.20+)
type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    // 拼接所有错误消息
}
func (m *MultiError) Unwrap() []error {
    return m.Errors
}

2.7 安全断言:避免类型断言陷阱

在错误处理中,类型断言是提取自定义错误字段的关键操作。但直接使用 e.(*CustomError) 存在风险,尤其是在错误被包装后。

安全断言规则:
  1. 优先使用 errors.As:它会遍历整个错误链,找到第一个匹配类型的错误。即使错误被多层 fmt.Errorf("%w") 包装,也能正确提取。
  2. 避免直接类型断言:if e, ok := err.(*DomainError); ok { ... } 仅检查最外层错误。如果错误被包装过,断言会失败。
  3. 使用 errors.Is 判断哨兵:对于 errors.New 创建的静态错误,使用 errors.Is 而非 == 直接比较,以支持错误链。
// 推荐:使用 errors.As 遍历链
var domainErr *DomainError
if errors.As(err, &domainErr) {
    fmt.Println("错误码:", domainErr.Code)
}

// 不推荐:直接类型断言,无法处理包装
if e, ok := err.(*DomainError); ok {
    // 如果 err 被 fmt.Errorf("%w", err) 包装过,这里永远不会执行
}

2.8 本章小结

  • error 接口是 Go 错误处理的基石,仅要求实现 Error() string 方法。
  • errors.New 适用于静态哨兵错误,fmt.Errorf 适用于动态消息和错误包装,自定义结构体适用于携带结构化领域信息。
  • 自定义错误应实现 Unwrap() error 以保持与标准错误检查机制的兼容性。
  • 在断言错误时,优先使用 errors.Is 和 errors.As,避免直接类型断言,确保错误链的完整遍历。
  • 在领域边界处,使用带错误码的自定义结构体,将技术错误转换为业务可理解的错误信息。