3、自定义错误类型:定义结构体错误,实现Error()方法,携带额外上下文信息

在Go语言中,error是一个内置接口,只要求实现一个Error() string方法。标准库的errors.New和fmt.Errorf虽然简单,但无法携带结构化上下文信息。当我们需要在错误传播链中保留错误码、请求ID、时间戳、业务字段等关键数据时,必须定义自定义错误类型。

3.1 自定义错误结构体的基本模式

定义一个结构体,实现Error() string方法,即可成为合法的error类型。这是Go中最灵活、最常用的自定义错误方式。

// 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)
}

// 使用示例
func DoSomething() error {
    return &MyError{
        Code:    1001,
        Message: "资源未找到",
    }
}
关键约定:自定义错误类型通常使用指针接收者实现Error()方法,返回的error值应为&MyError{...}。这保证了类型断言和errors.As的正确匹配。

3.2 携带丰富上下文信息的错误结构体

在实际业务中,错误需要携带更多上下文:错误码、HTTP状态码、内部错误链、请求追踪ID、时间戳、用户可读消息等。下面是一个生产级示例:

// AppError 应用层通用错误结构体
type AppError struct {
    Code       int       // 业务错误码
    HTTPStatus int       // HTTP 状态码
    Message    string    // 用户可读消息
    Detail     string    // 内部详细错误
    RequestID  string    // 请求追踪ID
    Timestamp  time.Time // 错误发生时间
    Err        error     // 原始错误(用于错误链)
}

// Error 实现 error 接口
func (e *AppError) Error() string {
    return fmt.Sprintf(
        "[%d] %s | request_id=%s | detail=%s",
        e.Code, e.Message, e.RequestID, e.Detail,
    )
}

// Unwrap 支持 errors.Is / errors.As 错误链遍历
func (e *AppError) Unwrap() error {
    return e.Err
}

// NewAppError 工厂函数,自动填充时间戳
func NewAppError(code int, httpStatus int, message, detail string, requestID string, err error) *AppError {
    return &AppError{
        Code:       code,
        HTTPStatus: httpStatus,
        Message:    message,
        Detail:     detail,
        RequestID:  requestID,
        Timestamp:  time.Now(),
        Err:        err,
    }
}
设计要点:
  • Unwrap方法:返回内部Err字段,使errors.Is和errors.As能穿透自定义错误,遍历整个错误链。
  • 工厂函数:统一创建入口,自动填充时间戳等字段,避免手动构造遗漏。
  • Error()格式化:包含关键字段,便于日志聚合和快速定位。

3.3 错误码与上下文分离:领域错误类型

在搜索错误码断言场景中,我们通常将错误码作为核心标识,其他上下文作为辅助信息。下面是一个领域专用的错误类型:

// SearchError 搜索领域专用错误
type SearchError struct {
    Code       SearchErrCode // 搜索错误码枚举
    Query      string        // 触发错误的搜索词
    IndexName  string        // 索引名称
    ShardID    int           // 分片ID
    Retryable  bool          // 是否可重试
    Internal   error         // 内部原始错误
}

// SearchErrCode 搜索错误码枚举
type SearchErrCode int

const (
    ErrCodeIndexNotFound SearchErrCode = 2001
    ErrCodeQueryTimeout  SearchErrCode = 2002
    ErrCodeShardFailure  SearchErrCode = 2003
    ErrCodeParseError    SearchErrCode = 2004
)

func (e *SearchError) Error() string {
    return fmt.Sprintf(
        "search_error: code=%d query=%q index=%s shard=%d retryable=%v",
        e.Code, e.Query, e.IndexName, e.ShardID, e.Retryable,
    )
}

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

3.4 自定义错误类型对比

特性 errors.New fmt.Errorf 自定义结构体
携带结构化字段 不支持 不支持 支持
类型断言/errors.As 不支持 有限支持(%w) 完全支持
错误链遍历 不支持 支持(%w) 支持(Unwrap)
携带错误码 需手动解析字符串 需手动解析字符串 原生字段
序列化/反序列化 困难 困难 容易(json.Marshal)
适用场景 简单哨兵错误 带格式的错误消息 业务错误、领域错误

3.5 自定义错误结构体架构图

下图展示了自定义错误类型在搜索错误码断言体系中的位置与数据流:

