3. 自定义错误类型:实现 error 接口、结构体错误、带上下文的错误类型

Go 语言内置的 error 接口只要求实现一个 Error() string 方法,这在业务系统中远远不够。真实场景下,我们需要区分错误种类、携带额外字段、支持错误链,甚至附加调用栈信息。本章将系统讲解如何通过自定义错误类型来满足这些需求。

3.1 error 接口的本质与自定义约束

Go 的 error 是一个极其简洁的接口:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为 error 使用。这意味着我们可以用结构体、指针、甚至函数来构造错误。自定义错误的核心价值在于:

  • 类型区分:通过类型断言或类型 switch 识别错误种类
  • 携带数据:附加错误码、HTTP 状态码、用户消息等字段
  • 错误链:包装底层错误,保留原始原因
  • 行为扩展:实现临时性、重试性等接口标记
设计原则:自定义错误类型应当只包含与错误处理相关的字段,不要将业务逻辑或持久化字段混入错误结构体中。

3.2 结构体错误:最基础的自定义方式

结构体错误是最常见的自定义错误形式。通过定义结构体字段,我们可以携带结构化信息,而不仅仅是字符串。

// 基础结构体错误
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %s 验证失败: %s (当前值: %v)", e.Field, e.Message, e.Value)
}

// 使用示例
func ValidateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "年龄必须在 0-150 之间",
        }
    }
    return nil
}

使用结构体错误时,调用方可以通过类型断言获取详细信息:

if err := ValidateAge(-5); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("验证失败字段: %s\n", ve.Field)
        fmt.Printf("错误消息: %s\n", ve.Message)
    }
}

3.3 带上下文的错误类型:包装与链式追踪

业务系统中,错误往往需要逐层包装,保留原始错误的同时添加当前层级的上下文信息。Go 1.13 引入的 %w 格式化动词和 errors.Is/errors.As 函数为错误链提供了标准支持。

// 带上下文的业务错误
type BusinessError struct {
    Code       int      // 业务错误码
    Message    string   // 用户可见消息
    Err        error    // 原始错误(可选)
    StackTrace string   // 调用栈(可选)
}

func (e *BusinessError) 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)
}

// Unwrap 方法使 errors.Is/As 可以遍历错误链
func (e *BusinessError) Unwrap() error {
    return e.Err
}

// 工厂函数:包装底层错误
func NewBusinessError(code int, message string, err error) *BusinessError {
    return &BusinessError{
        Code:       code,
        Message:    message,
        Err:        err,
        StackTrace: getStackTrace(), // 自定义堆栈捕获
    }
}
关键点:实现 Unwrap() error 方法后,errors.Is 和 errors.As 会自动遍历整个错误链,这是 Go 标准错误包装机制的核心。

3.4 错误类型对比与选型

错误类型 适用场景 优点 缺点
errors.New / fmt.Errorf 简单错误,无需额外字段 零依赖,简洁 无法携带结构化数据
结构体错误 需要区分错误类型、携带字段 类型安全,可扩展 需要手动实现 Error()
带上下文的包装错误 错误链追踪、多层包装 保留原始错误,支持 Unwrap 需要额外实现 Unwrap()
哨兵错误 预定义错误常量 可用 errors.Is 精确匹配 无法携带动态数据

3.5 实战:构建业务错误体系

下面是一个完整的业务错误体系示例,整合了错误码、用户消息、内部错误和堆栈信息:

// 错误码定义
const (
    ErrCodeSuccess       = 0
    ErrCodeInvalidParam  = 1001
    ErrCodeUnauthorized  = 2001
    ErrCodeForbidden     = 2003
    ErrCodeNotFound      = 3004
    ErrCodeInternal      = 5000
)

// 业务错误结构体
type AppError struct {
    Code       int         // 业务错误码
    Message    string      // 用户可见消息
    Internal   error       // 内部原始错误
    Stack      string      // 调用栈
    Metadata   map[string]interface{} // 额外元数据
}

func (e *AppError) Error() string {
    if e.Internal != nil {
        return fmt.Sprintf("code=%d message=%s internal=%v", e.Code, e.Message, e.Internal)
    }
    return fmt.Sprintf("code=%d message=%s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error {
    return e.Internal
}

// 工厂方法
func NewAppError(code int, message string, internal error) *AppError {
    return &AppError{
        Code:     code,
        Message:  message,
        Internal: internal,
        Stack:    captureStack(2), // 跳过当前帧
    }
}

// 便捷方法:带元数据
func (e *AppError) WithMetadata(key string, value interface{}) *AppError {
    if e.Metadata == nil {
        e.Metadata = make(map[string]interface{})
    }
    e.Metadata[key] = value
    return e
}

// 使用示例
func GetUser(ctx context.Context, userID string) (*User, error) {
    if userID == "" {
        return nil, NewAppError(ErrCodeInvalidParam, "用户 ID 不能为空", nil)
    }
    user, err := db.FindUser(ctx, userID)
    if err != nil {
        if errors.Is(err, db.ErrNotFound) {
            return nil, NewAppError(ErrCodeNotFound, "用户不存在", err)
        }
        return nil, NewAppError(ErrCodeInternal, "查询用户失败", err).
            WithMetadata("userID", userID)
    }
    return user, nil
}
注意:在生产环境中,堆栈捕获(captureStack)会带来性能开销。建议仅在开发或调试模式下启用,或使用 runtime.Caller 按需捕获关键帧。

3.6 自定义错误类型架构图

下图展示了自定义错误类型在业务系统中的层级关系与流转路径:

自定义错误类型架构图 自定义错误类型架构 error 接口 Error() string 哨兵错误 errors.New / 全局变量 结构体错误 实现 Error() string 包装错误 实现 Unwrap() error ErrNotFound ErrInvalidInput ValidationError BusinessError AppError (带堆栈+元数据) errors.Is 精确匹配 errors.As 类型断言 错误链遍历 自定义错误类型 = 类型安全 + 结构化数据 + 错误链追踪 通过 errors.Is/As 实现多态错误处理

3.7 最佳实践与避坑指南

  • 优先使用 errors.Is/As:不要直接使用 == 比较错误,除非是未包装的哨兵错误。
  • 避免过度包装:每层包装都会增加开销,只在需要添加上下文时包装。
  • 统一错误码规范:错误码应当在整个项目中保持一致,建议使用枚举或常量组。
  • 区分用户错误与系统错误:用户可见消息不应暴露内部实现细节(如 SQL 语句、堆栈信息)。
  • 考虑使用第三方库:如 github.com/pkg/errors(已归档)或 github.com/cockroachdb/errors 提供更丰富的错误链功能。
总结:自定义错误类型是 Go 业务系统的基石。通过结构体错误携带字段、通过 Unwrap 方法支持错误链、通过错误码实现分类,我们可以构建出既灵活又健壮的错误处理体系。下一节将在此基础上,深入探讨如何将错误类型与 HTTP 响应、日志系统、监控告警等基础设施集成。