檢視原始碼 監督樹和應用程式

在關於 GenServer 的前一章節中,我們實作了 KV.Registry 來管理儲存區。在某個時間點,我們開始監控儲存區,因此我們能夠在 KV.Bucket 崩潰時採取行動。儘管變更相對較小,但它引發了一個 Elixir 開發人員經常提出的問題:當某些事情失敗時會發生什麼事?

在我們加入監控之前,如果儲存區崩潰,註冊表將永遠指向不再存在的儲存區。如果使用者嘗試讀取或寫入崩潰的儲存區,它將會失敗。任何嘗試使用相同名稱建立新儲存區的動作只會傳回崩潰儲存區的 PID。換句話說,那個儲存區的註冊表條目將永遠處於不良狀態。一旦我們加入監控,註冊表會自動移除崩潰儲存區的條目。現在嘗試查詢崩潰的儲存區(正確地)會顯示儲存區不存在,而系統使用者可以視需要成功建立新的儲存區。

在實務上,我們不希望作為儲存區的處理程序會失敗。但是,如果出於任何原因確實發生這種情況,我們可以放心,我們的系統將繼續按照預期運作。

如果你有先前的程式設計經驗,你可能會想:「我們是否可以保證儲存區一開始就不會崩潰?」正如我們將看到的,Elixir 開發人員傾向於將這些做法稱為「防禦性程式設計」。這是因為實際的生產系統有數十種不同的原因可能導致問題。磁碟可能會故障,記憶體可能會損毀,有錯誤,網路可能會暫時停止運作,等等。如果我們要撰寫嘗試保護或規避所有這些錯誤的軟體,我們花在處理失敗的時間會比撰寫我們自己的軟體還多!

因此,Elixir 開發人員更喜歡「讓它崩潰」或「快速失敗」。而我們可以從失敗中復原最常見的方法之一,就是重新啟動系統中崩潰的任何部分。

例如,想像你的電腦、路由器、印表機或任何裝置無法正常運作。你有多常透過重新啟動來修復它?一旦我們重新啟動裝置,我們就會將裝置重設回其初始狀態,而該狀態經過充分測試且保證可以運作。在 Elixir 中,我們對軟體套用相同的方法:每當處理程序崩潰時,我們就會啟動新的處理程序來執行與崩潰處理程序相同的工作。

在 Elixir 中,這項工作是由 Supervisor 來完成的。Supervisor 是個處理程序,用來監督其他處理程序,並在它們崩潰時重新啟動它們。為此,Supervisor 會管理所有受監督處理程序的完整生命週期,包括啟動和關閉。

在本章中,我們將學習如何透過監督 KV.Registry 處理程序,將這些概念付諸實行。畢竟,如果註冊表出現問題,整個註冊表就會遺失,而且永遠都找不到任何儲存區!為了解決這個問題,我們將定義一個 KV.Supervisor 模組,以確保我們的 KV.Registry 在任何時刻都處於啟動並執行狀態。

在本章的最後,我們還將討論應用程式。正如我們所見,Mix 已將我們所有的程式碼打包到一個應用程式中,我們將學習如何自訂我們的應用程式,以確保我們的 Supervisor 和註冊表在我們的系統啟動時處於啟動並執行狀態。

我們的首個監督程式

監督程式是一個處理程序,用來監督其他處理程序,我們稱之為子處理程序。監督一個處理程序的行為包括三項不同的責任。第一項是啟動子處理程序。一旦子處理程序正在執行,監督程式可能會重新啟動子處理程序,可能是因為它異常終止,也可能是因為達到某個條件。例如,如果任何子處理程序死亡,監督程式可能會重新啟動所有子處理程序。最後,監督程式還負責在系統關閉時關閉子處理程序。請參閱 Supervisor 模組,以取得更深入的討論。

建立一個監督程式與建立一個 GenServer 沒有太大的不同。我們將在 lib/kv/supervisor.ex 檔案中定義一個名為 KV.Supervisor 的模組,它將使用 Supervisor 行為。

