Mix 和 OTP-Agent

  1. 状态的麻烦
  2. Agents
  3. 用ExUnit回调函数设置测试
  4. 其他Agent动作
  5. Agent的Client/Server

本章是Mix和OTP教程的一部分,它依赖这个教程的前面章节。要获得更多信息,请阅读本教程的第一章,或者查看本教程的章节索引。

在本章,我们将创建一个名为 KV.Bucket 的模块。这个模块将以某种方式存储我们的键值对实体,并允许其他进程读取和修改它们。

如果你跳过了入门教程或已经读过了很长时间,你应该重新读一下进程这一章。我们将用它作为起始点。

状态的麻烦

Elixir是一门不可修改的语言,也就是说默认情况下是没有任何东西是共享的。如果我们想要提供状态,在状态里我们创建“桶”,从其他地方可以放置和读取“桶”里的数据,我们在Elixir有两种主要的选择:

我们已经谈论过进程,而ETS是个新事物,我们将在本教程的后面讨论它。当需要使用进程的时候,我们几乎没有自己亲自处理它的,反而是使用Elixir和OTP里可用的进程抽象:

  • Agent - 对状态的简单封装
  • GenServer - “通用服务器”(进程),它封装状态,提供同步和异步调用,支持代码重载等等。
  • GenEvent - “通用事件”管理器,它允许发布事件给多个处理者。
  • Task - 计算的异步单元,它允许创建一个进程并且在稍后获取它的结果。

我们将在本教程里探讨这些抽象中的大多数。我们要记住:它们都是基于进程之上的实现,使用了Erlang虚拟机提供的基本特性,比如:send、receive、spawn和link。

Agents

Agent是状态的简单封装。如果你想要从一个进程的所有所得是保持状态,那么agent非常合适。让我们在项目里启动一个iex会话:

1
$ iex -S mix

并且玩一下agent:

1
2
3
4
5
6
7
8
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。我们修改agent的状态,增加我们新的元素到列表头部。Agent.update/3 的第二个入参是一个函数,这个函数将agent的当前状态作为输入并返回它期望的新状态。最后,我们获取整个列表。Agent.get/3 的第二个入参是一个函数,这个函数把状态当做输入,然后返回 Agent.get/3 将返回的值。一旦我们用完agent,我们可以调用 Agent.stop/3 来终止agent进程。

让我们用agent来实现我们的 KV.Bucket 。不过在开始实现之前,让我们首先写一些测试用例。创建一个文件 test/kv/bucket_test.exs (记住后缀是 .exs),内容如下:

1
2
3
4
5
6
7
8
9
10
11
defmodule KV.BucketTest do
use ExUnit.Case, async: true
test "stores values by key" do
{:ok, bucket} = KV.Bucket.start_link
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end

我们第一个测试用例启动一个新的 KV.Bucket 并且在其上执行一些 get/2 和 put/3 操作,断言结果。我们不需要明确地停止agent因为它被链接到测试进程,一旦测试结束agent自动地结束。这将一直有效除非这个进程被命名。

也请注意:async: true 选项被传给 ExUnit.Case 。这个选项通过使用我们机器的多核使得这个测试用例和其他 :async 测试用例平行地运行。这对加速我们的测试套件非常有用。但是, :async 必需在不依赖或修改任何全局值的情况下才能被设置。例如,如果测试需要写文件系统、注册进程或访问数据库,那么就保持它为同步的(删除 :async 选项)以避免测试之间的竞争条件。

不管异步与否,我们新的测试将明显会失败,因为被测试的模块里任何一个功能都没有实现。

为了修复失败的测试,让我们创建一个文件: lib/kv/bucket.ex ,它的内容如下所示。在看下面内容前,你自己尝试一下用agent实现 KV.Bucket 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
defmodule KV.Bucket do
@doc """
Starts a new bucket.
"""
def start_link 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

我们用一个映射来保存我们的键和值。捕获运算符,& ,在入门教程里有介绍。

现在 KV.Bucket 模块已经被定义,我们的测试应该pass!你可以运行 mix test 来尝试一下。

用ExUnit回调函数设置测试

在继续往 KV.Bucket 添加更多功能之前,我们来讨论一下 ExUnit 的回调函数。正如你所期望的,所有KV.Bucket的测试用例在设置和测试后停止期间将需要一个启动的bucket。幸运的是,ExUnit支持回调函数来允许我们来跳过这样的重复任务。

让我们用回调函数来重新写测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
defmodule KV.BucketTest do
use ExUnit.Case, async: true
setup do
{:ok, bucket} = KV.Bucket.start_link
{:ok, 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 回调。

注意我们需要一种机制从回调传递 bucket 的pid给测试用例。我们使用测试上下文来做这种机制。当我们从回调返回 {:ok, bucket: bucket} ,ExUnit将元组的第二个元素(一个字典)合并到测试上下文。测试上下文是一个映射,我们可以在测试定义匹配它,在代码块里访问这些值:

1
2
3
test "stores values by key", %{bucket: bucket} do
# `bucket` 正是从setup传递过来的bucket
end

你可以在ExUnit.Case模块文档里读到更多关于ExUnit用例的内容,在ExUnit.Callbacks文档里读到更多回调的内容。

其他Agent动作

除了获取一个值和修改agent状态,agent允许我们通过 Agent.get_and_update/2 在一个函数调用里获取一个值并且修改agent状态。让我们实现KV.Bucket.delete/2函数,它从bucket删除一个键并返回它的当前值:

1
2
3
4
5
6
7
8
@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模块的文档学习更多内容。

Agent的Client/Server

在我们转到下一章内容前,让我们讨论一下agent里的客户端/服务端的概念。让我们展开我们刚刚实现的delete/2函数:

1
2
3
4
5
def delete(bucket, key) do
Agent.get_and_update(bucket, fn dict->
Map.pop(dict, key)
end)
end

在函数内我们传递给agent的所有内容都发生在agent进程中。在这个场景里,因为agent进程是接收和相应我们消息的,那么我们说agent进程是服务器。函数外不的所有事情发生在客户端。

这个区分很重要。如果有耗时的动作要做,你必须要考虑是在服务端还是在客户端执行这些操作更好。例如:

1
2
3
4
5
6
7
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,它的客户机和服务器之间的隔离变得更加明显。

原文链接: http://elixir-lang.org/getting-started/mix-otp/agent.html