什麼是 OTP?

它是開放電信平台!

A telephone with someone on the other end saying 'Hullo'

OTP 代表開放電信平台,雖然它現在跟電信沒那麼相關了(它比較像是具有電信應用程式特性的軟體,但沒錯。)如果 Erlang 的一半偉大之處來自其並行和分散式特性,另一半來自其錯誤處理能力,那麼 OTP 框架就是它的第三部分。

在之前的章節中,我們已經看到了一些關於如何使用語言內建功能來編寫並行應用程式的常見實踐範例:連結、監視器、伺服器、逾時、捕獲退出等等。在這些過程中,關於事情需要完成的順序、如何避免競爭條件,或始終記得進程可能隨時終止,會有一些「陷阱」。還有熱碼加載、命名進程和添加監督者等等。

手動完成所有這些工作既耗時又容易出錯。會有被遺忘的邊緣情況和可能會掉入的陷阱。OTP 框架透過將這些基本實踐分組到一組經過多年精心設計和實戰考驗的函式庫中來解決這個問題。每個 Erlang 程式設計師都應該使用它們。

OTP 框架也是一組旨在幫助您建構應用程式的模組和標準。鑑於大多數 Erlang 程式設計師最終都會使用 OTP,因此您在野外遇到的大多數 Erlang 應用程式都會傾向於遵循這些標準。

抽象化的通用進程

在先前的進程範例中,我們多次做的一件事是根據非常特定的任務來劃分所有內容。在大多數進程中,我們有一個負責產生新進程的函式,一個負責賦予其初始值的函式,一個主迴圈等等。

事實證明,這些部分通常存在於您編寫的所有並行程式中,無論該進程可能用於什麼用途。

common process pattern: spawn -> init -> loop -> exit

OTP 框架背後的工程師和電腦科學家發現了這些模式,並將它們包含在一堆通用函式庫中。這些函式庫是用與我們使用的大多數抽象概念(例如使用參考來標記訊息)等效的程式碼建構的,其優點是已在該領域使用了多年,並且也比我們在實作時更加謹慎。它們包含安全產生和初始化進程的函式、以容錯方式向它們發送訊息的函式以及許多其他功能。有趣的是,您應該很少需要自己使用這些函式庫。它們包含的抽象概念是如此基本和通用,以至於在其之上建立了更多有趣的東西。那些函式庫是我們將要使用的。

graph of Erlang/OTP abstraction layers: Erlang -> Basic Abstraction Libraries (gen, sys, proc_lib) -> Behaviours (gen_*, supervisors)

在接下來的章節中,我們將看到一些進程的常見用途,然後看看如何將它們抽象化,然後使其通用化。然後,對於其中的每一個,我們還將看到使用 OTP 框架的行為的相應實作方式,以及如何使用它們中的每一個。

基本伺服器

我將描述的第一個常見模式是我們已經使用過的模式。在編寫事件伺服器時,我們擁有一個可以稱之為客戶端-伺服器模型的東西。事件伺服器會接收來自客戶端的調用,對它們執行操作,然後在協定要求的情況下回覆它。

在本章中,我們將使用一個非常簡單的伺服器,以便我們可以專注於它的基本屬性。這是 kitty_server

%%%%% Naive version
-module(kitty_server).

-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> spawn_link(fun init/0).

%% Synchronous call
order_cat(Pid, Name, Color, Description) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, {order, Name, Color, Description}},
    receive
        {Ref, Cat} ->
            erlang:demonitor(Ref, [flush]),
            Cat;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

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

%% Synchronous call
close_shop(Pid) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, terminate},
    receive
        {Ref, ok} ->
            erlang:demonitor(Ref, [flush]),
            ok;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.
    
%%% Server functions
init() -> loop([]).

