代理

本章是Mix和OTP指南的一部分,它取决于本指南的前几章。有关更多信息,请阅读简介指南或查看边栏中的章节索引。

在本章中,我们将创建一个名为的模块KV.Bucket。该模块将负责存储我们的键值条目,使其能够被其他流程读取和修改。

如果您已经跳过入门指南或很久以前阅读,请务必重新阅读“流程”一章。我们将以此为起点。

状态的麻烦

Elixir是一种不可变的语言,默认情况下不会共享任何内容。如果我们想要提供可从多个地方读取和修改的桶,我们在Elixir中有两个主要选项:

  • 流程

我们介绍了入门指南中的流程。ETS是一个将在后面的章节中探讨的新主题。当涉及到流程时,我们很少手动推出自己的流程,而是使用Elixir和OTP提供的抽象:

  • 代理 - 简单的状态包装。
  • GenServer - 封装状态,提供同步和异步调用,支持代码重新加载等的“通用服务器”(进程)。
  • 任务 - 异步计算单元,允许生成一个进程并在稍后时间检索其结果。

我们将在本指南中探索大部分这些抽象概念。请记住,他们都在使用由VM,如提供的基本功能,流程上实现sendreceivespawnlink

代理

代理是简单的状态包装。如果你想要从一个过程中保持状态,那么代理是非常合适的。让我们iex在项目内部开始一个会话:

$ iex -S mix

和代理玩一下:

