設計並行應用程式

An alarm clock

一切都很好。您了解這些概念,但話說回來,從本書一開始到現在,我們所舉的都是玩具範例:計算機、樹狀結構、希斯洛到倫敦等等。現在是時候做一些更有趣且更具教育意義的事情了。我們將用並行的 Erlang 編寫一個小型應用程式。這個應用程式會很小巧且基於行,但仍然有用且具有適度的可擴展性。

我是一個有點沒組織的人。我為家庭作業、公寓周圍的事情、這本書、工作、會議、約會等等而迷失方向。我到處都有十幾個待辦事項清單,但我還是會忘記去做或查看。希望您仍然需要提醒您要做什麼(但您沒有像我一樣容易分心的思緒),因為我們將編寫一個事件提醒應用程式,提示您執行操作並提醒您約會。

了解問題

第一步是了解我們到底在做什麼。「提醒應用程式」,您說。「當然」,我說。但還有更多。我們打算如何與軟體互動?我們希望它為我們做什麼?我們如何用程序表示這個程式?我們如何知道要發送哪些訊息?

正如引言所說,「如果兩者都凍結了,那麼在水上行走和根據規格開發軟體都很容易。」因此,讓我們取得一份規格並堅持下去。我們的小型軟體將允許我們執行以下操作

這是我選擇用來執行此操作的程式結構

5 components are there: A client (1) that can communicate with an event server (2) and 3 little circles labeled 'x', 'y', and 'z'. All three are linked to the event server.

其中客戶端、事件伺服器和 x、y 和 z 都是程序。以下是它們各自可以執行的操作

事件伺服器

客戶端

x、y 和 z

請注意,所有客戶端(本書中未實作的 IM、郵件等)都會收到所有事件的通知,而取消事件並不是要警告客戶端的事情。這裡的軟體是為您和我編寫的,並假設只有一個使用者會執行它。

以下是包含所有可能訊息的更複雜圖表

A visual representation of the list above

這表示我們將擁有的每個程序。透過在那裡繪製所有箭頭並說它們是訊息,我們已經編寫了一個高階協定,或至少是它的骨架。

應該注意的是,為要提醒的每個事件使用一個程序可能過於繁瑣,並且很難在真實世界的應用程式中擴展。但是,對於您將成為唯一使用者的應用程式來說,這已經足夠了。另一種方法是使用諸如 timer:send_after/2-3 之類的函式,以避免產生過多的程序。

定義協定

現在我們知道每個元件必須做什麼以及如何通訊,一個好主意是建立一個將被傳送的所有訊息的清單,並指定它們的外觀。讓我們首先從客戶端和事件伺服器之間的通訊開始

The client can send {subscribe, Self} to the event server, which can reply only with 'ok'. Note that both the client and server monitor eachother

在這裡,我選擇使用兩個監視器,因為客戶端和伺服器之間沒有明顯的相依性。我的意思是,當然,客戶端在沒有伺服器的情況下無法運作,但伺服器可以在沒有客戶端的情況下存在。連結可以在這裡完成工作,但因為我們希望我們的系統可以擴展到許多客戶端,所以我們不能假設其他客戶端都希望在伺服器當機時崩潰。我們也不能假設客戶端可以真正轉換為系統程序,並在伺服器當機時捕獲退出。現在開始下一個訊息集

The client can send the message {add, Name, Description, TimeOut}, to which the server can either reply 'ok' or {error, Reason}

這會將事件新增至事件伺服器。會以 ok 原子形式回傳確認,除非發生錯誤(也許 TimeOut 的格式錯誤。)反向操作(移除事件)可以執行如下操作

The client can send the message {cancel, Name} and the event server should return ok as an atom

然後,當事件到期時,事件伺服器可以稍後發送通知

The event server forwards a {done, Name, Description} message to the client

然後,我們只需要以下兩種特殊情況,當我們想要關閉伺服器或當機時

When the client sends the 'shutdown' atom to the event server, it dies and returns {'DOWN', Ref, process, Pid, shutdown} because it was monitored

當伺服器當機時,不會發送直接確認,因為監視器會警告我們。這幾乎就是客戶端和事件伺服器之間發生的所有事情。現在來說說事件伺服器和事件程序本身之間的訊息。

在開始之前,這裡要注意的是,讓事件伺服器連結到事件會很有用。原因是我們希望所有事件在伺服器當機時終止:它們在沒有伺服器的情況下毫無意義。

