Rust 错误处理的反面教材:半年里见过的最糟糕的错误处理代码分析
Rust 错误处理的反面教材半年里见过的最糟糕的错误处理代码分析一、一个 unwrap 让月活掉 15%今年三月的一个周五dayuan 的监控突然报警错误率从 0.1% 飙升到 12%。起因是一行代码let config Config::from_file(config.toml).unwrap();有个用户在 macOS 上升级系统后config.toml的权限被重置了。这行.unwrap()直接 panic整个进程退出——不是优雅报错不是降级运行是直接炸了。更讽刺的是这个函数的调用栈里三层外三层全是ResultT, E返回值也是Result就用到了最后这一行——一个.unwrap()就把所有错误处理体系毁掉了。Rust 的错误处理设计得很好但用不好就变成灾难。这半年我 review 了不少 Rust 项目的错误处理代码选出七个最经典的反面教材。二、反面教材全景三、类型与传播层面的反例反例 1unwrap()瘟疫 —— 在生产代码里埋定时炸弹/// ❌ 我在 GitHub 上真实见过的代码几乎一字未改 fn handle_request(payload: str) - ResultString, Error { // 第 1 个 unwrap let parsed: Value serde_json::from_str(payload).unwrap(); // 第 2 个 unwrap let user_id parsed[user_id].as_u64().unwrap(); // 第 3 个 unwrap let config CONFIG.lock().unwrap(); // 第 4 个 unwrap let db config.databases.get(primary).unwrap(); // 第 5 个 unwrap let conn db.connect().unwrap(); // 每个 unwrap 都是一颗生产环境的地雷 // 任何一个触发 panic整个服务的所有请求都会被中断 Ok(process(user_id, conn)) } /// ✅ 正确做法每一步都传播错误 fn handle_request_safe(payload: str) - ResultString, AppError { // 用 ? 传播错误保留上下文 let parsed: Value serde_json::from_str(payload) .context(请求体 JSON 解析失败)?; // anyhow context let user_id parsed[user_id].as_u64() .ok_or_else(|| AppError::MissingField(user_id.into()))?; let config CONFIG.lock() .map_err(|_| AppError::LockPoisoned(config.into()))?; let db config.databases.get(primary) .ok_or_else(|| AppError::ConfigMissing(databases.primary.into()))?; let conn db.connect() .context(数据库连接失败)?; process(user_id, conn) }经验法则.unwrap()只在两种情况下可以用测试代码和原型代码。.expect()可以用但必须写清楚为什么这里不会失败。生产代码里出现.unwrap()视为 code smell。反例 2Boxdyn Error吃掉一切/// ❌ 用了 Result但类型信息全丢了 fn process_data(input: str) - ResultString, Boxdyn std::error::Error { // 这个返回类型的意思是什么错都可能发生你自己看着办吧 ^^^^^^^^^^^^^^^^ let file std::fs::read_to_string(input)?; // io::Error let parsed: Config toml::from_str(file)?; // toml::de::Error let db Database::connect(parsed.db_url)?; // db::ConnectionError let result db.query(SELECT * FROM items)?; // db::QueryError let serialized serde_json::to_string(result)?; // serde_json::Error Ok(serialized) // 调用方拿到 Err 之后这到底是文件不存在配置格式不对数据库挂了 // 它只能把错误信息打印出来无法根据错误类型做差异化处理 } /// ✅ 正确做法定义明确的错误类型 use thiserror::Error; #[derive(Error, Debug)] pub enum AppError { #[error(文件读取失败: {path})] IoError { path: String, #[source] source: std::io::Error, }, #[error(配置文件解析失败)] ConfigParse { #[source] source: toml::de::Error, }, #[error(数据库错误: {context})] Database { context: String, #[source] source: db::Error, }, #[error(序列化失败)] Serialization { #[source] source: serde_json::Error, }, } fn process_data_typed(input: str) - ResultString, AppError { let file std::fs::read_to_string(input) .map_err(|e| AppError::IoError { path: input.to_string(), source: e, })?; let parsed: Config toml::from_str(file) .map_err(|e| AppError::ConfigParse { source: e })?; // ... 后续操作同理 Ok(serialized) } // 现在调用方可以 match process_data_typed(config.toml) { Ok(data) println!(成功), Err(AppError::IoError { path, .. }) { eprintln!(文件 {} 不存在请检查路径, path); } Err(AppError::ConfigParse { source }) { eprintln!(配置格式错误: {}, source); // 自动生成迁移指南 } Err(AppError::Database { .. }) { // 触发告警数据库可能挂了 alert_on_database_failure(); } _ { eprintln!(未知错误); } }传播层面的反例反例 3错误信息只说发生了错误/// ❌ 错误信息毫无价值 fn read_user_data(user_id: u64) - ResultUser { std::fs::read_to_string(data.json) .map_err(|_| anyhow::anyhow!(读取文件失败))?; // ^^^^^^^^^^^^ // 哪个文件什么原因user_id 是什么没有上下文 没有调试价值 } /// ✅ 错误信息包含所有上下文 fn read_user_data(user_id: u64) - ResultUser { let path format!(data/users/{}.json, user_id); std::fs::read_to_string(path) .with_context(|| { format!(读取用户数据失败: 路径{}, user_id{}, path, user_id) })?; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // 现在日志里能看到完整的上下文排查效率提升 10 倍 }反例 4吞掉错误 —— 最危险的反模式/// ❌ 静默地忽略错误 fn config_or_default(path: str) - Config { match Config::from_file(path) { Ok(config) config, Err(_) { // 没有日志、没有告警、用户完全不知道用了默认配置 Config::default() } } } /// ✅ 至少在日志里记录 fn config_or_default_safe(path: str) - Config { match Config::from_file(path) { Ok(config) config, Err(e) { // 至少打印一个 warning方便出问题时排查 tracing::warn!( error %e, path %path, 配置文件加载失败使用默认配置 ); Config::default() } } }反例 5map_err里丢掉原始错误/// ❌ 把原始错误变成了没有上下文的自定义错误 fn get_token() - ResultString { std::fs::read_to_string(.token) .map_err(|_| AppError::AuthFailed)?; // ^^^ 原始 io::Error 被丢弃了 // 是文件不存在还是权限不足还是磁盘满了无从得知。 } /// ✅ 保留原始错误作为 source fn get_token_safe() - ResultString { std::fs::read_to_string(.token) .map_err(|e| AppError::AuthFailed { reason: 读取认证令牌文件失败.to_string(), source: e, // ← 保留原始错误方便调试 })?; }四、恢复层面与分层架构反例 6所有错误都用同一种策略/// ❌ 不管什么错误重试 3 次 async fn resilient_request(url: str) - ResultString { for attempt in 1..3 { match reqwest::get(url).await { Ok(resp) return Ok(resp.text().await?), Err(e) { if attempt 3 { tokio::time::sleep(Duration::from_secs(1)).await; continue; } return Err(e.into()); } } } unreachable!() }问题DNS 解析失败重试有意义但 404 重试 3 次纯属浪费。不同错误需要不同的恢复策略。/// ✅ 根据错误类型采取不同策略 async fn resilient_request_smart(url: str) - ResultString { let mut retries 0; loop { match reqwest::get(url).await { Ok(resp) { if resp.status().is_success() { return Ok(resp.text().await?); } match resp.status().as_u16() { // 4xx: 客户端错误不重试 400..499 { return Err(AppError::ClientError { status: resp.status().as_u16(), body: resp.text().await.unwrap_or_default(), }); } // 5xx: 服务端错误重试 500..599 if retries 3 { retries 1; let delay Duration::from_secs(2u64.pow(retries)); tracing::warn!(status %resp.status(), retry retries, 服务端错误{}秒后重试, delay.as_secs()); tokio::time::sleep(delay).await; continue; } _ { return Err(AppError::UnexpectedStatus(resp.status().as_u16())); } } } // 超时和连接错误重试 Err(e) if e.is_timeout() || e.is_connect() { if retries 3 { retries 1; let delay Duration::from_secs(2u64.pow(retries)); tracing::warn!(error %e, retry retries, 网络错误{}秒后重试, delay.as_secs()); tokio::time::sleep(delay).await; continue; } return Err(AppError::NetworkUnreachable { source: e }); } // 其他错误不重试 Err(e) { return Err(e.into()); } } } }反例 7没有对用户友好的错误消息/// ❌ 用户看到Error: Io(Os { code: 2, kind: NotFound, message: No such file or directory }) /// ✅ 用户看到配置文件未找到 /home/user/.dayuan/config.toml请运行 dayuan init 初始化用thiserror的#[error(...)]宏写的错误消息应该像写给用户的邮件一样——说清楚发生了什么、为什么、怎么办。错误处理分层架构把这些反例翻转过来就是一套好的错误处理体系/// ✅ 实例完整的错误处理分层 // 层 1模块错误类型 // src/storage/error.rs #[derive(Error, Debug)] pub enum StorageError { #[error(文件未找到: {path})] NotFound { path: String }, #[error(权限不足: {path})] PermissionDenied { path: String, #[source] source: io::Error }, } // 层 2跨模块错误传播 // src/service.rs use anyhow::Context; fn load_data(path: str) - anyhow::ResultData { let raw std::fs::read_to_string(path) .with_context(|| format!(读取文件 {} 失败, path))?; let data: Data serde_json::from_str(raw) .context(JSON 反序列化失败)?; Ok(data) } // 层 3用户友好的展示 // src/main.rs fn main() { if let Err(e) run() { // 对用户友好 eprintln!(\x1b[31m错误: {}\x1b[0m, e); // 对开发者完整 eprintln!(\n调试信息:); for cause in e.chain().skip(1) { eprintln!( 原因: {}, cause); } std::process::exit(1); } }实操案例用 thiserror 重构 400 行错误处理dayuan 的第一个公开版本的错误处理简直就是反例集合。主函数里到处是.unwrap()配置文件加载用了Boxdyn ErrorAPI 调用失败时用户只看到Error: Io(Os { code: 2, kind: NotFound })——没有文件名、没有修复建议、没有上下文。重构花了整整一个周末。我先定义了三个错误枚举ConfigError配置相关含文件路径和具体原因、ApiErrorAPI 调用相关含状态码、重试次数、原始响应体摘要、CliError用户交互相关含友好的问题描述和修复建议。然后用#[from]和#[source]把错误链串起来——ConfigError自动从io::Error和toml::de::Error转换ApiError自动从reqwest::Error转换。最关键的一步是顶层展示。在main.rs里用anyhow::Result接住所有错误然后用e.chain()遍历错误链打印完整上下文用 ANSI 颜色区分错误级别红色 无法继续黄色 已降级绿色 已自动恢复。用户现在看到的不是冷冰冰的Os { code: 2 }而是错误: 配置文件未找到 /home/user/.dayuan/config.toml → 建议: 运行 dayuan init 初始化。重构后代码只多了 150 行主要是错误枚举定义但用户报 bug 时的沟通成本从请把完整错误日志发给我变成了我看到日志就能定位问题。错误处理的投资回报率远比大多数人想象的高。踩坑实录一个 anyhow context 引发的监控漏报去年 12 月我们的监控面板显示 dayuan 的错误率正常——0.05%。但用户反馈说commit命令时不时生成空的提交信息。翻了一天日志才发现commit命令里调用 AI 生成提交信息时响应有时候是空字符串模型偶尔会返回空内容但代码是这样写的let msg ai_client.generate_commit_message(diff) .context(AI 生成提交信息失败)?;anyhow::context只在Result::Err时才添加上下文但generate_commit_message返回的是ResultString空字符串是Ok()不是Err代码逻辑认为 Ai 返回空字符串 是成功后续的git commit -m 就被静默执行了生成一个空提交。更糟糕的是因为这个空字符串没有被当作错误传播tracing span 里也没有任何 warn 级别的日志监控完全看不到这个异常。直到一个用户录屏给我看我才明白为什么这几周总有人抱怨提交信息是空的。修复引入了两层防护第一层在generate_commit_message返回后立即验证——if msg.trim().is_empty() { return Err(AppError::EmptyAiResponse); }第二层在所有 AI 生成的输出落地前加长度和内容校验——提交信息至少 10 个字符、不能全是中英文标点、不能和上次提交信息完全一样。这个坑的教训是**错误传播链上的每一步都要验证值的合理性而不仅仅是检查 Result 的 Err 状态。**Ok 不代表可用.context() 只能在 Result 层面加信息管不了值的逻辑错误。从那天起我的代码规范里多了一条所有 AI 输出必须先 validate 再使用空值检查永远写在 ? 之前。五、总结Rust 的错误处理体系我认为是业界最好的——它既不像 Go 那样if err ! nil满屏飞也不像 Java 那样 checked exception 滥用。Result?thiserror的组合拳用顺手了之后写业务代码简直是享受。但最好的工具也架不住滥用。记住四条底线生产代码不用.unwrap()。?和.context()只多打几个字符换来的可维护性是指数级的。错误类型要有区分度。Boxdyn Error只能用在main()里业务层必须用枚举。错误消息要给人看。每一条错误消息都应该回答发生了什么为什么怎么办不同错误不同策略。DNS 失败重试404 不重试auth 失败告警配置缺失引导——不能一刀切。自学出身让我对用户看到的错误消息特别敏感——因为我就是那个看到panicked at index out of bounds时一脸懵的用户。好的错误处理不只是技术问题它是对用户的尊重。下一篇预告AI Agent 开发的十大陷阱从 prompt 幻觉到工具调用死循环的防范。