分布式任务和配置
本章是Mix和OTP指南的一部分,它取决于本指南的前几章。有关更多信息,请阅读简介指南或查看边栏中的章节索引。
在最后一章中,我们将回到:kv
应用程序并添加一个路由层,以便我们根据存储区名称在节点之间分配请求。
路由层将收到以下格式的路由表:
[{?a..?m, :"[email protected]"}, {?n..?z, :"[email protected]"}]
路由器将根据表检查存储桶名称的第一个字节,并根据此信息分配到适当的节点。例如,以字母“a”(?a
代表字母“a” 的Unicode代码点)开头的存储桶将被分派给节点[email protected]
。
如果匹配条目指向评估请求的节点,那么我们已经完成了路由,并且此节点将执行请求的操作。如果匹配条目指向另一个节点,我们会将请求传递给此节点,该节点将查看其自己的路由表(可能与第一个节点中的路由表不同)并采取相应措施。如果没有条目匹配,则会引发错误。
您可能想知道为什么我们不告诉我们在路由表中找到的节点直接执行请求的操作,而是将路由请求传递到该节点进行处理。虽然像上面那样简单的路由表可以在所有节点之间合理共享,但以这种方式传递路由请求使得在应用程序增长时将路由表分解为更小的部分变得更简单。也许在某些时候,[email protected]
只负责路由桶请求,并且它处理的桶将被分派到不同的节点。通过这种方式,[email protected]
不需要知道有关这种变化的任何信息。
注意:在本章中,我们将在同一台机器上使用两个节点。您可以在同一网络上自由使用两台(或更多台)不同的机器,但您需要做一些准备工作。首先,您需要确保所有机器都
~/.erlang.cookie
具有完全相同的文件。其次,您需要保证epmd在未被阻止的端口上运行(您可以运行epmd -d
调试信息)。第三,如果您想了解更多关于一般分发的信息,我们推荐您学习一些Erlang的这个伟大的Distribunomicon章节。
我们的第一个分发代码
Elixir提供设施连接节点并在它们之间交换信息。事实上,在分布式环境中工作时,我们使用相同的流程概念,消息传递和接收消息,因为Elixir流程是位置透明的。这意味着在发送消息时,无论收件人进程是在同一个节点上还是在另一个节点上,VM都将能够在两种情况下传递消息。
为了运行分布式代码,我们需要使用名称来启动虚拟机。名称可以很短(在同一网络中)或较长(需要完整的计算机地址)。让我们开始一个新的IEx会话:
$ iex --sname foo
您现在可以看到提示稍有不同,并显示节点名称后跟计算机名称:
Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help) iex([email protected])1>
我的电脑命名为jv,所以我在上面的例子中看到[email protected],但是你会得到不同的结果。 我们将在以下示例中使用[email protected],您应该在尝试代码时相应地更新它们。
我们来定义一个Hello
在这个shell中命名的模块:
iex> defmodule Hello do ...> def world, do: IO.puts "hello world" ...> end
如果您在安装了Erlang和Elixir的同一网络中安装了另一台计算机,则可以启动另一台计算机。如果你不这样做,你可以在另一个终端上启动另一个IEx会话。无论哪种情况,都要给它简称bar
:
$ iex --sname bar
请注意,在这个新的IEx会话中,我们无法访问Hello.world/0
:
iex> Hello.world ** (UndefinedFunctionError) undefined function: Hello.world/0 Hello.world()
但是,我们可以在[电子邮件保护]的[电子邮件保护]上产生新的过程! 让我们试一试(其中@ computer-name是您在本地看到的那个):
iex> Node.spawn_link :"[email protected]", fn -> Hello.world end #PID<9014.59.0> hello world
Elixir在另一个节点上产生了一个进程并返回了它的PID。 然后代码在Hello.world/0函数存在的另一个节点上执行并调用该函数。 请注意,“hello world”的结果打印在当前节点栏上,而不是foo上。 换句话说,要打印的消息从foo发回给bar。 发生这种情况是因为另一个节点(foo)上产生的进程仍然具有当前节点(bar)的组长。 我们在IO章节中简要地谈到了组长。
像往常一样,我们可以通过Node.spawn_link / 2返回的pid发送和接收消息。 让我们尝试一个快速乒乓的例子:
iex> pid = Node.spawn_link :"[email protected]", fn -> ...> receive do ...> {:ping, client} -> send client, :pong ...> end ...> end #PID<9014.59.0> iex> send pid, {:ping, self()} {:ping, #PID<0.73.0>} iex> flush() :pong :ok
从我们的快速探索中,我们可以得出结论:每次我们需要执行分布式计算时,我们都应该使用Node.spawn_link / 2在远程节点上产生进程。 但是,我们通过本指南了解到,如果可能的话应该避免监督树之外的产卵过程,因此我们需要寻找其他选择。
Node.spawn_link / 2有三种更好的选择,我们可以在我们的实现中使用它们:
- 我们可以使用Erlang的:rpc模块来执行远程节点上的功能。 在上面的[email protected] shell中,你可以调用:rpc.call(:“[email protected]”,Hello,:world,[]),它会打印出“hello world”
- 我们可以在另一个节点上运行服务器,并通过GenServer API向该节点发送请求。 例如,可以使用GenServer.call({name,node},arg)或传递远程进程PID作为第一个参数来调用远程节点上的服务器
我们可以使用我们在前一章中了解到的任务,因为它们可以在本地和远程节点上生成。以上选项具有不同的属性。 两者:rpc和使用GenServer都会在单个服务器上序列化您的请求,而任务在远程节点上异步有效地运行,唯一的序列化点是supervisor完成的产生。对于我们的路由层,我们将使用 任务,但随时可以探索其他选择。同步/等待到目前为止,我们已经探索了独立开始和运行的任务,不考虑它们的返回值。 但是,有时候运行一个任务来计算一个值并在以后读取结果是很有用的。 为此,任务还提供了异步/等待模式:
task = Task.async(fn -> compute_something_expensive end) res = compute_something_else() res + Task.await(task)async / await提供了一个非常简单的机制来同时计算值。 不仅如此,async / await也可以用于前面章节中使用的Task.Supervisor。 我们只需调用Task.Supervisor.async / 2而不是Task.Supervisor.start_child / 2并使用Task.await / 2稍后读取结果。分布式任务分布式任务与监督任务完全相同。 唯一的区别是我们在主管上产生任务时传递节点名称。 从:kv应用程序打开lib / kv / supervisor.ex。 让我们添加一个任务管理器作为树的最后一个孩子:{Task.Supervisor,name:KV.RouterTasks},现在,我们再次启动两个命名节点,但是在:kv应用程序中:
$ iex --sname foo -S mix $ iex --sname bar -S mix
从里面[email protected]
,我们现在可以通过主管直接在另一个节点上生成一个任务:
iex> task = Task.Supervisor.async {KV.RouterTasks, :"[email protected]"}, fn -> ...> {:ok, node()} ...> end %Task{owner: #PID<0.122.0>, pid: #PID<12467.88.0>, ref: #Reference<0.0.0.400>} iex> Task.await(task) {:ok, :"[email protected]"}
我们的第一个分布式任务检索任务正在运行的节点的名称。注意,我们给出了一个匿名函数Task.Supervisor.async/2
但是,在分布式情况下,最好明确地给出模块、函数和参数:
iex> task = Task.Supervisor.async {KV.RouterTasks, :"[email protected]"}, Kernel, :node, [] %Task{owner: #PID<0.122.0>, pid: #PID<12467.89.0>, ref: #Reference<0.0.0.404>} iex> Task.await(task) :"[email protected]"
不同之处在于,匿名函数要求目标节点具有与调用方完全相同的代码版本。使用模块、函数和参数更健壮,因为您只需要在给定模块中找到匹配性的函数。掌握了这些知识之后,让我们最终编写路由代码。路由层创建一个文件lib/kv/router.ex
内容如下:
defmodule KV.Router do @doc """ Dispatch the given `mod`, `fun`, `args` request to the appropriate node based on the `bucket`. """ def route(bucket, mod, fun, args) do # Get the first byte of the binary first = :binary.first(bucket) # Try to find an entry in the table() or raise entry = Enum.find(table(), fn {enum, _node} -> first in enum end) || no_entry_error(bucket) # If the entry node is the current node if elem(entry, 1) == node() do apply(mod, fun, args) else {KV.RouterTasks, elem(entry, 1)} |> Task.Supervisor.async(KV.Router, :route, [bucket, mod, fun, args]) |> Task.await() end end defp no_entry_error(bucket) do raise "could not find entry for #{inspect bucket} in table #{inspect table()}" end @doc """ The routing table. """ def table do # Replace computer-name with your local machine name. [{?a..?m, :"[email protected]"}, {?n..?z, :"[email protected]"}] end end
让我们编写一个测试来验证我们的路由器是否工作。创建一个名为test/kv/router_test.exs
包含:defmodule KV.RouterTest do
use ExUnit.Case, async: true
test "route requests across nodes" do
assert KV.Router.route("hello", Kernel, :node, []) ==
:"[email protected]"
assert KV.Router.route("world", Kernel, :node, []) ==
:"[email protected]"
end
test "raises on unknown entries" do
assert_raise RuntimeError, ~r/could not find entry/, fn ->
KV.Router.route(<<0>>, Kernel, :node, [])
end
end
end
第一个测试基于桶名“hello”和“world”调用Kernel.node / 0,它返回当前节点的名称。根据我们的路由表,到目前为止,我们应该分别获得[电子邮件保护]和[电子邮件保护]作为响应。第二个测试检查代码为未知条目引发。为了运行第一个测试,我们需要两个节点在运行。进入apps / kv,让我们重新启动将被测试使用的名为bar的节点$ iex --sname bar -S mixAnd现在运行测试:$ elixir --sname foo -S mix testThe test should pass.Test过滤器和标签尽管我们的测试通过,但我们的测试结构变得越来越复杂。特别是,仅使用混合测试运行测试会导致套件中出现故障,因为我们的测试需要连接到另一个节点。幸运的是,ExUnit附带了一个标记测试的工具,允许我们运行特定回调,甚至根据这些测试完成过滤测试标签。我们已经在前一章中使用过:capture_log标签,它的语义由ExUnit自己指定。这次我们添加一个:distributed标签来测试/ kv / router_test.exs:@tag:distributed
测试“节点之间的路由请求”doWriting @tag:分布式相当于编写@tag distributed:true.With测试正确标记后,我们现在可以检查节点是否在网络上活动,如果没有,我们可以排除所有分布式试验。在:kv应用程序中打开test / test_helper.exs并添加以下内容:exclude =
if Node.alive?, do: [], else: [distributed: true]
ExUnit.start(exclude: exclude)现在,使用mix test
*$ mix test
Excluding tags: [distributed: true]
.......
Finished in 0.1 seconds (0.1s on load, 0.01s on tests)
7 tests, 0 failures, 1 skipped
这次所有测试都通过了,ExUnit警告我们,分布式测试被排除在外。如果使用$ elixir --sname foo -S mix测试运行测试,只要[email protected]节点可用,就应该运行一个额外的测试并成功通过。mix test命令还允许我们动态地包含和排除标记。例如,无论test / test_helper.exs中设置的值如何,我们都可以运行$ mix test --include distributed来运行分布式测试。我们也可以通过--exclude从命令行中排除特定的标签。最后, - 只能用于只运行具有特定标记的测试:$ elixir --sname foo -S混合测试 - 仅限分布式您可以在ExUnit.Case模块文档中阅读有关过滤器,标记和默认标记的更多信息。应用程序环境和配置到目前为止,我们已经将路由表硬编码到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
阅读条目:routing_table
在:kv
环境。您可以在应用模块...因为我们的路由表现在是空的,所以我们的分布式测试应该失败。重新启动应用程序并重新运行测试以查看失败:
$ iex --sname bar -S mix $ elixir --sname foo -S mix test --only distributed
应用程序环境的有趣之处在于,它不仅可以为当前应用程序配置,还可以为所有应用程序配置。这样的配置由config/config.exs
档案。例如,我们可以将IEX默认提示配置为另一个值。只要打开apps/kv/config/config.exs
并在末尾加上以下内容:config :iex, default_prompt: ">>>"启动IEXiex -S mix
您可以看到IEX提示符已经更改。这意味着我们也可以配置:routing_table
直接在apps/kv/config/config.exs
档案:
# Replace computer-name with your local machine nodes. config :kv, :routing_table, [{?a..?m, :"[email protected]"}, {?n..?z, :"[email protected]"}]
重新启动节点并再次运行分布式测试。 现在他们都应该通过了。从Elixir v1.2开始,所有的伞应用程序共享它们的配置,这要归功于加载所有子项配置的伞形根中的config / config.exs行:import_config“../apps/*/ config / config.exs“mix run命令还接受--config标志,该标志允许按需提供配置文件。 这可以用于启动不同的节点,每个节点都有其自己的特定配置(例如,不同的路由表)。总体而言,内置的配置应用程序的能力以及我们将软件构建为伞式应用程序的事实为我们提供了丰富的内容 部署软件时的选项。 我们可以:
- 将伞形应用程序部署到可同时用作TCP服务器和键值存储的节点
- 只要路由表只指向其他节点,就部署:kv_server应用程序只能用作TCP服务器
- 当我们希望节点只能作为存储器工作时,只部署:kv应用程序(无TCP访问)
随着未来添加更多应用程序,我们可以继续以相同级别的粒度控制我们的部署,从而挑选出哪些应用程序正在生产。
您还可以考虑使用Distillery这样的工具来构建多个版本,该工具将打包选择的应用程序和配置,包括当前的Erlang和Elixir安装,因此即使运行时没有预先安装在目标系统上,我们也可以部署应用程序。
最后,我们在本章中学习了一些新的东西,它们也可以应用于:kv_server应用程序。 我们将作为练习离开下一步:
- 更改:kv_server应用程序以从其应用程序环境中读取端口,而不是使用4040的硬编码值
- 更改并配置:kv_server应用程序以使用路由功能,而不是直接调度到本地KV.Registry。 对于:kv_server测试,您可以使路由表指向当前节点本身
总结
在本章中,我们构建了一个简单的路由器,以探索Elixir和Erlang VM的分布式特性,并学习了如何配置其路由表。这是我们的Mix和OTP指南中的最后一章。
在整个指南中,我们已经构建了一个非常简单的分布式键值存储,以此作为探索诸如通用服务器,管理程序,任务,代理,应用程序等许多构造的机会。不仅如此,我们还为整个应用程序编写了测试,熟悉ExUnit,并学习了如何使用Mix构建工具来完成各种各样的任务。
如果你正在寻找一个分布式键值存储在生产环境中使用,你应该看看Riak,它也在Erlang虚拟机中运行。在Riak中,为了避免数据丢失,系统会复制存储桶,而不是路由器,它们使用一致的散列将存储区映射到节点。一致的哈希算法有助于减少将新节点存储桶添加到基础结构时需要迁移的数据量。
快乐编码!