MIX & OTP——使用 GenServer 进行客户端-服务器通信

发布于:2024-06-30 ⋅ 阅读:(14) ⋅ 点赞:(0)

在上一章中,我们使用代理来表示存储容器。在 mix 的介绍中,我们指定要命名每个存储容器,以便我们可以执行以下操作:

在上面的会话中,我们与“购物”存储容器进行了交互。

由于代理是进程,因此每个存储容器都有一个进程标识符 (PID),但存储容器没有名称。在“进程”一章中,我们了解到可以通过为进程赋予原子名称来在 Elixir 中注册进程:

但是,用原子命名动态进程是一个糟糕的想法!如果我们使用原子,我们需要将存储容器名称(通常从外部客户端接收)转换为原子,并且我们永远不应该将用户输入转换为原子。这是因为原子不会被垃圾收集。一旦创建了原子,它就永远不会被回收。从用户输入生成原子意味着用户可以注入足够多的不同名称来耗尽我们的系统内存!

实际上,在内存耗尽之前,您更有可能达到 Erlang VM 的最大原子数限制,这无论如何都会使您的系统崩溃。

我们不会滥用内置名称功能,而是创建自己的进程注册表,将存储容器名称与存储容器进程关联起来。

注册表需要保证它始终是最新的。例如,如果其中一个存储容器进程由于错误而崩溃,注册表必须注意到这一变化并避免提供过时的条目。在 Elixir 中,我们说注册表需要监视每个存储容器。由于我们的注册表需要能够接收和处理来自系统的临时消息,因此 Agent API 是不够的。

我们将使用 GenServer 创建一个可以监控存储桶进程的注册表进程。GenServer 为在 Elixir 和 OTP 中构建服务器提供了工业级功能。

如果您还没有阅读 GenServer 模块文档,请阅读概述。一旦您这样做,我们就可以继续了。

GenServer 回调

GenServer 是在特定条件下调用一组有限函数的过程。当我们使用 Agent 时,我们会将客户端代码和服务器代码并排放置,如下所示:

让我们稍微分解一下这段代码:

在上面的代码中,我们有一个进程,我们称之为“客户端”,它向代理(即“服务器”)发送请求。该请求包含一个匿名函数,该函数必须由服务器执行。

在 GenServer 中,上面的代码将是两个独立的函数,大致如下:

GenServer 代码中还有相当多的繁琐,但正如我们将看到的,它也带来了一些好处。

目前,我们将只为存储容器注册逻辑编写服务器回调,而不提供适当的 API,稍后我们将提供。

在 lib/kv/registry.ex 创建一个新文件,内容如下:

您可以向 GenServer 发送两种类型的请求:调用和强制类型转换。调用是同步的,服务器必须向此类请求发送响应。当服务器计算响应时,客户端正在等待。强制类型转换是异步的:服务器不会发送响应,因此客户端不会等待响应。这两种请求都是发送到服务器的消息,将按顺序处理。在上面的实现中,我们对 :create 消息进行模式匹配,将其作为强制类型转换处理,对 :lookup 消息进行模式匹配,将其作为调用处理。

为了调用上面的回调,我们需要遍历相应的 GenServer 函数。让我们启动一个注册表,创建一个命名的 bucket,然后查找它:

我们的 KV.Registry 进程按此顺序接收到一个带有 {:create, "shopping"} 的转换和一个带有 {:lookup, "shopping"} 的调用。一旦消息发送到注册表,GenServer.cast 将立即返回。另一方面,我们将在 GenServer.call 中等待由上述 KV.Registry.handle_call 回调提供的答案。

您可能还注意到,我们在每个回调之前都添加了 @impl true。@impl true 通知编译器,我们对后续函数定义的意图是定义一个回调。如果我们在函数名称或参数数量上犯了错误,例如我们定义了一个 handle_call/2,编译器会警告我们没有任何 handle_call/2 可定义,并为我们提供 GenServer 模块已知回调的完整列表。

这一切都很好,但我们仍然希望为用户提供一个允许我们隐藏实现细节的 API。

客户端 API

