3. 错误包装与解包:fmt.Errorf + %w、errors.Unwrap、errors.Is、errors.As

在领域驱动设计的报表系统中,错误往往需要跨越多个边界层(如基础设施层 → 应用层 → 领域层)。直接返回原始错误会丢失上下文,而过度包装又会导致调用方无法判断错误根源。Go 1.13 引入的错误链机制,通过 fmt.Errorf + %w 包装、errors.Unwrap 解包、errors.Is 与 errors.As 遍历检查,为领域边界提供了标准化的错误传递协议。

3.1 错误包装:fmt.Errorf + %w

%w 动词将错误包装进新的错误值,同时保留对原始错误的引用,形成一条可遍历的错误链。这是领域层向应用层传递错误时最常用的模式。

核心规则: 每次跨越领域边界(如 Repository → Service)时,使用 %w 附加当前层上下文,但不要包装已包装过的错误,避免链过长。
// 领域层定义哨兵错误
var ErrReportNotFound = errors.New("报表未找到")

// 基础设施层包装领域错误
func (r *PostgresReportRepo) FindByID(ctx context.Context, id ReportID) (*Report, error) {
    row := r.db.QueryRow(ctx, "SELECT ...", id)
    var report Report
    if err := row.Scan(&report); err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            // 使用 %w 包装领域错误,保留原始驱动错误
            return nil, fmt.Errorf("repo.FindByID %w: %w", ErrReportNotFound, err)
        }
        return nil, fmt.Errorf("repo.FindByID 查询失败: %w", err)
    }
    return &report, nil
}
包装方式 行为 适用场景
%w 包装后可通过 errors.Is/errors.As 遍历到原始错误 需要调用方判断错误类型或提取底层错误
%v 或 %s 仅保留错误消息字符串,丢失原始错误引用 不希望暴露内部错误细节给上层
自定义 fmt.Errorf 多 %w 可同时包装多个错误(Go 1.20+) 合并多个来源的错误(如批量校验)
避坑: 不要对 nil 错误使用 %w,这会导致运行时 panic。始终先检查 err != nil 再包装。

3.2 错误解包:errors.Unwrap

errors.Unwrap 是错误链遍历的基础函数。它检查错误是否实现了 Unwrap() error 接口,若实现则返回被包装的错误,否则返回 nil。大多数情况下,开发者不需要直接调用 Unwrap,而是通过 errors.Is 和 errors.As 间接使用。

// 手动解包示例(通常不推荐直接使用)
wrappedErr := fmt.Errorf("上层上下文: %w", ErrReportNotFound)
unwrapped := errors.Unwrap(wrappedErr)
fmt.Println(unwrapped == ErrReportNotFound) // true

// 多层解包
err1 := fmt.Errorf("层1: %w", ErrReportNotFound)
err2 := fmt.Errorf("层2: %w", err1)
fmt.Println(errors.Unwrap(err2) == err1) // true
fmt.Println(errors.Unwrap(errors.Unwrap(err2)) == ErrReportNotFound) // true

3.3 错误链检查:errors.Is

errors.Is 沿着错误链逐层调用 Unwrap,检查链中是否存在与目标哨兵错误相等的错误。它在领域边界判断中扮演关键角色:应用层无需知道底层实现细节,只需通过领域层定义的哨兵错误进行判断。

// 应用层使用 errors.Is 判断错误类型,不依赖底层实现
func (s *ReportService) GenerateReport(ctx context.Context, id ReportID) (*Report, error) {
    report, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, ErrReportNotFound) {
            // 返回领域层定义的业务错误,不暴露数据库细节
            return nil, fmt.Errorf("service.GenerateReport: %w", ErrReportNotFound)
        }
        return nil, fmt.Errorf("service.GenerateReport: %w", ErrInternal)
    }
    return report, nil
}
最佳实践: 在领域层定义哨兵错误(如 var ErrReportNotFound = errors.New("...")),所有下层包装时使用 %w 引用这些哨兵。上层只通过 errors.Is 与领域哨兵比较,实现层间解耦。

3.4 错误类型提取:errors.As

当错误链中包含自定义错误类型(如携带额外字段的 ValidationError)时,errors.As 可以沿着链查找第一个匹配类型的错误,并将其赋值给目标变量。这在报表系统中处理结构化错误(如字段校验失败列表)时非常有用。

// 定义领域层自定义错误类型
type ValidationError struct {
    Field   string
    Message string
    Err     error
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Message)
}

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

// 使用 errors.As 提取自定义错误
func handleReportError(err error) {
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        fmt.Printf("校验失败字段: %s, 原因: %s\n", valErr.Field, valErr.Message)
        // 可进一步处理 valErr.Err
    }
}
函数 比较方式 典型用途
errors.Is(err, target) 值相等(==) 判断是否为特定哨兵错误
errors.As(err, &target) 类型匹配(interface 赋值) 提取自定义错误类型中的附加信息

3.5 错误链在领域边界中的完整流程

下图展示了报表系统中一个典型请求从基础设施层到应用层的错误传递与解包路径:

错误链在领域边界中的传递与解包流程 基础设施层 PostgresReportRepo 原始错误: pgx.ErrNoRows ↓ 包装为领域哨兵 %w 包装 应用层 ReportService errors.Is(err, ErrReportNotFound) → true,返回业务错误 errors.Is 遍历链 错误链结构(从外到内) ① "service.GenerateReport: 报表未找到" ② "repo.FindByID: 报表未找到: no rows" ③ pgx.ErrNoRows(原始驱动错误) errors.Is 从①开始,逐层 Unwrap 到③ errors.Unwrap 解包路径 errors.Unwrap(①) → ② errors.Unwrap(②) → ③ 领域边界错误处理原则 • 基础设施层:使用 %w 包装原始错误 + 领域哨兵错误 • 应用层:使用 errors.Is 判断领域哨兵,errors.As 提取自定义类型 • 领域层:定义哨兵错误和自定义错误类型,不依赖下层实现

3.6 常见陷阱与对比

  • 不要用 errors.Is 比较非哨兵错误: 动态生成的错误(如 fmt.Errorf("id=%d 无效", id))每次都是新实例,errors.Is 永远返回 false。应使用 errors.As 提取类型或定义哨兵。
  • 避免多层包装同一错误: 每次 %w 都会创建新节点,链过长影响性能。建议每层只包装一次,且只在跨越领域边界时包装。
  • errors.Is vs ==: 直接使用 == 比较只能检查最外层错误,无法穿透包装层。始终使用 errors.Is 进行哨兵比较。
  • 自定义错误必须实现 Unwrap(): 如果自定义类型需要参与错误链,必须实现 Unwrap() error 方法,否则 errors.Is/errors.As 无法遍历到其内部。
领域边界检查清单:
  1. 领域层是否定义了清晰的哨兵错误(var ErrXxx = errors.New(...))?
  2. 基础设施层包装时是否使用了 %w 保留了领域哨兵?
  3. 应用层是否使用 errors.Is 而非 == 进行错误判断?
  4. 自定义错误类型是否实现了 Unwrap() 方法?
  5. 是否避免了在循环或高频路径中过度包装?