建立 OTP 應用程式

我為什麼要這麼做?

A construction sign with a squid holdin a shovel, rather than a man doing so

在看到我們整個應用程式的監督樹透過一個簡單的函數呼叫立即啟動後,我們可能會想知道為什麼要讓事情比現在更複雜。監督樹背後的概念有點複雜,我認為當系統第一次設定時,我可以使用腳本手動啟動所有這些樹狀結構和子樹狀結構。然後在那之後,我就可以自由地去外面試著找尋看起來像動物的雲朵來度過剩下的下午時光。

這完全沒錯,是的。這是一種可以接受的做法(尤其是關於雲的部分,因為現在一切都與雲端運算有關)。然而,就程式設計師和工程師所做的大多數抽象概念而言,OTP 應用程式是許多臨時系統被概括並變得清晰的結果。如果你要建立一個腳本和命令陣列來啟動你的監督樹(如上所述),而與你合作的其他開發人員也有他們自己的腳本和命令陣列,你很快就會遇到嚴重的問題。然後有人會問類似「如果每個人都使用相同的系統來啟動一切,那不是很好嗎?如果他們都有相同的應用程式結構,那不是更好嗎?」的問題。

OTP 應用程式試圖解決的就是這種問題。它們提供目錄結構、處理組態的方式、處理依賴項的方式、建立環境變數和組態、啟動和停止應用程式的方式,以及在不關閉應用程式的情況下偵測衝突和處理即時升級的大量安全控制。

因此,除非您不想要這些方面(也不想要它們帶來的好處,例如一致的結構和為其開發的工具),否則本章應該會讓您感興趣。

我的另一輛車是游泳池

我們將重複使用我們在上一章中編寫的 ppool 應用程式,並將其轉變為真正的 OTP 應用程式。

這樣做的第一步是將所有與 ppool 相關的檔案複製到整齊的目錄結構中

ebin/
include/
priv/
src/
 - ppool.erl
 - ppool_sup.erl
 - ppool_supersup.erl
 - ppool_worker_sup.erl
 - ppool_serv.erl
 - ppool_nagger.erl
test/
 - ppool_tests.erl

大多數目錄目前都將保持空白。正如在 設計並行應用程式 章節中所解釋的,ebin/ 目錄將保存編譯後的檔案,include/ 目錄將包含 Erlang 標頭 (.hrl) 檔案,priv/ 將保存可執行檔、其他程式以及應用程式運作所需的各種特定檔案,而 src/ 將保存您需要的 Erlang 源檔案。

A pool with wheels and an exhaust pipe

您會注意到我新增了一個 test/ 目錄,只是為了放我之前擁有的測試檔案。這樣做的原因是測試有點常見,但您不一定希望將它們作為應用程式的一部分發布 — 您只需要在開發程式碼和向您的經理證明自己時使用它們(「測試通過,我不明白為什麼應用程式會害人」)。其他類似的目錄會根據需要添加,具體取決於情況。一個例子是 doc/ 目錄,只要您有 EDoc 文件要添加到應用程式時就會新增。

四個基本目錄是 ebin/include/priv/src/,它們在您獲得的幾乎每個 OTP 應用程式中都很常見,儘管在部署真正的 OTP 系統時,只會匯出 ebin/priv/

應用程式資源檔案

我們該從哪裡開始?好吧,首先要做的是新增應用程式檔案。此檔案會告知 Erlang VM 應用程式是什麼、從哪裡開始和從哪裡結束。此檔案位於 ebin/ 目錄中,與所有已編譯的模組一起。

此檔案通常命名為 <yourapp>.app(在我們的例子中是 ppool.app),並且包含一堆 Erlang 術語,以 VM 可以理解的方式定義應用程式(VM 非常不擅長猜測東西!)

注意:有些人喜歡將此檔案保留在 ebin/ 之外,而將名為 <myapp>.app.src 的檔案作為 src/ 的一部分。他們使用的任何建置系統都會將此檔案複製到 ebin/,甚至會產生一個檔案,以保持一切井然有序。

應用程式檔案的基本結構很簡單

{application, ApplicationName, Properties}.

其中 ApplicationName 是一個 atom,而 Properties 是一個 {Key, Value} 元組列表,用於描述應用程式。它們由 OTP 使用,以了解您的應用程式的作用以及其他資訊,它們都是可選的,但始終攜帶它們可能很有用,並且對於某些工具是必要的。事實上,我們現在只會查看其中的一部分,並在需要時引入其他部分

