第2章 错误类型设计原则:领域错误枚举、错误等级、原因上下文

上一章我们用最简结构体验了 Result 的传递流程。但生产系统中 E 如果只用无结构字符串或整数码,会导致跨模块时无法区分错误种类、严重程度和原始原因。

本章聚焦 E 的三支柱设计原则:

  1. 领域错误枚举(DomainError)——穷举业务逻辑可能出现的错误变体;
  2. 错误等级(ErrorLevel)——按严重程度分级,支持过滤与告警;
  3. 原因上下文(ErrorContext)——保留原始错误、堆栈跟踪以及附加载荷。

三支柱组合在一起,让 Result 中的错误既保持了机器可读的结构化,又保留了人工调试所需的完整信息。

2.1 领域错误枚举

把模块或子系统中所有可能发生的业务错误定义成一个独立的 enum,每个变体只携带该错误必需的上下文数据,既不过重,也不过轻。

设计准则: 每个变体只携带最少的数据用于构造错误信息和定位问题。不要把整个请求对象或数据库连接塞进错误变体。
// 第1层:领域错误枚举(DomainError)
#[derive(Debug, Clone, PartialEq)]
pub enum DomainError {
    UserNotFound { user_id: String },
    PaymentFailed { transaction_id: String, reason: String },
    ValidationError { field: String, detail: String },
    DatabaseTimeout { operation: &'static str },
    InsufficientBalance { current: u64, required: u64 },
    OrderAlreadyShipped { order_id: String },
}

impl std::fmt::Display for DomainError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DomainError::UserNotFound { user_id } => write!(f, "user `{}` not found", user_id),
            DomainError::PaymentFailed { transaction_id, reason } =>
                write!(f, "payment `{}` failed: {}", transaction_id, reason),
            DomainError::ValidationError { field, detail } =>
                write!(f, "validation failed on `{}`: {}", field, detail),
            DomainError::DatabaseTimeout { operation } =>
                write!(f, "database timeout during `{}`", operation),
            DomainError::InsufficientBalance { current, required } =>
                write!(f, "balance {} insufficient, need {}", current, required),
            DomainError::OrderAlreadyShipped { order_id } =>
                write!(f, "order {} already shipped, cannot modify", order_id),
        }
    }
}

使用 DomainError 作为 E 时,调用方可以通过 match 精确处理每一个分支,而不是解析字符串或查错误码表。

2.2 错误等级

并非所有错误都需要立即告警或中断流程。引入 ErrorLevel 枚举,让每个错误附带一个严重级别,帮助监控与日志系统做分级处理。

// 第2层:严重等级
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ErrorLevel {
    Debug,
    Info,
    Warning,
    Error,
    Critical,
}

impl ErrorLevel {
    pub fn is_operational(&self) -> bool {
        matches!(self, ErrorLevel::Warning | ErrorLevel::Error | ErrorLevel::Critical)
    }
}
等级 典型场景 处理方式
Debug / Info 重试成功、降级调用、非关键延迟 记录日志,不触发告警
Warning 首次失败、即将达到配额、外部超时 记录日志 + 慢速告警
Error 业务校验失败、支付拒绝、资源不足 记录日志 + 钉钉/短信告警
Critical 数据库连接丢失、配置损坏、内存溢出 立即告警 + 自动止血/重启

2.3 原因上下文

在包装错误时,不能丢弃原始错误(source)和调用栈(backtrace)。定义一个通用 AppError 结构体,聚合领域错误、等级和上下文。

// 第3层:统一的 AppError,包装领域错误、等级和上下文
#[derive(Debug)]
pub struct AppError {
    pub level: ErrorLevel,
    pub domain: DomainError,
    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
    pub backtrace: std::backtrace::Backtrace,
}

impl AppError {
    pub fn new(level: ErrorLevel, domain: DomainError) -> Self {
        AppError {
            level,
            domain,
            source: None,
            backtrace: std::backtrace::Backtrace::capture(),
        }
    }

    pub fn with_source(
        mut self,
        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
    ) -> Self {
        self.source = Some(source.into());
        self
    }
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "[{:?}] {}", self.level, self.domain)?;
        if let Some(ref source) = self.source {
            write!(f, " (caused by: {})", source)?;
        }
        Ok(())
    }
}
错误类型三支柱结构图 AppError (统一错误包装) DomainError 领域枚举 ErrorLevel 错误等级 ErrorContext 原因上下文 UserNotFound PaymentFailed ValidationError Debug Info Warning Error Critical source: Box<dyn Error> backtrace: Backtrace 设计要点 DomainError 标记错误类型;ErrorLevel 界定严重程度;ErrorContext 携带原始错误与堆栈。 三者组合形成完备的错误信息体,让 Result 的 E 兼具机器可读性与人工可调试性。

2.4 三支柱整合流程

当实际产生错误时,按以下步骤构造并抛出 AppError:

  1. 选择对应的 DomainError 变体,填入仅有的必要字段;
  2. 根据业务影响判断 ErrorLevel;
  3. 创建 AppError::new(level, domain),若存在原始错误链,则调用 with_source() 保留;
  4. 将 AppError 放入 Result::Err 返回给上层。
// 实际使用示例
fn get_user_profile(user_id: &str) -> Result<UserProfile, AppError> {
    let record = db::find_user(user_id)
        .map_err(|e| AppError::new(ErrorLevel::Error, DomainError::DatabaseTimeout { operation: "find_user" })
            .with_source(e))?;

    let profile = record.try_into()
        .map_err(|e| AppError::new(ErrorLevel::Warning, DomainError::ValidationError {
            field: "profile".into(),
            detail: "data corruption detected".into(),
        }).with_source(e))?;

    Ok(profile)
}
关键收益: Flat string errors → 无结构化信息;三支柱设计 → 调用方可以通过 match 精确处理特定变体、根据 level 决定是否重试、通过 source 追踪完整的错误因果链。

2.5 对比总结

方面 平面错误(String / i32) 结构化错误(三支柱)
可读性 依赖格式化字符串,变体不明确 枚举变体自描述,Display 统一但保留细节
可操作性 必须解析字符串或查码表 match DomainError 穷举处理
严重度判定 难以区分(通常全部告警) ErrorLevel 字段直接支持分级过滤
原始原因保留 容易丢失,或手工拼接 source 字段保留完整错误链
序列化与上报 无统一格式 可统一序列化为 JSON/Protobuf
常见误区:
  • 超大枚举: 将所有模块的错误放在一个全局枚举中,导致 match 时需要处理无关变体。建议按模块分割,或在顶层使用组合模式。
  • 等级滥用: 全部使用 Error 或 Critical,导致告警疲劳。按实际业务影响准确分级。
  • 丢失source: 在构造 AppError 时忘记用 with_source(),排查时找不到根因。