GenServer 由两部分实现:客户端 API 和服务器回调。您可以将两个部分组合成一个模块,也可以将它们分成客户端模块和服务器模块。客户端是调用客户端函数的任何进程。服务器始终是我们将明确作为参数传递给客户端 API 的进程标识符或进程名称。在这里,我们将对服务器回调和客户端 API 使用单个模块。

编辑 lib/kv/registry.ex 文件,填写客户端 API 的空白:

第一个函数是 start_link/1,它通过传递选项列表来启动一个新的 GenServer。start_link/1 调用 GenServer.start_link/3,它接受三个参数:

1. 实现服务器回调的模块,在本例中为 __MODULE__(表示当前模块)
2. 初始化参数,在本例中为原子 :ok
3. 可用于指定服务器名称等内容的选项列表。现在,我们将在 start_link/1 上收到的选项列表转发到 GenServer.start_link/3

接下来的两个函数 lookup/2 和 create/2 负责将这些请求发送到服务器。在本例中,我们分别使用了 {:lookup, name} 和 {:create, name}。请求通常被指定为元组,就像这样,以便在第一个参数槽中提供多个“参数”。通常将请求的操作指定为元组的第一个元素,并在其余元素中指定该操作的参数。请注意,请求必须与 handle_call/3 或 handle_cast/2 的第一个参数匹配。

这就是客户端 API。在服务器端,我们可以实现各种回调来保证服务器初始化、终止和处理请求。这些回调是可选的,目前,我们只实现了我们关心的回调。让我们回顾一下。

第一个是 init/1 回调,它接收给 GenServer.start_link/3 的第二个参数并返回 {:ok, state},其中 state 是一个新的映射。我们已经注意到 GenServer API 如何使客户端/服务器隔离更加明显。start_link/3 发生在客户端,而 init/1 是在服务器上运行的相应回调。

对于 call/2 请求,我们实现了一个 handle_call/3 回调,它接收请求、我们从中接收请求的进程 (_from) 和当前服务器状态 (names)。handle_call/3 回调返回一个格式为 {:reply, reply, new_state} 的元组。元组的第一个元素 :reply 表示服务器应该将回复发送回客户端。第二个元素 reply 是将发送给客户端的内容,而第三个元素 new_state 是新的服务器状态。

对于 cast/2 请求,我们实现了一个 handle_cast/2 回调,它接收请求和当前服务器状态 (names)。handle_cast/2 回调返回一个格式为 {:noreply, new_state} 的元组。请注意,在实际应用中,我们可能会使用同步调用而不是异步转换来实现 :create 的回调。我们这样做是为了说明如何实现转换回调。

handle_call/3 和 handle_cast/2 回调都可能返回其他元组格式。我们还可以实现其他回调,例如,terminate/2 和 code_change/3。欢迎您浏览完整的 GenServer 文档以了解有关这些内容的更多信息。

现在,让我们编写一些测试来保证我们的 GenServer 能够按预期工作。

测试 GenServer

测试 GenServer 与测试代理没有太大区别。我们将在设置回调中生成服务器并在整个测试中使用它。在 test/kv/registry_test.exs 创建一个文件,其中包含以下内容:

我们的测试用例首先断言我们的注册表中没有存储容器,创建一个命名的存储容器,查找它,并断言它表现为存储容器。

我们为 KV.Registry 编写的设置块和为 KV.Bucket 编写的设置块之间有一个重要的区别。我们没有通过调用 KV.Registry.start_link/1 手动启动注册表,而是调用 ExUnit.Callbacks.start_supervised!/2 函数,传递 KV.Registry 模块。

通过使用 ExUnit.Case 将 start_supervised! 函数注入到我们的测试模块中。它通过调用其 start_link/1 函数来完成启动 KV.Registry 进程的工作。使用 start_supervised! 的优势!是 ExUnit 将保证在下一个测试开始之前关闭注册表进程。换句话说,它有助于保证一个测试的状态不会干扰下一个测试,以防它们依赖于共享资源。

在测试期间启动进程时,我们应该始终优先使用 start_supervised!。我们建议您将 bucket_test.exs 中的设置块也更改为使用 start_supervised!。

