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

Go 语言内置的 error 接口仅包含一个 Error() string 方法,这在复杂业务场景中往往不足以表达错误的完整语义。通过自定义错误类型,我们可以将领域边界内的业务规则、错误码、调用链、时间戳等上下文信息封装到错误结构中,使错误处理从“字符串匹配”升级为“结构化判断”。

2.1 自定义错误类型的基本模式

任何实现了 Error() string 方法的类型都可以作为 Go 的错误值。最简单的自定义错误类型是一个结构体,它允许我们携带多个字段:

type DomainError struct {
    Code    int
    Message string
}

func (e *DomainError) Error() string {
    return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

使用指针接收者 *DomainError 实现 Error() 是常见做法,这样可以避免复制整个结构体,并确保类型断言时的一致性。

2.2 携带额外上下文信息的错误结构体

在报表领域,一个错误往往需要包含:错误码、用户可读消息、内部技术细节、发生时间、关联的报表 ID 或批次号。下面是一个典型的报表领域错误结构体:

type ReportError struct {
    Code       int       // 业务错误码
    Message    string    // 对外展示的用户消息
    Detail     string    // 内部调试细节
    ReportID   string    // 关联的报表标识
    Timestamp  time.Time // 错误发生时间
    Err        error     // 可选的原始错误包装
}

func (e *ReportError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s | report=%s | detail=%s | cause=%v",
            e.Code, e.Message, e.ReportID, e.Detail, e.Err)
    }
    return fmt.Sprintf("[%d] %s | report=%s | detail=%s",
        e.Code, e.Message, e.ReportID, e.Detail)
}
设计建议:将 Message 与 Detail 分离,Message 用于日志或用户界面展示,Detail 用于开发人员排查问题。不要将敏感信息或堆栈信息放入 Message。

2.3 错误结构体的字段设计对比

字段 类型 用途 是否必须
Code int 业务错误码,用于程序化判断错误类型 是
Message string 面向用户的友好错误描述 是
Detail string 面向开发者的技术细节,如 SQL 语句、请求参数 推荐
ReportID string 关联的报表或批次标识,便于链路追踪 按需
Timestamp time.Time 错误发生时间,用于监控和延迟分析 推荐
Err error 包装的原始错误,支持 errors.Unwrap 链式追踪 按需

2.4 自定义错误类型与领域边界的关系

在领域驱动设计中,错误类型应当属于领域层的值对象。下图展示了自定义错误类型在报表领域边界内的位置:

自定义错误类型在领域边界中的位置 领域层(Domain) ReportService 报表生成、校验、导出核心逻辑 ReportError Code / Message / Detail / ReportID ReportRepository 数据持久化接口 应用层(Application) ReportController 接收请求、调用领域层、处理错误 错误转换器 领域错误 → HTTP 响应 / 日志 日志中间件 结构化记录错误上下文 自定义错误类型在领域层定义,应用层负责转换和记录,不跨层污染

2.5 使用自定义错误类型的完整示例

以下代码展示了如何在报表生成过程中创建并返回自定义错误:

func (s *ReportService) GenerateReport(ctx context.Context, reportID string) (*Report, error) {
    // 校验输入
    if reportID == "" {
        return nil, &ReportError{
            Code:    4001,
            Message: "报表 ID 不能为空",
            Detail:  "GenerateReport 收到空 reportID",
            Timestamp: time.Now(),
        }
    }

    // 查询数据
    data, err := s.repo.FetchData(ctx, reportID)
    if err != nil {
        return nil, &ReportError{
            Code:      5001,
            Message:   "获取报表数据失败",
            Detail:    fmt.Sprintf("repo.FetchData error: %v", err),
            ReportID:  reportID,
            Timestamp: time.Now(),
            Err:       err, // 包装原始错误
        }
    }

    // 生成报表...
    return report, nil
}

2.6 错误类型断言与判断

调用方可以通过类型断言获取自定义错误的上下文信息:

report, err := service.GenerateReport(ctx, "RPT-001")
if err != nil {
    var reportErr *ReportError
    if errors.As(err, &reportErr) {
        // 根据错误码做不同处理
        switch reportErr.Code {
        case 4001:
            // 参数校验失败,返回 400
        case 5001:
            // 数据获取失败,记录详细日志并返回 500
            log.Printf("报表生成失败: report=%s, detail=%s, cause=%v",
                reportErr.ReportID, reportErr.Detail, reportErr.Err)
        }
    }
}
注意:不要使用 err.(*ReportError) 直接断言,因为当错误被包装后断言会失败。始终使用 errors.As 进行安全断言,它会在错误链中递归查找匹配的类型。

2.7 自定义错误类型的最佳实践清单

  • 字段最小化:只包含当前错误必需的上下文,避免过度设计导致结构臃肿。
  • 不可变性:错误结构体创建后不应修改字段,保持错误值的不可变语义。
  • 实现 Unwrap:如果包装了原始错误,实现 Unwrap() error 方法以支持错误链。
  • Error() 方法简洁:Error() 返回的字符串应包含关键信息,但不要过长,详细上下文通过字段访问。
  • 领域边界内定义:将自定义错误类型放在领域层,应用层只做转换和记录。

2.8 错误结构体与标准库 errors 的协作

通过实现 Unwrap() error 方法,自定义错误类型可以无缝融入 Go 标准库的错误处理体系:

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

// 使用示例
if errors.Is(err, sql.ErrNoRows) {
    // 即使被 ReportError 包装,也能正确判断
}

下表总结了自定义错误类型与标准错误处理机制的协作方式:

标准函数 自定义错误需实现 作用
errors.Is(err, target) Unwrap() error 在错误链中查找特定错误值
errors.As(err, target) 类型本身 在错误链中查找特定错误类型并提取
fmt.Errorf("%w", err) Unwrap() error 支持多层包装与解包
核心原则:自定义错误类型是领域边界的“第一道防线”。通过结构化的错误上下文,应用层可以精确判断错误来源、生成合适的用户反馈、记录完整的排查信息,而领域层则保持对错误处理策略的完全无知——这正是领域边界隔离的价值所在。