客户端和服务器

回调到未来

Weird version of Marty from Back to The Future

我们将要看到的第一个 OTP 行为是最常用的行为之一。它的名字是 gen_server,它的接口与我们在 上一章 中用 my_server 编写的接口有点相似;它提供了一些函数供你使用,作为交换,你的模块必须已经包含 gen_server 将使用的几个函数。

init

第一个是 init/1 函数。它与我们在 my_server 中使用的类似,因为它用于初始化服务器的状态,并执行它将依赖的所有一次性任务。该函数可以返回 {ok, State}{ok, State, TimeOut}{ok, State, hibernate}{stop, Reason}ignore

正常的 {ok, State} 返回值不需要解释,除了说明 State 将直接传递到进程的主循环中,作为稍后保留的状态。TimeOut 变量的目的是在你需要在预期服务器接收消息之前的截止时间时添加到元组中。如果在截止时间之前没有收到消息,则会向服务器发送一个特殊的(原子 timeout)消息,应通过 handle_info/2 处理(稍后会详细介绍)。

另一方面,如果你确实希望进程在获得回复之前花费很长时间,并且担心内存,你可以将 hibernate 原子添加到元组中。休眠基本上会减少进程状态的大小,直到它收到消息,但代价是会消耗一些处理能力。如果你对是否使用休眠有疑问,你可能不需要它。

在初始化过程中出现错误时,应返回 {stop, Reason}

注意: 以下是进程休眠的更技术性的定义。如果一些读者不理解或不在乎它,也没关系。当调用 BIF erlang:hibernate(M,F,A) 时,当前正在运行进程的调用栈将被丢弃(函数永远不会返回)。然后垃圾收集会启动,剩下的就是一块连续的堆,它会被缩减到进程中数据的尺寸。这基本上会压缩所有数据,以便进程占用更小的空间。

一旦进程收到消息,M:F 函数将使用 A 作为参数被调用,执行将恢复。

注意:init/1 运行期间,执行将阻塞在生成服务器的进程中。这是因为它正在等待 gen_server 模块自动发送的“就绪”消息,以确保一切顺利。

handle_call

函数 handle_call/3 用于处理同步消息(我们很快就会看到如何发送它们)。它接受 3 个参数:RequestFromState。它与我们在 my_server 中编程自己的 handle_call/3 的方式非常相似。最大的区别在于你如何回复消息。在我们自己对服务器的抽象中,有必要使用 my_server:reply/2 来与进程进行通信。在 gen_server 的情况下,有 8 种不同的返回值可能,它们以元组的形式出现。

由于它们有很多,这里给出一个简单的列表代替

{reply,Reply,NewState}
{reply,Reply,NewState,Timeout}
{reply,Reply,NewState,hibernate}
{noreply,NewState}
{noreply,NewState,Timeout}
{noreply,NewState,hibernate}
{stop,Reason,Reply,NewState}
{stop,Reason,NewState}

对于所有这些,Timeouthibernate 的工作方式与 init/1 相同。Reply 中的内容将被发送回最初调用服务器的进程。请注意,有三种可能的 noreply 选项。当你使用 noreply 时,服务器的通用部分将假设你负责自己发送回复。这可以通过 gen_server:reply/2 完成,它可以像 my_server:reply/2 一样使用。

大多数情况下,你只需要 reply 元组。使用 noreply 仍然有几个有效的原因:每当你希望另一个进程为你发送回复,或者当你希望发送一个确认消息(“嘿!我收到了消息!”),但仍希望随后处理它(这次不回复)等。如果你选择这样做,必须使用 gen_server:reply/2,因为否则调用将超时并导致崩溃。

handle_cast

handle_cast/2 回调的工作方式非常类似于我们在 my_server 中的回调:它接收参数 MessageState,用于处理异步调用。你可以在其中做任何你想做的事情,与 handle_call/3 中可做的事情非常相似。另一方面,只有不带回复的元组是有效的返回值。

{noreply,NewState}
{noreply,NewState,Timeout}
{noreply,NewState,hibernate}
{stop,Reason,NewState}

handle_info

你知道吗,我们自己的服务器并没有真正处理不符合我们接口的消息,对吧?好吧,handle_info/2 就是解决方案。它与 handle_cast/2 非常相似,实际上返回相同的元组。区别在于此回调仅用于直接使用 ! 运算符发送的消息,以及 init/1timeout、监控器的通知和 'EXIT' 信号等特殊消息。

terminate

