Rust:2020年的结构化和处理错误(2020)

2021-01-17 05:10:18

我最近通过学习"开始学习Rust编程语言。这本书,在解释语言基础方面做得非常出色。

读完本书的主要内容后,我开始了我的第一个非平凡的,真实的应用程序。但是我很快发现自己遇到了一个问题,我还没有能力很好地处理:

本文介绍了寻找该问题答案的过程。我将尝试说明我所采用的模式以及示例代码,以展示其实现方式,以期希望其他新手可以更轻松地上手。

虽然本书介绍了错误处理的基础知识,包括使用std :: Result类型和带有?的错误传播。运营商,它很大程度上掩盖了在实际应用中使用这些工具的不同模式,或者涉及不同方法的权衡。 1个

当我开始研究最佳实践时,我遇到了很多使用故障包装箱的过时建议。由于位于rust-lang-nursery命名空间中,因此失败具有一种半官方的感觉,但最近已弃用了它。

在过去两年中,对std :: error :: Error特性进行了许多改进。 2这些使总体上减少了对故障的需求,并激发了许多更现代的图书馆,这些图书馆利用这些改进来提供更好的人体工程学。

在阅读了很多历史背景并评估了许多库之后,我现在选择了一种(主要与库无关的)模式来构造错误,该模式可以通过anyhow和thiserror板条箱实现。 3

让我们介绍一些示例代码,供本文其余部分使用。我们将构建一个程序来计算文本文件中的单词数,就像wc -w一样。

使用std :: env;使用std :: error :: Error;使用std :: fs :: File;使用std :: io :: prelude :: *;使用std :: io :: BufReader; ///计算给定输入中的单词数。 /// ////由于在split_whitespace()之前使用了line ?,任何潜在的错误(例如无法从输入中读取)都将按原样向上传播。 fn count_words< R:阅读> (输入:& mut R)->结果< u32,框< dyn错误>> {let reader = BufReader :: new(input);让mut wordcount = 0;为读者排队。 lines(){for _word in line? 。 split_whitespace(){wordcount + = 1; }}确定(wordcount)} fn main()->结果< (),方框< dyn错误>> {表示env :: args()中的文件名。跳过(1)。收集:< Vec<字符串>> (){let mut reader = File :: open(& filename)? ;让wordcount = count_words(& mut reader)吗? ; println! (" {} {}",wordcount,filename); } 好 (()) }

让我们为新字计数器生成一个输入文件,然后尝试运行它:

但是,如果没有words.txt,则会遇到以下错误:

$ cargo run --quiet-words.txt错误:Os {代码:2,种类:未找到,消息:"没有这样的文件或目录" }

为了使示例更完整,我们还模拟了在count_words()4内部发生的read()调用中发生的错误,因此我们可以看到如下所示:

$ cargo run --quiet-words.txt错误:自定义{种类:BrokenPipe,错误:"阅读:折断的管道" }

因此,上面的错误出了什么问题?虽然明确了潜在的错误原因(“破损的管道”),但我们缺少很多上下文。我们无法确定无法打开哪个文件,也没有关于该文件的信息。导致此错误的事件顺序。

count_words()返回错误,因为我们在遍历reader.lines()(第14-15行)时遇到了错误。

遍历reader.lines()错误,因为我们注入了std :: io :: Read的实现,该实现在第一次调用read()时失败。

在此示例中,文件名是程序本身的输入参数,这使得将错误与试图打开的文件相关联变得容易。

现在想象一下在更大的软件中,在库的深处发生5次调用时发生的错误,在这种情况下,如果没有事件链的任何信息,很快就很难理解导致错误的原因。

前面我提到了两个不同的库,无论如何和thiserror(尽管都是同一作者dtolnay)。您可能想知道为什么我们需要两个独立的库来做一些基本的处理错误的工作。

我花了一点时间来欣赏这种区别,但是在库和应用程序之间以不同的方式处理错误时,由于它们往往存在不同的关注点,因此它们具有一定的价值:

库应专注于产生有意义的,结构化的错误类型/变量,这使应用程序可以轻松地区分各种错误情况。

