GenServer(通用服务器)是一种行为,它把 Elixir 进程之间常见的客户端与服务器交互抽象了出来。
还记得我们学习进程时讲过的接收循环吗?GenServer行为为编写这类循环、以及和运行这类循环的进程交换消息提供了抽象。它让保存状态和运行异步代码都变得更容易。
要注意,GenServer这个名字承载的含义有点多。它也被用来指代_使用_GenServer行为的_模块_,以及由_使用_GenServer行为的模块启动的_进程_。
GenServer行为定义了一个必需的回调init/1,以及几个有意思的可选回调:handle_call/3、handle_cast/2 和 handle_info/3。使用GenServer的_客户端_不应该直接调用这些回调。相反,GenServer模块提供了一些函数,让客户端可以和GenServer进程通信。
通常,同一个模块既定义_客户端 API_(一组函数,供 Elixir 应用的其他部分调用,以便和这个GenServer进程通信),也定义_服务器回调的实现_,也就是这个GenServer的逻辑所在。
我们先来看一个简单的GenServer例子,然后再逐一了解每个回调的含义。
下面这个示例服务器,可以在漫长的公路旅行中回应烦人乘客没完没了的追问,确切地说,是这个问题:“到了吗?”。它会记录这个问题被问了多少次,回答也越来越不耐烦。
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
init/1可以通过调用GenServer.start/3或GenServer.start_link/3来启动服务器。我们在链接概念中学过这两个函数的区别。
这两个函数:
GenServer行为的模块。init_arg。顾名思义,这个实参会被传给init/1回调。通过调用GenServer.start/3或GenServer.start_link/3启动服务器时,会以阻塞的方式调用init/1回调。init/1的返回值决定服务器能否成功启动。
init/1回调通常返回下面这些值之一:
{:ok, state}。服务器会以state作为初始状态,开始自己的接收循环。state可以是任何类型。{:stop, reason}。reason可以是任何类型。服务器不会开始自己的接收循环。进程会以给定的原因退出。还有一些更高级的用法,这里暂不涉及。
如果服务器的接收循环启动了,GenServer.start/3和GenServer.start_link/3会返回一个{:ok, pid}元组。否则返回{:error, reason}。
handle_call/3用GenServer.call/2可以把需要回复的消息发送给服务器进程。这个函数把正在运行的服务器进程的pid作为第一个实参,把消息作为第二个实参。消息可以是任何类型。
handle_call/3回调负责处理和响应同步消息。它接收三个实参:
message,也就是作为第二个实参传给GenServer.call/2的值。from,也就是调用GenServer.call/2的进程的pid。大多数情况下这个实参可以忽略。state,也就是服务器的当前状态。记住,它的初始值是在init/1回调里设置的。handle_call/3回调通常返回一个三元组{:reply, reply, state}。这意味着元组中的第二个元素,也就是可以是任何类型的reply,会被发回给调用方。元组中的第三个元素state,是服务器处理完这条消息之后的新状态。
还有一些更高级的用法,这里暂不涉及。
想通过名字记住这个回调的作用, 可以把它想成给某人“打电话”。
如果对方有空,你会立刻收到回复(同步地)。
handle_cast/2用GenServer.cast/2可以把不需要回复的消息发送给服务器进程。它的实参和GenServer.call/2的实参完全相同。
handle_cast/2回调负责处理这些消息。它接收两个实参,message和state,和handle_call/3回调里的实参相同(只是没有from)。
handle_cast/2回调通常返回一个二元组{:noreply, state}。
还有一些更高级的用法,这里暂不涉及。
想通过名字记住这个回调的作用, 记住“cast”也有“扔出去”的意思。
如果你把一封瓶中信扔进大海, 你不会指望马上收到回复, 也许永远都收不到。
call还是cast?几乎总是应该用call,即使你的客户端代码并不需要服务器的回复。
使用call会等待回复,这起到背压机制的作用(防止客户端一次发送太多消息)。收到服务器的回复,也是确认服务器接收并处理了客户端消息的唯一方式。
handle_info/2消息也可能通过GenServer.call/2或GenServer.cast/2之外的方式进入服务器的收件箱,比如调用普通的send/2函数。
要处理这类消息,请使用handle_info/2回调。这个回调的工作方式和handle_cast/2完全相同。
GenServer行为提供了一个兜底的handle_info/2实现,会记录关于意外消息的错误。如果你覆盖了那个默认实现,请务必始终加入自己的兜底实现。否则,服务器一旦收到意外消息就会崩溃。
上面介绍的这四个回调,返回值都可以再扩展一个元组元素,即超时时间。例如,init/1可以不返回{:ok, state},而是返回{:ok, state, timeout}。
超时可以用于检测邮箱中在一段特定时间内没有消息。如果服务器从某个回调返回了超时,并且在指定的毫秒数内没有任何消息到达,就会以:timeout作为第一个实参调用handle_info/2。
基础版取号机卖得非常好,但一些用户抱怨说,与市场上其他在售机型相比,它缺少高级功能。
厂商听取了用户反馈,决定推出一款功能更多的豪华版机型,而你再次受命为这台机器编写软件。
豪华版新增的功能包括:
机器的业务逻辑已经由你的同事实现,可以在TakeANumberDeluxe.State模块中找到。现在你的任务是把它包装进一个GenServer。
在TakeANumberDeluxe模块中使用GenServer行为。
实现start_link/1函数以及必要的GenServer回调。
传给start_link/1的实参是一个关键字列表。它包含:min_number和:max_number这两个键。这些键对应的值需要传给TakeANumberDeluxe.State.new/2函数。
如果TakeANumberDeluxe.State.new/2返回{:ok, state}元组,机器就应当启动,并把返回的状态作为自身的状态。如果它返回的是{:error, error}元组,机器则应当停止,并把返回的错误作为停止原因。
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
你可能已经注意到,TakeANumberDeluxe.State.new/2函数还接受一个可选的第三个实参auto_shutdown_timeout。我们会在本练习的最后一步用到它。
实现report_state/1函数以及必要的GenServer回调。机器应当把当前状态回复给调用方。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
实现queue_new_number/1函数以及必要的GenServer回调。
它应当用机器的当前状态调用TakeANumberDeluxe.State.queue_new_number/1函数。
如果TakeANumberDeluxe.State.queue_new_number/1返回{:ok, new_number, new_state}元组,机器应当把新号码回复给调用方,并把新状态设为自身的状态。如果它返回的是{:error, error}元组,机器则应当把错误回复给调用方,并且不改变自身的状态。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
实现serve_next_queued_number/2函数以及必要的GenServer回调。
它应当用机器的当前状态以及它的第二个可选实参priority_number调用TakeANumberDeluxe.State.serve_next_queued_number/2函数。
如果TakeANumberDeluxe.State.serve_next_queued_number/2返回{:ok, next_number, new_state}元组,机器应当把下一个号码回复给调用方,并把新状态设为自身的状态。如果它返回的是{:error, error}元组,机器则应当把错误回复给调用方,并且不改变自身的状态。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
实现reset_state/1函数以及必要的GenServer回调。
它应当调用TakeANumberDeluxe.State.new/2函数,用当前状态的min_number和max_number创建一个新状态。机器应当把这个新状态设为自身的状态。它不应当回复调用方。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
修改机器的启动过程。它应当读取作为init_arg传入的关键字列表中:auto_shutdown_timeout键对应的值,并把它作为第三个实参传给TakeANumberDeluxe.State.new/3。如果没有给出:auto_shutdown_timeout,就使用默认值:infinity。
修改重置机器状态的过程,让它也把auto_shutdown_timeout传给TakeANumberDeluxe.State.new/3。
修改所有已实现回调(init/1以及所有handle_*回调)的返回值,以设置超时。使用当前机器状态中:auto_shutdown_timeout键对应的值。不要给init/1的{:stop, reason}返回值加上超时,超时只在服务器启动接收循环之后才生效。
实现一个GenServer回调,用来处理:timeout消息:如果在给定超时时间内没有收到其他任何消息,这条消息就会被发送给机器。它应当以:normal为原因退出进程。
还要记得处理任何意外消息,直接忽略它们。
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
# ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
# (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3