Rust 中的错误处理:thiserror 和 anyhow
1. 总体介绍
Rust 强制你通过 Result<T, E> 显式处理错误。这很安全,但手工编写错误类型很繁琐。你必须为每种情况自己实现 Display、Error 和 From。
本文介绍了两个最流行的 crate 来解决这个问题。thiserror 帮助定义结构化的错误类型。anyhow 帮助将所有错误聚合为一个类型,并添加上下文。读完本文后,你将了解在哪里使用哪个 crate、如何组合它们以及如何测试错误。
2. 手动编写错误时的问题
假设你从文件中读取订单的数量。可能会发生两个错误:I/O 错误和解析错误。如果不使用库,你必须这样编写:
use std::fmt;
#[derive(Debug)]
pub enum OrderError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::Io(_) => write!(f, "无法读取订单文件"),
OrderError::Parse(_) => write!(f, "订单数据格式错误"),
}
}
}
impl std::error::Error for OrderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OrderError::Io(e) => Some(e),
OrderError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for OrderError {
fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}
impl From<std::num::ParseIntError> for OrderError {
fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}
你需要近 30 行样板代码来处理仅两个变体。每次添加一个变体时,你都必须在四个地方进行修改。这正是 thiserror 为你做的事情。
另一个快捷方式是使用 Box<dyn std::error::Error>。这个方法很简洁,但会丢失类型信息,不允许你添加上下文。anyhow 是这个快捷方式的更好版本。
3. 安装
将两个依赖项添加到 Cargo.toml:
[dependencies]
thiserror = "2"
anyhow = "1"
这两个 crate 相互独立。一个库通常只需要 thiserror。一个二进制文件通常使用 anyhow,有时也会使用两者。
4. thiserror:为库提供结构化的错误类型
4.1. 定义错误枚举
使用 thiserror,第 2 节的代码简化为:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("找不到订单 #{0}")]
NotFound(u64),
#[error("数量无效:{quantity}(必须 > 0)")]
InvalidQuantity { quantity: i32 },
#[error("无法读取订单文件")]
Io(#[from] std::io::Error),
#[error("订单数据格式错误")]
Parse(#[from] std::num::ParseIntError),
}
每个部分都有自己的作用:
#[derive(Error)]生成std::error::Error的实现。#[error("...")]生成Display。{0}引用 tuple 变体的第一个字段。{quantity}引用具名字段。#[from]生成impl From<std::io::Error> for OrderError。这样,?操作符会自动转换错误。带有#[from]的字段也会自动成为source()。
现在让我们编写一个使用这个错误类型的函数:
use std::fs;
pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
let quantity: i32 = raw.trim().parse()?;
if quantity <= 0 {
return Err(OrderError::InvalidQuantity { quantity });
}
Ok(quantity)
}
pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
let content = fs::read_to_string(path)?;
parse_quantity(&content)
}
函数 fs::read_to_string 返回 io::Error。? 操作符调用 From 将其转换为 OrderError::Io。类似地,ParseIntError 被转换为 OrderError::Parse。调用者可以 match 每个变体来分别处理。
4.2. 使用 #[source] 遍历原因链
有时你想保留原始错误但不想用 From 自动转换。在这种情况下,你使用 #[source] 而不是 #[from]:
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("无法更新产品 {sku} 的库存")]
UpdateFailed {
sku: String,
#[source]
cause: std::io::Error,
},
}
这个变体有额外的 sku 字段,所以 From 无法自动生成。在这种情况下,#[source] 只是声明 cause 是原因。你可以像这样遍历原因链:
use std::error::Error as _;
fn print_chain(err: &dyn std::error::Error) {
eprintln!("错误:{err}");
let mut current = err.source();
while let Some(cause) = current {
eprintln!(" 原因:{cause}");
current = cause.source();
}
}
当调用 print_chain 时,如果有一个 UpdateFailed 包装了“permission denied”错误,你会看到:
错误:无法更新产品 SKU-42 的库存
原因:Permission denied (os error 13)
4.3. #[error(transparent)]
transparent 直接将 Display 和 source() 转发到内部错误。当变体只是一个包装器且不添加任何信息时,你使用它:
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("卡被拒绝")]
CardDeclined,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
变体 Other 充当“其他错误”的角色。调用者需要处理的具体错误有自己的变体。所有其他错误都被聚合到 Other。
5. anyhow:为整个应用程序提供一个错误类型
在应用层,你很少需要 match 每种错误类型。通常你只需要记录错误或将其打印给用户,并附带足够的信息以了解发生了什么。anyhow::Error 正好满足这个需求。
5.1. anyhow::Result 和上下文
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("无法读取配置文件 {path}"))?;
let port: u16 = raw
.trim()
.parse()
.context("端口必须是 0-65535 范围内的整数")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("app.conf")?;
println!("服务器运行在端口 {port}");
Ok(())
}
代码的工作方式如下:
anyhow::Result<T>是Result<T, anyhow::Error>的别名。- 任何实现
std::error::Error + Send + Sync + 'static的类型都可以通过?转换为anyhow::Error。 .context(...)用新消息包装错误,原始错误仍然作为原因保留。.with_context(|| ...)接受闭包。闭包仅在出现错误时运行,因此你避免了在正常路径上不必要地调用format!。
当 main 由于文件不存在而返回 Err 时,程序打印:
Error: 无法读取配置文件 app.conf
Caused by:
No such file or directory (os error 2)
你有三种方式格式化 anyhow::Error:
{}只打印最外层的消息。{:#}在一行上打印整个链,用:分隔。{:?}像上面那样打印多行格式。
使用 {:#} 记录单行时最方便。
Context 也可以处理 Option。这是将 None 转换为有意义的错误的简洁方式:
use anyhow::{Context, Result};
use std::collections::HashMap;
fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
customers.get(&id).with_context(|| format!("找不到客户 #{id}"))
}
5.2. bail!、ensure! 和 anyhow! 宏
这三个宏帮助创建临时错误,而无需定义类型:
use anyhow::{anyhow, bail, ensure, Result};
fn apply_discount(total: u64, percent: u8) -> Result<u64> {
ensure!(percent <= 100, "折扣百分比无效:{percent}");
if total == 0 {
bail!("无法对 0 元订单进行折扣");
}
total
.checked_sub(total * percent as u64 / 100)
.ok_or_else(|| anyhow!("计算折扣时发生溢出"))
}
ensure!(cond, ...)如果条件为假则返回错误。bail!(...)等价于return Err(anyhow!(...)).anyhow!(...)创建一个anyhow::Error值而不返回。
例如,apply_discount(500_000, 120) 返回错误 折扣百分比无效:120。
5.3. 当仍需分类错误时的 Downcast
anyhow::Error 仍然保留原始类型。你可以 downcast 来处理特定情况:
fn handle(err: &anyhow::Error) {
match err.downcast_ref::<OrderError>() {
Some(OrderError::NotFound(id)) => println!("为订单 #{id} 显示 404 页面"),
Some(other) => println!("订单错误:{other}"),
None => println!("系统错误:{err:#}"),
}
}
即使错误已被 .context(...) 包装,downcast_ref 也能正常工作。不过,如果你经常 downcast,这表明你应该使用 thiserror enum 而不是 anyhow。
6. 组合:下层使用 thiserror,上层使用 anyhow
一个简单有效的规则:
- 库或领域模块 使用
thiserror。调用者需要知道具体是什么错误以决定要采取的行动。 - 二进制文件、CLI、最顶层的处理程序 使用
anyhow。在这里你主要是报告错误。
use anyhow::{Context, Result};
fn process_order_file(path: &str) -> Result<()> {
let quantity = load_quantity(path)
.with_context(|| format!("处理订单文件 {path} 失败"))?;
println!("订单有 {quantity} 个产品");
Ok(())
}
函数 load_quantity 返回结构化的 OrderError。上层添加关于正在处理的文件的上下文。当使用包含 -3 的文件运行时,你会看到:
Error: 处理订单文件 orders/1001.txt 失败
Caused by:
数量无效:-3(必须 > 0)
7. 测试错误行为
结构化的错误类型使测试变得精确。你可以检查正确的变体,而不是模糊地比较字符串:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_quantity_returns_invalid_quantity() {
let err = parse_quantity("-3").unwrap_err();
assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
assert_eq!(err.to_string(), "数量无效:-3(必须 > 0)");
}
#[test]
fn non_numeric_input_returns_parse_error() {
let err = parse_quantity("abc").unwrap_err();
assert!(matches!(err, OrderError::Parse(_)));
}
#[test]
fn missing_config_keeps_io_error_as_root_cause() {
let err = read_port("/khong/ton/tai.conf").unwrap_err();
assert_eq!(
err.to_string(),
"无法读取配置文件 /khong/ton/tai.conf"
);
assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
}
}
使用以下命令运行测试:
cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok
test result: ok. 3 passed; 0 failed
第三个测试显示 root_cause() 到达链末端的错误。这样你可以确认上下文不会丢失原始错误。
8. 常见错误和何时不应使用
注意: 不要在
#[error]中重复原始错误的消息。如果你写#[error("io error: {0}")] Io(#[from] io::Error),当使用{:?}或{:#}打印整个链时,原始消息会出现两次。选择其中一种方式:要么嵌入{0}且不声明 source,要么声明 source 并保持消息简洁。
注意: 不要在库的公共 API 中使用
anyhow::Error。调用者将无法match错误,被迫进行 downcast。改为公开一个thiserrorenum。
你可能遇到的其他一些错误:
- 两个变体有相同的
#[from]源类型。 例如,两个变体都包装io::Error会生成两个重复的impl From并导致编译错误。对至少一个变体使用#[source]结合map_err。 - 滥用
#[from]。 如果每个io::Error都变成OrderError::Io,你会丢失关于哪个操作失败的信息。带有上下文字段的变体,如UpdateFailed { sku, cause },通常更有用。 - 忘记
anyhow::Error不实现std::error::Error。 它 deref 到dyn Error但不实现该 trait。你不能将其传递到需要E: std::error::Error的地方。如果需要,用#[error(transparent)]变体包装它。 - 调用
.context(format!(...))。 即使没有错误,字符串也会被格式化。使用.with_context(|| ...)。 - 性能极其敏感或
no_std代码。anyhow::Error在堆上分配。对于 hot path 或嵌入式环境,一个小的紧凑 enum 是更好的选择。
如果想要 backtrace,在运行时设置环境变量 RUST_BACKTRACE=1。anyhow 将打印 backtrace 以及错误。
9. 结论
thiserror 和 anyhow 满足两个不同的需求。thiserror 在定义错误类型时消除了样板代码,适合库和领域层,调用者需要分类错误。anyhow 将所有错误聚合为一个类型,允许逐步添加上下文,适合二进制文件和最顶层。
你应该结合两者使用:在下层定义结构化错误,在上层添加上下文。最后,保持消息简洁,不要重复原始错误,并编写测试来检查正确的变体而不仅仅是比较字符串。