错误处理很难

2020-11-30 22:29:22

该博客文章将主要使用Rust和Haskell代码片段来说明其要点。但是我根本不相信核心点是特定于语言的。

这是一些Rust代码,用于读取input.txt的内容并将其打印到stdout。它出什么问题了?

如果您能说流利的话,那么.unwrap()可能会像拇指酸痛地伸出来。您知道这意味着“将发生的所有错误转换为紧急情况”。恐慌是一件坏事。这不是正确的错误处理。相反,这样的事情“更好”:

fn main(){match std :: fs :: read_to_string(“ input.txt”){确定(s)=> println! (“ {}”,s),Err(e)=> eprintln! (“无法从input.txt中读取:{:?}”,e),}}

Rust中枚举的存在使确保您正确处理所有失败案例变得非常容易。上面的代码不会惊慌。如果发生I / O错误,例如找不到文件,权限被拒绝或硬件故障,它将向stderr打印一条错误消息。但这仍然不是很好的错误处理,原因有两个:

该程序的退出代码并不表示发生了错误。我们需要使用中止之类的方法来解决该问题,这并不难。但这是要记住的其他事情。

这很冗长!我们这里有一个琐碎的小程序,由于匹配了不同的枚举变量,所有这些行噪声都掩盖了程序的实际行为。

幸运的是,Rust语言是仁慈的,它使做事比以前更好。 ?操作员将尝试做某事,并在发生错误时自动短路。现在,我们可以避免那些烦人的恐慌而又不会使我们的代码混乱。并且我们获得了正确的退出代码来启动!

世界上一切都很好,我们可以在这里停止发布并回家。错误处理的最大奇迹来了!

所以事实证明我忘了创建我的input.txt文件。让我们看看我的程序生成的漂亮错误消息:

错误:Os {代码:2,种类:找不到,消息:“系统找不到指定的文件。” }

嗯...那是完全无益的。在我的5行程序中,找出哪个文件不存在很简单。但是想象一下一个5,000行的程序。或者,如果所讨论的代码在依赖项中。或者,如果您是运维团队的成员,一生中从未写过Rust文字,无权访问代码库,生产服务器在凌晨2点关闭,并且您在日志中看到此错误消息。

好吧,显然这仅仅是因为Rust使用错误返回而不是Good Ol'Runtime Exceptions。显然,像Haskell这样的东西可以更好地解决这个问题,对吗?好吧,有点。有了这个程序,没有input.txt:

我什至不需要在代码中包含任何错误处理逻辑。都是隐性的!但是实际上,此错误消息的清晰度与异常处理语义无关。它与此特定错误消息的构造有关。它包含足够的信息来帮助调试。

但是在Haskell中有很多反例。现在,在空列表上调用head会提供一个行号,但是您过去经常遇到这样的错误:“哎呀,试图在您的一个库中某个地方的一个空列表上添加头。祝您好运!”某些底层网络功能仍然给出模糊的错误消息。

甚至上面不存在的光荣消息也仅是微不足道的。那是因为...

在一个简单的两行程序中,现实情况是没有任何其他信息的“找不到文件”是完全合理的。那是因为我确切知道错误发生的上下文。它发生在第1行或第2行。相比之下,在500k SLOC代码库中,知道input.txt不存在可能不足以调试东西。

同样,在小型网络测试中,知道我无法连接到IP地址255.813.20.1就足够了。但是在一个相当复杂的程序中,我宁愿得到这样一个上下文,即我试图通过IP地址为255.813.20.1的服务器向example.com发出HTTPS请求,该服务器是通过HTTP_PROXY环境变量指定的。最后的信息可能会缩短调试时间,以指出“哦,我的Kubernetes清单文件中有错字!”

堆栈跟踪通常在这里有很大的帮助。它们告诉您很多有用的上下文。而且,Rust和Haskell在其错误表示形式中都很难提供这种上下文。但这仍然不是万能药。丑陋的现实是...

像其他许多事情一样,错误处理最终是一个权衡。在编写初始代码时,我们不想考虑错误。我们编码到幸福的道路。如果您不得不围绕思维过程以无数种方式失败的思维过程使每一行代码脱轨,那么您将有多大的生产力?