好的,回到事件。當事件伺服器啟動它們時,它會給每個事件一個特殊的識別碼(事件的名稱)。一旦其中一個事件的時間到來,它就需要發送一則訊息,說明它已經到來

An event can send {done, Id} to the event server

另一方面,事件必須監看來自事件伺服器的取消呼叫

The server sends 'cancel' to an event, which replies with 'ok'

應該就是這樣了。我們的協定還需要最後一個訊息,它可以讓我們升級伺服器

the event server has to accept a 'code_change' message from the shell

不需要回覆。當我們實際編寫該功能時,您將看到它有道理。

在定義協定和大致了解我們的程序層次結構的外觀後,我們實際上可以開始處理專案了。

奠定基礎

A cement truck

首先,我們應該奠定標準的 Erlang 目錄結構,如下所示

ebin/
include/
priv/
src/

ebin/ 目錄是編譯後檔案將放置的位置。include/ 目錄用於儲存將被其他應用程式包含的 .hrl 檔案;私有的 .hrl 檔案通常只保留在 src/ 目錄中。priv/ 目錄用於可能必須與 Erlang 互動的可執行檔,例如特定的驅動程式等等。我們實際上不會在這個專案中使用該目錄。然後,最後一個是 src/ 目錄,其中會保留所有 .erl 檔案。

在標準 Erlang 專案中,此目錄結構可能會有些不同。可以為特定的設定檔新增 conf/ 目錄、為文件新增 doc/ 目錄,以及為應用程式執行所需的第三方函式庫新增 lib/ 目錄。市場上不同的 Erlang 產品通常使用不同的名稱,但上述四個名稱通常保持不變,因為它們是 標準 OTP 實務 的一部分。

事件模組

進入 src/ 目錄並開始 event.erl 模組,它將實作早期圖紙中的 x、y 和 z 事件。我從這個模組開始,因為它是相依性最少的模組:我們將能夠在無需實作事件伺服器或客戶端函式的情況下嘗試執行它。

在真正編寫程式碼之前,我必須提到協定是不完整的。它有助於表示資料將從程序到程序傳送的內容,而不是它的複雜性:位址如何運作、我們是否使用參考或名稱等等。大多數訊息將以 {Pid, Ref, Message} 形式包裝,其中 Pid 是傳送者,而 Ref 是唯一的訊息識別碼,以協助了解哪個回覆來自誰。如果我們在尋找回覆之前傳送許多訊息,我們將不知道哪個回覆與哪個訊息對應,而沒有參考。

所以我們開始了。將執行 event.erl 程式碼的程序的核心將是函式 loop/1,如果您記得協定,它會看起來有點像下面的骨架

loop(State) ->
    receive
        {Server, Ref, cancel} ->
            ...
    after Delay ->
        ...
    end.

這顯示了我們必須支援的逾時,以宣告事件已到期,以及伺服器可以呼叫取消事件的方式。您會注意到迴圈中的變數 StateState 變數必須包含資料,例如逾時值(以秒為單位)和事件名稱(以便傳送訊息 {done, Id}。)它還需要知道事件伺服器的 PID,以便向其發送通知。

這些都適合保留在迴圈的狀態中。因此,讓我們在檔案頂部宣告一個 state 記錄

-module(event).
-compile(export_all).
-record(state, {server,
                name="",
                to_go=0}).

定義此狀態後,應該可以更精細地完善迴圈

