第2章 错误类型设计原则:领域错误枚举、错误等级、原因上下文
上一章我们用最简结构体验了 Result 的传递流程。但生产系统中 E 如果只用无结构字符串或整数码,会导致跨模块时无法区分错误种类、严重程度和原始原因。
本章聚焦 E 的三支柱设计原则:
- 领域错误枚举(DomainError)——穷举业务逻辑可能出现的错误变体;
- 错误等级(ErrorLevel)——按严重程度分级,支持过滤与告警;
- 原因上下文(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(())
}
}
2.4 三支柱整合流程
当实际产生错误时,按以下步骤构造并抛出 AppError:
- 选择对应的
DomainError变体,填入仅有的必要字段; - 根据业务影响判断
ErrorLevel; - 创建
AppError::new(level, domain),若存在原始错误链,则调用with_source()保留; - 将
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(),排查时找不到根因。