3、自定义错误类型:实现Error()方法、携带额外上下文信息、错误码与错误消息分离、自定义错误的结构体设计

在 Go 项目中,仅依赖标准库的 errors.New 或 fmt.Errorf 返回的简单字符串错误,无法满足业务层对错误分类、排查和展示的需求。业务错误需要携带错误码、上下文参数、调用链路等结构化信息。本章将系统讲解如何设计自定义错误类型,实现错误码与错误消息分离,并让错误携带丰富的上下文。

3.1 为什么需要自定义错误类型

标准错误本质是一个接口:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为错误。但内置错误类型存在以下局限:

局限 说明
无结构化字段 无法携带错误码、HTTP 状态码、业务参数等
难以分类 只能通过字符串匹配或类型断言判断错误类型
上下文丢失 无法记录发生错误时的关键变量值
展示与逻辑耦合 错误消息直接暴露给用户,无法做国际化或脱敏

自定义错误类型通过结构体字段和接口方法,可以完美解决上述问题。

3.2 核心设计:错误码与错误消息分离

错误码是业务层错误处理的核心抽象。它代表一个可枚举的错误类别,与具体的展示文案解耦。推荐使用整数或字符串常量定义错误码,并配合一个映射表管理错误消息模板。

// 错误码定义
const (
    ErrCodeSuccess       = 0
    ErrCodeInvalidParam  = 1001
    ErrCodeUserNotFound  = 2001
    ErrCodeOrderExpired  = 3001
    ErrCodeInternalError = 5000
)

// 错误码与默认消息映射
var codeMessages = map[int]string{
    ErrCodeSuccess:       "success",
    ErrCodeInvalidParam:  "invalid parameter",
    ErrCodeUserNotFound:  "user not found",
    ErrCodeOrderExpired:  "order has expired",
    ErrCodeInternalError: "internal server error",
}
设计原则:错误码一旦发布,不应修改其含义。新增业务场景时,新增错误码而非复用旧码。错误消息模板支持占位符,用于动态注入上下文信息。

3.3 自定义错误结构体设计

一个成熟的业务错误结构体应包含以下字段:

字段 类型 说明
Code int 业务错误码,用于程序逻辑判断
Message string 面向开发者的错误描述,可包含占位符
Details map[string]any 额外上下文键值对,用于日志和排查
Err error 可选的原始错误(wrap),用于错误链
// BizError 业务错误结构体
type BizError struct {
    Code    int            // 业务错误码
    Message string         // 错误描述(可含 %s 占位符)
    Details map[string]any // 上下文信息
    Err     error          // 原始错误(可选)
}

// Error 实现 error 接口
func (e *BizError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s | cause: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// Unwrap 支持 errors.Is / errors.As 错误链遍历
func (e *BizError) Unwrap() error {
    return e.Err
}
关键点:
  • Error() 方法返回的字符串用于日志和开发调试,不直接展示给终端用户。
  • Unwrap() 方法让自定义错误兼容 Go 1.13+ 的错误链机制。
  • Details 字段存储结构化上下文,便于日志系统自动索引。

3.4 携带额外上下文信息

业务错误发生时,往往需要记录当时的输入参数、用户 ID、订单号等关键信息。通过 Details 字段可以优雅地携带这些上下文:

// 创建带上下文的业务错误
func NewBizError(code int, msg string, details map[string]any) *BizError {
    return &BizError{
        Code:    code,
        Message: msg,
        Details: details,
    }
}

// 使用示例
func GetUser(ctx context.Context, userID string) (*User, error) {
    user, err := db.FindUser(ctx, userID)
    if err != nil {
        return nil, NewBizError(
            ErrCodeUserNotFound,
            "user not found",
            map[string]any{
                "user_id": userID,
                "trace_id": ctx.Value("trace_id"),
            },
        ).Wrap(err) // 包装原始数据库错误
    }
    return user, nil
}

在日志输出时,可以统一提取 Details 字段,实现结构化日志:

// 日志中间件中提取上下文
if bizErr, ok := err.(*BizError); ok {
    log.WithFields(bizErr.Details).Error(bizErr.Error())
}

3.5 错误码与错误消息的映射管理

推荐将错误码和消息模板集中管理,避免散落在各处。可以设计一个错误码注册表:

// ErrorRegistry 错误码注册表
type ErrorRegistry struct {
    mu       sync.RWMutex
    messages map[int]string
}

func (r *ErrorRegistry) Register(code int, message string) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.messages[code] = message
}

