Rust 项目中错误类型转换与上下文信息的组织方式
先说为什么开始较真这件事
最早写 Rust,我的错误处理基本就是两招:能 unwrap 就 unwrap,不能就 .map_err(|e| e.to_string())?。项目小的时候毫无问题,毕竟报错的时候日志里好歹有一行字符串。等到服务上线,半夜被叫起来看日志,看到一句 No such file or directory,然后就没了——哪个文件?在哪个环节读的?是配置加载还是证书加载?全靠猜。
那之后我才认真去理错误类型和上下文这两件事。说实话这东西没什么高深理论,主要是几个约定,加上一些踩过才知道的细节。
库里的错误:一个枚举,几个变体
如果是给别人用的 crate,或者项目内部相对独立的模块,我基本都用 thiserror 定义一个具体的枚举,每个变体对应一种“调用方可能想区分”的失败方式。
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("读取配置文件 {path} 失败")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("配置文件格式不合法")]
Parse(#[from] toml::de::Error),
#[error("缺少必填字段 `{field}`")]
MissingField { field: &'static str },
}
这里的关键是别把“所有错误”都塞进一个变体里。我以前图省事,整个模块只有一个 Other(String),结果就是调用方除了把字符串往下传什么都干不了,等于没定义类型。
#[error("...")] 里我倾向于把能拿到的上下文都写进去,比如 path、field、url。这一行是给最终看日志的人看的,尽量别写“发生错误”这种废话。
转换这件事,能自动就别手写
#[from] 会顺手生成一个 From 实现,于是 ? 就能直接用了:
let text = std::fs::read_to_string(&path)?; // io::Error -> ConfigError? 不行,Read 是具名变体
let cfg: Config = toml::from_str(&text)?; // 这个可以,因为 Parse 是元组变体
注意 #[from] 只对元组变体有效,而且同一类型只能有一个 #[from]——两个变体都从 std::io::Error 转,编译直接报冲突。所以我上面 Read 用的是 #[source] 加手写转换:
impl ConfigError {
fn read(path: &Path, source: std::io::Error) -> Self {
Self::Read { path: path.to_path_buf(), source }
}
}
#[source] 和 #[from] 不一样,前者只声明“这是下层错误”,会参与错误链,但不会生成 From。这两个我早期一直混着用,后来才明白:from 是给 ? 用的,source 是给排查用的,需要哪个就标哪个。
还有个小东西是 #[error(transparent)],当你的枚举里有个变体只是原样包装下层错误、不想加自己的描述时,用它,Display 会直接透传。
应用层:上下文比错误类型更重要
到了 main、HTTP handler、CLI 命令这种“错误只需要被打印或上报”的地方,我就不再定义枚举了,直接 anyhow::Result。
原因很实际:这一层没人会去 match 你的错误类型,大家只是想知道“哪一步挂了”。
use anyhow::{Context, Result};
fn load(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("读取配置 {path:?}"))?;
let cfg = toml::from_str(&text)
.with_context(|| format!("解析配置 {path:?}"))?;
Ok(cfg)
}
几个我后来才改过来的习惯:
with_context 传闭包,是惰性求值;.context("xxx") 是立即求值的。路径拼接这种开销小无所谓,但只要 format! 出现在可能高频失败的热路径上,我就统一用 with_context。
上下文文案别写句号、别大写首字母。因为 anyhow 打印时是层层冒号拼起来的,你写 "读取配置失败。",最后就是 读取配置失败。: 解析配置失败。: ...,看着很别扭。
也别每一层都加 context。我一开始恨不得每个 ? 前面都挂一句,最后打出来六层,全是“失败了”。现在的做法是:只在跨越语义边界的地方加,比如“从磁盘读”到“解析成结构”,或者“调用外部 API”到“解析响应”。中间的同层函数调用就让它自然传递。
错误链要怎么打出来
这是最容易踩的坑:anyhow::Error 用 {} 打印只输出最外层,用 {:?} 才会把整条链和 backtrace 一起打出来。
match load(path) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = ?e, "加载配置失败");
return Err(e);
}
}
tracing 里我一般写 error = ?e,不是 %e。%e 走 Display,你辛苦攒的那条链就没了,白干。
如果是库里的 thiserror 枚举,想要完整链就得自己走一遍 source(),或者用 anyhow::Chain。我现在的做法是:库层不管打印,只负责把 source 接好;打印统一在应用层做。
几个我踩过的坑
别急着 to_string()。 .map_err(|e| e.to_string()) 这一下,source 链就断了,后面想追也不可能了。真要转字符串,也放在最外层输出的时候。
Box<dyn Error> 别到处用。 它在库的公共 API 里意味着调用方没法 match,只能当黑盒。我现在的原则是:库用具体枚举,应用用 anyhow,Box<dyn Error> 基本只在写一次性脚本时出现。
枚举别带太大的 payload。 如果某个变体里塞了个几百字节的结构体,你的 Result<T, E> 整体尺寸就会被撑大,即使成功路径也要搬这么多内存。必要时 Box 一下。
From 别滥用。 两个语义完全不同的下层错误,如果都能自动转成同一个变体,? 会悄悄帮你转,出问题的时候你都不知道是在哪一步转的。这种时候我宁可手写转换,明确标出来。
我现在的默认做法
总结一下就是三句话:
库和模块内部,用 thiserror 定义具体枚举,把 #[from] 和 #[source] 用对,保证错误链完整;跨到应用层,换成 anyhow,在语义边界处补 with_context,文案短、小写、不加句号;输出的时候统一用 {:?} 打 anyhow::Error,别让链断在半路。
这套东西不复杂,但真能省掉很多半夜对着日志猜的 time。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。