anyhow vs error_stack: 从用户的角度来看错误处理

本文围绕一个例子,以用户的角度来讨论 anyhow, error_stack 解决了什么样的需求,能否给错误处理带来便利。

本文的例子改编自 error_stack README.md

Box<dyn std::error::Error>

在讨论 anyhow 和 error_stack 之前,我们先来看看我们的 baseline:用 Box<dyn std::error::Error> 来处理错误。

这是最粗暴的错误处理方式,所有的错误都被转换成了 Box<dyn std::error::Error>

看到下面这么大一段代码,你肯定很头疼,不过不用担心,我们并不需要阅读这段代码,你可以把自己想象成这段代码的维护者,刚接手这段代码,现在遇到一个报错,需要定位到错误的原因。你需要从报错信息入手,思考如何定位到错误的原因。

type BoxDynError = Box<dyn std::error::Error>;

fn parse_experiment(description: &str) -> Result<(u64, u64), BoxDynError> {
    let value = description.parse()?;

    Ok((value, 2 * value))
}

fn start_experiments(
    experiment_ids: &[usize],
    experiment_descriptions: &[&str],
) -> Result<Vec<u64>, BoxDynError> {
    let experiments = experiment_ids
        .iter()
        .map(|exp_id| {
            let description = match experiment_descriptions.get(*exp_id) {
                Some(desc) => desc,
                None => return Err(format!("experiment {exp_id} has no valid description").into()),
            };
            let experiment = parse_experiment(description)?;

            Ok(move || experiment.0 * experiment.1)
        })
        .collect::<Result<Vec<_>, BoxDynError>>()?;

    Ok(experiments.iter().map(|experiment| experiment()).collect())
}

fn main() -> Result<(), BoxDynError> {
    let experiment_ids = &[0, 2];
    let experiment_descriptions = &["10", "20", "3o"];
    start_experiments(experiment_ids, experiment_descriptions)?;

    Ok(())
}

运行这段代码,会得到如下的错误信息:

Error: ParseIntError { kind: InvalidDigit }

看到这段错误信息,很难快速定位到错误的原因。我需要阅读代码,才能知道错误发生在哪里。如果要定位到错误的原因,我希望得到什么样的信息呢? 最好有 backtrace, 帮助定位到错误发生的位置,还希望能够得到错误的上下文信息,比如和报错相关的变量的值。接下来我们来看看 anyhow 和 error_stack 能否帮助我们更快速的定位到错误的原因。

anyhow

anyhow 的使用方式和 Box<dyn std::error::Error> 非常类似,我们只需要将 Box<dyn std::error::Error> 替换成 anyhow::Error 即可。

+ use anyhow::Context;

- fn parse_experiment(description: &str) -> Result<(u64, u64), BoxDynError> {
+ fn parse_experiment(description: &str) -> Result<(u64, u64), anyhow::Error> {
    let value = description
        .parse()
+        .context(format!("{description:?} could not be parsed as experiment"))?;

    Ok((value, 2 * value))
}

fn start_experiments(
    experiment_ids: &[usize],
    experiment_descriptions: &[&str],
) -> Result<Vec<u64>, anyhow::Error> {
    let experiments = experiment_ids
        .iter()
        .map(|exp_id| {
            let description = experiment_descriptions
                .get(*exp_id)
+                .context(format!("experiment {exp_id} has no valid description"))?;
            let experiment = parse_experiment(description)
+                .context(format!("experiment {exp_id} could not be parsed"))?;

            Ok(move || experiment.0 * experiment.1)
        })
        .collect::<Result<Vec<_>, anyhow::Error>>()
+        .context(format!("unable to set up experiments"))?;

    Ok(experiments.iter().map(|experiment| experiment()).collect())
}

fn main() -> Result<(), anyhow::Error> {
    let experiment_ids = &[0, 2];
    let experiment_descriptions = &["10", "20", "3o"];
    start_experiments(experiment_ids, experiment_descriptions)?;

    Ok(())
}

注意:上述 diff 中并没有展示所有的改动,比如有些 Box<dyn std::error::Error> 的改动,以及 ? 的改动。

除了将 Box<dyn std::error::Error> 替换成 anyhow::Error 之外,我们还使用了 anyhow::Context 来为错误添加上下文信息。

运行这段代码,会得到如下的错误信息:

Error: unable to set up experiments

Caused by:
    0: experiment 2 could not be parsed
    1: "3o" could not be parsed as experiment
    2: invalid digit found in string

这里的错误信息多是通过 context 添加的上下文信息,可以看到,我们已经能够快速定位到错误的原因了: 3o 不能被解析成数字。

但我们现在不能快速定位到错误发生的具体位置,这需要 backtrace 信息。anyhow 能提供 backtrace 吗?当然可以,我们只需要在运行程序的时候,设置环境变量 RUST_BACKTRACE=1 即可。

Error: unable to set up experiments

Caused by:
    0: experiment 2 could not be parsed
    1: "3o" could not be parsed as experiment
    2: invalid digit found in string

Stack backtrace:
   0: anyhow::context::<impl anyhow::Context<T,E> for core::result::Result<T,E>>::context
             at /root/.cargo/registry/src/rsproxy.cn-8f6827c7555bfaf8/anyhow-1.0.71/src/context.rs:54:31
   1: anyhow::parse_experiment
             at ./src/bin/anyhow.rs:4:17
   2: anyhow::start_experiments::{{closure}}
             at ./src/bin/anyhow.rs:21:30
   3: core::iter::adapters::map::map_try_fold::{{closure}}
             at /rustc/90c541806f23a127002de5b4038be731ba1458ca/library/core/src/iter/adapters/map.rs:91:28
   4: core::iter::traits::iterator::Iterator::try_fold
             at /rustc/90c541806f23a127002de5b4038be731ba1458ca/library/core/src/iter/traits/iterator.rs:2304:21
   5: <core::iter::adapters::map::Map<I,F> as core::iter::traits::iterator::Iterator>::try_fold
             at /rustc/90c541806f23a127002de5b4038be731ba1458ca/library/core/src/iter/adapters/map.rs:117:9
   6: <core::iter::adapters::GenericShunt<I,R> as core::iter::traits::iterator::Iterator>::try_fold
             at /rustc/90c541806f23a127002de5b4038be731ba1458ca/library/core/src/iter/adapters/mod.rs:195:9
...

注意:如果用的是 stable 版本的 rust,需要对 anyhow 添加 "backtrace" feature,才能使用 backtrace 功能。

虽然 anyhow 能够提供 backtrace 信息,但是这个 backtrace 信息并不是很友好,包含了太多冗余信息(比如 rust core lib 的 backtrace)。这一点,我们可以通过 error_stack 来改进。

error_stack

+ use std::fmt;

+ use error_stack::{Context, IntoReport, Report, Result, ResultExt};

+ #[derive(Debug)]
+ struct ParseExperimentError;

+ impl fmt::Display for ParseExperimentError {
+     fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
+         fmt.write_str("invalid experiment description")
+     }
+ }