loop(Cats) ->
    receive
        {Pid, Ref, {order, Name, Color, Description}} ->
            if Cats =:= [] ->
                Pid ! {Ref, make_cat(Name, Color, Description)},
                loop(Cats); 
               Cats =/= [] -> % got to empty the stock
                Pid ! {Ref, hd(Cats)},
                loop(tl(Cats))
            end;
        {return, Cat = #cat{}} ->
            loop([Cat|Cats]);
        {Pid, Ref, terminate} ->
            Pid ! {Ref, ok},
            terminate(Cats);
        Unknown ->
            %% do some logging here too
            io:format("Unknown message: ~p~n", [Unknown]),
            loop(Cats)
    end.

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

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

所以這是一個小貓伺服器/商店。行為非常簡單:你描述一隻貓,你會得到那隻貓。如果有人退回一隻貓,它會被添加到列表中,然後會自動作為下一個訂單發送,而不是客戶實際要求的(我們在這裡的貓咪商店是為了賺錢,而不是微笑)

1> c(kitty_server).
{ok,kitty_server}
2> rr(kitty_server).
[cat]
3> Pid = kitty_server:start_link().
<0.57.0>
4> Cat1 = kitty_server:order_cat(Pid, carl, brown, "loves to burn bridges").
#cat{name = carl,color = brown,
     description = "loves to burn bridges"}
5> kitty_server:return_cat(Pid, Cat1).
ok
6> kitty_server:order_cat(Pid, jimmy, orange, "cuddly").
#cat{name = carl,color = brown,
     description = "loves to burn bridges"}
7> kitty_server:order_cat(Pid, jimmy, orange, "cuddly").
#cat{name = jimmy,color = orange,description = "cuddly"}
8> kitty_server:return_cat(Pid, Cat1).
ok
9> kitty_server:close_shop(Pid).
carl was set free.
ok
10> kitty_server:close_shop(Pid).
** exception error: no such process or port
     in function  kitty_server:close_shop/1

回顧該模組的原始程式碼,我們可以發現我們之前應用過的模式。我們設定和關閉監視器、應用計時器、接收資料、使用主迴圈、處理 init 函式等等的部分都應該很熟悉。應該可以將我們最終不斷重複的所有這些東西都抽象出來。

讓我們首先看看客戶端 API。我們可以注意到的第一件事是,同步調用非常相似。這些調用很可能會像上一節中提到的那樣進入抽象函式庫。現在,我們將它們抽象為 新模組 中的單一函式,該模組將保存小貓伺服器的所有通用部分

-module(my_server).
-compile(export_all).

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

這會接收一個訊息和一個 PID,將它們放入函式中,然後以安全的方式為您轉發訊息。從現在開始,我們可以簡單地將我們發送的訊息替換為對此函式的調用。因此,如果我們要重寫一個新的小貓伺服器以與抽象的 my_server 配對,它可能會像這樣開始

-module(kitty_server2).
-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> spawn_link(fun init/0).

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

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

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

我們擁有的下一個大的通用程式碼區塊不像 call/2 函式那麼明顯。請注意,我們到目前為止編寫的每個進程都有一個迴圈,其中所有訊息都進行模式匹配。這有點敏感,但在這裡我們必須將模式匹配與迴圈本身分開。一種快速的方法是添加

loop(Module, State) ->
    receive
        Message -> Module:handle(Message, State)
    end.

然後特定的模組可能看起來像這樣

handle(Message1, State) -> NewState1;
handle(Message2, State) -> NewState2;
...
handle(MessageN, State) -> NewStateN.

這樣好多了。仍然有辦法讓它更簡潔。如果您在閱讀 kitty_server 模組時有注意到(我希望您有!),您會注意到我們有一種特定的方式來同步調用,另一種方式來異步調用。如果我們的通用伺服器實作可以提供一種清晰的方法來知道哪種類型的調用是哪種類型,那將非常有幫助。

為了做到這一點,我們需要在 my_server:loop/2 中匹配不同類型的訊息。這意味著我們需要稍微更改 call/2 函式,以便透過將原子 sync 添加到該函式的第二行的訊息中來明確同步調用

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {sync, self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

我們現在可以為異步調用提供一個新的函式。函式 cast/2 將處理此問題

cast(Pid, Msg) ->
    Pid ! {async, Msg},
    ok.

完成此操作後,迴圈現在可以像這樣

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, Pid, Ref, State))
    end.
