0
点赞
收藏
分享

微信扫一扫

【翻译】如何在正确性至关重要的Rust项目中进行错误处理?


原文标题: Error Handling in a Correctness-Critical Rust Project

原文链接: http://sled.rs/errors

公众号: Rust碎碎念


让我们先从一篇名为Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems的论文中里的两段节选开始:

almost all (92%) of the catastrophic system failures
are the result of incorrect handling of non-fatal errors
explicitly signaled in software.
in 58% of the catastrophic failures, the underlying
faults could easily have been detected through simple
testing of error handling code.

这些统计数字困扰着我。这让我经常会问自己"我要如何设计自己的系统才能提升错误被正确处理的机会?"

这引出了两个目标:

  1. 当一个错误发生时,它能够被正确地处理
  2. 在测试中触发错误处理逻辑

Rust中的错误处理 ( error handling in Rust )

在Rust中,错误处理的核心是​​Result​​​枚举和​​try?​​​操作。
​​​Result​​的定义如下:

pub enum Result<T, E> {
Ok(T),
Err(E),
}

// This `use` lets us write `Ok(Happy)` instead
// of `Result::Ok(Happy)` as we need to do with
// other enums by default.
pub use Result::{Ok, Err};

用法如下:

fn may_fail() -> Result<Happy, Sad> {
if /* function succeeded */ {
Ok(Happy)
} else {
Err(Sad)
}
}

我们使用Result表示一个可能成功也可能失败的操作。我们往往不会写很多以​​Result​​​作为参数的函数,因为​​Result​​​从本质上来讲表示一个操作将会成功或者失败的不确定性。当我们已经实际拥有一个​​Result​​​对象的时候,这种不确定性也就不存在了。(译者注:这里是说拿到​​Result​​​对象的时候,操作的结果也就确定了)。我们知道发生了什么。​​Result​​​往往作为调用者的返回值而不是传入到新调用的函数中。

当一个错误发生的时候,就可以开始进行错误处理。但是,我们经常不希望在某个发生错误的位置处理错误。想象下面的代码:

fn may_fail() -> Result<Happy, Sad> {
/* either returns Ok(Happy) or Err(Sad) */
}

fn caller() {
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
return;
}
}
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
return;
}
}
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
return;
}
}
println!("I am so happy right now");
}

这里的错误处理很明显变得重复且易于出错。错误处理逻辑应该秉承单一职责原则(single responsibility principle)并能够随着时间推移真正减少bug。如果针对一种特殊类型的错误处理可以在一个地方发生,你就能消除这样一种可能-因为忘记重构代码库中5个需要关注错误处理的地方的其中1个而产生一个bug。在重构Rust的时候很容易引入这些bug, 因为在重构期间,我们往往花费很多精力来解决编译错误而忘记通览代码库来检查并确认是否一致以及所有分散在各处具有相似功能的代码是否彼此保持同步。

上面的代码很容易重构成下面这个样子,其中最后一个实例丢掉了新改变的逻辑:

match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
return;
}
}
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
return;
}
}
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
<----- we forgot to update this
return;
}
}

所以,将错误处理逻辑集中起来是有用的:

fn may_fail() -> Result<Happy, Sad> {
/* either returns Ok(Happy) or Err(Sad) */
}

fn call_and_handle() {
match may_fail() {
Ok(happy) => println!(":)"),
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
return;
}
}
}

fn caller() {
call_and_handle();
call_and_handle();
call_and_handle();
println!("I am so happy right now");
}

然而很不幸的是,原来程序的意图被扭曲了。现在即使​​may_fail()​​​函数出现几次调用失败之后,我们也会打印出"I am so happy right now"。而且在前面的​​call_and_handle()​​​函数失败之后,我们的程序还是会继续调用​​call_and_handle()​​。我们想要使打印语句短路(译者注:这里短路的意思是,前面的语句调用成功才继续调用后面的语句,前面的语句失败后面的语句就不用调用了),就像下面这样对​​call_and_handle​​​的调用,只要第一个遇到问题就打印。

通过引入​​​try?​​操作符来添加短路逻辑,这里开始进入险境:

fn may_fail() -> Result<Happy, Sad> {
/* either returns Ok(Happy) or Err(Sad) */
}

fn call_and_handle() -> Result<(), ()> {
match may_fail() {
Ok(happy) => {
println!(":)");
Ok(())
},
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
Err(())
}
}
}

fn caller() -> Result<(), ()> {
call_and_handle()?;
call_and_handle()?;
call_and_handle()?;
println!("I am so happy right now");
Ok(())
}

