BEAM 虚拟机(运行我们的 Elixir 和 Erlang 代码的虚拟机)以容错而闻名。关于用 Erlang 编写的许多可靠应用程序的好故事。人们倾向于将其高可靠性归因于语言的容错性。但我认为这种归因只对了一半。容错确实有帮助,但这只是一个很好的起点。容错性的真正亮点在于,一旦代码处于意外状态,我们就可以让它失败,我们可以编写更少的错误处理代码,我们可以更多地关注我们的业务逻辑。使用更简单的代码,我们可以更轻松地维护我们的应用程序,增加我们使这些应用程序更可靠并长期维护它们的机会。这篇文章解释了为什么会这样,以及如何利用容错语言和编写更简单的代码。

BEAM 虚拟机是如何进行容错处理?插图
虚拟机容错

区分预期和意外错误

这里最大的教训是,不要以各种可能的方式使用 Supervisors 并隐藏地毯下的错误处理代码。但是要真正考虑要编写哪些错误处理代码以及根本不编写哪些代码。为此,我们需要真正理解和区分系统中不同类型的“错误”。

简而言之,我会将“错误”分为两类:预期错误预期错误是可预测的情况,与快乐路径略有不同。当这些错误发生时,用户(或客户端代码)可以自行修复。无效的参数或输入就是最好的例子。意外错误意外错误是用户无法修复的不可预测的情况。大多数编程语言都会在出现意外错误时引发异常。当这些错误发生时,我们的代码根本无法继续运行。修复它的责任落在了开发人员或运营商身上。磁盘空间不足,网络故障就是最好的例子。

错误是一个简单的词,但有太多的含义。对于用户来说,“用户名被占用”错误与“500 内部服务器错误”页面大致相同。
当用户看到错误页面或 Flash 消息时,她不知道错误是预期的还是意外的。对于开发人员来说,一个函数调用可能会返回一个错误元组,而另一个函数可能会引发异常。但是错误元组可能是意外的,而异常可能是预期的。因此,除非我们作为开发人员在编写代码时真正考虑到这一点,否则很容易混合预期和意外错误。

Erlang 论文(在存在软件错误的情况下制作可靠的分布式系统)对“什么是错误?”这个问题给出了很好的回答。

由程序员决定异常是否对应于错误……

Schneider 在他 1990 年的 ACM 教程论文中给出了许多容错定义。
在这篇论文中,他说:
一旦组件的行为不再符合其规范,它就被认为是有缺陷的

最后,这一切都归结为规范(即预期或意外)。如果规范已经定义了在某种异常情况下的响应,那么这是一个预期的错误。如果代码发现自己处于未定义状态,那么最好尽早失败。

以下是一些示例,可帮助您在考虑错误时区分预期和意外:

  1. 除以零误差
    • 预期的时候
      当您构建计算器时,预计用户可能会输入1 / 0.
      因此在这种情况下应考虑除零误差。
      开发人员应该考虑为这些可能的输入返回什么。
    • 出乎意料的时候
      当您真正将 0 作为除数传递时,运行时不知道如何进行。行为在数学
      上是未定义的,周期。 所以提出一个例外是合理的。
  2. 文件不存在错误
    • 预期的时候
      在构建文本编辑器时,预计用户可能会打开一个不存在的文件来创建它。
      文本编辑器甚至可能不会将其视为错误,而是日常操作。
    • 出乎意料的时候
      当您的应用在启动过程中需要读取配置文件时,通常最好将丢失的配置文件视为意外错误。
      在这种情况下,一个常见的解决方案是回退到一些默认值,
      我发现它在操作级别和代码级别都太混乱了。
      缺少配置文件时很难调试,但应用程序仍然可以工作(但不同)。
      很难理解代码,因为回退可能发生在任何级别。
      所以我总是假设应用程序启动时配置文件就在那里,如果不是这样,就提出来。
      (我从Chris Keathley 那里学到了这个想法 – 使用 Stacking 构建弹性系统 – ElixirConf EU 2019 – YouTube)

通过“忽略”意外错误来减少代码编写

现在我们已经区分了预期和意外错误,这将如何帮助我们编写更少的代码?

诀窍是我们现在可以一劳永逸地“忽略”意外错误。让容错运行时处理它。

首先,让我们承认,我们无法预测或处理所有意外错误。我们希望我们的节点始终在线,但最终,只需短暂断电或网络中断即可关闭我们的一个节点。我们无法在软件级别处理这种硬件错误。所以我们不会尝试处理它们,而是通过链接和主管来检测它们。

相同的逻辑适用于所有意外错误。根据规范,它们是意外的,我们不知道如何处理它们。因此,让我们停止继续并退回到更简单的任务。

更简单的任务可能是:

  • 记录意外错误
  • 从头开始重试(即在 BEAM 中重新启动进程)
  • 停止应用程序

通过断言我们的假设来编写更自信的代码

凭借“忽略”意外错误的特权,我们可以编写更少但更自信的代码。
因为我们可以围绕我们的代码建立一个围栏,一个断言外壳来检查我们的假设。

  • 如果函数只接受非空列表,则在函数头中进行模式匹配:
    def average ([ _ | _ ] = list ) do … end
  • 如果总是期望从列表中获得结果,则对其进行模式匹配并确保它不是nil
    % SomeStruct {} =结果=枚举。查找(列表,…)
  • 如果case语句只期望一些可能的输入,则无需在最后添加始终匹配子句:
    案例文件。read ( path ) do {: ok , binary } -> … {: error , : enoent } -> {: error , : file_does_not_exist } # 其他错误是意外的,我们应该在它们出现时中止end

通过以这种风格编写代码,我们基本上是在提取函数的输入1,使其在深入到我们的核心逻辑中时变得越来越纯净。
我将这种编码风格称为Assertive Shell、Confident Core。(受Gary Bernhardt-YouTube 的 RubyConf 12-Boundaries启发)或者 Erlang 的老手可能会称之为让它失败

而且这种编码风格也非常 MVP-ish 和可扩展的。当我们刚开始使用应用程序时,在许多情况下,规范总是含糊不清且未定义。因此,与其花太多精力去覆盖我们能想到的所有情况,我们只需确保我们始终在我们设计的快乐道路上,如果不再是这种情况,就提出一个例外。随着规范变得越来越复杂,我们的代码可以相应地增长并根据规范处理更多案例。

BEAM VM 让“让它失败”的生产就绪

我们可以在任何编程语言中应用这种编码风格。那么 BEAM 的特别之处和它的容错性呢?答案是隔离

BEAM VM 中的每个进程都是相互隔离的。因此,如果一个进程因异常而死亡,那也没关系。它的主管会将其带回干净的状态。这种机制已融入语言。

在 Ruby 等其他语言中,我不太有信心在随机函数中引发异常。恐怕这个异常会使整个应用程序崩溃。我只能依靠框架(Rails、Sidekiq 等)来捕捉这个异常并神奇地处理它。

我想这就是为什么这种编码风格毕竟不那么受欢迎的原因。BEAM VM 为我们提供了此特权。

概括

“让它失败”和容错是 BEAM 社区的陈词滥调。我对写这篇文章犹豫不决,因为我总是告诉自己这个想法已经被很好地解释了,就像 Erlang 论文(在存在软件错误的情况下制作可靠的分布式系统)所做的那样。

不过我对最后的部分感到高兴,因为我认为我们没有足够强调“让它失败”编码风格给我们带来的简单性和信心。请记住更彻底地定义您的规范,并享受对意外错误的无知!