{description, "您的應用程式的簡短描述"}

這會為系統提供應用程式的簡短描述。該欄位是可選的,預設為空字串。我建議始終定義描述,即使只是因為它使事情更容易閱讀。

{vsn, "1.2.3"}

告知您的應用程式的版本。該字串採用您想要的任何格式。通常最好堅持採用 <major>.<minor>.<patch> 或類似形式的方案。當我們使用工具來協助升級和降級時,該字串會用於識別您的應用程式的版本。

{modules, ModuleList}

包含您的應用程式引入系統的所有模組的列表。一個模組始終最多屬於一個應用程式,並且不能同時出現在兩個應用程式的 app 檔案中。此列表允許系統和工具查看您的應用程式的依賴項,確保所有內容都位於其應該在的位置,並且您與已載入系統的其他應用程式沒有衝突。如果您使用標準的 OTP 結構並使用像 rebar3 這樣的建置工具,則會為您處理此問題。

{registered, AtomList}

包含應用程式註冊的所有名稱的列表。這讓 OTP 知道當您嘗試將一堆應用程式捆綁在一起時,何時會發生名稱衝突,但完全基於信任開發人員提供良好資料。我們都知道情況並非總是如此,因此在這種情況下不應使用盲目的信任。

{env, [{Key, Val}]}

這是一個鍵/值列表,可以用作應用程式的組態。它們可以在執行時透過呼叫 application:get_env(Key)application:get_env(AppName, Key) 取得。第一個將嘗試在您呼叫時所在的任何應用程式的應用程式檔案中尋找該值,第二個允許您特別指定一個應用程式。可以根據需要覆寫這些內容(無論是在啟動時還是透過使用 application:set_env/3-4)。

總而言之,這是一個非常方便的地方來儲存組態資料,而不是擁有一堆組態檔案,以任何格式讀取,而不知道將它們儲存在哪裡以及其他問題。人們通常傾向於在上面推出自己的系統,因為並非每個人都喜歡在組態檔案中使用 Erlang 語法。

{maxT, Milliseconds}

這是應用程式可以執行的最長時間,之後它將關閉。這是一個相當少使用的項目,而 Milliseconds 預設為 infinity,因此您通常根本不需要理會這個項目。

{applications, AtomList}

您的應用程式依賴的應用程式列表。Erlang 的應用程式系統將確保它們在允許您的應用程式執行之前已載入和/或啟動。所有應用程式至少依賴於 kernelstdlib,但如果您的應用程式依賴於 ppool 已啟動,那麼您應該將 ppool 新增到列表中。

注意:是的,標準函式庫和 VM 的核心本身就是應用程式,這表示 Erlang 是一種用於建置 OTP 的語言,但其執行環境依賴於 OTP 才能運作。它是循環的。這讓您了解為什麼該語言正式命名為「Erlang/OTP」。

{mod, {CallbackMod, Args}}

使用應用程式行為(我們將在 下一節 中看到)為應用程式定義回呼模組。這告訴 OTP,當啟動您的應用程式時,它應該呼叫 CallbackMod:start(normal, Args)。當 OTP 在停止您的應用程式時呼叫 CallbackMod:stop(StartReturn) 時,將使用此函數的回傳值。人們傾向於將 CallbackMod 以其應用程式命名。

這涵蓋了我們現在(以及您將編寫的大多數應用程式)可能需要的大部分內容。

轉換池

我們如何將其付諸實踐?我們將把上一章中的 ppool 處理程序集轉換為基本的 OTP 應用程式。這樣做的第一步是將所有內容重新分發到正確的目錄結構下。只需建立五個目錄並按如下方式分發檔案

ebin/
include/
priv/
src/
	- ppool.erl
	- ppool_serv.erl
	- ppool_sup.erl
	- ppool_supersup.erl
	- ppool_worker_sup.erl
test/
	- ppool_tests.erl
	- ppool_nagger.erl

您會注意到我將 ppool_nagger 移到 test 目錄。這是有充分理由的 — 它只是一個演示案例,與我們的應用程式無關,但對於測試仍然是必要的。我們實際上可以在應用程式完成封裝後稍後嘗試它,以便我們確保一切仍然正常運作,但目前它有點沒用。