虽然这样满足了上面的要求,但是现在,即使在我们已经处理了所有出现的错误之后,​​caller​​​仍需要返回一个​​Result​​​。​​caller​​​的调用者不需要关心在执行过程中会突然出现任何问题,因为它已经处理过所有的问题了。我们不想让已经被处理过的错误继续传播给调用者,因为这只会让调用者开始去关心其职责之外的问题。这在程序和人际关系中都是不合理的,因为它鼓励一个对核心问题知之甚少的人(代码片段)来处理核心问题,这会导致不利的耦合,并随着时间的推移,产生更多的Bug。

任何​​​caller()​​​的调用者如果不对返回的​​Result​​作处理,都会得到一个编译期警告。

warning: unused `std::result::Result` that must be used
--> src/main.rs:6:5
|
| caller();
| ^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant,
which should be handled

所以,你就不难想象某人会写出下面这样的代码,代码里的​​main()​​​函数使用了​​try?​​操作符来避免编译器警告。

fn may_fail() -> Result<Happy, Sad> {
/* either returns Ok(Happy) or Err(Sad) */
}

fn call_and_handle() -> Result<(), ()> {
match may_fail() {
Ok(happy) => {
println!(":)");
Ok(())
},
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
Err(())
}
}
}

fn caller() -> Result<(), ()> {
call_and_handle()?;
call_and_handle()?;
call_and_handle()?;
println!("I am so happy right now");
Ok(())
}

fn main() -> Result<(), ()> {
caller()?;
caller()?;
caller()
}

这样做确实避免了编译器的警告。但是,这样是有问题的,因为​​caller()​​​函数已经处理好了自己内部可能出现的问题,我们不需要去关心它是否调用成功。如果我们是想调用这个函数3次,现在编译器会鼓励我们提前退出(译者注: 这里应该是指提前返回,也就是上面提到的短路,如果调用出错就会直接从main函数返回)。

​​​try?​​​操作符很省事,但是也有风险。我们容易被这种提前返回所引诱。但是​​try?​​​本质上是传递错误。当我们需要调用者处理抛出的问题时我们必须只能使用它。

我们的意图是不管​​​caller()​​​是否需要处理错误,在​​main()​​​函数里调用​​caller()​​3次。下面这样是我们真正想要的:

fn may_fail() -> Result<Happy, Sad> {
/* either returns Ok(Happy) or Err(Sad) */
}

fn call_and_handle() -> bool {
match may_fail() {
Ok(happy) => {
println!(":)");
true
},
Err(sad) => {
eprintln!(":(");
/* handle error */
/* new and improved extra step */
false
}
}
}

fn caller() {
// using && will also short-circuit evaluation
if call_and_handle()
&& call_and_handle()
&& call_and_handle() {
println!("I am so happy right now");
}
}

fn main() {
// our intention is to call `caller()` 3 times,
// whether it needs to handle errors internally
// or not.
caller();
caller();
caller();
}

为什么这很重要? ( why does this matter? )

Rust社区有这样一种趋势-把所有的错误塞进一个全局的错误类型,这是一个很大的枚举类型,能够包含在程序中有可能遇到的所有错误。显然,这会使错误处理工作变得异常简单。

但是,使其简单的原因是错误的。

记住,我们在文章开头提到的第一个目标:

当错误出现的时候,它能够被正确地处理

如果我们在程序的不同地方抛出不同的错误,我们的目标是能够正确地处理这些错误。当所有可能的错误被转换成这样一个类似大煤球一样的错误枚举时,想要正确处理这些错误几乎是不可能的。

想象这样一个系统,我们在这个系统里会遇到能够处理的简单错误,也会遇到许多严重的致命错误。我们将会使用这种流行的全局枚举风格:

struct LocalError;
struct FatalError;

enum Error {
Local(LocalError),
Fatal(FatalError),
}

// these conversions allow the try `?` operator
// to automatically turn a specific error into
// the global `Error` when early-returning
// from functions
impl From<LocalError> for Error {
// Error::Local(LocalError)
}
impl From<FatalError> for Error {
// Error::Fatal(FatalError)
}

fn subtask_a() -> Result<(), LocalError> {
/* perform work, maybe fail */
}

fn subtask_b() -> Result<(), FatalError> {
/* perform work, maybe fail */
}

// the try `?` operator uses the `From` impl to convert
// from `LocalError` and `FatalError` into `Error`
fn perform_work() -> Result<(), Error> {
subtask_a()?;
subtask_b()?;
subtask_a()?;
Ok(())
}

fn main() -> Result<(), Error> {
loop {
perform_work()?;
}
}

一切看起来都相当正常。主要是因为没有发生错误处理,违反上面提到的第一个目标。我们来处理局部错误:

