查看源代码 监管树和应用程序

在上一章关于 GenServer 的内容中,我们实现了 KV.Registry 来管理桶。在某个时刻,我们开始监控桶,以便在 KV.Bucket 崩溃时采取行动。尽管更改相对较小,但它引出了 Elixir 开发人员经常问的一个问题:当出现问题时会发生什么?

在我们添加监控之前,如果桶崩溃,注册表将永远指向不再存在的桶。如果用户尝试读取或写入崩溃的桶,它将失败。任何尝试使用相同名称创建新桶的操作只会返回崩溃桶的 PID。换句话说,该桶在注册表中的条目将永远处于错误状态。一旦我们添加了监控,注册表会自动删除崩溃桶的条目。现在尝试查找崩溃的桶(正确地)会显示该桶不存在,并且如果需要,系统用户可以成功创建一个新的桶。

实际上,我们并不期望作为桶工作的进程会崩溃。但是,如果它确实发生了,无论出于何种原因,我们都可以放心,我们的系统将继续按预期工作。

如果你有先前的编程经验,你可能想知道:“我们能保证桶在第一时间不会崩溃吗?”。正如我们将会看到的,Elixir 开发人员倾向于将这些实践称为“防御性编程”。这是因为一个实时生产系统有数十种不同的原因会导致出现问题。磁盘可能发生故障,内存可能被破坏,错误,网络可能停止工作一秒钟等等。如果我们要编写试图保护或绕过所有这些错误的软件,我们将花费更多的时间来处理故障,而不是编写我们自己的软件!

因此,Elixir 开发人员更喜欢“让它崩溃”或“快速失败”。而我们恢复故障的最常见方法之一是重新启动系统中崩溃的任何部分。

例如,想象一下你的电脑、路由器、打印机或任何设备无法正常工作。你多久重启一次来修复它?一旦我们重新启动设备,我们将设备重置回其初始状态,该状态经过充分测试,保证可以工作。在 Elixir 中,我们将这种方法应用于软件:每当一个进程崩溃时,我们都会启动一个新进程来执行与崩溃进程相同的作业。

在 Elixir 中,这是通过监管者完成的。监管者是一个进程,它监管其他进程,并在它们崩溃时重新启动它们。为此,监管者管理任何受监管进程的整个生命周期,包括启动和关闭。

在本章中,我们将学习如何通过监管 KV.Registry 进程来将这些概念付诸实践。毕竟,如果注册表出现问题,整个注册表就会丢失,任何桶都无法找到!为了解决这个问题,我们将定义一个 KV.Supervisor 模块,它保证我们的 KV.Registry 在任何给定时刻都处于运行状态。

在本章结束时,我们还将讨论应用程序。正如我们将会看到的,Mix 一直将我们的所有代码打包到一个应用程序中,我们将学习如何自定义我们的应用程序,以保证我们的监管者和注册表在我们的系统启动时处于运行状态。

我们的第一个监管者

监管者是一个进程,它监管其他进程,我们将其称为子进程。监管进程的行为包括三个不同的职责。第一个是启动子进程。一旦子进程运行,监管者可以重新启动子进程,无论是由于它异常终止,还是由于达到某个条件。例如,如果任何子进程死亡,监管者可以重新启动所有子进程。最后,监管者还负责在系统关闭时关闭子进程。请参见 Supervisor 模块以更深入地讨论。

创建监管者与创建 GenServer 并没有太大区别。我们将在 lib/kv/supervisor.ex 文件中定义一个名为 KV.Supervisor 的模块,它将使用 Supervisor 行为。