func (r *ErrorRegistry) Message(code int) string {
    r.mu.RLock()
    defer r.mu.RUnlock()
    if msg, ok := r.messages[code]; ok {
        return msg
    }
    return "unknown error"
}

// 全局注册表
var GlobalErrorRegistry = &ErrorRegistry{
    messages: make(map[int]string),
}
避坑提示:不要将错误消息模板与用户展示文案混用。错误消息面向开发者,使用英文或固定占位符;用户展示文案应在前端或 API 网关层根据错误码做国际化映射。

3.6 自定义错误类型架构图

下图展示了自定义错误类型的核心结构及其与外部系统的关系:

自定义错误类型架构 BizError 结构体 Code: int Message: string Details: map[string]any Err: error (wrap) + Error() string + Unwrap() error ErrorRegistry messages: map[int]string + Register(code, msg) + Message(code) string + Unregister(code) 使用方 业务逻辑层 API 网关 日志中间件 引用 创建/返回 输出目标 ▸ 日志系统:Error() 字符串 + Details 结构化字段 ▸ API 响应:Code 用于前端判断,Message 用于开发调试 ▸ 错误链:Unwrap() 支持 errors.Is / errors.As 遍历

3.7 完整示例:业务层错误处理

以下是一个完整的业务函数示例,展示如何创建、包装和返回自定义错误:

// 预定义错误码常量
const (
    ErrOrderNotFound = 3002
    ErrOrderPaid     = 3003
)

// 创建业务错误工厂函数
func OrderNotFound(orderID string) *BizError {
    return &BizError{
        Code:    ErrOrderNotFound,
        Message: "order not found",
        Details: map[string]any{"order_id": orderID},
    }
}

func OrderAlreadyPaid(orderID string) *BizError {
    return &BizError{
        Code:    ErrOrderPaid,
        Message: "order already paid",
        Details: map[string]any{"order_id": orderID},
    }
}

// 业务函数
func PayOrder(ctx context.Context, orderID string) error {
    order, err := repo.GetOrder(ctx, orderID)
    if err != nil {
        return OrderNotFound(orderID).Wrap(err)
    }
    if order.Status == "paid" {
        return OrderAlreadyPaid(orderID)
    }
    // 执行支付逻辑...
    return nil
}

// 调用方处理
func HandlePayOrder(w http.ResponseWriter, r *http.Request) {
    orderID := r.URL.Query().Get("order_id")
    err := PayOrder(r.Context(), orderID)
    if err != nil {
        var bizErr *BizError
        if errors.As(err, &bizErr) {
            // 根据错误码返回不同的 HTTP 状态码
            switch bizErr.Code {
            case ErrOrderNotFound:
                http.Error(w, "order not found", http.StatusNotFound)
            case ErrOrderPaid:
                http.Error(w, "order already paid", http.StatusConflict)
            default:
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
            // 记录结构化日志
            log.WithFields(bizErr.Details).Error(bizErr.Error())
            return
        }
        // 非业务错误,按未知错误处理
        http.Error(w, "internal error", http.StatusInternalServerError)
        log.Error(err)
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"success"}`))
}

3.8 设计要点总结

设计维度 推荐做法 避免做法
错误码 使用整数常量,集中注册 使用字符串散落定义
错误消息 面向开发者,支持占位符 直接拼接用户展示文案
上下文 使用 map[string]any 结构化存储 拼接在 Error() 字符串中
错误链 实现 Unwrap() 方法 忽略原始错误
工厂函数 为每个错误码提供构造函数 到处手动创建结构体
最佳实践:在项目初期就定义好错误码范围和结构体规范,避免后期大规模重构。建议将错误码定义放在独立的 pkg/errcode 包中,供所有模块引用。