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 层(如 AssetRepository、PlanRepository、SnapshotRepository)现已返回 AppResult<T>,利用 ? 运算符自动转换 rusqlite::Error 为 AppError::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()
这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。
更好的策略(从易到难):
- 学习期(低成本):用
expect("...")明确崩溃原因(至少别静默) - 工程期(推荐):把解析失败转成
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() 升级为结构化错误传播。
建议步骤:
- 修改
row_to_asset返回类型为Result<Asset, AppError>(而非rusqlite::Result<Asset>) - 把
Uuid::parse_str(&id).unwrap_or_default()改为:#![allow(unused)] fn main() { Uuid::parse_str(&id) .map_err(|_| AppError::parse(format!("无效的资产 ID: {}", id)))? } - 同理处理
Decimal::from_str、DateTime::parse_from_rfc3339等 - 调整调用处(
query_map的闭包)以适配新的返回类型
注:
AppError已定义在src/error.rs,Repository 已使用AppResult<T>。
练习 C:把错误展示到 UI(而不是只打印)
本项目 AppState 有 error: Option<String> 字段。你可以在:
- 写入失败时:
state.write().error = Some(e.to_string()) - 页面顶部:渲染一个小红条/Toast(先做最简单版本)
这会让你把错误处理从"工程内部"扩展到"产品体验"。