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。改为公开一个 thiserror enum。

你可能遇到的其他一些错误:

  • 两个变体有相同的 #[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 将所有错误聚合为一个类型,允许逐步添加上下文,适合二进制文件和最顶层。

你应该结合两者使用:在下层定义结构化错误,在上层添加上下文。最后,保持消息简洁,不要重复原始错误,并编写测试来检查正确的变体而不仅仅是比较字符串。

rusterror-handlingthiserroranyhow