15 个版本

0.2.3 2022 年 10 月 13 日
0.2.2 2022 年 10 月 12 日
0.1.15 2022 年 10 月 9 日
0.1.2 2022 年 9 月 28 日

Rust 模式 中排名 #1338

每月下载 34
oofs_derive 中使用

MIT 许可协议

73KB
1.5K SLoC

oofs

Crates.io MIT licensed

生成并注入上下文的错误处理库。

此库提供三个主要功能

  • #[oofs] 属性可以生成并注入带有 ? 操作符的函数调用上下文。
  • 为分类错误处理标记错误。
  • 附加自定义上下文。

目录

基本示例 1

以下是通过 #[oofs] 属性注入上下文的示例。

use oofs::{oofs, Oof};

#[oofs]
fn outer_fn() -> Result<(), Oof> {
    let x = 123;
    let y = "hello world";

    inner_fn(x, y)?;

    Ok(())
}

#[oofs]
fn inner_fn(x: usize, y: &str) -> Result<(), Oof> {
    let _ = y.parse::<usize>()?;

    Ok(())
}

运行 outer_fn() 输出

inner_fn($0, $1) failed at `oofs/tests/basic.rs:6:5`

Parameters:
    $0: usize = 123
    $1: &str = "hello world"

Caused by:
    0: y.parse() failed at `oofs/tests/basic.rs:17:13`

    1: invalid digit found in string

错误显示方法失败链、代码中的位置、参数类型和它们的调试值。这是格式化错误为 Debug 时显示的内容(即 {:?})。

性能上几乎没有开销,因为所有注入的代码要么是常量评估的(例如类型名、调用名等),要么是在遇到错误时才懒加载(每个参数的调试字符串)。

注意: 实际上,上述陈述是半真半假的。让我来解释一下。