fn subtask_a() -> Result<(), LocalError> {
/* perform work, maybe fail */
}

fn subtask_b() -> Result<(), FatalError> {
/* perform work, maybe fail */
}

fn perform_work() -> Result<(), Error> {
subtask_a()?;
subtask_b()?;
subtask_a()?;
Ok(())
}

fn call_and_handle_local_error() -> Result<(), Error> {
match perform_work() {
Err(Error::Local(local_error)) => {
/* handle error */
Ok(())
}
other => other
}
}

fn perform_work() -> Result<(), Error> {
call_and_handle_local_error()?;
call_and_handle_local_error()?;
call_and_handle_local_error()
}

Ok, 现在一切都没有问题。我们通过执行一个模式匹配来处理局部错误,然后把执行成功以及致命的错误向上传递。但是我们都知道,代码是如何随着时间而改变的。在某个时间点,有人将会写出下面这样的代码:

fn subtask_a() -> Result<(), LocalError> {
/* perform work, maybe fail */
}

fn subtask_b() -> Result<(), FatalError> {
/* perform work, maybe fail */
}

fn perform_work() -> Result<(), Error> {
subtask_a()?;
subtask_b()?;
subtask_a()?;
Ok(())
}

fn call_and_handle_local_error() -> Result<(), Error> {
match perform_work() {
Err(Error::Local(local_error)) => {
/* handle error */
Ok(())
}
other => other
}
}

fn perform_work() -> Result<(), Error> {
call_and_handle_local_error()?;
subtask_a()?; <----- unhandled local error
call_and_handle_local_error()?;
subtask_b()?;
call_and_handle_local_error()
}

编译器连眼睛都不眨一下。它可能在很长一段时间内都不会失败。但是,局部错误没有被处理,现在可能会产生一个灾难性的系统错误。在实际生产环境的代码中,这也会发生。

案例学习: seld的比较交换错误 ( case study: sled’s compare and swap error )

seld有一个属于自己的​​Error​​​枚举。它可以保存各种你想要用户知晓的各种糟糕的失败,比如,如果备份文件的操作失败。我们想要在用户没有察觉到问题之前,立即关闭系统来最小化可能发生的数据丢失的可能。

sled有一个方法能够让用户原子地(atomically)改变一个key的value,如果他们能够正确猜到当前的值。这个原语(priomitive)在无锁编程中相当常见,这也是很多我们日常依赖的复杂算法的基础。在过去,它的签名类似下面这样:

fn compare_and_swap(
&mut self,
key: Key,
old_value: Value,
new_value: Value
) -> Result<(), sled::Error>

然而​​Error​​枚举有一些不同的变量,看上去像下面这样:

enum Error {
Io(std::io::Error),
CompareAndSwap(CompareAndSwapError),
}

如果你能够正确猜中给定key原来的值,sled将会原子地把值更新为你所提供的值,然后返回​​Ok(())​​​。如果你猜的不对,将会返回​​Err(sled::Error::CompareAndSwap(current_value))​​​。

但是,如果在程序执行操作的某个时间点遇到了一个IO问题,它也会返回一个错误。

主要问题在于,这两类错误需要完全不同的响应处理。IO错误需要立即关闭系统直到底层系统的问题被解决。但是,比较交换(the compare and swap)一直是有可能失败的。这完全是正常的行为。不幸的是,用户不再能够依赖​​​try?​​操作符了,因为他们不得不对返回的结果对象做一个部分模式匹配(partial pattern match)。

let result = sled.compare_and_swap(
"dogs",
"pickles",
"catfood"
);
match result {
Ok(()) => {},
Err(sled::Error::Io(io_error)) =>
return Err(io_error.into()),
Err(sled::Error::CompareAndSwap(cas_error)) => {
// handle expected issue
}
}

这就有点恶心了。此外,在sled的代码库里,内部系统执行自己的原子性CAS操作,并且依赖于相同的​​Error​​​枚举来表示成功,预期的失败以及致命性失败。这让代码库变成了一个梦魇。在数年的开发过程中,出现了无数的bug,要么是因为本该被处理的局部错误使用了​​try?​​操作符,要么是因为在执行部分模式匹配的时候使用了通配符。这面临着巨大的挑战。

让Bug跳出来 ( making bugs jump out )

久而久之,我研究出了几种发现这些bug的方法。其中成效最大的是,通过PingCAP的​​fail​​​crate并且和属性测试(property test)相结合,引起在测试下触发的各种失败(failure)的组合,从而找出最多的bug。这种测试是我目前为sled写过的bug/测试比例最高的。

