在上一章关于 GenServer 的内容中,我们实现了 KV.Registry 来管理存储容器。在某个时候,我们开始监控存储容器,这样每当 KV.Bucket 崩溃时,我们就能采取行动。虽然变化相对较小,但它提出了一个 Elixir 开发人员经常问的问题:当出现故障时会发生什么?
在我们添加监控之前,如果存储容器崩溃,注册表将永远指向不再存在的存储容器。如果用户尝试读取或写入崩溃的存储容器,它将失败。任何尝试创建具有相同名称的新存储容器的操作都只会返回崩溃存储容器的 PID。换句话说,该存储容器的注册表条目将永远处于不良状态。一旦我们添加了监控,注册表就会自动删除崩溃存储容器的条目。现在尝试查找崩溃的存储容器(正确)会显示存储容器不存在,系统用户可以根据需要成功创建一个新的存储容器。
实际上,我们并不希望作为存储容器工作的进程失败。但是,如果确实发生了这种情况,无论出于何种原因,我们都可以放心,我们的系统将继续按预期工作。
如果您有编程经验,您可能会想:“我们能保证存储容器不会首先崩溃吗?”。正如我们将看到的,Elixir 开发人员倾向于将这些做法称为“防御性编程”。这是因为实时生产系统有几十种不同的原因导致某些事情可能出错。磁盘可能会发生故障,内存可能会损坏,错误,网络可能会停止工作一秒钟,等等。如果我们要编写试图保护或规避所有这些错误的软件,我们将花费更多时间来处理故障,而不是编写自己的软件!
因此,Elixir 开发人员更喜欢“让它崩溃”或“快速失败”。我们从故障中恢复的最常见方法之一是重新启动系统崩溃的任何部分。
例如,想象一下您的计算机、路由器、打印机或任何设备无法正常工作。您多久通过重新启动来修复它一次?一旦我们重新启动设备,我们就会将设备重置回其初始状态,该状态经过充分测试并保证可以正常工作。在 Elixir 中,我们将同样的方法应用于软件:每当一个进程崩溃时,我们都会启动一个新进程来执行与崩溃进程相同的工作。
在 Elixir 中,这是由 Supervisor 完成的。Supervisor 是一个监督其他进程并在它们崩溃时重新启动它们的进程。为此,Supervisor 管理任何受监督进程的整个生命周期,包括启动和关闭。
在本章中,我们将学习如何通过监督 KV.Registry 进程将这些概念付诸实践。毕竟,如果注册表出现问题,整个注册表都会丢失,并且永远找不到任何存储容器!为了解决这个问题,我们将定义一个 KV.Supervisor 模块,以确保我们的 KV.Registry 在任何给定时刻都处于启动和运行状态。
在本章的最后,我们还将讨论应用程序。正如我们将看到的,Mix 已将我们所有的代码打包到一个应用程序中,我们将学习如何定制我们的应用程序,以确保我们的 Supervisor 和 Registry 在系统启动时正常运行。
我们的第一个监督者
监督者是一个监督其他进程的进程,我们将其称为子进程。监督进程的行为包括三个不同的职责。第一个是启动子进程。一旦子进程运行因为异常终止或达到某个条件,监督者可能会重新启动子进程。例如,如果任何子进程死亡,监督者可能会重新启动所有子进程。最后,监督者还负责在系统关闭时关闭子进程。有关更深入的讨论,请参阅监督者模块。
创建监督者与创建 GenServer 没有太大区别。我们将在 lib/kv/supervisor.ex 文件中定义一个名为 KV.Supervisor 的模块,它将使用 Supervisor 行为:
到目前为止,我们的监督者只有一个子进程:KV.Registry。定义子进程列表后,我们调用 Supervisor.init/2,传递子进程和监督策略。
监督策略规定当其中一个子进程崩溃时会发生什么。:one_for_one 表示如果子进程死亡,它将是唯一重新启动的进程。由于我们现在只有一个子进程,这就是我们所需要的。监督者行为支持多种策略,我们将在本章中讨论。
一旦监督者启动,它将遍历子进程列表,并在每个模块上调用 child_spec/1 函数。
child_spec/1 函数返回子进程规范,该规范描述了如何启动进程,进程是工作者进程还是监督者进程,进程是临时的、瞬态的还是永久的等等。当我们使用 Agent、使用 GenServer、使用 Supervisor 等时,会自动定义 child_spec/1 函数。让我们在终端中使用 iex -S mix 尝试一下:
随着我们继续学习本指南,我们将了解这些细节。如果您想提前了解,请查看 Supervisor 文档。
在 Supervisor 检索所有子规范后,它会按照子规范中 :start 键中的信息,按照定义顺序逐个启动其子规范。对于我们当前的规范,它将调用 KV.Registry.start_link([])。
让我们试用一下 Supervisor:
到目前为止,我们已经启动了监督程序并列出了其子程序。一旦监督程序启动,它也会启动其所有子程序。
如果我们故意使监督程序启动的注册表崩溃,会发生什么?让我们通过在调用时向其发送错误输入来实现这一点:
请注意,一旦我们因错误输入导致注册表崩溃,监管者就会自动启动一个具有新 PID 的新注册表来代替第一个注册表。
在前面的章节中,我们总是直接启动进程。例如,我们将调用 KV.Registry.start_link([]),它将返回 {:ok, pid},这将允许我们通过其 pid 与注册表进行交互。既然进程是由监管者启动的,我们必须直接询问监管者它的子进程是谁,并从返回的子进程列表中获取 PID。实际上,每次这样做都会非常昂贵。为了解决这个问题,我们经常给进程命名,允许它们在我们的代码中的任何位置在单个机器中被唯一地标识。
让我们学习如何做到这一点。
命名进程
虽然我们的应用程序有许多存储容器,但它只有一个注册表。因此,每当我们启动注册表时,我们都希望为其赋予一个唯一的名称,以便我们可以从任何地方访问它。我们通过将 :name 选项传递给 KV.Registry.start_link/1 来实现这一点。
让我们稍微改变一下我们的 children 定义(在 KV.Supervisor.init/1 中),将其改为元组列表,而不是原子列表:
有了这个,监督者现在将通过调用 KV.Registry.start_link(name: KV.Registry) 来启动 KV.Registry。
如果您重新访问 KV.Registry.start_link/1 实现,您会记得它只是将选项传递给 GenServer:
反过来,它将使用给定的名称注册进程。:name 选项需要一个用于本地命名进程的原子(本地命名意味着它可用于此机器 - 还有其他选项,我们不会在这里讨论)。由于模块标识符是原子(在 IEx 中尝试 i(KV.Registry)),我们可以用实现它的模块来命名进程,前提是该名称只有一个进程。这有助于调试和自省系统。
让我们在 iex -S mix 中尝试更新后的监督器:
这次,监管者启动了一个命名注册表,这样我们就可以创建存储容器,而不必从监管者那里显式获取 PID。您还应该知道如何使注册表再次崩溃,而无需查找其 PID:试一试。
此时,您可能想知道:您还应该在本地命名存储容器进程吗?请记住,存储容器是根据用户输入动态启动的。由于本地名称必须是原子,因此我们必须动态创建原子,这是一个坏主意,因为一旦定义了原子,它就永远不会被擦除或垃圾收集。这意味着,如果我们根据用户输入动态创建原子,我们最终将耗尽内存(或者更准确地说,VM 将崩溃,因为它对原子数量施加了硬性限制)。这个限制正是我们创建自己的注册表的原因(或者为什么人们会使用 Elixir 的内置注册表模块)。
我们越来越接近一个完全正常工作的系统。监管者会自动启动注册表。但是,我们如何在系统启动时自动启动监管者?要回答这个问题,让我们谈谈应用程序。
了解应用程序
我们一直在应用程序内部工作。每次我们更改文件并运行 mix compile 时,我们都可以在编译输出中看到一条 Generated kv app 消息。
我们可以在 _build/dev/lib/kv/ebin/kv.app 找到生成的 .app 文件。让我们看看它的内容:
此文件包含 Erlang 术语(使用 Erlang 语法编写)。即使我们不熟悉 Erlang,也很容易猜到这个文件包含我们的应用程序定义。它包含我们的应用程序版本、它定义的所有模块,以及我们依赖的应用程序列表,如 Erlang 的内核、elixir 本身和记录器。
记录器应用程序作为 Elixir 的一部分提供。我们通过在 mix.exs 中的 :extra_applications 列表中指定它来表明我们的应用程序需要它。有关更多信息,请参阅官方文档。
简而言之,应用程序由 .app 文件中定义的所有模块组成,包括 .app 文件本身。应用程序通常只有两个目录:ebin,用于存放 Elixir 工件,例如 .beam 和 .app 文件;priv,用于存放应用程序中可能需要的任何其他工件或资产。
虽然 Mix 会为我们生成并维护 .app 文件,但我们可以通过在 mix.exs 项目文件内的 application/0 函数中添加新条目来自定义其内容。我们很快就会进行第一次自定义。
启动应用程序
我们系统中的每个应用程序都可以启动和停止。启动和停止应用程序的规则也在 .app 文件中定义。当我们调用 iex -S mix 时,Mix 会编译我们的应用程序然后启动它。
让我们在实践中看看这一点。使用 iex -S mix 启动控制台并尝试:
它已经启动了。Mix 会自动启动当前应用程序及其所有依赖项。对于 mix test 和许多其他 Mix 命令也是如此。
但是,我们可以停止 :kv 应用程序以及 :logger 应用程序:
让我们尝试再次启动我们的应用程序:
现在我们收到错误,因为 :kv 所依赖的应用程序(在本例中为 :logger)未启动。我们需要以正确的顺序手动启动每个应用程序,或者调用 Application.ensure_all_started/1,如下所示:
实际上,我们的工具总是会为我们启动应用程序,但如果您需要细粒度的控制,可以使用 API。
应用程序回调
每当我们调用 iex -S mix 时,Mix 都会通过调用 Application.start(:kv) 自动启动我们的应用程序。但是我们可以自定义应用程序启动时发生的情况吗?事实上,我们可以!为此,我们定义一个应用程序回调。
第一步是告诉我们的应用程序定义(例如,我们的 .app 文件)哪个模块将实现应用程序回调。让我们通过打开 mix.exs 并将 def application 更改为以下内容来做到这一点:
:mod 选项指定“应用程序回调模块”,后跟应用程序启动时要传递的参数。应用程序回调模块可以是实现应用程序行为的任何模块。
要实现应用程序行为,我们必须使用 Application 并定义一个 start/2 函数。start/2 的目标是启动一个监督器,然后它将启动任何子服务或执行我们的应用程序可能需要的任何其他代码。让我们利用这个机会启动我们在本章前面实现的 KV.Supervisor。
由于我们已将 KV 指定为模块回调,因此让我们更改 lib/kv.ex 中定义的 KV 模块以实现 start/2 函数:
请注意,这样做会破坏测试 KV 中 hello 函数的样板测试用例。您可以简单地删除该测试用例。
当我们使用 Application 时,我们可能会定义几个函数,类似于使用 Supervisor 或 GenServer 时。这次我们只需要定义一个 start/2 函数。Application 行为也有一个 stop/1 回调,但在实践中很少使用。您可以查看文档以获取更多信息。
现在您已经定义了一个启动我们的监督器的应用程序回调,我们希望 KV.Registry 进程在我们启动 iex -S mix 时立即启动并运行。让我们再试一次:
让我们回顾一下发生了什么。每当我们调用 iex -S mix 时,它都会通过调用 Application.start(:kv) 自动启动我们的应用程序,然后调用应用程序回调。应用程序回调的工作是启动监督树。目前,我们的监督者有一个名为 KV.Registry 的子节点,以名称 KV.Registry 开头。我们的监督者可以有其他子节点,其中一些子节点可以成为自己的监督者,并拥有自己的子节点,从而形成所谓的监督树。
项目还是应用程序?
Mix 区分了项目和应用程序。根据我们的 mix.exs 文件的内容,我们可以说我们有一个定义 :kv 应用程序的 Mix 项目。正如我们将在后面的章节中看到的那样,有些项目没有定义任何应用程序。
当我们说“项目”时,您应该考虑 Mix。Mix 是管理项目的工具。它知道如何编译您的项目、测试您的项目等等。它还知道如何编译和启动与您的项目相关的应用程序。
当我们谈论应用程序时,我们谈论的是 OTP。应用程序是由运行时作为一个整体启动和停止的实体。您可以在应用程序模块的文档中了解有关应用程序的更多信息以及它们与整个系统的启动和关闭的关系。
下一步
虽然本章是我们第一次实现监督器,但这并不是我们第一次使用它!在上一章中,当我们在测试期间使用 start_supervised! 启动注册表时,ExUnit 在由 ExUnit 框架本身管理的监督器下启动了注册表。通过定义我们自己的监督器,我们提供了更多关于如何在应用程序中初始化、关闭和监督进程的结构,使我们的生产代码和测试与最佳实践保持一致。
但我们还没有完成。到目前为止,我们正在监督注册表,但我们的应用程序也在启动存储容器。由于存储容器是动态启动的,我们可以使用一种称为 DynamicSupervisor 的特殊类型的监督器,它经过优化以处理此类场景。接下来让我们探索它。