3. 自定义错误类型:实现 Error() 方法、携带错误码的结构体、错误码与错误消息分离

在 Go 语言中,error 是一个内置接口,仅包含一个 Error() string 方法。标准库的 errors.New 和 fmt.Errorf 只能返回简单的字符串错误,无法携带结构化信息(如错误码、上下文)。在生产级系统中,我们需要自定义错误类型,将错误码与错误消息分离,实现可编程的错误处理与安全断言。

3.1 自定义错误类型的基本结构

自定义错误类型的核心是定义一个结构体,并为其实现 Error() string 方法。该结构体至少应包含两个字段:错误码(Code) 和 错误消息(Message)。错误码用于程序逻辑判断,错误消息用于人类阅读。

// MyError 自定义错误类型,携带错误码与错误消息
type MyError struct {
    Code    int    // 错误码,用于程序断言
    Message string // 错误消息,用于日志与展示
}

// Error 实现 error 接口
func (e *MyError) Error() string {
    return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}
设计原则:错误码使用整数类型,便于 switch/case 断言;错误消息使用字符串,支持格式化。两者分离后,上层调用方可以根据 Code 做安全断言,而不必解析字符串。

3.2 错误码与错误消息分离的价值

维度 传统字符串错误 自定义错误码分离
程序断言 需要字符串匹配,脆弱且不安全 直接比较整数错误码,稳定高效
国际化 错误消息硬编码,难以翻译 错误码不变,消息可替换为多语言
日志分析 日志中混杂自然语言,难以聚合 错误码可索引,便于监控告警
安全审计 用户输入可能注入错误字符串 错误码为预定义常量,无注入风险

3.3 预定义错误码常量

为了在项目中统一管理错误码,建议使用 const 块定义错误码常量,并配合 iota 或显式赋值。错误码通常按模块或严重程度分段。

const (
    // 通用错误码 1xx
    ErrCodeSuccess       = 0
    ErrCodeUnknown       = 100
    ErrCodeInvalidInput  = 101
    ErrCodeNotFound      = 102

    // 业务错误码 2xx
    ErrCodeUserNotExist  = 201
    ErrCodeOrderExpired  = 202
    ErrCodeBalanceInsufficient = 203
)
安全注意:错误码不应暴露内部实现细节(如数据库行 ID、堆栈路径)。错误码范围应提前规划,避免与第三方库错误码冲突。建议为每个模块分配独立的错误码段。

3.4 带上下文的错误包装

在实际调用链中,我们经常需要保留原始错误(wrap)。自定义错误类型可以嵌套 error 接口,形成错误链,同时保持错误码的独立访问。

// WrapError 包装错误,携带错误码与原始错误
type WrapError struct {
    Code    int
    Message string
    Err     error  // 原始错误,可为 nil
}

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

// Unwrap 支持 errors.Is 与 errors.As 链式查找
func (e *WrapError) Unwrap() error {
    return e.Err
}

3.5 错误码断言实战:类型断言与 errors.As

调用方可以通过类型断言或 errors.As 获取自定义错误类型,然后直接比较错误码,实现安全断言。

func processOrder(orderID string) error {
    // 模拟业务逻辑
    return &WrapError{
        Code:    ErrCodeOrderExpired,
        Message: "订单已过期",
        Err:     nil,
    }
}

func main() {
    err := processOrder("ORD-12345")
    if err != nil {
        var myErr *WrapError
        if errors.As(err, &myErr) {
            switch myErr.Code {
            case ErrCodeOrderExpired:
                fmt.Println("安全断言:订单过期,触发重新下单流程")
            case ErrCodeBalanceInsufficient:
                fmt.Println("安全断言:余额不足,引导充值")
            default:
                fmt.Println("未知错误码,记录告警")
            }
        }
    }
}
实战要点:使用 errors.As 而非直接类型断言,因为 errors.As 可以遍历错误链,找到匹配的自定义类型。错误码断言应放在系统边界层(如 HTTP handler、gRPC interceptor),避免在业务逻辑深处散落断言。

3.6 自定义错误类型架构图

下图展示了自定义错误类型在 Go 接口契约中的位置:错误码与消息分离后,通过断言层实现安全路由。

自定义错误类型架构:错误码与消息分离 调用方 (HTTP Handler / Service) 错误码断言层 errors.As + switch/case 自定义错误 WrapError / MyError 返回 error 类型断言 错误码常量 ErrCodeXxx = iota 错误消息 人类可读字符串 错误链 Unwrap errors.Is / errors.As 组合构成自定义错误 安全断言路径:调用方 → 断言层 → 错误码比较 → 触发对应处理流程 错误码与消息分离后,断言不依赖字符串,避免注入与脆弱匹配

3.7 常见陷阱与最佳实践

  • 陷阱1:在 Error() 方法中拼接敏感信息(如密码、token)。
    解决:错误消息只包含错误码和通用描述,敏感信息通过独立字段传递。
  • 陷阱2:错误码使用 string 类型,导致断言时仍需字符串比较。
    解决:统一使用 int 或 uint32 作为错误码类型,配合常量枚举。
  • 陷阱3:忘记实现 Unwrap() 方法,导致 errors.Is 无法遍历错误链。
    解决:包装错误时始终实现 Unwrap() 方法,返回原始错误。
  • 陷阱4:错误码重复或冲突,不同模块使用相同数值表示不同含义。
    解决:为每个模块分配独立错误码段(如 1000-1999 为用户模块,2000-2999 为订单模块)。

3.8 完整示例:生产级自定义错误

package errcode

import "fmt"

// Error 自定义错误结构体
type Error struct {
    Code    int
    Message string
    Err     error
}

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

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

// New 创建带错误码的错误
func New(code int, message string) *Error {
    return &Error{Code: code, Message: message}
}

// Wrap 包装已有错误
func Wrap(code int, message string, err error) *Error {
    return &Error{Code: code, Message: message, Err: err}
}

// 预定义错误码
const (
    Success       = 0
    InvalidInput  = 1001
    NotFound      = 1002
    InternalError = 5000
)