自定义错误结构体架构 错误定义层 AppError 结构体 SearchError 结构体 Error() 方法实现 Unwrap() 方法实现 上下文字段层 Code (错误码) RequestID / Query Timestamp / Retryable Internal (原始错误) 断言与处理层 errors.As 类型断言 errors.Is 错误链匹配 错误码提取 上下文日志输出 自定义错误结构体将错误码、业务上下文、原始错误封装为一个整体, 通过 errors.As 断言提取后,可直接访问所有字段,无需字符串解析。 业务函数返回错误 errors.As 断言 提取错误码+上下文 断言决策 图3-1 自定义错误结构体在搜索错误码断言中的三层架构与处理流程

3.6 使用自定义错误进行断言

自定义错误类型的最大优势在于:断言时可以直接访问结构体字段,无需解析字符串。

// 搜索函数返回自定义错误
func SearchIndex(indexName, query string) error {
    if indexName == "" {
        return &SearchError{
            Code:      ErrCodeIndexNotFound,
            Query:     query,
            IndexName: indexName,
            Retryable: false,
        }
    }
    // ... 正常搜索逻辑
    return nil
}

// 调用方进行断言
err := SearchIndex("", "golang")

var searchErr *SearchError
if errors.As(err, &searchErr) {
    // 直接访问错误码和上下文
    switch searchErr.Code {
    case ErrCodeIndexNotFound:
        fmt.Printf("索引 %s 不存在,搜索词: %s\n", searchErr.IndexName, searchErr.Query)
        // 执行索引不存在时的处理逻辑
    case ErrCodeQueryTimeout:
        if searchErr.Retryable {
            // 重试逻辑
        }
    }
    // 记录日志时直接输出结构化字段
    log.Printf("search_error: %+v", searchErr)
}
常见陷阱:
  • 值接收者 vs 指针接收者:如果Error()使用值接收者,errors.As断言时类型必须严格匹配(值类型 vs 指针类型)。建议统一使用指针接收者。
  • 忘记实现Unwrap:如果自定义错误包装了原始错误,必须实现Unwrap() error方法,否则errors.Is无法穿透。
  • Error()方法不包含关键字段:Error()字符串应包含错误码等关键标识,否则日志聚合时无法快速分类。

3.7 自定义错误与标准错误链的集成

自定义错误可以无缝融入Go 1.13+的错误链机制。通过fmt.Errorf的%w包装自定义错误,或直接在自定义错误中实现Unwrap,都能让errors.Is和errors.As正常工作。

// 方式一:在自定义错误中包装原始错误
func ReadConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return &AppError{
            Code:       3001,
            Message:    "读取配置文件失败",
            Detail:     path,
            RequestID:  getRequestID(),
            Timestamp:  time.Now(),
            Err:        err, // 原始错误
        }
    }
    // ...
    return nil
}

// 方式二:使用 fmt.Errorf 包装自定义错误
func ProcessRequest() error {
    err := validateInput()
    if err != nil {
        return fmt.Errorf("请求验证失败: %w", err)
    }
    return nil
}

// 调用方统一使用 errors.As 断言
err := ReadConfig("/etc/app.yaml")
var appErr *AppError
if errors.As(err, &appErr) {
    fmt.Printf("错误码: %d, 原始错误: %v\n", appErr.Code, appErr.Err)
}

3.8 最佳实践总结

  • 为每个领域定义专用错误类型:如SearchError、AuthError、StorageError,每个类型携带该领域特有的上下文。
  • 错误码使用枚举常量:定义type ErrCode int和const块,避免魔法数字。
  • 工厂函数统一构造:自动填充时间戳、请求ID等公共字段,减少重复代码。
  • 始终实现Unwrap:如果错误内部包含原始错误,必须实现Unwrap() error以支持错误链遍历。
  • Error()包含关键标识:至少包含错误码和简短描述,便于日志系统索引。
  • 优先使用errors.As进行断言:而非类型断言err.(*MyError),因为errors.As能穿透错误链。
核心结论:自定义错误结构体是Go语言中实现错误码断言的最佳载体。它将错误码、上下文信息、原始错误封装为一个可断言、可遍历、可序列化的整体,为搜索错误码断言领域边界提供了坚实的数据基础。