跳转到内容

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。很多项目两个都用,这一页的最后你就会看到。

Terminal window
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 写的只是你本来要手写的代码,调用者看到的就是一个普通的枚举。

错误往往有一个原因(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 string
▶ 动手试一试

invalid 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 string
▶ 动手试一试

anyhow:一个 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 type
0: 无法加载设置
1: `99999` 不是有效的端口
2: number too large to fit in target type
▶ 动手试一试

number 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 type

Caused 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!("...") 立刻返回一个错误。它是 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,想取出 250
Ferris
错误:没有 ID 为 7 的用户
▶ 动手试一试

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 scope
help: 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