运行测试,它们都应该通过!

监控的必要性

到目前为止,我们所做的一切都可以通过 Agent 来实现。在本节中,我们将看到 GenServer 可以实现的众多功能之一,而 Agent 无法实现这些功能。

让我们从一个测试开始,该测试描述了当存储容器停止或崩溃时我们希望注册表如何表现:

上面的测试将在最后一个断言上失败,因为即使我们停止存储容器进程,存储容器名称仍保留在注册表中。

为了修复这个错误,我们需要注册表监控它生成的每个存储容器。一旦我们设置了一个监视器,每次存储容器进程退出时,注册表都会收到通知,让我们可以清理注册表。

让我们首先使用 iex -S mix 启动一个新控制台来使用监视器:

注意 Process.monitor(pid) 返回一个唯一引用,允许我们将即将到来的消息与该监视引用匹配。停止代理后,我们可以 flush/0 所有消息并注意到 :DOWN 消息到达,其中包含监视器返回的确切引用,通知存储容器进程因 :normal 原因退出。

让我们重新实现服务器回调以修复错误并使测试通过。首先,我们将 GenServer 状态修改为两个字典:一个包含 name -> pid,另一个包含 ref -> name。然后我们需要在 handle_cast/2 上监视存储桶,并实现 handle_info/2 回调来处理监视消息。完整的服务器回调实现如下所示:

请注意,我们能够在不更改任何客户端 API 的情况下显著更改服务器实现。这是明确隔离服务器和客户端的好处之一。

最后,与其他回调不同,我们为 handle_info/2 定义了一个“catch-all”子句,用于丢弃和记录任何未知消息。要了解原因,让我们继续下一节。

call, cast or info?

到目前为止,我们已经使用了三个回调:handle_call/3、handle_cast/2 和 handle_info/2。在决定何时使用每个回调时,我们需要考虑以下几点:

1.handle_call/3 必须用于同步请求。这应该是默认选择,因为等待服务器回复是一种有用的背压机制。
2.handle_cast/2 必须用于异步请求,当您不关心回复时。强制转换不能保证服务器已收到消息,因此应谨慎使用。例如,我们在本章中定义的 create/2 函数应该使用 call/2。我们使用 cast/2 是为了教学目的。
3.handle_info/2 必须用于服务器可能收到的所有其他未通过 GenServer.call/2 或 GenServer.cast/2 发送的消息,包括使用 send/2 发送的常规消息。监控 :DOWN 消息就是一个例子。

由于任何消息(包括通过 send/2 发送的消息)都会转到 handle_info/2,因此可能会有意外消息到达服务器。因此,如果我们不定义 catch-all 子句,这些消息可能会导致我们的注册表崩溃,因为没有子句匹配。不过,我们不必担心 handle_call/3 和 handle_cast/2 的此类情况。调用和强制转换仅通过 GenServer API 完成,因此未知消息很可能是开发人员的错误。

为了帮助开发人员记住 call、cast 和 info 之间的区别、支持的返回值等,我们准备了一个小型 GenServer 备忘单。

监视器还是链接?

我们之前在“进程”一章中学习了链接。现在,注册表已完成,您可能想知道:我们何时应该使用监视器,何时应该使用链接?

链接是双向的。如果您链接两个进程,其中一个崩溃,则另一端也会崩溃(除非它捕获退出)。监视器是单向的:只有监视进程会收到有关被监视进程的通知。换句话说:当您想要链接崩溃时使用链接,当您只想收到崩溃、退出等通知时使用监视器。

回到我们的 handle_cast/2 实现,您可以看到注册表既链接又监视存储容器:

这是一个坏主意,因为我们不希望注册表在存储容器崩溃时崩溃。正确的解决方法是实际上不将存储桶链接到注册表。相反,我们将每个存储容器链接到一种称为 Supervisors 的特殊类型的进程,这些进程明确设计用于处理故障和崩溃。我们将在下一章中了解有关它们的更多信息。


网站公告

今日签到

点亮在社区的每一天
去签到