造芯的质检工序收尾:失败路径与成功路径同权重。本节把前几节里一直用 Result<T, String> 凑合的错误处理升级成正式方案——thiserror 定义错误族、错误跨桥序列化、前端按码分流——并顺带盘点类型安全的其余两道闸:编译期闸与校验闸。读完本节,芯的每个失败都有名字、有语义、有出口。
Result<Note, String> 的问题不在"不能用",在"越长越贵":错误信息是人话不是数据,前端想区分"文件不存在"与"路径越界"只能做字符串匹配——文案一改就崩;新加一种失败就要在全前端 grep 错误文案;测试断言错误只能断言整串文本。错误是接口的一部分,接口要的是可辨识、可分流、可演进——这三样都指向同一个答案:错误类型化。
thiserror 用派生宏把错误类型与 Display 文案绑在一起,样板代码压到最低:
use serde::Serialize; use thiserror::Error; #[derive(Debug, Error, Serialize)] #[serde(tag = "kind", content = "message")] pub enum AppError { #[error("笔记不存在: {0}")] NotFound(String), #[error("路径越界: {0}")] PathEscape(String), #[error("存储已损坏: {0}")] StoreCorrupted(String), #[error("网络错误: {0}")] Network(String), } impl From<std::io::Error> for AppError { fn from(e: std::io::Error) -> Self { use std::io::ErrorKind::*; match e.kind() { NotFound => AppError::NotFound(e.to_string()), _ => AppError::StoreCorrupted(e.to_string()), } } } pub type AppResult<T> = Result<T, AppError>;
逐段拆解设计意图:枚举即错误目录——全应用错误在类型上一览无余,新加失败种类改一处;#[error(...)] 给人话文案——Display 用于日志与调试;Serialize 派生加 tag/content——错误过桥后是 { kind: "NotFound", message: "..." } 形态,前端按 kind 分流;From 转换——底层 io::Error 在 ? 处自动升格为业务错误,并把"不存在"这类可恢复情形归入正确分支。命令签名从此升级:
#[tauri::command] fn read_note(app: tauri::AppHandle, name: String) -> AppResult<String> { let base = notes_dir(app)?; let target = base.join(&name); ensure_within(&base, &target)?; // 失败时返回 PathEscape std::fs::read_to_string(&target)?; // io::Error 经 From 自动转 AppError }
对比 5.3 的版本:业务逻辑一字未变,失败路径全部获得了类型身份。
export interface AppErrorShape { kind: string; message: string; } export async function readNote(name: string): Promise<string> { try { return await invoke<string>('readNote', { name }); } catch (raw) { const err = normalizeError(raw); switch (err.kind) { case 'PathEscape': showDanger('路径不合法'); break; case 'NotFound': showTip('笔记不存在,可能已被删除'); break; case 'StoreCorrupted': showDanger('数据文件异常,请备份后重置'); break; default: showDanger(err.message); } throw err; } } function normalizeError(raw: unknown): AppErrorShape { if (typeof raw === 'object' && raw !== null && 'kind' in raw) { return raw as AppErrorShape; } return { kind: 'Unknown', message: String(raw) }; }
normalizeError 兜住两类漏网:芯侧未升级的旧命令返回裸字符串;框架层错误(命令未登记、权限拒绝)形态各异。分流的价值在 UX:NotFound 给轻提示,StoreCorrupted 引导备份,Unknown 才给通用报错——用户看到的不再是千篇一律的"出错了"。
错误类型化是第一道闸(运行时失败可辨识)。完整防线还有两道:
编译期闸。 Rust 的 Option 与 Result 把"可能没有"与"可能失败"塞进类型系统——Option<Note> 让"笔记可能不存在"无法被假装不存在,编译器强制你处理。写命令时克制 unwrap 的冲动:unwrap 是"我赌它一定成功",赌输即崩溃;只在 truly 不可能失败处(如刚构建的合法值)使用,且注释说明理由。
校验闸。 类型对不等于值对:String 类型的 name 可以是空、可以是越界路径。校验闸在命令入口集中放——空串、长度、格式、路径范围,非法即早退。两道闸的分工:编译期拦"类型的错",校验拦"值的错",错误类型化保证"拦不住的错体面落地"。
? 处自动升格,可恢复情形归入正确分支;芯造完了:契约、状态、并发、能力、错误五件齐备。下一章给整个应用上锁——Capabilities 权限清单、CSP 与代码签名,把第 5 章开放的接口收进笼子。