loop(S = #state{server=Server}) ->
    receive
        {Server, Ref, cancel} ->
            Server ! {Ref, ok}
    after S#state.to_go*1000 ->
        Server ! {done, S#state.name}
    end.

在這裡,乘以一千是為了將 to_go 值從秒更改為毫秒。

別喝太多 Kool-Aid
語言缺陷即將到來!我之所以在函式標頭中繫結變數 'Server',是因為它在接收區段中的模式比對中使用。請記住,記錄是駭客行為! 運算式 S#state.server 會秘密擴展為 element(2, S),這不是有效的比對模式。

after 部分之後,對於 S#state.to_go 來說,這仍然可以正常運作,因為它可以是一個稍後才要評估的運算式。

現在來測試迴圈

6> c(event).
{ok,event}
7> rr(event, state).
[state]
8> spawn(event, loop, [#state{server=self(), name="test", to_go=5}]).
<0.60.0>
9> flush().
ok
10> flush().
Shell got {done,"test"}
ok
11> Pid = spawn(event, loop, [#state{server=self(), name="test", to_go=500}]).
<0.64.0>
12> ReplyRef = make_ref().
#Ref<0.0.0.210>
13> Pid ! {self(), ReplyRef, cancel}.
{<0.50.0>,#Ref<0.0.0.210>,cancel}
14> flush().
Shell got {#Ref<0.0.0.210>,ok}
ok

這裡有很多東西要看。首先,我們使用 rr(Mod) 從事件模組匯入記錄。然後,我們以 shell 作為伺服器 (self()) 產生事件迴圈。此事件應該在 5 秒後觸發。第 9 個運算式在 3 秒後執行,第 10 個運算式在 6 秒後執行。您可以看到我們在第二次嘗試時確實收到了 {done, "test"} 訊息。

在那之後,我嘗試取消功能(有足夠的 500 秒來輸入它)。您可以看到我建立了參考、傳送了訊息,並收到了具有相同參考的回覆,所以我知道我收到的 ok 是來自此程序,而不是系統上的任何其他程序。

取消訊息使用參照 (reference) 包裝,而 done 訊息則否的原因很簡單,因為我們不希望它來自任何特定的地方(任何地方都可以,我們不會匹配接收),也不應該回覆它。我還想事先做另一個測試。如果明年發生事件呢?

15> spawn(event, loop, [#state{server=self(), name="test", to_go=365*24*60*60}]).
<0.69.0>
16> 
=ERROR REPORT==== DD-MM-YYYY::HH:mm:SS ===
Error in process <0.69.0> with exit value: {timeout_value,[{event,loop,1}]}

哎呀。看來我們遇到了實作上的限制。原來 Erlang 的逾時值限制在大約 50 天的毫秒數。這可能不重要,但我顯示這個錯誤有三個原因:

  1. 在撰寫模組並測試它時,在章節的一半時,它給我帶來了麻煩。
  2. Erlang 當然不是所有任務的完美選擇,而我們在這裡看到的是以實作者不打算的方式使用計時器的後果。
  3. 這其實不是問題;我們來解決它。

我決定採用的解決方法是編寫一個函數,如果逾時值太長,會將其分割成多個部分。這也需要 loop/1 函數的一些支援。所以,分割時間的方法基本上是將其分成 49 天的相等部分(因為限制大約是 50 天),然後將餘數與所有這些相等的部分放在一起。秒數列表的總和現在應該是原始時間。

%% Because Erlang is limited to about 49 days (49*24*60*60*1000) in
%% milliseconds, the following function is used
normalize(N) ->
    Limit = 49*24*60*60,
    [N rem Limit | lists:duplicate(N div Limit, Limit)].

函數 lists:duplicate/2 將以第二個參數取得給定的表達式,並將其重複多次,次數為第一個參數的值 ([a,a,a] = lists:duplicate(3, a))。如果我們要將值 98*24*60*60+4 發送給 normalize/1,它會回傳 [4,4233600,4233600]loop/1 函數現在應該像這樣來容納新的格式:

%% Loop uses a list for times in order to go around the ~49 days limit
%% on timeouts.
loop(S = #state{server=Server, to_go=[T|Next]}) ->
    receive
        {Server, Ref, cancel} ->
            Server ! {Ref, ok}
    after T*1000 ->
        if Next =:= [] ->
            Server ! {done, S#state.name};
           Next =/= [] ->
            loop(S#state{to_go=Next})
        end
    end.

您可以試試看,它應該會像平常一樣運作,但現在支援數年甚至數年的逾時時間。它的運作方式是取得 to_go 列表的第一個元素,並等待其整個持續時間。完成後,會驗證逾時列表的下一個元素。如果它是空的,則逾時結束,並通知伺服器。否則,迴圈會繼續使用列表的其餘部分,直到完成。

每次啟動事件程序時都必須手動呼叫類似 event:normalize(N) 的東西會非常惱人,特別是因為我們的解決方法不應該讓使用我們程式碼的程式設計師擔心。標準的做法是改用 init 函數來處理迴圈函數良好運作所需的所有資料初始化。在我們進行此操作時,我們將新增標準的 startstart_link 函數。

start(EventName, Delay) ->
    spawn(?MODULE, init, [self(), EventName, Delay]).

start_link(EventName, Delay) ->
    spawn_link(?MODULE, init, [self(), EventName, Delay]).

%%% Event's innards
init(Server, EventName, Delay) ->
    loop(#state{server=Server,
                name=EventName,
                to_go=normalize(Delay)}).

現在介面更簡潔了。不過在測試之前,最好讓唯一可以發送的訊息,即取消訊息,也擁有自己的介面函數。

cancel(Pid) ->
    %% Monitor in case the process is already dead
    Ref = erlang:monitor(process, Pid),
    Pid ! {self(), Ref, cancel},
    receive
        {Ref, ok} ->
            erlang:demonitor(Ref, [flush]),
            ok;
        {'DOWN', Ref, process, Pid, _Reason} ->
            ok
    end.

哦!一個新技巧!在這裡我使用監視器來查看程序是否存在。如果程序已經死亡,我會避免無謂的等待時間,並根據協定傳回 ok。如果程序使用參照回覆,那麼我知道它很快就會死亡:我會移除參照,以避免在不再關心它們時收到它們。請注意,我也提供了 flush 選項,如果 DOWN 訊息在我們有時間取消監視之前就已傳送,則會清除該訊息。

讓我們測試這些。

17> c(event).
{ok,event}
18> f().
ok
19> event:start("Event", 0).
<0.103.0>
20> flush().
Shell got {done,"Event"}
ok
21> Pid = event:start("Event", 500).
<0.106.0>
22> event:cancel(Pid).
ok

它運作正常!事件模組最後一件惱人的事是我們必須以秒為單位輸入剩餘時間。如果我們可以使用標準格式(例如 Erlang 的日期時間 ({{Year, Month, Day}, {Hour, Minute, Second}})),那就更好了。只需新增以下函數,它將計算您電腦上的目前時間與您插入的延遲之間的差異。

time_to_go(TimeOut={{_,_,_}, {_,_,_}}) ->
    Now = calendar:local_time(),
    ToGo = calendar:datetime_to_gregorian_seconds(TimeOut) -
           calendar:datetime_to_gregorian_seconds(Now),
    Secs = if ToGo > 0  -> ToGo;
              ToGo =< 0 -> 0
           end,
    normalize(Secs).

哦,對了。calendar 模組的函數名稱非常古怪。如上所述,這會計算現在和事件應該觸發之間經過的秒數。如果事件在過去,我們會改為傳回 0,以便它能夠盡快通知伺服器。現在修正 init 函數以呼叫此函數,而不是 normalize/1。如果您希望名稱更具描述性,也可以將 Delay 變數重新命名為 DateTime

init(Server, EventName, DateTime) ->
    loop(#state{server=Server,
                name=EventName,
                to_go=time_to_go(DateTime)}).

現在完成這些後,我們可以休息一下。啟動一個新的事件,去喝一杯(半公升)牛奶/啤酒,然後及時回來查看傳入的事件訊息。

事件伺服器

讓我們處理事件伺服器。根據協定,它的骨架應該看起來像這樣:

-module(evserv).
-compile(export_all).

loop(State) ->
    receive
        {Pid, MsgRef, {subscribe, Client}} ->
            ...
        {Pid, MsgRef, {add, Name, Description, TimeOut}} ->
            ...
        {Pid, MsgRef, {cancel, Name}} ->
            ...
        {done, Name} ->
            ...
        shutdown ->
            ...
        {'DOWN', Ref, process, _Pid, _Reason} ->
            ...
        code_change ->
            ...
        Unknown ->
            io:format("Unknown message: ~p~n",[Unknown]),
            loop(State)
    end.

您會注意到,我使用與先前相同的 {Pid, Ref, Message} 格式包裝了需要回覆的呼叫。現在,伺服器需要在其狀態中保留兩件事:訂閱用戶端的列表和它產生的所有事件程序的列表。如果您有注意到,協定表示當事件完成時,事件伺服器應該接收 {done, Name},但發送 {done, Name, Description}。這裡的想法是盡可能減少流量,並且只讓事件程序關心絕對必要的事情。所以,是的,用戶端列表和事件列表。

-record(state, {events,    %% list of #event{} records
                clients}). %% list of Pids

-record(event, {name="",
                description="",
                pid,
                timeout={{1970,1,1},{0,0,0}}}).

現在迴圈的開頭有記錄定義:

loop(S = #state{}) ->
    receive
        ...
    end.

如果事件和用戶端都是 orddict 就太好了。我們不太可能一次有數百個。如果您還記得關於資料結構的章節,orddict 非常符合該需求。我們會編寫一個 init 函數來處理此事。

init() ->
    %% Loading events from a static file could be done here.
    %% You would need to pass an argument to init telling where the
    %% resource to find the events is. Then load it from here.
    %% Another option is to just pass the events straight to the server
    %% through this function.
    loop(#state{events=orddict:new(),
                clients=orddict:new()}).

在完成骨架和初始化後,我將逐一實作每個訊息。第一個訊息是關於訂閱的訊息。我們希望保留所有訂閱者的列表,因為當事件完成時,我們必須通知他們。此外,上面的協定提到我們應該監視它們。這是有道理的,因為我們不希望保留已崩潰的用戶端,並無緣無故地發送無用的訊息。總之,它應該看起來像這樣:

{Pid, MsgRef, {subscribe, Client}} ->
    Ref = erlang:monitor(process, Client),
    NewClients = orddict:store(Ref, Client, S#state.clients),
    Pid ! {MsgRef, ok},
    loop(S#state{clients=NewClients});
Hand drawn RSS logo

因此,這段 loop/1 的作用是啟動監視器,並以 Ref 金鑰將用戶端資訊儲存在 orddict 中。原因很簡單:我們需要取得用戶端 ID 的唯一另一個時間是當我們收到監視器的 EXIT 訊息時,該訊息將包含參照(這將讓我們擺脫 orddict 的條目)。

下一個要處理的訊息是新增事件的訊息。現在,有可能傳回錯誤狀態。我們將執行的唯一驗證是檢查我們接受的時間戳記。雖然很容易訂閱 {{Year,Month,Day}, {Hour,Minute,seconds}} 佈局,但我們必須確保我們不會執行諸如在非閏年時接受 2 月 29 日的事件,或任何其他不存在的日期。此外,我們不希望接受不可能的日期值,例如「5 小時,減 1 分鐘和 75 秒」。單一函數可以負責驗證所有這些。

我們將使用的第一個建置區塊是函數 calendar:valid_date/1。顧名思義,這個函數會檢查日期是否有效。可悲的是,calendar 模組的怪異之處並不僅僅止於古怪的名稱:實際上沒有任何函數可以確認 {H,M,S} 是否具有有效的值。我們也必須遵循古怪的命名方案來實作它。

valid_datetime({Date,Time}) ->
    try
        calendar:valid_date(Date) andalso valid_time(Time)
    catch
        error:function_clause -> %% not in {{Y,M,D},{H,Min,S}} format
            false
    end;
valid_datetime(_) ->
    false.

valid_time({H,M,S}) -> valid_time(H,M,S).
valid_time(H,M,S) when H >= 0, H < 24,
                       M >= 0, M < 60,
                       S >= 0, S < 60 -> true;
valid_time(_,_,_) -> false.

現在,valid_datetime/1 函數可以在我們嘗試新增訊息的部分中使用:

{Pid, MsgRef, {add, Name, Description, TimeOut}} ->
    case valid_datetime(TimeOut) of
        true ->
            EventPid = event:start_link(Name, TimeOut),
            NewEvents = orddict:store(Name,
                                      #event{name=Name,
                                             description=Description,
                                             pid=EventPid,
                                             timeout=TimeOut},
                                      S#state.events),
            Pid ! {MsgRef, ok},
            loop(S#state{events=NewEvents});
        false ->
            Pid ! {MsgRef, {error, bad_timeout}},
            loop(S)
    end;

如果時間有效,我們會產生一個新的事件程序,然後在將確認訊息傳送給呼叫者之前,將其資料儲存在事件伺服器的狀態中。如果逾時錯誤,我們會通知用戶端,而不是讓錯誤悄無聲息地通過或使伺服器崩潰。可以新增其他檢查來處理名稱衝突或其他限制(請記住更新協定文件!)

我們協定中定義的下一個訊息是取消事件的訊息。取消事件永遠不會在用戶端端失敗,因此程式碼在那裡更簡單。只需檢查事件是否在程序的狀態記錄中。如果是,請使用我們定義的 event:cancel/1 函數將其終止並發送 ok。如果找不到,只需告訴使用者一切正常即可 - 事件未執行,而這正是使用者想要的。

{Pid, MsgRef, {cancel, Name}} ->
    Events = case orddict:find(Name, S#state.events) of
                 {ok, E} ->
                     event:cancel(E#event.pid),
                     orddict:erase(Name, S#state.events);
                  error ->
                     S#state.events
             end,
    Pid ! {MsgRef, ok},
    loop(S#state{events=Events});

很好,很好。現在,從用戶端到事件伺服器的所有自願互動都已涵蓋。讓我們處理伺服器和事件本身之間發生的事情。有兩個訊息要處理:取消事件(已完成)和事件逾時。該訊息只是 {done, Name}

{done, Name} ->
    case orddict:find(Name, S#state.events) of
        {ok, E} ->
            send_to_clients({done, E#event.name, E#event.description},
                            S#state.clients),
            NewEvents = orddict:erase(Name, S#state.events),
            loop(S#state{events=NewEvents});
        error ->
            %% This may happen if we cancel an event and
            %% it fires at the same time
            loop(S)
    end;

send_to_clients/2 函數正如其名稱所示,定義如下:

send_to_clients(Msg, ClientDict) ->
    orddict:map(fun(_Ref, Pid) -> Pid ! Msg end, ClientDict).

這應該是大部分迴圈程式碼的內容。剩下的就是設定不同的狀態訊息:用戶端當機、關機、程式碼升級等等。它們來了:

shutdown ->
    exit(shutdown);
{'DOWN', Ref, process, _Pid, _Reason} ->
    loop(S#state{clients=orddict:erase(Ref, S#state.clients)});
code_change ->
    ?MODULE:loop(S);
Unknown ->
    io:format("Unknown message: ~p~n",[Unknown]),
    loop(S)

第一個案例(shutdown)非常明確。您收到終止訊息,讓程序終止。如果您想將狀態儲存到磁碟,這可能是執行此操作的一個可能位置。如果您想要更安全的儲存/結束語意,則可以在每個 addcanceldone 訊息上執行此操作。然後可以在 init 函數中完成從磁碟載入事件,並在它們出現時產生它們。

'DOWN' 訊息的操作也很簡單。這表示用戶端已死亡,因此我們會將其從狀態中的用戶端列表中移除。

為了除錯目的,不明訊息只會以 io:format/2 顯示,儘管真正的生產應用程式可能會使用專用的記錄模組。

接著是程式碼變更訊息。這個訊息非常有趣,我會單獨用一個章節來介紹。

熱愛熱程式碼

為了執行熱程式碼載入,Erlang 有一個稱為程式碼伺服器的東西。程式碼伺服器基本上是一個 VM 程序,負責管理ETS 表格(VM 原生的記憶體內資料庫表格)。程式碼伺服器可以在記憶體中保留單一模組的兩個版本,而且這兩個版本可以同時執行。當使用 c(Module) 編譯模組、使用 l(Module) 載入或使用 code 模組的許多函數之一載入時,會自動載入新版本的模組。

要了解的概念是,Erlang 同時具有本機外部呼叫。本機呼叫是您可以使用可能未匯出的函數進行的函數呼叫。它們只是 Atom(Args) 格式。另一方面,外部呼叫只能使用匯出的函數來完成,並且具有 Module:Function(Args) 格式。

當虛擬機中載入了同一個模組的兩個版本時,所有本地呼叫都會透過目前程序中執行的版本來完成。然而,外部呼叫總是會使用程式碼伺服器中可用的最新版本程式碼。因此,如果本地呼叫是從外部呼叫內發起的,它們就會在新版本的程式碼中執行。

A fake module showing local calls staying in the old version and external calls going on the new one

考量到 Erlang 中每個程序/actor 都需要進行遞迴呼叫才能改變其狀態,因此可以透過外部遞迴呼叫載入 actor 的全新版本。

注意: 如果在某個程序仍然使用第一個版本時載入模組的第三個版本,該程序會被虛擬機終止,因為虛擬機認定該程序是一個沒有監管者或無法自我升級的孤立程序。如果沒有任何程序執行最舊的版本,它就會被直接丟棄,而保留最新的版本。

有一些方法可以將自己綁定到系統模組,這樣每當載入模組的新版本時,它就會傳送訊息。透過這樣做,你可以在收到此類訊息時才觸發模組重新載入,並且始終使用程式碼升級函式,例如 MyModule:Upgrade(CurrentState),它將能夠根據新版本的規格轉換狀態資料結構。這種「訂閱」處理是由 OTP 框架自動完成的,我們很快就會開始學習它。對於提醒應用程式,我們不會使用程式碼伺服器,而是使用 shell 的自訂 code_change 訊息來進行非常基本的重新載入。這幾乎是你需要了解的所有關於熱程式碼載入的知識。不過,這裡有一個更通用的範例

-module(hotload).
-export([server/1, upgrade/1]).

server(State) ->
    receive
        update ->
            NewState = ?MODULE:upgrade(State),
            ?MODULE:server(NewState);  %% loop in the new version of the module
        SomeMessage ->
            %% do something here
            server(State)  %% stay in the same version no matter what.
    end.

upgrade(OldState) ->
    %% transform and return the state here.

正如你所見,我們的 ?MODULE:loop(S) 符合這種模式。

我說,隱藏你的訊息

隱藏訊息!如果你希望其他人基於你的程式碼和程序進行建構,你必須將訊息隱藏在介面函式中。以下是我們用於 evserv 模組的內容

start() ->
    register(?MODULE, Pid=spawn(?MODULE, init, [])),
    Pid.

start_link() ->
    register(?MODULE, Pid=spawn_link(?MODULE, init, [])),
    Pid.

terminate() ->
    ?MODULE ! shutdown.

我決定註冊伺服器模組,因為目前我們應該一次只執行一個。如果要擴展提醒功能以支援多個使用者,最好使用 global 模組gproc 程式庫來註冊名稱。為了這個範例應用程式,這樣做就足夠了。

我們寫的第一個訊息是下一個應該抽象化的內容:如何訂閱。我上面寫的小協定或規格要求一個監視器,因此這裡添加了一個監視器。在任何時候,如果訂閱訊息傳回的參考在 DOWN 訊息中,客戶端就會知道伺服器已關閉。

subscribe(Pid) ->
    Ref = erlang:monitor(process, whereis(?MODULE)),
    ?MODULE ! {self(), Ref, {subscribe, Pid}},
    receive
        {Ref, ok} ->
            {ok, Ref};
        {'DOWN', Ref, process, _Pid, Reason} ->
            {error, Reason}
    after 5000 ->
        {error, timeout}
    end.

下一個是事件添加

add_event(Name, Description, TimeOut) ->
    Ref = make_ref(),
    ?MODULE ! {self(), Ref, {add, Name, Description, TimeOut}},
    receive
        {Ref, Msg} -> Msg
    after 5000 ->
        {error, timeout}
    end.

請注意,我選擇將可能收到的 {error, bad_timeout} 訊息轉發給客戶端。我也可能決定透過引發 erlang:error(bad_timeout) 來使客戶端崩潰。是否要使客戶端崩潰還是轉發錯誤訊息仍然是社群中爭論的話題。這是另一個使客戶端崩潰的替代函式

add_event2(Name, Description, TimeOut) ->
    Ref = make_ref(),
    ?MODULE ! {self(), Ref, {add, Name, Description, TimeOut}},
    receive
        {Ref, {error, Reason}} -> erlang:error(Reason);
        {Ref, Msg} -> Msg
    after 5000 ->
        {error, timeout}
    end.

然後是事件取消,它只接收一個名稱

cancel(Name) ->
    Ref = make_ref(),
    ?MODULE ! {self(), Ref, {cancel, Name}},
    receive
        {Ref, ok} -> ok
    after 5000 ->
        {error, timeout}
    end.

最後一個是為客戶端提供的小便利功能,它用於累積一段時間內的所有訊息。如果找到訊息,它們將全部被提取,且該函式會盡快傳回

listen(Delay) ->
    receive
        M = {done, _Name, _Description} ->
            [M | listen(0)]
    after Delay*1000 ->
        []
    end.

試駕

現在你應該能夠編譯應用程式並進行試執行。為了使事情更簡單一點,我們將編寫一個特定的 Erlang makefile 來建置專案。開啟一個名為 Emakefile 的檔案並將其放在專案的基礎目錄中。該檔案包含 Erlang 術語,並為 Erlang 編譯器提供了烹調精美酥脆 .beam 檔案的配方

An old oven with smoke coming out of it
{'src/*', [debug_info,
           {i, "src"},
           {i, "include"},
           {outdir, "ebin"}]}.

這告訴編譯器將 debug_info 新增到檔案中(這很少是你會放棄的選項),在 src/include/ 目錄中尋找檔案,並將它們輸出到 ebin/ 中。

現在,在你的命令列中執行 erl -make,所有檔案都應該被編譯並放入 ebin/ 目錄中。透過執行 erl -pa ebin/ 來啟動 Erlang shell。-pa <directory> 選項告訴 Erlang 虛擬機將該路徑新增到它可以尋找模組的位置中。

另一個選項是像平常一樣啟動 shell 並呼叫 make:all([load])。這會在目前目錄中尋找名為 'Emakefile' 的檔案,重新編譯它(如果它已變更)並載入新檔案。

現在你應該能夠追蹤數千個事件(只需在你編寫文字時將 DateTime 變數替換為任何有意義的內容即可)

1> evserv:start().
<0.34.0>
2> evserv:subscribe(self()).
{ok,#Ref<0.0.0.31>}
3> evserv:add_event("Hey there", "test", FutureDateTime).
ok
4> evserv:listen(5).
[]
5> evserv:cancel("Hey there").
ok
6> evserv:add_event("Hey there2", "test", NextMinuteDateTime).
ok
7> evserv:listen(2000).
[{done,"Hey there2","test"}]

好棒好棒。考慮到我們建立的幾個基本介面函式,現在編寫任何客戶端應該都非常簡單了。

新增監管

為了使應用程式更穩定,我們應該像在 上一章 中所做的那樣,編寫另一個「重新啟動程式」。開啟一個名為 sup.erl 的檔案,我們的監管者將在這裡

-module(sup).
-export([start/2, start_link/2, init/1, loop/1]).

start(Mod,Args) ->
    spawn(?MODULE, init, [{Mod, Args}]).

start_link(Mod,Args) ->
    spawn_link(?MODULE, init, [{Mod, Args}]).

init({Mod,Args}) ->
    process_flag(trap_exit, true),
    loop({Mod,start_link,Args}).

loop({M,F,A}) ->
    Pid = apply(M,F,A),
    receive
        {'EXIT', _From, shutdown} ->
            exit(shutdown); % will kill the child too
        {'EXIT', Pid, Reason} ->
            io:format("Process ~p exited for reason ~p~n",[Pid,Reason]),
            loop({M,F,A})
    end.

這有點類似於「重新啟動程式」,雖然這個更通用一些。它可以接收任何模組,只要它具有 start_link 函式。它會無限期地重新啟動它監看的程序,除非監管者本身被關閉結束訊號終止。以下是如何使用它

1> c(evserv), c(sup).
{ok,sup}
2> SupPid = sup:start(evserv, []).
<0.43.0>
3> whereis(evserv).
<0.44.0>
4> exit(whereis(evserv), die).
true
Process <0.44.0> exited for reason die
5> exit(whereis(evserv), die).
Process <0.48.0> exited for reason die
true
6> exit(SupPid, shutdown).
true
7> whereis(evserv).
undefined

如你所見,關閉監管者也會關閉其子程序。

注意: 我們會在關於 OTP 監管者的章節中看到更先進和靈活的監管者。當人們提到監管樹時,他們想到的就是這些監管者。這裡展示的監管者只是存在的最基本形式,與真正的監管者相比,它並不完全適合生產環境。

命名空間(或缺乏命名空間)

A Gentleman about to step in a pile of crap

由於 Erlang 具有扁平的模組結構(沒有階層),因此某些應用程式經常會發生衝突。其中一個例子是經常使用的 user 模組,幾乎每個專案都會嘗試至少定義一次。這與 Erlang 隨附的 user 模組發生衝突。你可以使用函式 code:clash/0 來測試任何衝突。

因此,常見的模式是在每個模組名稱前面加上你的專案名稱。在這種情況下,我們的提醒應用程式的模組應重新命名為 reminder_evservreminder_supreminder_event

然後,一些程式設計師會決定新增一個模組,其名稱與應用程式本身相同,該模組會包裝程式設計師在使用他們自己的應用程式時可以使用的常見呼叫。範例呼叫可以是諸如使用監管者啟動應用程式、訂閱伺服器、新增和取消事件等函式。

務必注意其他命名空間,例如必須不發生衝突的已註冊名稱、資料庫表格等等。

這是一個非常基本的並行 Erlang 應用程式的全部內容。這個應用程式展示了我們可以擁有一堆並行程序,而無需過於費力地思考:監管者、客戶端、伺服器、用作計時器的程序(我們可以擁有數千個),等等。無需同步它們,沒有鎖定,沒有真正的主迴圈。訊息傳遞使我們能夠輕鬆地將應用程式劃分為幾個具有獨立關注點和任務的模組。

evserv.erl 中的基本呼叫現在可以用於建構客戶端,這些客戶端允許從 Erlang 虛擬機外部與事件伺服器互動,並使該程式真正有用。

但是,在此之前,我建議你閱讀有關 OTP 框架的內容。接下來的幾章將涵蓋它的一些建置區塊,這將允許開發更穩健和優雅的應用程式。Erlang 的強大功能很大一部分來自於使用它。這是一個經過精心設計且工程良好的工具,任何自重的 Erlang 程式設計師都必須了解。