库可能希望将错误从一种类型转换为另一种类型。IO错误应该由库提供的高级错误类型包装。否则,无法将库foo中的IO错误与库栏中的类似IO错误区分开。

不这样做还需要使用者知道库的内部知识,例如,可能返回的仅仅是IO错误吗?可能来自库内部的HTTP客户端的HTTP错误呢?

图书馆在更改错误或创建新错误时必须小心谨慎,因为它们很容易为消费者带来重大变化,它们可能在内部产生新错误,但这些错误不太可能需要特殊的结构并且可以更轻松地进行更改。

库在哪里返回错误,应用程序决定是否格式化这些错误以及如何将这些错误格式化并显示给用户。

应用程序可能还希望解析和检查错误,例如将错误转发给异常跟踪服务或在认为安全的情况下重试操作。

此外,我认为这很重要,库应始终使用std :: Result以及在其公共API中实现std :: error :: Error的错误类型.fail :: Fail之类的自定义结果类型可能无法与其他类型很好地组合用户代码的一部分,并迫使他们学习另一个库。

回到我们的字数统计示例,想象一下我们想将count_words作为公共库提供。您通常不会在这么小的和简单的代码中就这样做,但是在通过内部的公共包装箱提供功能方面会有价值较大的项目。

作为演示,我们可以在字计数器中定义边界,以将该代码分为库和应用程序部分。

我们将count_words提取到名为wordcounter的库箱中。下面将重点介绍相关部分,但是如果您要跳过,可以在GitHub上找到完整的src / wordcounter.rs。

count_words之外的所有内容都是我们的应用程序代码。它将生活在一个称为rwc的二进制条板箱中(Rust Word Count –我知道这很原始)。与此相关的文件是src / main.rs和src /lib.rs。

对于字计数器库,我们将定义一个称为WordCountError的顶级错误类型。此枚举具有库可能遇到的每种可能错误的错误变体。

这是此错误起作用的地方。虽然我们可以手动实现此错误,但此错误使我们可以避免编写大量样板代码:

使用thiserror :: Error; /// WordCountError枚举此库返回的所有可能的错误。 #[derive(Error,Debug)] pub枚举WordCountError {///表示一个空源。例如,给///一个空的文本文件作为`count_words()`的输入。 #[error(" Source不包含任何数据")] EmptySource,///表示从输入中读取失败。 #[error(" Read error")] ReadError {源:std :: io :: Error},///表示所有其他情况`std :: io :: Error`。 #[error(transparent)] IOError(#[from] std :: io :: Error),}

