客戶端與伺服器

回呼到未來

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) 時,會捨棄目前正在執行的進程的呼叫堆疊(該函數永遠不會返回)。然後會啟動垃圾回收,剩下的就是一個連續的堆積,它會縮小到進程中資料的大小。這基本上會壓縮所有資料,以便進程佔用的空間更少。

一旦進程收到訊息,就會呼叫以 A 作為引數的函數 M:F,並恢復執行。

注意: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

當三個 handle_Something 函數之一返回 {stop, Reason, NewState}{stop, Reason, Reply, NewState} 形式的元組時,就會呼叫回呼 terminate/2。它採用兩個參數,ReasonState,對應於 stop 元組中的相同值。

如果且僅當 gen_server 正在追蹤退出時,當其父進程(產生它的進程)死亡時,也會呼叫 terminate/2

注意:如果在呼叫 terminate/2 時使用了 normalshutdown{shutdown, Term} 以外的任何原因,則 OTP 架構會將其視為失敗,並開始為您在各處記錄大量內容。

此函數與 init/1 幾乎完全相反,因此在其中完成的任何操作都應該在 terminate/2 中相反。它是伺服器的清潔工,負責確保每個人都離開後關上門。當然,該函數由 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。

傳送我,史考特!

這將是 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 您的模組
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

喔,天啊,它成功了!

那麼我們對這個通用冒險有什麼看法?可能和之前一樣的通用內容:將通用部分與特定部分分離在各方面都是一個好主意。維護更簡單,複雜性降低,程式碼更安全,更容易測試,並且更不容易出錯。如果有錯誤,也更容易修復。通用伺服器只是眾多可用的抽象化之一,但它們絕對是最常用的之一。在接下來的章節中,我們將看到更多這些抽象化和行為。