查看源代码 配置和发布

在本指南的最后,我们将使分布式键值存储的路由表可配置,然后最终将软件打包以供生产使用。

让我们来做这件事。

应用程序环境

到目前为止,我们已将路由表硬编码到 KV.Router 模块中。但是,我们希望使该表动态化。这不仅允许我们配置开发/测试/生产,还允许不同的节点使用路由表中的不同条目运行。OTP 中有一个功能可以做到这一点:应用程序环境。

每个应用程序都有一个环境,该环境通过键存储应用程序的特定配置。例如,我们可以将路由表存储在 :kv 应用程序环境中,为其提供默认值,并允许其他应用程序根据需要更改该表。

打开 apps/kv/mix.exs 并更改 application/0 函数以返回以下内容

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

我们在应用程序中添加了一个新的 :env 键。它返回应用程序的默认环境,该环境有一个键为 :routing_table,值为一个空列表的条目。应用程序环境随附一个空表是有意义的,因为特定的路由表取决于测试/部署结构。

为了在我们的代码中使用应用程序环境,我们需要用下面的定义替换 KV.Router.table/0

@doc """
The routing table.
"""
def table do
  Application.fetch_env!(:kv, :routing_table)
end

我们使用 Application.fetch_env!/2 读取 :kv 环境中 :routing_table 的条目。您可以在 Application 模块中找到有关操作应用程序环境的其他信息和其他函数。

由于我们的路由表现在为空,因此我们的分布式测试应该会失败。重启应用程序并重新运行测试以查看失败。

$ iex --sname bar -S mix
$ elixir --sname foo -S mix test --only distributed

我们需要一种方法来配置应用程序环境。这就是我们使用配置文件的时候。

配置

配置文件为我们提供了一种机制来配置任何应用程序的环境。Elixir 提供了两个配置入口点

  • config/config.exs — 此文件在构建时读取,在我们编译应用程序之前以及加载依赖项之前。这意味着我们无法访问应用程序或依赖项中的代码。但是,这意味着我们可以控制它们的编译方式

  • config/runtime.exs — 此文件在我们应用程序和依赖项编译后读取,因此它可以配置我们的应用程序在运行时的工作方式。如果您想读取系统环境变量(通过 System.get_env/1)或任何类型的外部配置,这是合适的位置

例如,我们可以将 IEx 默认提示配置为另一个值。让我们用以下内容创建 config/runtime.exs 文件

import Config
config :iex, default_prompt: ">>>"

使用 iex -S mix 启动 IEx,您会看到 IEx 提示已更改。

这意味着我们也可以直接在 config/runtime.exs 文件中配置我们的 :routing_table。但是,我们应该使用哪个配置值呢?

目前,我们有两个使用 @tag :distributed 标记的测试。KVServerTest 中的“服务器交互”测试,以及 KV.RouterTest 中的“跨节点路由请求”。这两个测试都失败了,因为它们需要一个路由表,而该路由表目前为空。

为了简单起见,我们将定义一个始终指向当前节点的路由表。这是我们将在开发和大多数测试中使用的表。回到 config/runtime.exs,添加以下行

config :kv, :routing_table, [{?a..?z, node()}]

有了这样一个简单的表,我们现在可以从 test/kv_server_test.exs 中的测试中删除 @tag :distributed。如果您运行完整的套件,该测试现在应该通过。

但是,对于 KV.RouterTest 中的测试,我们实际上需要路由表中的两个节点。为此,我们将编写一个在该文件中的所有测试之前运行的设置块。设置块将更改应用程序环境并在完成后恢复它,如下所示

defmodule KV.RouterTest do
  use ExUnit.Case

  setup_all do
    current = Application.get_env(:kv, :routing_table)

    Application.put_env(:kv, :routing_table, [
      {?a..?m, :"foo@computer-name"},
      {?n..?z, :"bar@computer-name"}
    ])

    on_exit fn -> Application.put_env(:kv, :routing_table, current) end
  end

  @tag :distributed
  test "route requests across nodes" do

请注意,我们从 use ExUnit.Case 中删除了 async: true。由于应用程序环境是全局存储,因此修改它的测试不能并发运行。完成所有更改后,所有测试都应通过,包括分布式测试。

发布

现在我们的应用程序可以分布式运行,您可能想知道我们如何将应用程序打包以在生产环境中运行。毕竟,到目前为止,我们所有的代码都依赖于您当前系统中安装的 Erlang 和 Elixir 版本。为了实现这个目标,Elixir 提供了发布。

发布是一个独立的目录,它包含您的应用程序代码、所有依赖项以及整个 Erlang 虚拟机 (VM) 和运行时。发布组装好后,只要目标运行在与组装发布的机器相同的操作系统 (OS) 发行版和版本上,就可以将其打包并部署到目标。