+ impl Context for ParseExperimentError {}

- fn parse_experiment(description: &str) -> Result<(u64, u64), BoxDynError> {
+ fn parse_experiment(description: &str) -> Result<(u64, u64), ParseExperimentError> {
    let value = description
        .parse()
+         .into_report()
+         .attach_printable_lazy(|| format!("{description:?} could not be parsed as experiment"))
+         .change_context(ParseExperimentError)?;

    Ok((value, 2 * value))
}

+ #[derive(Debug)]
+ struct ExperimentError;

+ impl fmt::Display for ExperimentError {
+     fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
+         fmt.write_str("experiment error: could not run experiment")
+     }
+ }

+ impl Context for ExperimentError {}

fn start_experiments(
    experiment_ids: &[usize],
    experiment_descriptions: &[&str],
) -> Result<Vec<u64>, ExperimentError> {
    let experiments = experiment_ids
        .iter()
        .map(|exp_id| {
            let description = experiment_descriptions.get(*exp_id).ok_or_else(|| {
+                 Report::new(ExperimentError)
+                     .attach_printable(format!("experiment {exp_id} has no valid description"))
            })?;

            let experiment = parse_experiment(description)
+                 .attach_printable(format!("experiment {exp_id} could not be parsed"))
+                 .change_context(ExperimentError)?;

            Ok(move || experiment.0 * experiment.1)
        })
        .collect::<Result<Vec<_>, ExperimentError>>()
+       .attach_printable("unable to set up experiments")?;

    Ok(experiments.iter().map(|experiment| experiment()).collect())
}

fn main() -> Result<(), ExperimentError> {
    let experiment_ids = &[0, 2];
    let experiment_descriptions = &["10", "20", "3o"];
    start_experiments(experiment_ids, experiment_descriptions)?;

    Ok(())
}

注意:以上并不是严格的 diff,只是展示了主要的改动。

运行这段代码,会得到如下的错误信息:

Error: experiment error: could not run experiment
├╴at src/bin/error_stack.rs:51:18
├╴unable to set up experiments
│
├─▶ invalid experiment description
│   ├╴at src/bin/error_stack.rs:21:10
│   ╰╴experiment 2 could not be parsed
│
╰─▶ invalid digit found in string
    ├╴at src/bin/error_stack.rs:19:10
    ╰╴"3o" could not be parsed as experiment

和 anyhow 相比,error_stack 需要我们编写更多的代码,但是 error_stack 提供的错误信息更加友好,给我们展示了每一个层级的错误信息,上下文信息以及错误发生的具体位置。


总结一下,anyhow 和 error_stack 都是非常优秀的 error handling crate,都提供了友好的错误信息,可以帮助我们快速定位错误。anyhow 的优势在于它的使用非常简单,几乎不需要我们编写额外的代码。error_stack 的优势在于它的错误信息更加友好,更直观地展示了错误发生地位置,但是它的使用相对复杂一点。