我們將新增一個 Emakefile(適當命名為 Emakefile,放置在應用程式的根目錄中),以協助我們稍後編譯和執行程式

{"src/*", [debug_info, {i,"include/"}, {outdir, "ebin/"}]}.
{"test/*", [debug_info, {i,"include/"}, {outdir, "ebin/"}]}.

這只是告訴編譯器包含 src/test/ 中所有檔案的 debug_info,告訴它去 include/ 目錄中尋找(如果需要的話),然後將這些檔案推入其 ebin/ 目錄。

說到這裡,讓我們將 app 檔案 新增到 ebin/ 目錄中

{application, ppool,
 [{vsn, "1.0.0"},
  {modules, [ppool, ppool_serv, ppool_sup, ppool_supersup, ppool_worker_sup]},
  {registered, [ppool]},
  {mod, {ppool, []}}
 ]}.

此檔案僅包含我們認為必要的欄位;未使用 envmaxTapplications。我們現在需要變更回呼模組 (ppool) 的運作方式。我們究竟該如何做到這一點?

首先,讓我們看看應用程式行為。

注意:即使所有應用程式都依賴於 kernelstdlib 應用程式,我也不會包含它們。ppool 仍然可以運作,因為啟動 Erlang VM 會自動啟動這些應用程式。您可能會覺得為了明確起見而新增它們,但現在沒有需要

Parody of Indiana Jones' scene where he substitutes a treasure for a fake weight. The piece of gold has 'generic' written on it, and the fake weight has 'specific' on it

應用程式行為

如同我們所見的大多數 OTP 抽象概念,我們想要的是預先建置的實作。Erlang 程式設計師不喜歡將設計模式作為一種慣例,他們需要的是一個穩固的抽象概念。這為我們提供了應用程式的行為。請記住,行為始終是將通用程式碼與特定程式碼分離。它們表示您的特定程式碼放棄自己的執行流程,並將自身插入為一組回呼函式,供通用程式碼使用。簡而言之,行為處理無聊的部分,而您則負責將各個環節連結起來。以應用程式來說,這個通用部分相當複雜,並不如下其他行為那麼簡單。

每當虛擬機器首次啟動時,就會啟動一個名為「應用程式控制器」的處理程序(名稱為 application_controller)。它會啟動所有其他應用程式,並且位於它們的大多數之上。事實上,您可以說應用程式控制器的作用有點像所有應用程式的監管者。我們將在從混亂到應用程式章節中看到有哪些監管策略。

注意:從技術上來說,應用程式控制器並非位於所有應用程式之上。一個例外是核心應用程式,它本身會啟動一個名為 user 的處理程序。實際上,user 處理程序會作為應用程式控制器和核心應用程式的群組領導者,因此需要一些特殊處理。我們不必在意這一點,但為了精確起見,我覺得應該將其包含在內。

在 Erlang 中,IO 系統取決於一個名為「群組領導者」的概念。群組領導者代表標準輸入和輸出,並由所有處理程序繼承。存在一個隱藏的 IO 協定,群組領導者和任何呼叫 IO 函式的處理程序都會與其通信。然後,群組領導者負責將這些訊息轉發到任何可用的輸入/輸出通道,並在本文範圍內進行一些我們無需關心的神奇操作。

無論如何,當有人決定要啟動應用程式時,應用程式控制器(在 OTP 術語中通常表示為 AC)會啟動一個「應用程式主控」。應用程式主控實際上是兩個負責管理每個個別應用程式的處理程序:它們設定應用程式,並充當應用程式的頂層監管者和應用程式控制器之間的中間人。OTP 是一種官僚體系,我們有很多層中間管理人員!我不會深入探討其中發生的細節,因為大多數 Erlang 開發人員實際上永遠不需要關心這些,而且幾乎沒有任何文件(程式碼就是文件)。只需知道應用程式主控的作用有點像應用程式的保姆(嗯,是一個非常瘋狂的保姆)。它會關注其子代和孫代,當情況不妙時,它會變得狂暴並終止其整個家族樹。殘酷地殺害孩子是 Erlang 使用者之間常見的話題。

具有一堆應用程式的 Erlang VM 可能看起來有點像這樣

The Application controller stands over three application masters (in this graphic, in real life it has many more), which each stand on top of a supervisor process