iex> {:ok, agent} = Agent.start_link fn -> [] end
{:ok, #PID<0.57.0>}
iex> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok
iex> Agent.get(agent, fn list -> list end)
["eggs"]
iex> Agent.stop(agent)
:ok

我们启动了一个初始状态为空列表的代理。我们更新了代理人的状态,将我们的新项目添加到列表的头部。第二个参数Agent.update/3是一个函数,它将代理的当前状态作为输入并返回其所需的新状态。最后,我们检索了整个列表。第二个参数Agent.get/3是一个函数,它将状态作为输入并返回Agent.get/3本身将返回的值。一旦我们完成了代理,我们可以调用Agent.stop/3终止代理进程。

让我们KV.Bucket来实现我们的使用代理。但在开始实施之前,我们先写一些测试。在下面创建一个文件test/kv/bucket_test.exs(记住.exs扩展名):

defmodule KV.BucketTest do
  use ExUnit.Case, async: true

  test "stores values by key" do
    {:ok, bucket} = start_supervised KV.Bucket
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end
end

我们的第一次测试开始一个新的KV.Bucket使用start_supervised功能,并执行一些get/2put/3操作就可以了,断言结果。我们不需要明确地停止代理,因为我们使用了该代理,start_supervised/2并且在测试完成时自动终止测试中的进程。

还要注意async: true传递给的选项ExUnit.Case。该选项:async通过在我们的机器中使用多个核心,使测试用例与其他测试用例并行运行。这对加速我们的测试套件非常有用。但是,:async必须如果测试情况下不依赖于或更改任何全局值进行设定。例如,如果测试需要写入文件系统或访问数据库,请保持同步(省略:async选项)以避免测试之间的竞争状况。

异步与否,我们的新测试显然会失败,因为在被测试的模块中没有实现任何功能:

** (ArgumentError) The module KV.Bucket was given as a child to a supervisor but it does not implement child_spec/1

由于模块尚未定义,因此child_spec/1尚不存在。

为了修复失败的测试,让我们lib/kv/bucket.ex用下面的内容创建一个文件。KV.Bucket在下面的实现中窥视之前,请随时尝试使用代理自己实现模块。

defmodule KV.Bucket do
  use Agent

  @doc """
  Starts a new bucket.
  """
  def start_link(_opts) do
    Agent.start_link(fn -> %{} end)
  end

  @doc """
  Gets a value from the `bucket` by `key`.
  """
  def get(bucket, key) do
    Agent.get(bucket, &Map.get(&1, key))
  end

  @doc """
  Puts the `value` for the given `key` in the `bucket`.
  """
  def put(bucket, key, value) do
    Agent.update(bucket, &Map.put(&1, key, value))
  end
end

我们实施的第一步是打电话use Agent。通过这样做,它将定义一个child_spec/1包含开始我们的过程的确切步骤的函数。

然后我们定义一个start_link/1函数,它将有效地启动代理。该start_link/1函数总是收到一个选项列表,但我们现在不打算使用它。然后我们继续调用Agent.start_link/1,它接收一个返回代理初始状态的匿名函数。

我们在代理内部保存地图来存储我们的密钥和值。&通过入门指南中介绍的Agent API和捕获操作符来完成在地图上获取和放置值。

现在KV.Bucket模块已经被定义,我们的测试应该通过!你可以通过运行:mix test

使用ExUnit回调测试设置

在继续并添加更多功能之前KV.Bucket,让我们先谈谈ExUnit回调。正如您所预料的那样,所有KV.Bucket测试都需要一个存储桶代理才能启动并运行。幸运的是,ExUnit支持回调,允许我们跳过这些重复的任务。

让我们重写测试用例以使用回调:

defmodule KV.BucketTest do
  use ExUnit.Case, async: true

  setup do
    {:ok, bucket} = start_supervised(KV.Bucket)
    %{bucket: bucket}
  end

  test "stores values by key", %{bucket: bucket} do
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end
end

我们首先在setup/1宏的帮助下定义了一个设置回调函数。该setup/1回调在每个测试之前运行,与测试本身在相同的过程中运行。

请注意,我们需要一种机制将bucketpid从回调传递到测试。我们通过使用测试环境来这样做。当我们%{bucket: bucket}从回调中返回时,ExUnit会将此映射合并到测试上下文中。由于测试环境本身就是一张地图,我们可以对它进行模式匹配,从而在测试中提供对存储桶的访问:

test "stores values by key", %{bucket: bucket} do
  # `bucket` is now the bucket from the setup block
end

您可以在ExUnit.Case模块文档中阅读有关ExUnit案例的更多信息,以及关于ExUnit.Callbacks文档中回调的更多信息。

其他代理行为

除了获取值和更新代理状态外,代理还允许我们通过一个函数调用来获取值并更新代理状态Agent.get_and_update/2。让我们实现一个KV.Bucket.delete/2从存储桶中删除一个密钥的函数,返回它的当前值:

@doc """
Deletes `key` from `bucket`.

Returns the current value of `key`, if `key` exists.
"""
def delete(bucket, key) do
  Agent.get_and_update(bucket, &Map.pop(&1, key))
end

现在轮到你为上面的功能编写测试了!此外,请务必浏览Agent模块的文档以了解更多信息。

代理中的客户端/服务器

在继续下一章之前,我们来讨论代理中的客户端/服务器二分法。我们来扩展delete/2我们刚刚实现的功能:

def delete(bucket, key) do
  Agent.get_and_update(bucket, fn dict ->
    Map.pop(dict, key)
  end)
end

我们传递给代理的函数中的所有内容都发生在代理进程中。在这种情况下,由于代理进程是接收和响应我们消息的进程,我们说代理进程就是服务器。功能以外的所有内容都发生在客户端。

这个区别很重要。如果要执行昂贵的操作,则必须考虑在客户端上还是在服务器上执行这些操作会更好。例如:

def delete(bucket, key) do
  Process.sleep(1000) # puts client to sleep
  Agent.get_and_update(bucket, fn dict ->
    Process.sleep(1000) # puts server to sleep
    Map.pop(dict, key)
  end)
end

当在服务器上执行一个长操作时,对该特定服务器的所有其他请求将等待操作完成,这可能会导致某些客户端超时。

在下一章中,我们将探讨GenServers,客户端和服务器之间的隔离更加明显。