在常规项目中,我们可以通过简单地运行 mix release 来组装发布。但是,我们有一个伞形项目,在这种情况下,Elixir 需要我们提供一些额外的输入。让我们看看需要什么

$ MIX_ENV=prod mix release
** (Mix) Umbrella projects require releases to be explicitly defined with a non-empty applications key that chooses which umbrella children should be part of the releases:

releases: [
  foo: [
    applications: [child_app_foo: :permanent]
  ],
  bar: [
    applications: [child_app_bar: :permanent]
  ]
]

Alternatively you can perform the release from the children applications

这是因为伞形项目在部署软件时为我们提供了许多选项。我们可以

  • 将伞形项目中的所有应用程序部署到一个节点上,该节点将作为 TCP 服务器和键值存储

  • 部署 :kv_server 应用程序仅作为 TCP 服务器运行,只要路由表仅指向其他节点即可

  • 当我们希望一个节点仅作为存储(没有 TCP 访问权限)时,仅部署 :kv 应用程序

作为起点,让我们定义一个包含 :kv_server:kv 应用程序的发布。我们还将为它添加一个版本。打开伞形根目录中的 mix.exs 并添加到 def project 内部

releases: [
  foo: [
    version: "0.0.1",
    applications: [kv_server: :permanent, kv: :permanent]
  ]
]

这定义了一个名为 foo 的发布,它包含 kv_serverkv 应用程序。它们的模式设置为 :permanent,这意味着如果这些应用程序崩溃,整个节点将终止。这是合理的,因为这些应用程序对于我们的系统至关重要。

在我们组装发布之前,让我们也为生产定义我们的路由表。鉴于我们预计会有两个节点,我们需要更新 config/runtime.exs 以使其看起来像这样

import Config

config :kv, :routing_table, [{?a..?z, node()}]

if config_env() == :prod do
  config :kv, :routing_table, [
    {?a..?m, :"foo@computer-name"},
    {?n..?z, :"bar@computer-name"}
  ]
end

我们已将表和节点名称硬编码,这对于我们的示例来说已经足够了,但在实际生产环境中,您可能将它移动到外部配置系统。我们还将其包装在 config_env() == :prod 检查中,因此此配置不适用于其他环境。

配置就位后,让我们再尝试组装发布

$ MIX_ENV=prod mix release foo
* assembling foo-0.0.1 on MIX_ENV=prod
* skipping runtime configuration (config/runtime.exs not found)

Release created at _build/prod/rel/foo!

    # To start your system
    _build/prod/rel/foo/bin/foo start

Once the release is running:

    # To connect to it remotely
    _build/prod/rel/foo/bin/foo remote

    # To stop it gracefully (you may also send SIGINT/SIGTERM)
    _build/prod/rel/foo/bin/foo stop

To list all commands:

    _build/prod/rel/foo/bin/foo

太好了!发布已组装到 _build/prod/rel/foo 中。在发布内部,将有一个 bin/foo 文件,它是您系统的入口点。它支持多个命令,例如

  • bin/foo startbin/foo start_iexbin/foo restartbin/foo stop — 用于发布的常规管理

  • bin/foo rpc COMMANDbin/foo remote — 用于在正在运行的系统上运行命令或连接到正在运行的系统

  • bin/foo eval COMMAND — 启动一个运行单个命令然后关闭的新系统

  • bin/foo daemonbin/foo daemon_iex — 在类 Unix 系统上将系统作为守护进程启动

  • bin/foo install — 将系统安装为 Windows 机器上的服务

如果您运行 bin/foo start,它将使用一个与发布名称相同的简短名称 (--sname) 启动系统,在本例中为 foo。下一步是启动一个名为 bar 的系统,这样我们就可以像上一章中那样将 foobar 连接在一起。但在我们实现这一点之前,让我们谈谈发布的优势。

为什么要发布?

发布允许开发人员将所有代码和运行时预编译并打包到一个单元中。发布的优势包括

  • 代码预加载。VM 有两种加载代码的机制:交互式和嵌入式。默认情况下,它以交互模式运行,该模式在第一次使用模块时动态加载模块。您的应用程序第一次调用 Enum.map/2 时,VM 将找到 Enum 模块并加载它。有一个缺点。当您在生产环境中启动一个新服务器时,它可能需要加载许多其他模块,导致第一次请求的响应时间出现异常峰值。发布在嵌入模式下运行,该模式会预先加载所有可用模块,从而确保您的系统在启动后准备处理请求。

  • 配置和自定义。发布让开发人员可以对系统配置和用于启动系统的 VM 标志进行细粒度控制。

  • 自包含。发布不需要在您的生产工件中包含源代码。所有代码都已预编译并打包。发布甚至不需要服务器上的 Erlang 或 Elixir,因为它们默认包含 Erlang VM 及其运行时。此外,Erlang 和 Elixir 标准库都已剥离,只保留了您实际使用的部分。

  • 多个发布。您可以组装具有不同应用程序配置甚至不同应用程序的发布。

