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

在 Go 语言中,error 是一个内置接口,仅包含一个 Error() string 方法。标准库的 errors.New 和 fmt.Errorf 只能传递静态字符串或格式化文本,无法携带结构化上下文。在安全审计与错误码断言场景中,我们需要自定义错误类型来附加错误码、操作类型、请求 ID、堆栈信息等关键字段,从而实现精确的断言与溯源。

3.1 自定义错误结构体的核心设计

一个健壮的自定义错误类型应满足以下要求:

  • 实现 Error() 方法:返回人类可读的错误描述。
  • 携带错误码:用于程序化断言与分类。
  • 携带上下文:如操作名称、请求标识、内部原因等。
  • 支持错误链:通过 Unwrap() 方法支持 errors.Is 和 errors.As。
审计要点:自定义错误类型必须导出字段(首字母大写),否则外部断言包无法通过 errors.As 提取上下文信息。

3.2 标准实现模板

// BizError 业务错误类型,携带错误码与上下文
type BizError struct {
    Code    int    // 错误码,用于断言
    Message string // 人类可读描述
    Op      string // 操作名称,如 "CreateUser"
    ReqID   string // 请求追踪 ID
    Err     error  // 内部包装错误(可选)
}

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

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

// NewBizError 构造函数,确保字段完整性
func NewBizError(code int, message, op, reqID string, err error) *BizError {
    return &BizError{
        Code:    code,
        Message: message,
        Op:      op,
        ReqID:   reqID,
        Err:     err,
    }
}

3.3 错误码断言实战:errors.As 提取上下文

在审计测试中,我们通过 errors.As 将错误断言为自定义类型,然后断言其字段值:

func TestBizErrorAssertion(t *testing.T) {
    // 模拟被测试函数返回的错误
    got := NewBizError(1001, "用户不存在", "GetUserByID", "req-abc-123", nil)

    // 断言:提取 BizError 并验证错误码
    var bizErr *BizError
    if !errors.As(got, &bizErr) {
        t.Fatal("期望错误类型为 *BizError")
    }

    // 断言错误码
    if bizErr.Code != 1001 {
        t.Errorf("错误码断言失败: got %d, want %d", bizErr.Code, 1001)
    }

    // 断言操作名称
    if bizErr.Op != "GetUserByID" {
        t.Errorf("操作名称断言失败: got %s, want %s", bizErr.Op, "GetUserByID")
    }

    // 断言请求 ID
    if bizErr.ReqID != "req-abc-123" {
        t.Errorf("请求ID断言失败: got %s, want %s", bizErr.ReqID, "req-abc-123")
    }
}

3.4 错误码与上下文字段对比

字段 类型 审计断言用途 是否必须导出
Code int 错误码精确匹配,分类统计 是
Message string 人类可读描述,日志输出 是
Op string 定位错误发生的操作/函数 是
ReqID string 追踪请求链路,关联日志 是
Err error 包装底层原因,支持错误链 是

3.5 自定义错误类型结构图

下图展示了自定义错误类型 BizError 的内部结构及其与 error 接口、断言层的关系:

自定义错误类型 BizError 结构图 error 接口 Error() string BizError Code int Message string Op string ReqID string Err error + Error() string + Unwrap() error 审计断言层 errors.As → 提取字段 错误链支持 Unwrap() 返回 Err errors.Is / errors.As 遍历包装链

3.6 常见陷阱与审计避坑

  • 未导出字段:字段首字母小写,导致 errors.As 无法在外部包中赋值,断言失败。
  • 忘记实现 Unwrap:包装了内部错误但不实现 Unwrap(),导致 errors.Is 无法穿透到根因。
  • Error() 方法接收者为值类型:若使用 func (e BizError) Error(),则 errors.As 需要传入 &BizError{} 而非 &bizErr,容易混淆。
  • 错误码重复:不同模块使用相同错误码,导致断言误判。建议使用模块前缀或全局错误码注册表。
  • 忽略 ReqID 传递:在异步或跨服务调用中丢失请求 ID,导致无法关联审计日志。
安全审计红线:自定义错误类型中禁止包含敏感信息(如密码、令牌、数据库连接串)。Error() 方法输出的字符串可能被记录到日志或返回给客户端,必须做脱敏处理。

3.7 进阶:带堆栈的自定义错误

在审计场景中,仅靠 Op 字段不足以定位代码路径。推荐集成 github.com/pkg/errors 或 Go 1.20+ 的 fmt.Errorf %w 结合 runtime.Caller 记录堆栈:

import (
    "fmt"
    "runtime"
)

// StackError 携带堆栈信息的错误类型
type StackError struct {
    BizError
    Stack []uintptr // 程序计数器
}

func NewStackError(code int, message, op, reqID string, err error) *StackError {
    pc := make([]uintptr, 10)
    n := runtime.Callers(2, pc) // 跳过当前函数和构造函数
    return &StackError{
        BizError: BizError{
            Code:    code,
            Message: message,
            Op:      op,
            ReqID:   reqID,
            Err:     err,
        },
        Stack: pc[:n],
    }
}

func (e *StackError) Error() string {
    return fmt.Sprintf("%s\nStack: %v", e.BizError.Error(), e.Stack)
}

在审计断言中,可通过 errors.As 提取 StackError 并检查堆栈是否包含预期函数,用于验证错误是否在正确的代码路径上产生。