defmodule KV.Supervisor do
  use Supervisor

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @impl true
  def init(:ok) do
    children = [
      KV.Registry
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

到目前為止,我們的監督程式只有一個子處理程序:KV.Registry。在我們定義一個子處理程序清單後,我們呼叫 Supervisor.init/2,傳遞子處理程序和監督策略。

監督策略決定當其中一個子處理程序崩潰時會發生什麼事。:one_for_one 表示如果一個子處理程序死亡,它將是唯一重新啟動的子處理程序。由於我們現在只有一個子處理程序,這就是我們所需要的。 Supervisor 行為支援多種策略,我們將在本章中討論這些策略。

一旦監督程式啟動,它將遍歷子處理程序清單,並在每個模組上呼叫 child_spec/1 函式。

函數 child_spec/1 會回傳子規格,說明如何啟動程序,如果程序是工作程序或監督程序,如果程序是暫時、瞬時或永久等。當我們 use Agentuse GenServeruse Supervisor 等時,會自動定義 child_spec/1 函數。讓我們在終端機中使用 iex -S mix 來試試看

iex> KV.Registry.child_spec([])
%{id: KV.Registry, start: {KV.Registry, :start_link, [[]]}}

在我們繼續本指南時,我們將了解這些詳細資訊。如果你想先睹為快,請查看 Supervisor 文件。

在監督程序擷取所有子規格後,它會依序啟動其子代,順序為定義順序,並使用子規格中 :start 鍵中的資訊。對於我們的目前規格,它會呼叫 KV.Registry.start_link([])

讓我們來試用一下監督程序

iex> {:ok, sup} = KV.Supervisor.start_link([])
{:ok, #PID<0.148.0>}
iex> Supervisor.which_children(sup)
[{KV.Registry, #PID<0.150.0>, :worker, [KV.Registry]}]

到目前為止,我們已經啟動了監督程序並列出其子代。監督程序啟動後,也會啟動其所有子代。

如果我們故意讓監督程序啟動的註冊表發生異常,會發生什麼事?讓我們在 call 上傳送錯誤輸入來這麼做

iex> [{_, registry, _, _}] = Supervisor.which_children(sup)
[{KV.Registry, #PID<0.150.0>, :worker, [KV.Registry]}]
iex> GenServer.call(registry, :bad_input)
08:52:57.311 [error] GenServer #PID<0.150.0> terminating
** (FunctionClauseError) no function clause matching in KV.Registry.handle_call/3
iex> Supervisor.which_children(sup)
[{KV.Registry, #PID<0.157.0>, :worker, [KV.Registry]}]

請注意,在我們因為錯誤輸入而導致第一個註冊表發生異常後,監督程序如何自動啟動新的註冊表,並使用新的 PID。

在先前的章節中,我們總是直接啟動程序。例如,我們會呼叫 KV.Registry.start_link([]),它會回傳 {:ok, pid},這讓我們可以透過其 pid 與註冊表互動。現在程序是由監督程序啟動,我們必須直接詢問監督程序其子代是誰,並從回傳的子代清單中擷取 PID。實際上,每次都這麼做會非常昂貴。為了解決這個問題,我們通常會為程序命名,讓它們可以在我們的程式碼中的任何地方在單一機器中被唯一識別。

讓我們來學習如何這麼做。

命名程序

雖然我們的應用程式會有許多儲存區,但它只會有一個註冊表。因此,每當我們啟動註冊表時,我們都希望為它命名一個唯一名稱,以便我們可以從任何地方存取它。我們透過將 :name 選項傳遞給 KV.Registry.start_link/1 來這麼做。

讓我們稍微變更我們的子代定義(在 KV.Supervisor.init/1 中),讓它成為一個元組清單,而不是原子清單

  def init(:ok) do
    children = [
      {KV.Registry, name: KV.Registry}
    ]

這樣一來,監督程序現在會透過呼叫 KV.Registry.start_link(name: KV.Registry) 來啟動 KV.Registry

如果你重新檢視 KV.Registry.start_link/1 實作,你會記得它只是將選項傳遞給 GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

反過來會使用給定的名稱註冊程序。 :name 選項預期本地命名程序的原子(本地命名表示它可供這部機器使用,還有其他選項,我們在此不討論)。由於模組識別碼是原子(在 IEx 中嘗試 i(KV.Registry)),我們可以根據實作它的模組命名程序,前提是該名稱只有一個程序。這有助於除錯和內省系統。

讓我們在 iex -S mix 中嘗試更新的監督程式

iex> KV.Supervisor.start_link([])
{:ok, #PID<0.66.0>}
iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.70.0>}

這次監督程式啟動了命名登錄,讓我們能夠建立儲存區,而不必從監督程式明確擷取 PID。你也應該知道如何讓登錄再次崩潰,而不用查詢其 PID:試試看。

在這個時候,你可能會想:你是否也應該本地命名儲存區程序?請記住,儲存區是根據使用者輸入動態啟動的。由於本地名稱必須是原子,我們必須動態建立原子,這是一個壞主意,因為一旦定義原子,它就永遠不會被刪除或垃圾回收。這表示,如果我們根據使用者輸入動態建立原子,我們最終會用完記憶體(或更精確地說,VM 會崩潰,因為它對原子數目施加硬性限制)。這個限制正是我們建立自己的登錄(或使用 Elixir 內建的 Registry 模組)的原因。

我們越來越接近一個完全運作的系統。監督程式會自動啟動登錄。但是,我們如何在系統啟動時自動啟動監督程式?要回答這個問題,讓我們來談談應用程式。

了解應用程式

我們一直都在應用程式內工作。每次我們變更檔案並執行 mix compile 時,我們可以在編譯輸出中看到 Generated kv app 訊息。

我們可以在 _build/dev/lib/kv/ebin/kv.app 找到已產生的 .app 檔案。讓我們看看它的內容

{application,kv,
             [{applications,[kernel,stdlib,elixir,logger]},
              {description,"kv"},
              {modules,['Elixir.KV','Elixir.KV.Bucket','Elixir.KV.Registry',
                        'Elixir.KV.Supervisor']},
              {registered,[]},
              {vsn,"0.1.0"}]}.

此檔案包含 Erlang 詞彙(使用 Erlang 語法撰寫)。即使我們不熟悉 Erlang,也很容易猜出此檔案包含我們的應用程式定義。它包含我們的應用程式 版本、它定義的所有模組,以及我們依賴的應用程式清單,例如 Erlang 的 kernelelixir 本身和 logger

logger 應用程式與 Elixir 一起提供。我們在 mix.exs:extra_applications 清單中指定它,表示我們的應用程式需要它。請參閱 官方文件 以取得更多資訊。

簡而言之,應用程式包含 .app 檔案中定義的所有模組,包括 .app 檔案本身。應用程式通常只有兩個目錄:ebin,用於 Elixir 製成品,例如 .beam.app 檔案,以及 priv,其中包含應用程式中可能需要的任何其他製成品或資產。

儘管 Mix 為我們產生並維護 .app 檔案,但我們可以透過在 mix.exs 專案檔案中的 application/0 函式中新增新項目來自訂其內容。我們很快就會進行第一次自訂。

啟動應用程式

我們系統中的每個應用程式都可以啟動和停止。啟動和停止應用程式的規則也在 .app 檔案中定義。當我們呼叫 iex -S mix 時,Mix 會編譯我們的應用程式,然後啟動它。

讓我們實際看看。使用 iex -S mix 啟動主控台,然後嘗試

iex> Application.start(:kv)
{:error, {:already_started, :kv}}

哎呀,它已經啟動了。Mix 會自動啟動目前的應用程式及其所有相依性。這也適用於 mix test 和許多其他 Mix 指令。

不過,我們可以停止我們的 :kv 應用程式,以及 :logger 應用程式

iex> Application.stop(:kv)
:ok
iex> Application.stop(:logger)
:ok

讓我們再次嘗試啟動我們的應用程式

iex> Application.start(:kv)
{:error, {:not_started, :logger}}

現在我們會收到錯誤,因為 :kv 相依的應用程式(在本例中為 :logger)尚未啟動。我們需要以正確的順序手動啟動每個應用程式,或呼叫 Application.ensure_all_started/1,如下所示

iex> Application.ensure_all_started(:kv)
{:ok, [:logger, :kv]}

實際上,我們的工具總是為我們啟動應用程式,但如果您需要細緻的控制,則可以使用 API。

應用程式回呼

每當我們呼叫 iex -S mix 時,Mix 會自動透過呼叫 Application.start(:kv) 來啟動我們的應用程式。但我們可以自訂應用程式啟動時發生的動作嗎?事實上,我們可以!為此,我們定義一個應用程式回呼。

第一步是告訴我們的應用程式定義(例如我們的 .app 檔案),哪個模組將實作應用程式回呼。我們透過開啟 mix.exs 並將 def application 變更為以下內容來執行此動作:

  def application do
    [
      extra_applications: [:logger],
      mod: {KV, []}
    ]
  end

:mod 選項指定「應用程式回呼模組」,後面接著應用程式啟動時要傳遞的參數。應用程式回呼模組可以是實作 Application 行為的任何模組。

若要實作 Application 行為,我們必須 use Application 並定義一個 start/2 函式。start/2 的目標是啟動一個監督者,然後監督者將啟動任何子服務或執行我們的應用程式可能需要的任何其他程式碼。我們利用這個機會來啟動我們在本章節稍早實作的 KV.Supervisor

由於我們已將 KV 指定為模組回呼,因此我們變更在 lib/kv.ex 中定義的 KV 模組,以實作一個 start/2 函式

defmodule KV do
  use Application

  @impl true
  def start(_type, _args) do
    # Although we don't use the supervisor name below directly,
    # it can be useful when debugging or introspecting the system.
    KV.Supervisor.start_link(name: KV.Supervisor)
  end
end

請注意,這麼做會中斷測試 KVhello 函式的樣板測試案例。您可以簡單地移除該測試案例。

當我們 use Application 時,我們可以定義幾個函式,類似於我們使用 SupervisorGenServer 時。這次我們只需要定義一個 start/2 函式。Application 行為也有 stop/1 回呼,但實際上很少使用。您可以查看文件以取得更多資訊。

現在您已定義一個啟動我們監督者的應用程式回呼,我們預期在我們啟動 iex -S mix 時,KV.Registry 程序會啟動並執行。我們再試一次

iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.88.0>}

讓我們回顧一下發生了什麼事。每當我們呼叫 iex -S mix 時,它會自動透過呼叫 Application.start(:kv) 來啟動我們的應用程式,然後呼叫應用程式回呼。應用程式回呼的工作是啟動一個**監督樹狀結構**。現在,我們的監督者有一個名為 KV.Registry 的單一子項,並以 KV.Registry 名稱啟動。我們的監督者可以有其他子項,而且其中一些子項可能是它們自己的監督者,並有自己的子項,進而形成所謂的監督樹狀結構。

專案或應用程式?

Mix 區分專案和應用程式。根據我們的 mix.exs 檔案內容,我們會說我們有一個定義 :kv 應用程式的 Mix 專案。正如我們在後面的章節中所見,有些專案沒有定義任何應用程式。

當我們說「專案」時,你應該想到 Mix。Mix 是用來管理專案的工具。它知道如何編譯專案、測試專案等等。它也知道如何編譯和啟動與專案相關的應用程式。

當我們談論應用程式時,我們談論的是 OTP。應用程式是作為一個整體由執行時間啟動和停止的實體。你可以在 Application 模組的說明文件瞭解更多關於應用程式,以及它們如何與系統整體的開機和關機相關。

後續步驟

儘管這章節是我們第一次實作監督者,但這不是我們第一次使用它!在前一章節中,當我們在測試期間使用 start_supervised! 來啟動註冊表時,ExUnit 會在 ExUnit 架構本身管理的監督者下啟動註冊表。透過定義我們自己的監督者,我們提供了更結構化的方式來初始化、關閉和監督應用程式中的程序,使我們的生產程式碼和測試與最佳實務保持一致。

但我們還沒完成。到目前為止,我們正在監督註冊表,但我們的應用程式也在啟動儲存區。由於儲存區是動態啟動的,因此我們可以使用一種稱為 DynamicSupervisor 的特殊監督者類型,它經過最佳化以處理此類場景。讓我們接著探討它。