Rust 错误处理:anyhow 与 thiserror
在 Result 和自定义错误类型这两页,你都是手写错误类型的:一个枚举、一个 impl Display,也许还有一个 impl From,好让 ? 能用。学习的时候这么做是对的。但在真实项目里,你会一遍又一遍地写同样的样板代码。
有两个小 crate 能帮你省掉这些样板代码,它们出自同一位作者,解决的是两个不同的问题:
thiserror帮你定义错误类型。枚举还是你自己写,但Display、Error和From交给一个#[derive]来写。anyhow帮你传递错误。它提供一个能装下任何错误的错误类型,还能方便地补上一句说明,比如“在加载配置文件时出错”。
选哪一个,取决于由谁来处理这个错误。
库(library)是给别人调用的代码。调用者可能想对每种失败做出不同的反应:超时就重试,输入有误就在表单上显示错误。所以库应该返回一个精确的枚举,让调用者可以对它 match。这就是 thiserror 的用武之地。
应用(application,也就是你的 main.rs)通常是错误的最后一站。大多数错误最终都会变成一条给人看的消息,可能再加一个退出码。你很少会对它们 match,你想要的是用 ? 轻松往上传、打印出来容易看懂。这就是 anyhow 的用武之地。
一个好用的经验法则:给别人调用的代码用 thiserror,程序的最顶层用 anyhow。很多项目两个都用,这一页的最后你就会看到。
cargo add thiserror anyhow[dependencies]anyhow = "1"thiserror = "2"还不熟悉 Cargo.toml?看看 Cargo 与 crate。这两个 crate 在 Rust Playground 里都能用,所以这一页的每个“Try it Yourself”按钮都能运行。
thiserror:derive Error 并编写错误信息
Section titled “thiserror:derive Error 并编写错误信息”在枚举上加 #[derive(Debug, Error)],再给每个变体写一条 #[error("...")] 信息。这条信息就成了这个变体的 Display 文本。在信息里,{0} 表示第一个元组字段,{balance} 表示名为 balance 的字段:
use thiserror::Error;
#[derive(Debug, Error)]enum BankError { #[error("账户 `{0}` 不存在")] NotFound(String), #[error("余额不足:余额为 {balance},想取出 {amount}")] InsufficientFunds { balance: u32, amount: u32 }, #[error("金额必须大于零")] ZeroAmount,}
fn main() { let errors = vec![ BankError::NotFound(String::from("alice")), BankError::InsufficientFunds { balance: 50, amount: 80 }, BankError::ZeroAmount, ];
for e in &errors { println!("{e}"); } println!("{:?}", errors[1]);}输出:
账户 `alice` 不存在余额不足:余额为 50,想取出 80金额必须大于零InsufficientFunds { balance: 50, amount: 80 }这正是自定义错误类型那一页的枚举,只是整个 impl Display 块都不见了。运行时并没有藏着什么魔法:thiserror 写的只是你本来要手写的代码,调用者看到的就是一个普通的枚举。
thiserror:#[from] 和 #[source]
Section titled “thiserror:#[from] 和 #[source]”错误往往有一个原因(cause):“端口无效”,是因为“字符串里有无效的数字”。标准库的 Error trait 为此提供了 .source() 方法,thiserror 会帮你把它填好。
#[from] 做了两件事:它把这个字段标记为原因(source),并且生成一个 impl From,这样 ? 就会自动把内部错误转换成你的变体:
use std::error::Error; // 把 .source() 方法引入作用域use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]enum ConfigError { #[error("{0:?} 这一行缺少 `=`")] NoEquals(String), #[error("端口不是有效的数字")] BadPort(#[from] ParseIntError),}
fn parse_port(line: &str) -> Result<u16, ConfigError> { let (_, value) = line .split_once('=') .ok_or_else(|| ConfigError::NoEquals(line.to_string()))?; let port = value.trim().parse::<u16>()?; // ParseIntError 变成 ConfigError::BadPort Ok(port)}
fn main() { for line in ["port = 8080", "port 8080", "port = eighty"] { match parse_port(line) { Ok(port) => println!("成功:{port}"), Err(e) => { println!("错误:{e}"); if let Some(cause) = e.source() { println!(" 原因:{cause}"); } } } }}输出:
成功:8080错误:"port 8080" 这一行缺少 `=`错误:端口不是有效的数字 原因:invalid digit found in stringinvalid digit found in string 是标准库自带的英文信息,意思是“字符串里有无效的数字”。
有时你想要一个原因,但不想要自动的 From,因为你还需要附加额外的数据,比如行号。这时就在字段上用 #[source],然后用 .map_err() 自己构造错误。(名字就叫 source 的字段会被自动当作原因。)另外,thiserror 不只能用在枚举上,结构体也行:
use std::error::Error;use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]#[error("第 {line} 行不是数字")]struct LineError { line: usize, #[source] cause: ParseIntError,}
fn sum_lines(text: &str) -> Result<i32, LineError> { let mut total = 0; for (i, row) in text.lines().enumerate() { let n: i32 = row .trim() .parse() .map_err(|cause| LineError { line: i + 1, cause })?; total += n; } Ok(total)}
fn main() { println!("合计:{}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\ntwenty\n30").unwrap_err(); println!("{err}"); println!("原因:{}", err.source().unwrap());}输出:
合计:60第 2 行不是数字原因:invalid digit found in stringanyhow:一个 Result 类型通吃所有错误
Section titled “anyhow:一个 Result 类型通吃所有错误”anyhow::Result<T> 是 Result<T, anyhow::Error> 的简写。一个 anyhow::Error 能装下任何实现了标准 Error trait 的错误类型。所以 ? 对它们全都管用,哪怕一个函数会碰到好几种不同的错误类型:
use anyhow::Result;
// 两种不同的错误类型:ParseIntError 和 ParseFloatError。// anyhow::Result 通过 `?` 两种都能接收。fn total_price(quantity: &str, price: &str) -> Result<f64> { let quantity: u32 = quantity.trim().parse()?; let price: f64 = price.trim().parse()?; Ok(quantity as f64 * price)}
fn main() { for (quantity, price) in [("3", "2.50"), ("three", "2.50"), ("3", "cheap")] { match total_price(quantity, price) { Ok(total) => println!("总价:{total}"), Err(e) => println!("错误:{e}"), } }}输出:
总价:7.5错误:invalid digit found in string错误:invalid float literal如果没有 anyhow,你就得自己写一个带两个变体的枚举,或者用 Box<dyn Error>。anyhow 可以看作一个更好用的 Box<dyn Error>。不过注意这里的问题:“invalid digit found in string”(字符串里有无效的数字)并没有说清楚哪个值错了。这就是上下文(context)要解决的问题。
anyhow:添加上下文并打印错误链
Section titled “anyhow:添加上下文并打印错误链”导入 Context trait 之后,每个 Result 都会多出两个方法:
.context("信息")用一条固定的信息把错误包起来。.with_context(|| format!(...))接受一个闭包。只有真的出错时它才会去构造这条信息,所以当信息需要用format!拼接时,就用它。
每加一层上下文,错误链(error chain)上就多一环。你用什么格式打印错误,决定了能看到错误链的多少:
use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> { let port: u16 = text .parse() .with_context(|| format!("`{text}` 不是有效的端口"))?; Ok(port)}
fn load_settings(port_text: &str) -> Result<u16> { let port = parse_port(port_text).context("无法加载设置")?; Ok(port)}
fn main() { let err = load_settings("99999").unwrap_err();
println!("{err}"); // 只有最外层的信息 println!("{err:#}"); // 整条错误链,打印在一行里
for (i, cause) in err.chain().enumerate() { println!("{i}: {cause}"); }}输出:
无法加载设置无法加载设置: `99999` 不是有效的端口: number too large to fit in target type0: 无法加载设置1: `99999` 不是有效的端口2: number too large to fit in target typenumber too large to fit in target type 是标准库的信息,意思是“数字太大,目标类型装不下”(u16 最大只到 65535)。各层之间的 : 是 anyhow 用 {:#} 打印时加上的分隔符。
第三种格式是 {:?}。它会打印一份多行的报告,main 返回错误时你看到的就是这种格式:
println!("{err:?}");无法加载设置
Caused by: 0: `99999` 不是有效的端口 1: number too large to fit in target typeCaused by: 是 anyhow 加上的标题,意思是“原因如下”。
如果设置了环境变量 RUST_BACKTRACE=1,{:?} 还会附上一份栈回溯(backtrace),告诉你错误是在哪里产生的。参见环境变量与进程。
一个典型的实际用法是读取文件:操作系统给出的信息(“No such file or directory”,找不到文件或目录)从来不会告诉你是哪个文件:
let text = std::fs::read_to_string(path) .with_context(|| format!("读取配置文件 {path} 失败"))?;anyhow:bail!、ensure! 和 anyhow!
Section titled “anyhow:bail!、ensure! 和 anyhow!”有时并没有什么底层错误,只是你自己判断出某件事不对。anyhow 为此准备了三个宏:
bail!("...")立刻返回一个错误。它是return Err(anyhow!("..."))的简写。ensure!(条件, "...")在条件为 false 时返回一个错误。anyhow!("...")只创建一个错误值,并不返回它。
这三个宏都接受 format! 风格的信息。
use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> { ensure!(amount > 0, "金额必须大于零"); if amount > balance { bail!("余额不足:余额为 {balance},想取出 {amount}"); } Ok(balance - amount)}
fn find_user(id: u32) -> Result<&'static str> { match id { 1 => Ok("Ferris"), _ => Err(anyhow!("没有 ID 为 {id} 的用户")), }}
fn main() { println!("{:?}", withdraw(100, 30).ok()); for (balance, amount) in [(100, 0), (100, 250)] { if let Err(e) = withdraw(balance, amount) { println!("错误:{e}"); } } println!("{}", find_user(1).unwrap()); println!("错误:{}", find_user(7).unwrap_err());}输出:
Some(70)错误:金额必须大于零错误:余额不足:余额为 100,想取出 250Ferris错误:没有 ID 为 7 的用户让 main 返回 anyhow::Result
Section titled “让 main 返回 anyhow::Result”anyhow 最常出现的地方就是这里:main 返回 anyhow::Result<()>,到处都用 ?。如果有错误一路传到了 main,Rust 会用 {:?} 把它打印到 stderr,并以退出码 1 退出:
use anyhow::{Context, Result};
fn main() -> Result<()> { let text = "forty-two"; let n: i32 = text .parse() .with_context(|| format!("无法把 {text:?} 解析为数字"))?; println!("{n}"); Ok(())}标准输出(stdout)上什么也不会打印。在标准错误(stderr)上你会看到:
Error: 无法把 "forty-two" 解析为数字
Caused by: invalid digit found in string下面是真实项目里常见的结构。inventory 模块扮演库的角色:它返回一个用 thiserror 写的精确的 InventoryError。应用代码则使用 anyhow,添加上下文,只在真正关心的地方才去看错误内部。downcast_ref 是在问一个 anyhow::Error:“你其实是这个类型吧?”即使外面已经包了上下文,它也照样能用:
// ----- “库”部分:用 thiserror 定义精确的错误 -----mod inventory { use thiserror::Error;
#[derive(Debug, Error)] pub enum InventoryError { #[error("未知商品 `{0}`")] UnknownItem(String), #[error("只剩 {available} 个,无法取出 {wanted} 个")] NotEnough { available: u32, wanted: u32 }, }
pub struct Store { pub apples: u32, }
impl Store { pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> { if item != "苹果" { return Err(InventoryError::UnknownItem(item.to_string())); } if wanted > self.apples { return Err(InventoryError::NotEnough { available: self.apples, wanted }); } self.apples -= wanted; Ok(()) } }}
// ----- “应用”部分:anyhow + 上下文 -----use anyhow::{Context, Result};use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> { store .take(item, amount) .with_context(|| format!("订单 #{id} 失败"))?; println!("订单 #{id} 已完成"); Ok(())}
fn main() { let mut store = Store { apples: 5 }; let orders = [(1, "苹果", 2), (2, "李子", 1), (3, "苹果", 9)];
for (id, item, amount) in orders { if let Err(e) = process_order(&mut store, id, item, amount) { println!("错误:{e:#}"); // 仍然可以往里看,找出原来的 thiserror 类型: if let Some(InventoryError::NotEnough { available, .. }) = e.downcast_ref::<InventoryError>() { println!(" 提示:试试订购 {available} 个或更少"); } } } println!("剩余苹果:{}", store.apples);}输出:
订单 #1 已完成错误:订单 #2 失败: 未知商品 `李子`错误:订单 #3 失败: 只剩 3 个,无法取出 9 个 提示:试试订购 3 个或更少剩余苹果:3忘了导入 Context。.context() 是 anyhow::Context 这个 trait 的方法,而 trait 的方法只有在 trait 位于作用域中时才能用:
use anyhow::Result;
fn parse(text: &str) -> Result<i32> { let n: i32 = text.parse().context("不是数字")?; Ok(n)}error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope --> src/main.rs:4:31 |4 | let n: i32 = text.parse().context("不是数字")?; | ^^^^^^^ | = help: items from traits can only be used if the trait is in scopehelp: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it |1 + use anyhow::Context; |这个错误的意思是:“在当前作用域里,Result 上找不到名为 context 的方法。”help 部分已经告诉你:提供 context 的 Context trait 存在,只是没有导入。
修复方法:写 use anyhow::{Context, Result};。
在自己的信息里重复原因的信息。用了 #[from],内部错误就已经是原因(source)了。如果你还在信息里写上 {0},完整的报告就会把它打印两遍:
#[derive(Debug, thiserror::Error)]enum AppError { #[error("端口无效:{0}")] // 包含了原因的文本…… BadPort(#[from] ParseIntError), // ……而它同时也是原因}通过 anyhow 用 {:#} 打印时,会得到 端口无效:invalid digit found in string: invalid digit found in string。修复方法:要么写成 #[error("端口无效")],让错误链去显示原因;要么保留 {0},但不要把这个字段标记为原因。二选一。
用 {} 打印,结果丢了原因。对 anyhow::Error 用 println!("{e}"),只会显示最外层的信息,比如“无法加载设置”。要把错误打印给人看时,用 {:#}(一行)或 {:?}(完整报告)。
在库的公有 API 里使用 anyhow。如果你的库返回 anyhow::Result,调用者就没法对你的错误做 match,只能靠 downcast_ref 去猜类型。公有函数应该返回 thiserror 定义的枚举,anyhow 留给应用、测试和示例代码用。
- thiserror 用来定义错误:
#[derive(Error)]加上每个变体一条#[error("...")]信息,就能代替手写的Display和Error实现。 #[from]让?把内部错误转换成你的变体,并把它记录为原因;#[source]只记录原因,不做转换。- anyhow 用来往上传递错误:
anyhow::Result<T>通过?接收任何错误。 .context()和.with_context()给错误链添加一层人能看懂的说明。用{:#}(一行)或{:?}(报告)打印整条链。bail!立刻返回一个错误,ensure!在条件为 false 时返回错误,anyhow!创建一个错误。- 常见搭配:库代码用 thiserror,
main里用 anyhow。
小测验
1. 你在写一个库,调用者需要对超时和输入错误做出不同的反应。哪个 crate 最合适?
2. 变体字段上的 #[from] 起什么作用?
3. 哪种格式会把 anyhow::Error 连同整条错误链打印在一行里?
4. 什么时候应该用 .with_context(|| ...) 而不是 .context(...)?
5. 当 amount 为 0 时,ensure!(amount > 0, "金额必须为正数") 会做什么?
得分: 0 / 5
- anyhow 文档 — docs.rs(英文):完整的 API,包括
Context、chain()和 downcast(向下转型)。 - thiserror 文档 — docs.rs(英文):所有属性的说明,比如
#[error(transparent)]和 backtrace 字段。 - Context trait — anyhow(英文):
context和with_context的工作方式,附示例。 - 错误处理 — Rust 程序设计语言:这两个 crate 共同的基础知识。
- std::error::Error(英文):
.source()和错误链背后的标准 trait。