回调 terminate/2 在任何三个 handle_Something 函数返回 {stop, Reason, NewState}{stop, Reason, Reply, NewState} 形式的元组时被调用。它接收两个参数,ReasonState,对应于 stop 元组中的相同值。

terminate/2 也会在它的父进程(生成它的进程)死亡时被调用,前提是 gen_server 正在捕获退出。

注意: 如果在调用 terminate/2 时使用了 normalshutdown{shutdown, Term} 以外的任何原因,OTP 框架将将其视为失败,并开始为你在此处和彼处记录大量信息。

此函数几乎是 init/1 的直接相反,因此 init/1 中完成的任何操作都应该在 terminate/2 中有其相反的操作。它是你的服务器的清洁工,负责确保每个人都离开后锁定大门的功能。当然,该函数得到了 VM 本身的帮助,VM 通常会为你删除所有 ETS 表、关闭所有 端口 等。请注意,此函数的返回值并不重要,因为代码在它被调用后就会停止执行。

code_change

函数 code_change/3 用于让你升级代码。它采用 code_change(PreviousVersion, State, Extra) 的形式。这里,变量 PreviousVersion 在升级的情况下是版本项本身(如果你忘记了什么是版本项,请再次阅读 更多关于模块),或者在降级的情况下是 {down, Version}(只是重新加载旧的代码)。State 变量保存了所有当前服务器的状态,以便你可以转换它。

想象一下,我们使用了一个 orddict 来存储所有数据。但是,随着时间的推移,orddict 变得太慢了,我们决定将其更改为一个普通的 dict。为了避免进程在下次函数调用时崩溃,可以安全地完成从一种数据结构到另一种数据结构的转换。你所要做的就是使用 {ok, NewState} 返回新状态。

a cat with an eye patch

Extra 变量是我们现在不会关心的东西。它主要用于大型 OTP 部署中,在其中存在专门的工具来升级 VM 上的整个发布。我们还没有到那里。

因此,现在我们已经定义了所有回调。如果你有点迷茫,不要担心:OTP 框架有时有点循环,要理解框架的 A 部分,你必须理解 B 部分,但 B 部分需要看到 A 部分才能有用。克服这种困惑的最佳方法是实际实现一个 gen_server。

.BEAM 我起来,史葛蒂!

这将是 kitty_gen_server。它将与 kitty_server2 大致相同,只有 API 发生了微小的变化。首先,在其中使用以下几行创建一个新的模块

-module(kitty_gen_server).
-behaviour(gen_server).

并尝试编译它。你应该得到类似以下的结果

1> c(kitty_gen_server).
./kitty_gen_server.erl:2: Warning: undefined callback function code_change/3 (behaviour 'gen_server')
./kitty_gen_server.erl:2: Warning: undefined callback function handle_call/3 (behaviour 'gen_server')
./kitty_gen_server.erl:2: Warning: undefined callback function handle_cast/2 (behaviour 'gen_server')
./kitty_gen_server.erl:2: Warning: undefined callback function handle_info/2 (behaviour 'gen_server')
./kitty_gen_server.erl:2: Warning: undefined callback function init/1 (behaviour 'gen_server')
./kitty_gen_server.erl:2: Warning: undefined callback function terminate/2 (behaviour 'gen_server')
{ok,kitty_gen_server}

编译成功,但存在关于缺少回调的警告。这是因为 gen_server 行为。行为基本上是一种模块指定它期望另一个模块具有的函数的方法。行为是契约,它密封了良好行为的通用代码部分与特定且容易出错的代码部分(你的代码)之间的交易。

注意: Erlang 编译器接受“behavior”和“behaviour”。

定义你自己的行为非常简单。你只需要导出一个名为 behaviour_info/1 的函数,并按如下方式实现它

-module(my_behaviour).
-export([behaviour_info/1]).

%% init/1, some_fun/0 and other/3 are now expected callbacks
behaviour_info(callbacks) -> [{init,1}, {some_fun, 0}, {other, 3}];
behaviour_info(_) -> undefined.

这就是关于行为的全部内容。你可以在实现它们的模块中使用 -behaviour(my_behaviour). 来获得编译器警告,以防你忘记某个函数。无论如何,让我们回到我们的第三个 kitty 服务器。

我们拥有的第一个函数是 start_link/0。这个可以更改为以下内容

start_link() -> gen_server:start_link(?MODULE, [], []).