(引用官方文档:“故意在公共API中未出现Thiserror。您将获得与手工编写std :: error :: Error实现,然后从手写的impls切换到thiserror或相反的效果相同的东西。并非重大变化。")

与以前的版本相比,我们的新代码更加具体,用户现在可以更深入地了解可能返回的错误情况,此外,由于WordCountError的大小,我们也不再需要Box Error可以在编译时确定。

EmptySource可能被视为与我们的业务领域有关的错误。我们可以使用以下代码从count_words函数返回此错误:

ReadError是将低级错误包装到高级库错误中的示例,用于为读取错误返回有意义的错误,可以在此处看到:

为读者排队。 lines(){让line = line。 map_err(|源| WordCountError :: ReadError {源})? ;对于_word行。 split_whitespace(){wordcount + = 1; }}

上面代码段中最有趣的代码位于第2行,其中包含line.map_err(| source | WordCountError :: ReadError {source})?;。虽然这里有很多工作,所以让我们逐步解压缩:

我们遍历来自阅读器的行,这些行以io :: Result< String>返回。因为读取操作可能会失败。

如果结果是Err变体,则我们使用map_err()会将嵌入在该结果中的错误值从io :: Error转换为WordCountError :: ReadError。如果结果是Ok变体,则它保持不变。

然后,我们用?解压结果。操作员。如果它是Ok变体,则将其分配给变量行。如果是Err变体,则函数在此处退出,将其作为返回值返回(请记住,返回类型为Result< u32,WordCountError>)。

因为我们将io :: Error封装在WordCountError :: ReadError的source属性下,所以我们的上下文/错误链保持不变。这确保了我们在以下应用程序端使用的方式最终会显示这两个错误5。

在这一点上,值得注意的是,错误可以使用error(透明)将源方法和Display方法直接转发给基础错误,而无需添加其他消息。这可以在WordCountError :: IOError案例中看到,它充当“捕获” -all”所有其他IO错误的变体。

如果我们不关心特殊的WordCountError :: ReadError变体,则意味着我们也可以按照以下方式编写代码,在这种情况下,我们不再需要使用map_err()并可以使用?直:

使用这种模式,我们避免添加其他错误包装代码,同时仍将错误转换为高级WordCountError,以保持公共API的整洁。

使用上面的API,我们可以调整其余代码以处理应用程序级的问题,例如参数解析和wordcounter :: count_words的调用。

//为了简洁起见,这里省略了一些`use`语句:{{Context,Result}; fn main()->结果< ()> {表示env :: args()中的文件名。跳过(1)。收集:< Vec<字符串>> (){let mut reader = File :: open(& filename)。上下文(格式!(无法打开' {}'",文件名))? ;让wordcount = count_words(& mut reader)。上下文(格式!("无法计算' {}'",文件名)中的单词))? ; println! (" {} {}",wordcount,filename); } 好 (()) }

不必创建自定义错误类型或使用std :: Result< T,而使用Box< dyn Error>>。在任何地方,我们都可以使用anyhow :: Result作为更方便的类型,并且减少样板。

在上面的main()情况下,这使我们可以直接返回:: Result<()>这似乎是一件小事,但我发现能够仅关注成功数据类型而无需注释其他错误类型在这里增加了很多清晰度。

通过上面使用anyhow :: Context引入的anyhow :: Context特性,它对Result类型启用了context()方法。这使我们可以用比编写更加符合人体工程学的方式包装/注释错误,并提供更多信息。库代码中使用的map_err方法:

let mut reader = File ::打开(& filename)。上下文(格式!(无法打开' {}'",文件名))? ;让wordcount = count_words(& mut reader)。上下文(格式!("无法计算' {}'",文件名)中的单词))? ;

这为应用程序的用户提供了有关发生错误时正在尝试的操作的有价值的信息。通过这些调用,我们的错误现在将显示如下:

$ cargo run --quiet-words.txt错误:无法打开' words.txt'原因:没有这样的文件或目录(操作系统错误2)

$ cargo run --quiet-words.txt错误:无法计算' words.txt'中的单词造成原因:0:从输入中读取时遇到错误1:读取:管道损坏

在这两种情况下,我们的错误消息现在都包含正在使用的文件的名称。我们还描述了发生问题时正在尝试的高级操作。

您会注意到,我们不必编写任何额外的错误格式代码即可显示这些精美的错误消息。我们要做的就是将main的返回类型更改为Result类型。

不必依赖于从ma​​in返回Result的隐式行为。我们可以选择将所有代码移到run函数中,然后按以下方式编写main:

这种方法的优势(除了可以更好地控制程序的退出方式,例如通过不同的退出代码),还可以使我们更改消息的格式。

例如,如果我们使用eprintln!(" {:#?}&#34 ;, err)代替(注意{:#?}与{:?}),我们将获得结构样式表示:

$ cargo run --quiet-words.txtError {上下文:"无法计算\' words.txts \'"中的单词,来源:ReadError {来源:自定义{种类:BrokenPipe,错误:"阅读:管道破损&#34 ;、},},}

到目前为止,我们还没有讨论过回溯,回溯是调试复杂问题时常用的工具。

无论如何,也允许我们在发生错误时捕获并显示回溯痕迹。目前,仅在每晚Rust上才提供对回溯痕迹的支持,因为std :: backtrace模块当前是仅夜间实验的API。

$ RUST_BACKTRACE = 1货物运行--quiet-words.txt错误:无法计算' words.txt'中的单词原因:0:从输入1读取时遇到错误1:读取:管道破裂0:&lt ; E无论如何:: context :: ext :: StdError> ::: ext_context位于/home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/src/backtrace.rs:26 1 :anyhow :: context ::< impl anyhow :: Context< T,E>对于/home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/上的core :: result :: Result< T,E>> :: context :: {{closure}} src / context.rs:50 2:core :: result :: Result< T,E> :: map_err at /rustc/2454a68cfbb63aa7b8e09fe05114d5f98b2f9740/src/libcore/result.rs:612 3:anyhow :: context ::< impl无论如何:: Context< T,E>对于core :: result :: Result< T,E>> ::: context位于/home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/src/context.rs:50 4:wordcount ::在src / lib.rs:58运行5:rwc :: main在src / main.rs:9 6:std :: rt :: lang_start :: {{closure}}在/ rustc / 2454a68cfbb63aa7b8e09fe05114d5f98b2f9740 / src / libstd / rt.rs:67 7:std :: rt :: lang_start_internal :: {{closure}} at src / libstd / rt.rs:52 std :: panicking :: try :: do_call at src / libstd / panicking.rs:297 std :: panicking ::在src / libstd / panicking.rs:274 std :: panic :: catch_unwind在src / libstd / panic.rs:394 std :: rt :: lang_start_internal在src / libstd /rt.rs:51 8:std :: rt :: lang_start位于/rustc/2454a68cfbb63aa7b8e09fe05114d5f98b2f9740/src/libstd/rt.rs:67 9:main 10:__libc_start_main 11:_start

我通常发现Rust的回溯图太神秘且令人困惑,以至于无济于事,因此就我个人而言,在稳定渠道上缺乏支持并不是我个人的问题。到目前为止,无论如何显示的错误链对我来说已经绰绰有余。

这还不是Rust的错误故事的终点。变化仍在进行之中,这两个库是否会像今天一样受到青睐尚待观察。

但是可以肯定的是:关于错误处理的故事已经走了很长一段路,并且在Rust的当前状态下,您可以以一种愉快而实用的方式编写非常强大的软件。

希望本文对您有所帮助,请考虑通过电子邮件或推文向@NickGroenen发送感谢信。

Reddit上正在进行一些讨论,并在此处发布了一条评论,似乎值得在此添加。 u / Yaahallo写道:

我认为错误处理的不同之处取决于您是在编写库还是在编写应用程序,这是rust社区中的常见简化,也是造成混乱的根源。

无论如何,使用此错误与此错误的原因并不完全基于它是库还是应用程序,而实际上是关于您是否需要处理错误或报告错误。

库通常希望为其使用者提供尽可能多的错误处理用例,这最终意味着他们希望导出既可处理(又称枚举)又可报告(也实现std :: error :: Error)的错误类型。 。

另一方面,应用程序通常最终会执行错误处理或报告。对于处理,您通常不需要库,您只需要使用match;对于报告,您确实需要错误类型,或更准确地说是错误报告类型,即到底是什么设计了:: Error的目的。

Burntsushi(来自ripgrep知名度)同意我的许多观点,但也对某些用例挑战了使用基于proc-macro的库(例如thiserror),这主要是由于使用它们导致编译时间增加。 ,他向我们展示了如何手工编写本文中的WordCountError实现。

关于使用context()与with_context()的性能影响,还有一个有趣的话题。

但这并不意味着批评。引入整个问题空间以及处理错误的所有不同考虑因素似乎并不适合本书的目的,也就是说,从新手的角度来看,更多的是官方指导在这个主题上,一个易于发现和访问的地方将是一件很高兴的事情。 ↩︎

这些包括围绕Display的改进,适当的源方法以及对backtrace API的支持。有关详细信息,请参阅RFC 2504及其相关的跟踪问题。 ↩︎

我强烈建议阅读Yoshua Wuyts的《错误处理调查》,以概述各种替代方案。 ↩︎

count_words()将实现特征std :: io :: Read的任何类型作为参数。Read特征的实现者称为阅读器。读者通过一种必需的方法read()进行定义。 ↩︎

因为这是在std :: error :: Error上使用source()方法,所以它不是特定的。它将与支持RFC 2504的任何库一起使用。