到目前為止,我們仍然在關注行為的通用部分,但特定部分呢?畢竟,這才是我們真正需要編寫程式碼的部分。嗯,應用程式回呼模組只需要很少的函式就能運作:start/2stop/1

第一個函式的形式為 YourMod:start(Type, Args)。目前,Type 將永遠是 normal(接受的其他可能性與分散式應用程式有關,我們將在稍後看到)。Args 是來自您的應用程式檔案的內容。此函式會初始化應用程式的所有內容,並且只需要以以下兩種形式之一傳回應用程式頂層監管者的 PID:{ok, Pid}{ok, Pid, SomeState}。如果您不傳回 SomeState,它會預設為 []

stop/1 函式會將 start/2 傳回的狀態作為引數。它會在應用程式執行完成後執行,並且只會執行必要的清理工作。

就這樣。一個龐大的通用部分,一個小型的特定部分。對此感到慶幸吧,因為您不會想太常編寫其餘的程式碼(如果您想這麼做,請查看原始碼!)您還可以選擇性地使用一些其他函式來更精確地控制應用程式,但我們目前不需要它們。這表示我們可以繼續處理我們的 ppool 應用程式!

從混亂到應用程式

我們有了應用程式檔案,並且大致了解應用程式的運作方式。兩個簡單的回呼函式。開啟 ppool.erl,我們變更以下幾行

-export([start_link/0, stop/0, start_pool/3,
         run/2, sync_queue/2, async_queue/2, stop_pool/1]).

start_link() ->
    ppool_supersup:start_link().

stop() ->
    ppool_supersup:stop().

改為以下幾行

-behaviour(application).
-export([start/2, stop/1, start_pool/3,
         run/2, sync_queue/2, async_queue/2, stop_pool/1]).

start(normal, _Args) ->
    ppool_supersup:start_link().

stop(_State) ->
    ok.

然後,我們可以確保測試仍然有效。挑選舊的 ppool_tests.erl 檔案(我為前一章編寫的檔案,現在又將其帶回來),並將對 ppool:start_link/0 的單一呼叫取代為 application:start(ppool),如下所示

find_unique_name() ->
    application:start(ppool),
    Name = list_to_atom(lists:flatten(io_lib:format("~p",[now()]))),
    ?assertEqual(undefined, whereis(Name)),
    Name.

您也應該花時間從 ppool_supersup 中移除 stop/0(並移除 export),因為 OTP 應用程式工具會為我們處理此問題。

我們終於可以重新編譯程式碼並執行所有測試,以確保一切仍然正常運作(我們稍後會看到 eunit 的運作方式,請別擔心)

$ erl -make
Recompile: src/ppool_worker_sup
Recompile: src/ppool_supersup
...
$ erl -pa ebin/
...
1> make:all([load]).
Recompile: src/ppool_worker_sup
Recompile: src/ppool_supersup
Recompile: src/ppool_sup
Recompile: src/ppool_serv
Recompile: src/ppool
Recompile: test/ppool_tests
Recompile: test/ppool_nagger
up_to_date
2> eunit:test(ppool_tests).
  All 14 tests passed.
ok

由於 timer:sleep(X) 用於在某些地方同步所有內容,因此測試需要一段時間才能執行,但它應該會告訴您一切都正常運作,如上所示。好消息,我們的應用程式很健康。

現在,我們可以透過使用我們新的超讚回呼函式來研究 OTP 應用程式的奇妙之處