它能够触发各种确定的可重现的bug,这些bug只能在指定的错误组合和高级操作下才会出现。

高层次的想法是,当你写一个有可能失败的函数,添加一些条件编译的逻辑并且检查其是否支持返回一个错误而不是继续执行。当你写测试时,留意偶尔去引发一些注入失败。可能是随机的,也可能是以预期的方式,不管怎样都很有效。我把它和属性测试结合使用,因为我更喜欢让机器为我写测试,而我就可以专注于应该发生的事情上。

但是,即使是翻转一个在测试期间被编译的全局静态的​​​AtomicBool​​​,也会导致代码走向故意地失败,这个全局变量有时候也会让你系统里的很多Bug显现出来。

这能够使上面和错误枚举相关的很多Bug显露出来,但是仍会持续引入Bug,因为很难保持清醒,知道哪里有可能会突然出现比较交换相关的失败,因为在sled数据库里有很多无锁的条件逻辑。

如果你不想引入一个依赖于旧版本rand等的crate,​​​fail​​crate背后的核心功能是相对简单的,我将会为sled实现一个简单的内部版本来缩短编译时间。测试速度 = 随着时间推移进行更多的测试 = 更少的Bug。

使未处理的错误不再出现 ( making unhandled errors unrepresentable )

最终,这让我开始思考什么是有效的解决方案,但是在看到很多Bug在对代码库进行简单的重构之后是如何立即被根除的,我确信,这是我们今天使用Rust在有很多错误处理相关的系统中处理错误的唯一方式。

方案是: 让全局的​​​Error​​​枚举只持有可能会引起整个系统宕机,即需要人工干预保存状态的错误。将关注点不同的错误保存在不同的错误类型当中。通过让错误在各自对应的类型中被处理,我们减少了这种情况:​​try?​​​操作符突然把一个应该局部关注的错误丢给一个无法处理它的调用者。如果你使这种错误能够被表示,它们就能发生。并且在我开发sled的过程中的确发生过。

今天,比较交换操作有一个看起来比较笨重的前面,但是它十分易用,并且通过隔离各自类型的关注点,系统内部已经变得十分稳定。

fn compare_and_swap(
&mut self,
key: Key,
old_value: Value,
new_value: Value
) -> Result<Result<(), CompareAndSwapError>, sled::Error>

虽然第一眼看上去似乎不是很好,但是它极大地改善了这种情况:用户可以恰当处理比较交换相关的错误。

// we can actually use try `?` now
let cas_result = sled.compare_and_swap(
"dogs",
"pickles",
"catfood"
)?;

if let Err(cas_error) = cas_result {
// handle expected issue
}

通过使用嵌入的​​Result​​​,我们能够充分利用短路和​​try?​​​操作的错误传播处理,而不用面对因为把错误揉进一个单一类型而引发的无穷无尽的bug。我们能够依靠通过再次执行模式匹配而消除bug的能力,而不用去猜在某个地方需不需要去处理某个变量。通过分离错误类型,我们从整体上提高了正确处理错误的可能。

使用​​​try?​​来传递错误。对需要处理的关注点使用穷尽模式匹配(exhaustive pattern matching)。不要进行从局部关注点到全局枚举的转换,或者随着时间推移,你的局部关注点发现自己处于不合适的位置。使用单独的类型可以将其封锁在属于自己的地方。

总结 (in summary)

Rust社区有一种很强的趋势,把所有的错误丢进单一的全局错误枚举里。这使得​​try?​​​操作的使用容易产生bug, 也会增加这样的可能-让局部错误溜走进而传递给无法处理它的调用者。

通过让错误被独立的类型以独立的方式来处理,我们解决了这个困扰。代价是函数签名里需要嵌入​​​Result​​​,这种嵌入使得代码看起来不是那么优雅,但是增加了可维护性, 相比较于优雅,花更少的时间在bug上更加值得。

使用PingCAP的​​​fail​​​crate能够很容易地测试我们的失败处理逻辑。

让我们以文章开头的两段引用开作为结束:

almost all (92%) of the catastrophic system failures
are the result of incorrect handling of non-fatal errors
explicitly signaled in software.
in 58% of the catastrophic failures, the underlying
faults could easily have been detected through simple
testing of error handling code.

感谢阅读!

译者注: 这篇博客是sled的作者所写,很多地方感觉翻译地词不达意,希望各位读者可以与原文进行比较阅读。如有翻译不到之处,万望见谅!欢迎关注我的微信公众号: Rust碎碎念

【翻译】如何在正确性至关重要的Rust项目中进行错误处理?_代码库


举报

相关推荐

0 条评论