A kitchen sink

然後您還可以添加特定的插槽來處理不符合同步/異步概念的訊息(也許它們是意外發送的),或者在那裡放置您的除錯函式和其他東西,例如熱碼重載。

上面的迴圈令人失望的一件事是抽象洩漏了。將使用 my_server 的程式設計師仍然需要在發送同步訊息並回覆它們時了解參考。這會使抽象無用。要使用它,您仍然需要了解所有無聊的細節。這是對此的快速修復

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, {Pid, Ref}, State))
    end.

透過將變數 PidRef 放在一個元組中,它們可以作為一個單一參數傳遞給另一個函式,作為一個具有類似 From 名稱的變數。然後用戶不必了解變數的內部構造。相反,我們將提供一個函式來發送回覆,該函式應該了解 From 包含什麼

reply({Pid, Ref}, Reply) ->
    Pid ! {Ref, Reply}.

剩下要做的就是指定啟動函式 (startstart_linkinit),它們會傳遞模組名稱等等。添加它們後,模組應如下所示

-module(my_server).
-export([start/2, start_link/2, call/2, cast/2, reply/2]).

%%% Public API
start(Module, InitialState) ->
    spawn(fun() -> init(Module, InitialState) end).

start_link(Module, InitialState) ->
    spawn_link(fun() -> init(Module, InitialState) end).

call(Pid, Msg) ->
    Ref = erlang:monitor(process, Pid),
    Pid ! {sync, self(), Ref, Msg},
    receive
        {Ref, Reply} ->
            erlang:demonitor(Ref, [flush]),
            Reply;
        {'DOWN', Ref, process, Pid, Reason} ->
            erlang:error(Reason)
    after 5000 ->
        erlang:error(timeout)
    end.

cast(Pid, Msg) ->
    Pid ! {async, Msg},
    ok.

reply({Pid, Ref}, Reply) ->
    Pid ! {Ref, Reply}.

%%% Private stuff
init(Module, InitialState) ->
    loop(Module, Module:init(InitialState)).

loop(Module, State) ->
    receive
        {async, Msg} ->
             loop(Module, Module:handle_cast(Msg, State));
        {sync, Pid, Ref, Msg} ->
             loop(Module, Module:handle_call(Msg, {Pid, Ref}, State))
    end.

接下來要做的是重新實作小貓伺服器,現在是 kitty_server2,作為一個回呼模組,它將遵循我們為 my_server 定義的介面。我們將保留與先前實作相同的介面,除了現在所有調用都被重新導向以通過 my_server

-module(kitty_server2).

-export([start_link/0, order_cat/4, return_cat/2, close_shop/1]).
-export([init/1, handle_call/3, handle_cast/2]).

-record(cat, {name, color=green, description}).

%%% Client API
start_link() -> my_server:start_link(?MODULE, []).

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

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

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

請注意,我在模組的頂部添加了第二個 -export()。這些是 my_server 需要調用才能使所有功能正常運作的函式

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

handle_call({order, Name, Color, Description}, From, Cats) ->
    if Cats =:= [] ->
        my_server:reply(From, make_cat(Name, Color, Description)),
        Cats;
       Cats =/= [] ->
        my_server:reply(From, hd(Cats)),
        tl(Cats)
    end;

handle_call(terminate, From, Cats) ->
    my_server:reply(From, ok),
    terminate(Cats).

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

然後需要做的是重新添加私有函式

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

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

只需確保在 terminate/1 中將我們之前的 ok 替換為 exit(normal),否則伺服器將繼續運行。

該程式碼應該是可編譯和可測試的,並且以與之前完全相同的方式運行。程式碼非常相似,但讓我們看看有什麼變化。

特定與通用