3> application:start(ppool).
ok
4> ppool:start_pool(nag, 2, {ppool_nagger, start_link, []}).
{ok,<0.142.0>}
5> ppool:run(nag, [make_ref(), 500, 10, self()]).
{ok,<0.146.0>}
6> ppool:run(nag, [make_ref(), 500, 10, self()]).
{ok,<0.148.0>}
7> ppool:run(nag, [make_ref(), 500, 10, self()]).
noalloc
9> flush().
Shell got {<0.146.0>,#Ref<0.0.0.625>}
Shell got {<0.148.0>,#Ref<0.0.0.632>}
...
received down msg
received down msg

這裡的神奇命令是 application:start(ppool)。這會告訴應用程式控制器啟動我們的 ppool 應用程式。它會啟動 ppool_supersup 監管者,從那時起,一切都可以像往常一樣使用。我們可以透過呼叫 application:which_applications() 來查看目前正在執行的所有應用程式

10> application:which_applications().
[{ppool,[],"1.0.0"},
 {stdlib,"ERTS  CXC 138 10","1.17.4"},
 {kernel,"ERTS  CXC 138 10","2.14.4"}]

真是令人驚訝,ppool 正在執行。如先前所述,我們可以發現所有應用程式都依賴於 kernelstdlib,它們也都在執行中。如果我們想要關閉池

11> application:stop(ppool).

=INFO REPORT==== DD-MM-YYYY::23:14:50 ===
    application: ppool
    exited: stopped
    type: temporary
ok

就完成了。您應該注意到,我們現在獲得了乾淨的關機以及一些資訊豐富的報告,而不是上一章中混亂的 ** exception exit: killed

注意:您有時會看到人們執行類似 MyApp:start(...) 的操作,而不是 application:start(MyApp)。雖然這在測試時有效,但它會破壞實際擁有應用程式的許多優勢:它不再是 VM 監管樹的一部分、無法存取其環境變數、不會在啟動前檢查相依性等等。如果可能,請盡量堅持使用 application:start/1

看看這個!關於我們的應用程式是「暫時」的,這又是怎麼回事?我們編寫 Erlang 和 OTP 程式碼是因為它們應該永遠執行,而不只是執行一段時間!VM 怎麼敢這麼說?秘密在於我們可以將不同的引數傳遞給 application:start。根據這些引數,VM 會對其應用程式之一的終止做出不同的反應。在某些情況下,VM 會是願意為其子女犧牲的慈愛野獸。在其他情況下,它則是一個冷酷無情且務實的機器,願意容忍其許多子女死亡,以求其物種的生存。

使用以下程式碼啟動的應用程式:application:start(AppName, temporary)
正常結束:沒有發生任何特殊情況,應用程式已停止。
異常結束:會回報錯誤,並且應用程式會終止,而不會重新啟動。
使用以下程式碼啟動的應用程式:application:start(AppName, transient)
正常結束:沒有發生任何特殊情況,應用程式已停止。
異常結束:會回報錯誤,所有其他應用程式都會停止,並且 VM 會關閉。
使用以下程式碼啟動的應用程式:application:start(AppName, permanent)
正常結束:所有其他應用程式都會終止,並且 VM 會關閉。
異常結束:相同;所有應用程式都會終止,並且 VM 會關閉。

當涉及到應用程式時,您可以看到監管策略中出現了一些新東西。VM 不再會嘗試拯救您。到目前為止,必須發生非常非常嚴重的錯誤,才會使其向上追溯其中一個重要應用程式的整個監管樹,足以使其崩潰。當這種情況發生時,VM 對您的程式已失去所有希望。鑑於瘋狂的定義是一遍又一遍地做同樣的事情,同時每次都期望不同的結果,VM 更喜歡理智地死去並直接放棄。當然,真正的原因與需要修正的損壞有關,但您了解我的意思。請注意,可以透過呼叫 application:stop(AppName) 來終止所有應用程式,而不會像發生崩潰一樣影響其他應用程式。

程式庫應用程式

當我們想要將平面模組包裝在應用程式中,但我們沒有要啟動的處理程序,因此不需要應用程式回呼模組時,會發生什麼情況?

在抓狂地拔頭髮並哭泣幾分鐘後,唯一剩下的事情就是從應用程式檔案中移除 tuple {mod, {Module, Args}}。就這樣。這稱為「程式庫應用程式」。如果您想要範例,Erlang 的 stdlib(標準程式庫)應用程式就是其中之一。

如果您有 Erlang 的原始碼套件,您可以前往 otp_src_<release>/lib/stdlib/src/stdlib.app.src 並看到以下內容

{application, stdlib,
 [{description, "ERTS  CXC 138 10"},
  {vsn, "%VSN%"},
  {modules, [array,
	 ...
     gen_event,
     gen_fsm,
     gen_server,
     io,
	 ...
     lists,
	 ...
     zip]},
  {registered,[timer_server,rsh_starter,take_over_monitor,pool_master,
               dets]},
  {applications, [kernel]},
  {env, []}]}.

您可以看到這是一個相當標準的應用程式檔案,但沒有回呼模組。這是一個程式庫應用程式。

我們來深入了解應用程式如何?