defmodule KV.Supervisor do
  use Supervisor

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @impl true
  def init(:ok) do
    children = [
      KV.Registry
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

我们的监管者目前只有一个子进程:KV.Registry。在我们定义了子进程列表之后,我们调用 Supervisor.init/2,传递子进程和监管策略。

监管策略决定了当一个子进程崩溃时会发生什么。 :one_for_one 意味着如果一个子进程死亡,它将是唯一一个被重新启动的进程。由于我们现在只有一个子进程,这就是我们所需要的。 Supervisor 行为支持几种策略,我们将在本章中讨论。

一旦监管者启动,它将遍历子进程列表,并在每个模块上调用 child_spec/1 函数。

child_spec/1 函数返回子进程规范,它描述了如何启动进程,该进程是工作进程还是监管进程,该进程是临时的、短暂的还是永久的等等。当我们使用 use Agentuse GenServeruse Supervisor 等等时,child_spec/1 函数会自动定义。让我们在终端中使用 iex -S mix 来尝试一下。

iex> KV.Registry.child_spec([])
%{id: KV.Registry, start: {KV.Registry, :start_link, [[]]}}

我们将随着本指南的推进而学习这些细节。如果你想提前了解,请查看 Supervisor 文档。

在监管者检索到所有子进程规范后,它将使用子进程规范中 :start 键中的信息,按定义顺序依次启动其子进程。对于我们当前的规范,它将调用 KV.Registry.start_link([])

让我们试试这个监管者

iex> {:ok, sup} = KV.Supervisor.start_link([])
{:ok, #PID<0.148.0>}
iex> Supervisor.which_children(sup)
[{KV.Registry, #PID<0.150.0>, :worker, [KV.Registry]}]

到目前为止,我们已经启动了监管者并列出了它的子进程。监管者启动后,它也启动了所有子进程。

如果我们故意使监管者启动的注册表崩溃会发生什么?让我们通过在 call 上发送一个错误的输入来做到这一点。

iex> [{_, registry, _, _}] = Supervisor.which_children(sup)
[{KV.Registry, #PID<0.150.0>, :worker, [KV.Registry]}]
iex> GenServer.call(registry, :bad_input)
08:52:57.311 [error] GenServer #PID<0.150.0> terminating
** (FunctionClauseError) no function clause matching in KV.Registry.handle_call/3
iex> Supervisor.which_children(sup)
[{KV.Registry, #PID<0.157.0>, :worker, [KV.Registry]}]

请注意,一旦我们由于错误输入导致第一个注册表崩溃,监管者会自动启动一个新的注册表(带有新的 PID)来代替第一个注册表。

在前面的章节中,我们总是直接启动进程。例如,我们将调用 KV.Registry.start_link([]),它将返回 {:ok, pid},这将允许我们通过它的 pid 与注册表进行交互。现在进程由监管者启动,我们必须直接询问监管者它的子进程是谁,并从返回的子进程列表中获取 PID。实际上,每次都这样做会非常昂贵。为了解决这个问题,我们经常给进程命名,使它们能够在单台机器上从代码中的任何地方被唯一地识别。

让我们学习如何做到这一点。

命名进程

虽然我们的应用程序将拥有许多桶,但它只会有一个注册表。因此,每当我们启动注册表时,我们希望给它一个唯一的名称,以便我们可以从任何地方访问它。我们通过向 KV.Registry.start_link/1 传递一个 :name 选项来做到这一点。

让我们稍微更改一下我们的子进程定义(在 KV.Supervisor.init/1 中),使其成为一个元组列表而不是原子列表。

  def init(:ok) do
    children = [
      {KV.Registry, name: KV.Registry}
    ]

有了这个,监管者现在将通过调用 KV.Registry.start_link(name: KV.Registry) 来启动 KV.Registry

如果你重新访问 KV.Registry.start_link/1 的实现,你会记得它只是将选项传递给 GenServer。

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

这反过来将使用给定的名称注册进程。 :name 选项期望对本地命名进程使用一个原子(本地命名意味着它对这台机器可用——还有其他选项,我们不会在这里讨论)。由于模块标识符是原子(在 IEx 中尝试 i(KV.Registry)),我们可以根据实现它的模块来命名进程,前提是该名称只有一个进程。这有助于调试和检查系统。

让我们在 iex -S mix 中试试更新后的监管者。

iex> KV.Supervisor.start_link([])
{:ok, #PID<0.66.0>}
iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.70.0>}

这一次,监管者启动了一个命名的注册表,允许我们创建桶,而无需显式地从监管者那里获取 PID。你应该也知道如何使注册表再次崩溃,而无需查找它的 PID:试一试。

在这一点上,你可能想知道:你是否也应该本地命名桶进程?请记住,桶是根据用户输入动态启动的。由于本地名称必须是原子,我们将不得不动态创建原子,这是一个坏主意,因为一旦定义了原子,它就不会被擦除或垃圾回收。这意味着,如果我们根据用户输入动态创建原子,我们最终会耗尽内存(或者更准确地说,VM 会崩溃,因为它对原子数量设置了硬限制)。这个限制正是我们创建了自己的注册表(或者为什么有人会使用 Elixir 的内置 Registry 模块)的原因。

我们越来越接近一个完全可工作的系统。监管者会自动启动注册表。但是我们如何在系统启动时自动启动监管者呢?为了回答这个问题,让我们来谈谈应用程序。

了解应用程序

我们一直都在一个应用程序中工作。每当我们更改一个文件并运行 mix compile 时,我们都会在编译输出中看到一个 Generated kv app 消息。

我们可以在 _build/dev/lib/kv/ebin/kv.app 处找到生成的 .app 文件。让我们看看它的内容。

{application,kv,
             [{applications,[kernel,stdlib,elixir,logger]},
              {description,"kv"},
              {modules,['Elixir.KV','Elixir.KV.Bucket','Elixir.KV.Registry',
                        'Elixir.KV.Supervisor']},
              {registered,[]},
              {vsn,"0.1.0"}]}.

此文件包含 Erlang 术语(使用 Erlang 语法编写)。即使我们不熟悉 Erlang,也很容易猜到这个文件包含我们的应用程序定义。它包含我们的应用程序 version、它定义的所有模块,以及我们依赖的应用程序列表,例如 Erlang 的 kernelelixir 本身和 logger

logger 应用程序作为 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 启动一个控制台并尝试

iex> Application.start(:kv)
{:error, {:already_started, :kv}}

哦,它已经启动了。Mix 自动启动当前应用程序及其所有依赖项。这对于 mix test 和许多其他 Mix 命令也是如此。

但是,我们可以停止我们的 :kv 应用程序,以及 :logger 应用程序

iex> Application.stop(:kv)
:ok
iex> Application.stop(:logger)
:ok

让我们尝试再次启动我们的应用程序

iex> Application.start(:kv)
{:error, {:not_started, :logger}}

现在我们收到一个错误,因为 :kv 依赖的应用程序(在本例中为 :logger)尚未启动。我们需要手动按正确顺序启动每个应用程序,或者调用 Application.ensure_all_started/1,如下所示

iex> Application.ensure_all_started(:kv)
{:ok, [:logger, :kv]}

实际上,我们的工具总是为我们启动我们的应用程序,但如果您需要细粒度的控制,则可以使用 API。

应用程序回调

每当我们调用 iex -S mix 时,Mix 会通过调用 Application.start(:kv) 自动启动我们的应用程序。但我们可以自定义应用程序启动时发生的事情吗?事实上,我们可以!为此,我们定义了一个应用程序回调。

第一步是告诉我们的应用程序定义(例如,我们的 .app 文件)哪个模块将实现应用程序回调。让我们通过打开 mix.exs 并将 def application 更改为以下内容来完成此操作

  def application do
    [
      extra_applications: [:logger],
      mod: {KV, []}
    ]
  end

:mod 选项指定“应用程序回调模块”,后跟在应用程序启动时传递的参数。应用程序回调模块可以是任何实现 Application 行为的模块。

要实现 Application 行为,我们必须 use Application 并定义一个 start/2 函数。 start/2 的目标是启动一个监督器,它将启动任何子服务或执行应用程序可能需要的任何其他代码。让我们利用这个机会启动我们在本章前面实现的 KV.Supervisor

由于我们已经指定了 KV 作为模块回调,让我们更改在 lib/kv.ex 中定义的 KV 模块以实现 start/2 函数

defmodule KV do
  use Application

  @impl true
  def start(_type, _args) do
    # Although we don't use the supervisor name below directly,
    # it can be useful when debugging or introspecting the system.
    KV.Supervisor.start_link(name: KV.Supervisor)
  end
end

请注意,通过这样做,我们正在破坏测试 KVhello 函数的样板测试用例。您可以简单地删除该测试用例。

当我们 use Application 时,我们可以定义几个函数,类似于我们使用 SupervisorGenServer 时。这次我们只需要定义一个 start/2 函数。 Application 行为也有一个 stop/1 回调,但它在实践中很少使用。您可以查看文档以获取更多信息。

现在您已经定义了一个启动我们监督器的应用程序回调,我们希望 KV.Registry 进程在我们启动 iex -S mix 后立即启动并运行。让我们再试一次

iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.88.0>}

让我们回顾一下正在发生的事情。每当我们调用 iex -S mix 时,它会通过调用 Application.start(:kv) 自动启动我们的应用程序,然后调用应用程序回调。应用程序回调的工作是启动一个**监督树**。现在,我们的监督器只有一个名为 KV.Registry 的子进程,以 KV.Registry 的名称启动。我们的监督器可以有其他子进程,其中一些子进程可能是它们自己的监督器,有它们自己的子进程,从而导致所谓的监督树。

项目还是应用程序?

Mix 区分项目和应用程序。根据我们 mix.exs 文件的内容,我们可以说我们有一个 Mix 项目,它定义了 :kv 应用程序。正如我们将在后面的章节中看到的那样,有些项目没有定义任何应用程序。

当我们说“项目”时,您应该想到 Mix。Mix 是管理您项目的工具。它知道如何编译您的项目、测试您的项目等等。它还知道如何编译和启动与您的项目相关的应用程序。

当我们谈论应用程序时,我们谈论的是 OTP。应用程序是作为整体由运行时启动和停止的实体。您可以在 Application 模块的文档中了解更多有关应用程序及其与整个系统启动和关闭之间的关系的信息。

下一步

虽然本章是我们第一次实现监督器,但它并不是我们第一次使用监督器!在上一章中,当我们使用 start_supervised! 在我们的测试期间启动注册表时, ExUnit 在由 ExUnit 框架本身管理的监督器下启动了注册表。通过定义我们自己的监督器,我们在应用程序中初始化、关闭和监督进程的方式上提供了更多结构,使我们的生产代码和测试与最佳实践保持一致。

但我们还没有完成。到目前为止,我们一直在监督注册表,但我们的应用程序也在启动存储桶。由于存储桶是动态启动的,我们可以使用一种特殊的监督器类型,称为 DynamicSupervisor,它针对此类场景进行了优化。让我们在下一章中探索它。