但是随后我们正在调试生产问题,我们当然想考虑错误。我们诅咒我们懒惰的自我,因为他们没有处理显然可能出现的错误情况。 “为什么在TCP连接失败时我为什么决定中止该进程?我应该重试!我应该已经记录了尝试连接的地址!”

然后,我们用日志消息充斥我们的​​代码,当我们看不到重要的位时感到沮丧。

找到正确的平衡是一门艺术。通常,这是一门我们不会花太多时间思考的艺术。为此有一些完善的工具,例如运行时可配置的日志级别。这是朝正确方向迈出的巨大一步。

Rust是一个很好的例子。明确匹配Result值确实会迫使您考虑所有不同的错误情况以及如何正确报告它们。复杂的自定义枚举错误类型使您可以定义要报告的所有不同值。但是,与?相比,所有这些都会增加巨大的线路噪声。那呢赢得胜利。

Rust社区承认恐慌是有害的。 Haskell社区不断争论运行时异常是好事还是坏事。 Java是检查异常还是喜欢还是讨厌。如果err!= nil,则对Golang表示赞赏或嘲笑。

我一点也不认为这些讨论无关紧要。这些方法之间存在重大折衷。它们会影响性能,错误的可跟踪性等等。

我在这里争论的是,我们在报告和从错误中恢复方面花费的时间不成比例,而在讨论好的错误实际上包含的内容上花费的时间更少。

这些是我不断发展的想法。所以把它们和一粒盐一起吃。我很想听到不同的意见。

我一直认为在Haskell中,我们应该使用运行时异常。许多人将其解释为我对运行时异常的提倡。相反,我主张:使用语言的本机机制。在编写Rust时,我不会为例外而努力。实际上恰恰相反。我总体上更喜欢显式错误处理。但是当它们已经无处不在时,与运行时异常作斗争是不值得的。

我认为Rust和Haskell都接近错误处理的最佳位置。添加此处理的详细程度相对较低。如果您像在Rust中那样以任何方式利用库,那就更少了。

无论如何,我对图书馆的最大关注是做错事情变得多么容易。从上面举一个破碎的例子。 “升级”它以任何方式使用都是微不足道的:

但是,这仍然会产生与我们相同的无用错误消息。取而代之的是,我们需要通过上下文方法调用来更明确地显示一条更好的消息:

使用anyhow :: Context; fn main()->总之::结果 {让s = std :: fs :: read_to_string(“ input.txt”)。上下文(“无法读取input.txt”)? ; println! (“ {}”,s);好 (())}

错误:无法读取input.txt原因:系统找不到指定的文件。 (操作系统错误2)

这是简洁和有用之间的良好平衡。缺点是缺乏执法。没有什么迫使我添加.context调用的。我担心在大型代码库中或在时间压力下,像我这样的人最终会忘记添加有用的上下文。

没有任何工具可以强制需要人类洞察力和思想的“正确”上下文。这些都是供不应求的数量,通常对错误消息不感兴趣。

我在这里没有答案。我建议人们首先认识到良好的错误处理是困难的。我们喜欢将其视为一项琐碎但繁琐的任务。不是。正确执行此操作需要真实的思想和设计。作为我们代码中不重要的部分,我们太快地把它扫了底。

我将继续建议您使用您的语言的首选机制进行错误处理。在Rust中,这意味着使用Result并避免出现恐慌。在Haskell中,这意味着显式的Either返回值和运行时异常的某种混合(确切的混合非常有争议)。在Java中,它通常是受检查的异常,尽管也添加了许多未经检查的异常来完善工作。

但是,请考虑花更多的时间来思考,不仅要考虑如何报告/引发/抛出错误/异常,还要思考您到底在报告/引发/抛出什么。想一想这个可怜的操作人员在凌晨4点喝了7杯咖啡,试图弄清代码库的哪个部分需要input.txt,或者为什么程序在世界上试图连接到无效的IP地址。

您是否喜欢这篇博客文章,并且需要有关DevOps,Rust或函数式编程的帮助?联系我们。