Rust 错误处理分层:库代码别急着打印日志
Rust 错误处理分层:库代码别急着打印日志
一、错误处理不是到处写 println
刚写 Rust 项目时,我很容易在出错的地方直接println!,然后返回一个字符串错误。项目小的时候还能看,模块一多就乱了:有些错误被打印两次,有些错误丢了上下文,有些库函数直接决定了用户提示。后来才慢慢理解,错误处理应该分层。
库代码负责描述错误,应用入口负责展示错误。也就是说,底层模块应该返回结构化错误,让上层决定是否记录日志、是否重试、是否展示给用户。库函数里到处打印日志,会让 CLI 输出不可控,也不利于测试。
记得有次写 CLI 工具,调用了一个别人写的 HTTP 库。请求失败时,终端同时输出了三行错误:库里打印的日志、我封装的日志、还有 main 里的打印。三行说的同一个事,但措辞完全不同。用户复制给我看,我都不确定到底哪条是自己写的。从那次开始,我给自己立了一个规矩:库代码不打印,入口层统一展示。
二、分层模型:底层保留原因,顶层决定表达
flowchart TD A[文件模块] --> D[业务服务] B[网络模块] --> D C[解析模块] --> D D --> E[CLI 入口] E --> F[用户提示] E --> G[调试日志]底层错误应尽量具体,例如文件不存在、权限不足、响应格式不合法、配置缺少字段。业务层可以把多个底层错误转换成领域错误,例如“加载插件失败”。CLI 入口再根据错误类型决定退出码和提示语。
这套分层的好处是可测试。测试库函数时,只需要断言返回了某个错误,不需要捕获 stdout。用户界面也更统一,不会出现一部分模块中文提示、一部分模块英文 panic 的情况。错误信息也是产品体验的一部分。
三、代码示例:thiserror 给库,anyhow 给入口
下面是一个常见组合:库模块用thiserror定义错误,应用入口用anyhow汇总上下文。
use thiserror::Error; #[derive(Debug, Error)] pub enum ConfigError { #[error("config file not found: {0}")] NotFound(String), #[error("invalid config format: {0}")] InvalidFormat(String), } pub fn load_config(path: &str) -> Result<String, ConfigError> { std::fs::read_to_string(path) .map_err(|_| ConfigError::NotFound(path.to_string())) }入口层可以补上下文:
use anyhow::{Context, Result}; fn main() -> Result<()> { let config = load_config("agent.toml") .context("failed to start agent because config loading failed")?; println!("{config}"); Ok(()) }这样底层错误保留类型,上层错误保留场景。用户看到的不是一个孤立的 IO error,而是知道程序启动失败和配置有关。
生产环境实战经验
用thiserror时有个坑,#[from]会自动做错误转换。有一次我在 ConfigError 上加了#[from],结果 IO 错误被自动转成了 ConfigError。排查的人看到"配置文件错误",查了半天文件格式,其实是文件不存在。自动转换很方便,但会让错误类型变模糊。现在我只在明确因果关系时用#[from],其他情况手动map_err。
四、实践边界:什么时候 panic
panic!不应该用于可预期错误。用户配置错、文件不存在、网络失败、接口超时,这些都应该返回Result。panic!更适合表达程序员错误,例如不可能出现的内部状态、测试断言失败或原型阶段暂时没有处理的分支。
但也不要把错误处理写得过度复杂。小工具里可以先用anyhow快速串起来,等模块稳定后,再把核心库错误改成明确枚举。学习 Rust 的过程也是逐步抽象的过程,不必第一天就写出大型框架。
一个因错误处理不当导致线上问题的小案例
之前一个后台服务,某个协程里unwrap()了一个None,直接 panic。因为JoinHandle没被 await,panic 被默默吞掉了。服务表面还在运行,但那个模块已经不处理新请求了。等发现时,已经有上百条请求被丢弃。从那以后,所有 spawn 的 handle 都会在退出前 join,任何 panic 都会记录到告警通道。
日志方面,建议入口层或任务边界记录。库函数只返回错误,不主动打印。这样用户开启 verbose 时能看到更多细节,默认模式保持干净。CLI 工具最怕失败时刷一屏重复堆栈,用户反而不知道该改哪里。
五、总结
Rust 错误处理可以按层设计:库代码描述错误,业务层补充语义,CLI 入口决定展示和日志。thiserror适合定义明确错误,anyhow适合应用入口串联上下文。别急着到处打印日志,先把错误边界说清楚。
