06 错误处理:Result / ? / thiserror(让代码可维护)

本章目标

  • 你能熟练使用 Option / Result / ? 来表达"可能失败/可能缺失"。
  • 你能区分"可以 panic 的地方"和"必须返回错误的地方"。
  • 你能看懂本项目的错误处理设计(已使用 thiserror 定义统一的 AppError)。

1. Rust 的错误处理哲学(和 JS/Java 的差别)

1.1 Rust 默认不鼓励用异常控制流程

Rust 当然也有 panic(相当于“不可恢复异常”),但日常工程里更推荐:

  • 可预期失败:用 Result<T, E>
  • 可缺失值:用 Option<T>

这让函数签名本身就能告诉你“调用者需要处理什么情况”。

1.2 ? 运算符:把错误向上传播

当你拿到 Result<T, E>

  • 成功:取出 T
  • 失败:立刻 return Err(E)

这就是 ? 的含义。

理解 ? 会极大提升你写 Rust 的速度,因为你不需要层层 match


2. Option vs Result:什么时候用哪个?

2.1 Option<T>:没有就是没有(不是“错误”)

例子:

  • notes: Option<String>:资产可以没有备注
  • find_latest() -> Result<Option<Snapshot>, String>:查询可能成功但“没有数据”

这里“没有数据”不是错误,所以用 Option

2.2 Result<T, E>:操作可能失败

例子:

  • DB 访问失败(IO/SQL/锁/约束)
  • 数据解析失败(Uuid/Decimal/DateTime 字符串不合法)
  • 业务校验失败(比例不等于 100,盘点次数超限等)

3. 本项目当前错误处理现状(你需要先读懂)

3.1 已实现:统一错误类型 AppError

本项目在 src/error.rs 中定义了统一的错误类型:

#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum AppError {
    #[error("数据库错误: {0}")]
    Database(#[from] rusqlite::Error),

    #[error("数据解析错误: {0}")]
    Parse(String),

    #[error("{resource}未找到: {id}")]
    NotFound { resource: &'static str, id: String },

    #[error("{0}")]
    Business(String),

    #[error("IO错误: {0}")]
    Io(#[from] std::io::Error),

    #[error("数据库未初始化")]
    DatabaseNotInitialized,
}

pub type AppResult<T> = Result<T, AppError>;
}

Repository 层(如 AssetRepositoryPlanRepositorySnapshotRepository)现已返回 AppResult<T>,利用 ? 运算符自动转换 rusqlite::ErrorAppError::Database

3.2 待改进:row_to_xxx 仍使用 unwrap_or_default()

虽然 Repository 函数返回类型已升级,但在 row_to_asset 等内部方法中仍有:

  • Uuid::parse_str(&id).unwrap_or_default()
  • Decimal::from_str(&value_str).unwrap_or_default()

这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。详见第 4 节的改进建议。

3.3 UI 层目前多用 eprintln! 打印错误

例如 src/pages/assets.rs 保存失败时:

  • eprintln!("保存资产失败: {}", e);

这在学习期 OK,但如果想做成"可用的产品",需要把错误反馈到 UI(AppState.error 或 toast)。


4. 当前设计与改进方向

4.1 已完成:统一错误类型 AppError

本项目已在 src/error.rs 定义了应用级错误:

  • Database(rusqlite::Error):数据库操作错误
  • Parse(String):数据解析错误
  • NotFound { resource, id }:资源未找到
  • Business(String):业务规则错误
  • Io(std::io::Error):IO 错误
  • DatabaseNotInitialized:数据库未初始化

收益:

  • DB 层保留了结构化错误(自动从 rusqlite::Error 转换)
  • UI 层可以统一把 AppError 格式化成用户可读文本
  • 未来可做更细粒度处理(例如 NotFound 走黄色提示,Database 错误走红色)

4.2 待改进:不要用 unwrap_or_default() 静默吞掉解析失败

AssetRepository::row_to_asset 里,目前策略是:

  • Uuid::parse_str(&id).unwrap_or_default()
  • Decimal::from_str(&value_str).unwrap_or_default()

这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。

更好的策略(从易到难):

  1. 学习期(低成本):用 expect("...") 明确崩溃原因(至少别静默)
  2. 工程期(推荐):把解析失败转成 AppError::Parse(...) 向上传播

5. unwrap / expect / panic!:什么时候可以用?

经验规则(适合这个项目):

  • 测试代码:可以用 unwrap()(失败就失败,快速定位)
  • “绝不可能失败”的地方:可以用 expect("不可能失败的原因说明")
  • 用户输入/DB/IO:不要 panic,应该返回 Result 给上层处理

在本项目里,以下位置是“典型可改进点”:

  • Mutex::lock().unwrap():可用 expect("数据库连接锁被 poison") 至少增强可诊断性
  • parse_from_rfc3339(...).unwrap_or_else(|_| Utc::now()):会把时间解析失败吞掉,建议改为错误传播或显式 fallback 记录日志

6. 本章练习(建议按顺序做)

练习 A:把一个 unwrap_or_default() 改成显式错误

选择一个 Repository(例如 AssetRepository::row_to_asset),把:

  • Uuid::parse_str(&id).unwrap_or_default()

改为:

  • 学习期:Uuid::parse_str(&id).expect("assets.id 必须是合法 uuid")

你会马上感受到"更早失败、更容易定位"。

练习 B:把 row_to_asset 的解析错误传播出去

目标:利用已有的 AppError::Parse,把 row_to_asset 中的 unwrap_or_default() 升级为结构化错误传播。

建议步骤:

  1. 修改 row_to_asset 返回类型为 Result<Asset, AppError>(而非 rusqlite::Result<Asset>
  2. Uuid::parse_str(&id).unwrap_or_default() 改为:
    #![allow(unused)]
    fn main() {
    Uuid::parse_str(&id)
        .map_err(|_| AppError::parse(format!("无效的资产 ID: {}", id)))?
    }
  3. 同理处理 Decimal::from_strDateTime::parse_from_rfc3339
  4. 调整调用处(query_map 的闭包)以适配新的返回类型

注:AppError 已定义在 src/error.rs,Repository 已使用 AppResult<T>

练习 C:把错误展示到 UI(而不是只打印)

本项目 AppStateerror: Option<String> 字段。你可以在:

  • 写入失败时:state.write().error = Some(e.to_string())
  • 页面顶部:渲染一个小红条/Toast(先做最简单版本)

这会让你把错误处理从"工程内部"扩展到"产品体验"。