我们已经编写了关于发布的详细文档,因此请 查看官方文档以了解更多信息。现在,我们将继续探索上面概述的一些功能。

组装多个发布

到目前为止,我们已经组装了一个名为 foo 的发布,但是我们的路由表包含 foobar 的信息。让我们启动 foo

$ _build/prod/rel/foo/bin/foo start
16:58:58.508 [info]  Accepting connections on port 4040

让我们连接到它并在另一个终端中发出请求

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE bitsandpieces
OK
PUT bitsandpieces sword 1
OK
GET bitsandpieces sword
1
OK
GET shopping foo
Connection closed by foreign host.

当我们在名为“bitsandpieces”的存储桶上操作时,我们的应用程序已经可以工作。但由于“shopping”存储桶将存储在 bar 上,因此请求失败,因为 bar 不可用。如果您回到运行 foo 的终端,您将看到

17:16:19.555 [error] Task #PID<0.622.0> started from #PID<0.620.0> terminating
** (stop) exited in: GenServer.call({KV.RouterTasks, :"bar@computer-name"}, {:start_task, [{:"foo@josemac-2", #PID<0.622.0>, #PID<0.622.0>}, [#PID<0.622.0>, #PID<0.620.0>, #PID<0.618.0>], :monitor, {KV.Router, :route, ["shopping", KV.Registry, :lookup, [KV.Registry, "shopping"]]}], :temporary, nil}, :infinity)
    ** (EXIT) no connection to bar@computer-name
    (elixir) lib/gen_server.ex:1010: GenServer.call/3
    (elixir) lib/task/supervisor.ex:454: Task.Supervisor.async/6
    (kv) lib/kv/router.ex:21: KV.Router.route/4
    (kv_server) lib/kv_server/command.ex:74: KVServer.Command.lookup/2
    (kv_server) lib/kv_server.ex:29: KVServer.serve/1
    (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
    (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Function: #Function<0.128611034/0 in KVServer.loop_acceptor/1>
    Args: []

现在让我们为 :bar 定义一个发布。第一步可能是像 mix.exs 中的 foo 一样定义一个发布。此外,我们将把两个发布上的 cookie 选项设置为 weknoweachother,以便它们允许彼此之间的连接。有关此主题的更多信息,请参阅 分布式 Erlang 文档

releases: [
  foo: [
    version: "0.0.1",
    applications: [kv_server: :permanent, kv: :permanent],
    cookie: "weknoweachother"
  ],
  bar: [
    version: "0.0.1",
    applications: [kv_server: :permanent, kv: :permanent],
    cookie: "weknoweachother"
  ]
]

现在让我们组装这两个发布

$ MIX_ENV=prod mix release foo
$ MIX_ENV=prod mix release bar

如果 foo 还在运行,请停止它并重新启动,以便加载 cookie

$ _build/prod/rel/foo/bin/foo start

在另一个终端启动 bar

$ _build/prod/rel/bar/bin/bar start

你应该会看到以下类似的错误出现 5 次,然后应用程序最终关闭。

    17:21:57.567 [error] Task #PID<0.620.0> started from KVServer.Supervisor terminating
    ** (MatchError) no match of right hand side value: {:error, :eaddrinuse}
        (kv_server) lib/kv_server.ex:12: KVServer.accept/1
        (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
        (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
    Function: #Function<0.98032413/0 in KVServer.Application.start/2>
        Args: []

这是因为发布的 foo 已经在端口 4040 上监听,而 bar 试图在同一端口上监听!一个可选方案是将 :port 配置移动到应用程序环境中,就像我们对路由表所做的那样,并为每个节点设置不同的端口。

但让我们尝试其他方法。让我们使 bar 发布版本只包含 :kv 应用程序。这样它就可以作为存储使用,但它将没有前端。将 :bar 信息更改为以下内容。

releases: [
  foo: [
    version: "0.0.1",
    applications: [kv_server: :permanent, kv: :permanent],
    cookie: "weknoweachother"
  ],
  bar: [
    version: "0.0.1",
    applications: [kv: :permanent],
    cookie: "weknoweachother"
  ]
]

现在让我们再次组装 bar

$ MIX_ENV=prod mix release bar

最后成功启动它。

$ _build/prod/rel/bar/bin/bar start

如果你再次连接到 localhost 并执行另一个请求,现在一切应该都能正常工作,只要路由表包含正确的节点名称。太棒了!

通过发布,我们可以将我们的项目“切成不同的片”,并将它们准备好在生产环境中运行,所有这些都打包在一个目录中。

配置发布

发布还提供了内置钩子,用于配置生产系统几乎所有需求。

  • config/config.exs — 提供构建时应用程序配置,在应用程序编译之前执行。此文件通常会根据环境导入配置文件,例如 config/dev.exsconfig/prod.exs

  • config/runtime.exs — 提供运行时应用程序配置。它在每次发布启动时执行,并且可以通过配置提供者进一步扩展。

  • rel/env.sh.eexrel/env.bat.eex — 模板文件,复制到每个发布版本中,并在每个命令上执行以设置环境变量,包括特定于 VM 的变量和通用环境变量。

  • rel/vm.args.eex — 模板文件,复制到每个发布版本中,并提供 Erlang 虚拟机和其他运行时标志的静态配置。

正如我们所见,config/config.exsconfig/runtime.exs 在发布和常规 Mix 命令期间加载。另一方面,rel/env.sh.eexrel/vm.args.eex 是特定于发布的。让我们看一下。

操作系统环境配置

每个发布版本都包含一个环境文件,在类 Unix 系统上名为 env.sh,在 Windows 机器上名为 env.bat,它在 Elixir 系统启动之前执行。在此文件中,您可以执行任何操作系统级别的代码,例如调用其他应用程序、设置环境变量等等。其中一些环境变量甚至可以配置发布本身的运行方式。

例如,发布使用短名称 (--sname) 运行。但是,如果你想在生产环境中实际运行分布式键值存储,则需要多个节点并使用 --name 选项启动发布。我们可以通过在 env.shenv.bat 文件中设置 RELEASE_DISTRIBUTION 环境变量来实现。Mix 已经有一个用于这些文件的模板,我们可以自定义它,所以让我们让 Mix 将它们复制到我们的应用程序中。

$ mix release.init
* creating rel/vm.args.eex
* creating rel/remote.vm.args.eex
* creating rel/env.sh.eex
* creating rel/env.bat.eex

如果你打开 rel/env.sh.eex,你会看到

#!/bin/sh

# # Sets and enables heart (recommended only in daemon mode)
# case $RELEASE_COMMAND in
#   daemon*)
#     HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND"
#     export HEART_COMMAND
#     export ELIXIR_ERL_OPTIONS="-heart"
#     ;;
#   *)
#     ;;
# esac

# # Set the release to load code on demand (interactive) instead of preloading (embedded).
# export RELEASE_MODE=interactive

# # Set the release to work across nodes.
# # RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
# export RELEASE_DISTRIBUTION=name
# export RELEASE_NODE=<%= @release.name %>

在节点之间工作的必要步骤已作为示例注释掉。您可以通过取消注释最后两行(删除开头的 #)来启用完全分布式。

如果你使用的是 Windows,则需要打开 rel/env.bat.eex,你将在其中找到以下内容

@echo off
rem Set the release to load code on demand (interactive) instead of preloading (embedded).
rem set RELEASE_MODE=interactive

rem Set the release to work across nodes.
rem RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
rem set RELEASE_DISTRIBUTION=name
rem set RELEASE_NODE=<%= @release.name %>

同样,通过删除开头的 rem 来取消注释最后两行,以启用完全分布式。就是这样!

VM 参数

rel/vm.args.eex 允许您指定控制 Erlang 虚拟机及其运行时操作的低级标志。您可以像在命令行中指定参数一样指定条目,也支持代码注释。以下是默认生成的

## Customize flags given to the VM: https://erlang.ac.cn/doc/man/erl.html
## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here

## Increase number of concurrent ports/sockets
##+Q 65536

## Tweak GC to run more often
##-env ERL_FULLSWEEP_AFTER 10

您可以在 Erlang 文档中查看 VM 参数和标志的完整列表

总结

在整个指南中,我们构建了一个非常简单的分布式键值存储,作为探索通用服务器、监督器、任务、代理、应用程序等许多构造的机会。不仅如此,我们还为整个应用程序编写了测试,熟悉了 ExUnit,并学习了如何使用 Mix 构建工具来完成各种任务。

如果你正在寻找一个在生产环境中使用的分布式键值存储,你应该查看 Riak,它也在 Erlang 虚拟机上运行。在 Riak 中,桶被复制,以避免数据丢失,并且它们使用 一致哈希 将桶映射到节点,而不是路由器。一致哈希算法有助于减少在将新的存储节点添加到你的实时系统时需要迁移的数据量。

当然,Elixir 可以用于远远超过分布式键值存储的领域。嵌入式系统、数据处理和数据摄取、Web 应用程序、音频/视频流系统等等,都是 Elixir 擅长的许多不同领域。我们希望本指南能让你准备好探索这些领域中的任何一个,或任何你可能希望将 Elixir 带入的未来领域。

祝你编码愉快!