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 自定义错误类型与领域边界的关系
在领域驱动设计中,错误类型应当属于领域层的值对象。下图展示了自定义错误类型在报表领域边界内的位置:
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 |
支持多层包装与解包 |
核心原则:自定义错误类型是领域边界的“第一道防线”。通过结构化的错误上下文,应用层可以精确判断错误来源、生成合适的用户反馈、记录完整的排查信息,而领域层则保持对错误处理策略的完全无知——这正是领域边界隔离的价值所在。