只有引用或可复制的对象(如基本类型(即 boolusize 等)的参数可以懒加载它们的调试字符串。对于不可复制的对象(例如,将拥有对象传递给函数参数,如 String),在调试模式下调用之前,会立即加载它们的调试字符串,并在发布模式下禁用。

对于调试不可复制的值(String、自定义对象等)的默认行为是

  • 对于调试模式,在调用每个函数之前加载调试格式化的值,每次调用都会产生开销。
  • 对于发布模式,跳过调试不可复制的值。

您可以通过属性参数或启用以下功能之一来更改此默认行为:debug_non_copyable_disableddebug_non_copyable_full。更多详细信息请参阅功能

基本示例 2

现在,让我们看一个稍微长一点的例子。下面是来自 oofs/tests/basic.rs 的一个示例。

该示例展示了上下文生成、标记和附加自定义上下文。

use oofs::{oofs, Oof, OofExt};

// Marker type used for tagging.
struct RetryTag;

#[oofs]
fn application() -> Result<(), Oof> {
    if let Err(e) = middlelayer("hello world") {
        // Check if any of internal errors is tagged as `RetryTag`; if so, try again.
        if e.tagged_nested::<RetryTag>() {
            println!("Retrying middlelayer!\n");

            // If the call fails again, return it.
            // Since `?` operator is used, context is generated and injected to the call.
            middlelayer("hello world")?;
        } else {
            return Err(e);
        }
    }

    Ok(())
}

#[oofs]
fn middlelayer(text: &str) -> Result<u64, Oof> {
    let my_struct = MyStruct {
        field: text.to_owned(),
    };

    // Passing an expression as arg is also fine.
    // All args are evaluated before being debugged in the error.
    // Context is generated and injected to both `?`s in this statement.
    let ret = my_struct.failing_method(get_value()?)?;

    Ok(ret)
}

fn get_value() -> Result<usize, std::io::Error> {
    Ok(123)
}

#[derive(Debug)]
struct MyStruct {
    field: String,
}

// #[oofs] can also be used to `impl` blocks.
// Context will be injected to all methods that return a `Result`.
#[oofs]
impl MyStruct {
    fn failing_method(&self, x: usize) -> Result<u64, Oof> {
        let ret = self
            .field
            .parse::<u64>()
            ._tag::<RetryTag>()                 // tags the error with the type `RetryTag`.
            ._attach(x)                         // attach anything that implements `Debug` as custom context.
            ._attach(&self.field)               // attach the receiver as attachment to debug.
            ._attach_lazy(|| "extra context")?; // lazily evaluate context; useful for something like `|| serde_json::to_string(&x)`.

        Ok(ret)
    }
}

运行 application() 输出

Retrying middlelayer!

middlelayer($0) failed at `oofs/tests/basic.rs:11:13`

Parameters:
    $0: &str = "hello world"

Caused by:
    0: my_struct.failing_method($0) failed at `oofs/tests/basic.rs:26:15`

       Parameters:
           $0: usize = 123

    1: self.field.parse() failed at `oofs/tests/basic.rs:46:14`

       Attachments:
           0: 123
           1: "hello world"
           2: "extra context"

    2: invalid digit found in string

漂亮的错误信息并不是全部;我们还可以获得带有标签的分类错误处理。

在源方法 failing_method 中,我们使用 RetryTag 类型标记 parse 方法。在最高级别的函数 application 中,我们调用 e.tagged_nested::<RetryTag> 来检查是否有任何内部调用被标记为 RetryTag。当找到标签时,我们通过再次调用 middlelayer 来处理这种情况。

有了标记,我们不再需要逐个检查每个错误变体。我们只需查找我们想要处理的标记,然后相应地处理标记错误。在上面的示例中,如果找到 RetryTag 标签,我们将再次调用 middlelayer

#[oofs] 属性

默认行为

该属性选择了一些默认行为

  1. 对于 impl 块,返回 Result<_, _> 的方法将注入上下文。

    • 通过在 fn 之前指定 #[oofs(skip)] 来跳过特定的方法。
  2. 对于不返回 Result<_, _> 的方法将被跳过。

    • 通过指定在 #[oofs] 上方的 fn 来覆盖行为,以应用注入,无论何时。
  3. 闭包(即 || { ... })内的 ? 运算符不会注入上下文。

    • 通过指定在 #[oofs(closures)] 上方的 fn 来覆盖行为,以将注入应用于闭包内部。
  4. 异步块(即 async { ... })内的 ? 运算符不会注入上下文。

    • 通过指定在 #[oofs(async_blocks)] 上方的 fn 来覆盖行为,以将注入应用于异步块内部。
  5. 没有分号的 return ... 语句和最后一个表达式不会注入上下文。

可以通过属性参数更改这些默认行为。

属性参数

可能的属性参数包括:tagattachattach_lazyclosuresasync_blocksskipdebug_skipdebug_withdebug_non_copyable

有关如何使用它们的详细信息,请参阅文档

标记错误

如上例所示,您可以使用 _tag 标记错误,并使用 taggedtagged_nested 检测标记。

struct MyTag;

#[oofs]
fn application_level() -> Result<(), Oof> {
    if let Err(e) = source() {
        if e.tagged_nested::<MyTag>() {
            ...handle for this tag
        } else if e.tagged_nested::<OtherTag>() {
            ...handle for this tag
        } else {
            ...
        }
    }
}

...

#[oofs]
fn source() -> Result<(), Oof> {
    some_fn()._tag::<MyTag>()?;

    Ok(())
}

这允许您将错误分类到不同的标记组中,并相应地处理它们。与在每个嵌套函数调用中匹配每个枚举变体相比,这提供了更好的错误处理体验。

请注意,您还可以使用多个不同的标记标记错误。

我选择类型作为标记,因为类型小、可读且唯一。 Stringusize 可能会意外地产生重复的值。

附加自定义上下文

在某个时候,您可能会发现生成的上下文信息不足。毕竟,它只显示了失败的调用及其传入的参数。它不会捕获所有其他可能的上下文信息。

您可以使用 _attach_attach_lazy 方法将您自己的上下文信息附加到错误中。

#[oofs]
fn outer_fn() -> Result<(), Oof> {
    let x = 123usize;
    let y = std::time::Instant::now();

    "hello world"
        .parse::<usize>()
        ._attach(&x)
        ._attach(&y)?;

    Ok(())
}

以上将打印以下错误

$0.parse() failed at `oofs/tests/basic.rs:10:10`

Parameters:
    $0: &str = "hello world"

Attachments:
    0: 123
    1: Instant { t: 11234993365176 }

Caused by:
    invalid digit found in string

_attach 接受任何实现 std::fmt::Debug 的类型。

_attach_lazy,另一方面,接受任何返回实现 ToString 的闭包。

它可以是一个类似 &str 的东西,例如 ._attach_lazy(|| "some context"),或者类似 String 的东西,例如 ._attach_lazy(|| format!("some context {:?}", x)) 的字符串,或者需要一些工作才能显示的某些函数,例如 ._attach_lazy(|| serde_json::to_string(&x))

返回自定义错误

在某个时候,你还需要返回自定义错误。

对于这些情况,你有一些选项:oof!(...)wrap_err(_)ensure!(...)ensure_eq!(...)

  • oof!(...):这与 anyhow!eyre! 很相似;你像对 println! 一样将输入到宏中。这返回一个 Oof 结构体,你可以在返回的 Oof 上调用方法,如

    return oof!("my custom error").tag::<MyTag>().attach(&x).into_res();
    

    into_res()Oof 包装到 Result::Err(_) 中。

  • wrap_err(_):这是一个包装自定义错误和 Oof 的函数。

    return wrap_err(std::io::Error::new(std::io::ErrorKind::Other, "Some Error")).tag::<MyTag>().into_res();
    

    into_res()Oof 包装到 Result::Err(_) 中。

  • ensure!(...):这与其他很多库类似,但有一些细微的差异。

    可选地,你可以输入自定义上下文消息,例如 format!(...)

    此外,你也可以可选地提供括号内的标签和属性。

    ensure!(false, "custom context with value {:?}", x, {
      tag: [MyTag, OtherTag],
      attach: [&y, "attachment", Instant::now()],
      attach_lazy: [|| serde_json::to_string(&y), || format!("lazy attachment {}", &z)]
    });
    
  • ensure_eq!(...):这与其他很多库类似,但有一些细微的差异。

    可选地,你可以输入自定义上下文消息,例如 format!(...)

    此外,你也可以可选地提供括号内的标签和属性。

    ensure_eq!(1u8, 2u8, "custom context with value {:?}", x, {
      tag: [MyTag, OtherTag],
      attach: [&y, "attachment", Instant::now()],
      attach_lazy: [|| serde_json::to_string(&y), || format!("lazy attachment {}", &z)]
    });
    

功能

  • location (默认值: true): 启用打印失败代码的位置。

  • debug_non_copyable_disabled (默认值: false): 禁用调试不可复制的函数参数。

    默认行为是在调试模式下每次调用之前即时加载不可复制参数的调试字符串,但在发布模式下禁用。

  • debug_non_copyable_full (默认值: false): 即使在发布模式下,也启用即时加载不可复制参数的调试字符串。

关于此库的注意事项/限制

关于 #[oofs] 属性

  • #[oofs] 生成并注入所有具有 ? 操作符的语句和表达式的上下文。

  • return Err(...) 或不带分号的最后一个表达式不会注入上下文。

  • 如果方法接收者是变量(即 x.some_method()),或变量的字段(即 x.field.some_method()),则不会显示 xx.field 的值。这是因为无法在宏中确定此接收者是引用、可变引用还是拥有变量。

    • 对于这些情况,您可以将变量附加如下 x.some_method()._attach(&x) 来显示错误中的 x 的值。

关于 Oof 错误结构体

  • Oof 未实现 From<E> where E: std::error::Error,因此必须通过属性宏构建。因此,如果不包含 #[oofs],则会导致编译错误;这是故意的,因为这将吸引用户的注意并迫使他们包含属性。

  • anyhow::Erroreyre::Report 不同,Oof 实现了 std::error::Error。这很棒,因为它使其与这些装箱错误类型兼容。例如,这可以工作

    #[oofs]
    fn outer_fn() -> Result<(), anyhow::Error> {
        inner_fn()?;
        Ok(())
    }
    

    它之所以可以工作,是因为 ? 操作符会隐式地将 Oof 转换为 anyhow::Error

关于下划线方法如 ._tag()._attach(_)

在上面的基本示例中,您可能已经注意到所有用于 oof 的方法都以下划线开头;您可以称它们为 '元方法',因为它们不影响逻辑,只影响返回的结果。

这是因为宏必须有一种方法来区分功能方法和元方法。这是因为宏也会尝试将这些元方法作为显示方法链的一部分,像 _attach(x) 这样的方法在 ParametersAttachments 部分中会显示两次。

一开始这可能会让人感到困扰和不自然;对我也是如此。但是尝试过后,我习惯了它;现在我认为我喜欢它,因为可以轻松地区分功能方法和元方法。

我为带来的不便表示歉意,并请告诉我是否有更好的处理方式。

调试不可复制的参数

在创建库时的一个痛点是在编译时懒加载可复制参数的值,并立即加载不可复制参数的值。我找到了一种使用酷炫的Rust技巧来实现这一点的办法。

现在,默认行为应该是始终立即加载不可复制参数的值吗?这可能会带来不必要的性能开销,因为它会在非错误情况下加载它们。

作为妥协,我让它这样:在调试模式下,将立即加载不可复制参数的值;而在发布模式下,则不会加载不可复制参数的值。

您可以使用功能 debug_non_copyable_disableddebug_non_copyable_full 来更改此行为。

debug_non_copyable_disabled 将禁用即使在调试模式下也加载不可复制参数的值。 debug_non_copyable_full 将允许即使在发布模式下也加载不可复制参数的值。

#[async_trait] 的兼容性

#[async_trait] 解析并将特质中 fnasync fn 转换为 fn -> Box<Future<Output = Result<...>>>. 由于 #[oofs] 默认情况下只将上下文注入应用到返回 Result<_, _> 的方法上,因此一旦应用了 #[async_trait],就不会再应用注入。

有两种方法来处理这个问题

  • #[oofs] 放在 #[async_trait] 上面,这样首先应用 oofs,然后才是 #[async_trait].

    #[oofs]
    #[async_trait]
    impl Trait for Struct {
      ...
    }
    
  • 在impl块中,将#[oofs(closures, async_blocks)]放置在fn ...上方,并且oofs属性将告诉宏无论何种情况都应用注入,而closuresasync_blocks将告诉宏对闭包和异步块应用注入,这些默认是禁用的。

    #[async_trait]
    #[oofs]
    impl Trait for Struct {
      #[oofs(closures, async_blocks)]
      async fn do_something() -> Result<(), _> {
          ...
      }
    }
    

未来计划

这个库仍然是一个非常多的WIP(工作进行中)。

我计划测试错误处理性能,优化错误内存占用,并实现类似#[oofs(tag(MyTag))]#[oofs(skip)]等的属性参数。

此外,它不会将上下文注入到闭包和异步块中。我计划添加类似#[oofs(closures)]#[oofs(async_blocks)]的属性参数,以启用将上下文注入到闭包和异步块中。

依赖关系

~1.6–2.2MB
~50K SLoC