我們剛才所做的是了解 OTP 的核心(從概念上講)。這就是 OTP 的真正意義:取得所有通用元件,將它們提取到函式庫中,確保它們運作良好,然後在可能的情況下重複使用該程式碼。然後剩下要做的就是專注於特定的東西,這些東西會隨著應用程式的不同而改變。

顯然,僅使用小貓伺服器以這種方式做事並沒有太多可以節省的地方。它看起來有點像為了抽象而抽象。如果我們必須交付給客戶的應用程式只有小貓伺服器,那麼第一個版本可能就足夠了。如果您將擁有更大的應用程式,那麼將程式碼的通用部分與特定部分分開可能是值得的。

讓我們想像一下,我們有一些 Erlang 軟體在伺服器上運行。我們的軟體運行著一些小貓伺服器、一個獸醫進程(你送出你壞掉的小貓,它會將它們修復後退回)、一個小貓美容院、一個寵物食品、用品等等的伺服器。這些大部分都可以透過客戶端-伺服器模式實作。隨著時間的推移,您複雜的系統會充滿不同的伺服器到處運行。

新增伺服器會增加程式碼的複雜性,也會增加測試、維護和理解方面的複雜性。每個實作可能都不同,由不同的人以不同的風格編寫等等。但是,如果所有這些伺服器都共享相同的通用 my_server 抽象,您將大大降低這種複雜性。您可以立即理解模組的基本概念(「哦,這是一個伺服器!」),有一個單一的通用實作可供測試、記錄等等。其餘的工作可以放在每個特定的實作上。

A dung beetle pushing its crap

這意味著您可以減少很多追蹤和解決錯誤的時間(只需在一個地方為所有伺服器執行此操作)。這也意味著您可以減少引入的錯誤數量。如果您要不斷地重寫 my_server:call/3 或進程的主迴圈,不僅會更耗時,而且忘記一個或另一個步驟的可能性也會飆升,錯誤也會飆升。錯誤越少意味著晚上需要去修復錯誤的電話越少,這對我們所有人來說絕對是件好事。您的結果可能會有所不同,但我敢打賭您也不喜歡在休假日時去上班修復錯誤。

當我們將通用與特定分開時所做的另一件有趣的事情是,我們立即使測試我們的個別模組變得容易得多。如果您想對舊的小貓伺服器實作進行單元測試,您需要為每個測試產生一個進程,為其提供正確的狀態,發送您的訊息並希望得到您期望的回覆。另一方面,我們的第二個小貓伺服器只需要我們在 'handle_call/3' 和 'handle_cast/2' 函式上運行函式調用,並查看它們輸出的新狀態。無需設定伺服器,操作狀態。只需將其作為函式參數傳遞即可。請注意,這也意味著伺服器的通用方面更容易測試,因為您可以只實作非常簡單的函式,這些函式除了讓您專注於您想要觀察的行為之外,什麼都不做,而無需其餘部分。

以這種方式使用通用抽象的更「隱藏」的優點是,如果每個人都為他們的進程使用完全相同的後端,當有人優化該單一後端使其速度更快一點時,使用它的每個進程也將會運行得更快一點。為了使這一原則在實踐中發揮作用,通常需要有很多人使用相同的抽象並為它們付出努力。幸運的是,對於 Erlang 社群來說,這就是 OTP 框架所發生的事情。

回到我們的模組。還有很多事情我們尚未處理:命名程序、設定逾時、加入除錯資訊、如何處理非預期的訊息、如何連結熱代碼載入、處理特定錯誤、抽象化掉撰寫大多數回覆的需求、處理大多數關閉伺服器的方式、確保伺服器與監督者良好協作等等。詳細介紹所有這些內容對本文而言是多餘的,但對於需要發布的實際產品而言是必要的。再次強調,您可能會理解為什麼自己完成所有這些事情是有點冒險的任務。幸運的是,對於您(以及將會支援您的應用程式的人們)來說,Erlang/OTP 團隊已經透過 `gen_server` 行為幫您處理了這一切。`gen_server` 有點像是加強版的 `my_server`,只是它背後有著多年來經過測試和生產使用的經驗。