第一个参数是回调模块,第二个参数是要传递给 init/1 的参数列表,第三个参数是关于调试选项的内容,我们现在不会讨论。你可以在第一个位置添加一个 第四个参数,它将是服务器注册的名称。请注意,虽然该函数的先前版本只是返回一个 pid,但此版本返回 {ok, Pid}

现在是接下来的函数

%% Synchronous call
order_cat(Pid, Name, Color, Description) ->
   gen_server:call(Pid, {order, Name, Color, Description}).

%% This call is asynchronous
return_cat(Pid, Cat = #cat{}) ->
    gen_server:cast(Pid, {return, Cat}).

%% Synchronous call
close_shop(Pid) ->
    gen_server:call(Pid, terminate).

所有这些调用都是一对一的更改。请注意,可以将第三个参数传递给 gen_server:call/2-3 来设置超时。如果你没有为该函数设置超时(或原子 infinity),则默认值为 5 秒。如果在超时之前没有收到回复,则调用会崩溃。

现在我们将能够添加 gen_server 回调。下表显示了我们之间调用和回调之间的关系

gen_server YourModule
start/3-4 init/1
start_link/3-4 init/1
call/2-3 handle_call/3
cast/2 handle_cast/2

然后你有其他的回调,这些回调更多地是关于特殊情况

让我们从更改我们已经拥有的那些开始,以适应模型:init/1handle_call/3handle_cast/2

%%% Server functions
init([]) -> {ok, []}. %% no treatment of info here!

handle_call({order, Name, Color, Description}, _From, Cats) ->
    if Cats =:= [] ->
        {reply, make_cat(Name, Color, Description), Cats};
       Cats =/= [] ->
        {reply, hd(Cats), tl(Cats)}
    end;
handle_call(terminate, _From, Cats) ->
    {stop, normal, ok, Cats}.

handle_cast({return, Cat = #cat{}}, Cats) ->
    {noreply, [Cat|Cats]}.

同样,那里变化很小。事实上,由于更智能的抽象,代码现在更短了。现在我们来看看新的回调。第一个是handle_info/2。鉴于这是一个玩具模块,而且我们没有预先定义日志系统,因此只输出意外消息就足够了。

handle_info(Msg, Cats) ->
    io:format("Unexpected message: ~p~n",[Msg]),
    {noreply, Cats}.

下一个是terminate/2回调。它将与我们之前的terminate/1私有函数非常类似。

terminate(normal, Cats) ->
    [io:format("~p was set free.~n",[C#cat.name]) || C <- Cats],
    ok.

然后是最后一个回调,code_change/3

code_change(_OldVsn, State, _Extra) ->
    %% No change planned. The function is there for the behaviour,
    %% but will not be used. Only a version on the next
    {ok, State}. 

请记住保留make_cat/3私有函数。

%%% Private functions
make_cat(Name, Col, Desc) ->
    #cat{name=Name, color=Col, description=Desc}.

现在我们可以尝试全新的代码了。

1> c(kitty_gen_server).
{ok,kitty_gen_server}
2> rr(kitty_gen_server).
[cat]
3> {ok, Pid} = kitty_gen_server:start_link().
{ok,<0.253.0>}
4> Pid ! <<"Test handle_info">>.
Unexpected message: <<"Test handle_info">>
<<"Test handle_info">>
5> Cat = kitty_gen_server:order_cat(Pid, "Cat Stevens", white, "not actually a cat").
#cat{name = "Cat Stevens",color = white,
     description = "not actually a cat"}
6> kitty_gen_server:return_cat(Pid, Cat).
ok
7> kitty_gen_server:order_cat(Pid, "Kitten Mittens", black, "look at them little paws!").
#cat{name = "Cat Stevens",color = white,
     description = "not actually a cat"}
8> kitty_gen_server:order_cat(Pid, "Kitten Mittens", black, "look at them little paws!").
#cat{name = "Kitten Mittens",color = black,
     description = "look at them little paws!"}
9> kitty_gen_server:return_cat(Pid, Cat).
ok       
10> kitty_gen_server:close_shop(Pid).
"Cat Stevens" was set free.
ok
pair of wool mittens

哦,天哪,它真的可以运行!

那么,关于这个通用的冒险,我们能说些什么呢?可能与之前一样:将通用部分与特定部分分离是一个好主意。维护更简单,复杂性降低,代码更安全,更容易测试,不易出错。如果有错误,也更容易修复。通用服务器只是众多可用抽象中的一种,但它们无疑是最常用的抽象之一。我们将在接下来的章节中看到更多这些抽象和行为。