5-監督者和應用程式

StraightDave發表於2014-10-30

5-監督者和應用程式

  • 第一個監督者
  • 理解應用程式
    • 啟動應用程式
    • 應用程式的回撥函式
    • 工程還是應用程式?
  • 簡單的一對一監督者
  • 監督樹

到目前為止,我們的程式需要一個事件管理器和一個登錄檔程式。它還會有不是成百,就是上千的bucket程式。 你是不是覺得這個還不錯?沒有東西是完美的,也許馬上就要出現bug了。

當有東西掛了,我們的第一反應是:“快拯救這些錯誤”。但是,像在《入門》中學到的那樣,不同於其它多數語言,Elixir不太做“防禦性程式設計”。 相反,我們說“要掛快點掛”,或是“就讓它掛”。 如果有bug要讓我們的登錄檔程式掛掉,啥也不怕,因為我們即將用監督者啟動一個新的登錄檔程式。

本章,我們就要學習監督者,還會講到應用程式。一個不夠,我們要建立兩個監督者,用它們監督我們的程式。

5.1-第一個監督者

建立一個監督者跟建立GenServer比沒多少不同。 定義一個名為KV.Supervisor的模組,這個模組使用Supervisor行為抽象。此檔案lib/kv/supervisor.ex的內容:

defmodule KV.Supervisor do
  use Supervisor

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

  @manager_name KV.EventManager
  @registry_name KV.Registry

  def init(:ok) do
    children = [
      worker(GenEvent, [[name: @manager_name]]),
      worker(KV.Registry, [@manager_name, [name: @registry_name]])
    ]

    supervise(children, strategy: :one_for_one)
  end
end

我們的監督者有兩個孩子:事件管理器和登錄檔程式。通常會給監督者旗下的程式起個名字,好讓別的程式用名字而不是pid訪問它們。 這很有用,因為一個被監督的程式可能會掛,一掛再重啟,pid就變了。
@manager_name@registry_name這兩個模組屬性給我們監督者的倆孩子宣告名字,然後在“工人(worker)”的定義中引用這兩個屬性值。儘管不是必須用模組屬性來定義名字,但很實用,因為這樣讀起來很醒目。

舉個例子,KV.Registry工人接受兩個引數,第一個是事件管理器的名字,第二個是一個選項鍵值列表。 在這裡,我們設定一個名字選項[name: KV.Registry](使用之前定義的模組屬性:@registry_name),確保我們可以在整個應用程式中通過名字KV.Registry訪問登錄檔程式。用定義它們的模組名稱給監督者孩子起名的做法十分普遍,因為在除錯一個正則執行的系統時很有用。

監督者中孩子們宣告的順序也是有區別的。因為登錄檔依賴於事件管理器,我們必須先啟動前者。這也是在孩子列表中,GenEvent工人的位置靠前的原因。

最後,我們呼叫了supervisor/2,給它傳遞了一個孩子列表,以及策略::one_for_one

監督的策略指明瞭當一個孩子程式掛了會發生什麼。:one_for_one意思是如果一個孩子程式掛了,只有一個它的“複製品”會啟動來替代它。 這個策略現在是說得通的。如果事件管理器掛了,沒理由連登錄檔程式也重啟。反之亦然。 但是如果監督者旗下的孩子越來越多時,這個策略就需要改變了。Supervisor行為抽象支援許多不同的策略,我們在本章中將會討論其中三種。

如果我們在工程中啟動命令列對話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>}

當我們啟動監督樹,事件管理器和登錄檔程式都自動被啟動,允許我們建立bucket。不再需要手動啟動它們。

儘管在實戰中,我們很少手動啟動應用程式的監督者。相反,它的啟動是應用程式回撥的一部分。

5.2-理解應用程式

我們已經在這個應用程式上花了很多時間。每次修改了一個檔案,執行mix compile,我們都能看到Generated kv.app訊息列印出來。

我們可以在_build/dev/lib/kv/ebin/kv.app找到.app檔案。我們來看一下它的內容:

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

該檔案包含Erlang的語句(使用Erlang的語法寫的)。即使我們不熟悉Erlang,也能很容易猜到這個檔案儲存的是我們應用程式的定義。 它包括應用程式的版本,定義的所有模組,還有它依賴的應用程式列表,如Erlang的Kernel,elixir本身,logger(我們在mix.exs裡新增的)。

要是每次我們新增一個新的模組就要手動修改這個檔案,是很討厭的。這也是為啥把它交給mix來自動維護的原因。

我們還可以通過修改mix.exs工程檔案中的函式application/0的返回值,來配置產生的.app檔案。 我們會在接下來幾章講到這個。

5.2.1-啟動應用程式

定義了.app檔案(裡面是應用程式的定義),我們就可以將應用程式視作一個整體形式來啟動和停止。 到目前為止我們還沒有考慮過這個問題,這是因為:

  1. Mix為我們自動啟動了應用程式
  2. 即使Mix不自動啟動我們的程式,要啟動該程式時也不需要做啥特別的事兒

總之,讓我們看看Mix如何為我們啟動應用程式,先啟動工程下的命令列,然後試著:

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

擦,已經啟動了?

我們可以給mix一個選項,讓它不要啟動我們的應用程式。命令:iex -S mix run --no-start

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

這次我們得到的錯誤是由於:kv所依賴的應用程式(這裡是:logger)沒有啟動。 Mix一般會根據工程中的mix.exs啟動整個應用程式結構;對其依賴的每個應用程式來說也是這樣(如果它們還依賴於其它應用程式)。 但是這次我們用了--no-start標誌,因此我們需要手動按順序啟動所有應用程式,或者像這樣呼叫Application.ensure_all_started:

iex> Application.ensure_all_started(:kv)
{:ok, [:logger, :kv]}
iex> Application.stop(:kv)
18:12:10.698 [info] Application kv exited :stopped
:ok

沒什麼激動人心的,但是這演示瞭如何控制我們的應用程式。

當你執行iex -S mix,它相當於iex -S mix run。因此無論何時你啟動iex會話,傳遞引數給mix run,實際上是傳遞給run命令。你可以在命令列中執行mix help run獲取關於run的更多資訊。

5.2.2-應用程式的回撥函式

因為我們幾乎都在講應用程式如何啟動和停止,你能猜到肯定有某個方法能在啟動的當兒做點有意義的事情。沒錯,有的!

我們可以定義應用程式的回撥函式。在應用程式啟動時,該函式將被呼叫。 這個函式必須返回{:ok, pid},其中pid是其內部監督者程式的識別符號。

我們分兩步來定義這個回撥函式。首相,開啟mix.exs檔案,修改def application部分為:

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

選項:mod指出了“應用程式回撥函式的模組”,後面跟著個在應用程式啟動是會被傳遞過來的引數。 這個回撥函式的模組可以是任意的,只要它實現了Application行為。

現在,我們讓KV來做這個回撥函式的模組。在檔案lib/kv.ex中,做一下修改:

defmodule KV do
  use Application

  def start(_type, _args) do
    KV.Supervisor.start_link
  end
end

當我們use Application,我們僅僅需要定義一個start/2函式。 而如果我們想在應用程式停止時定義一個自定義的行為,我們可以定義一個stop/1函式。 在這裡,我們不這麼做,就用其預設的、自動定義在use Application中的行為。

現在我們再次用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>}

好牛逼!

5.2.3-工程還是應用程式?

Mix是區分工程(projects)和應用程式(applications)的。 基於目前的mix.exs,我們可以說,我們有一個Mix 工程,該工程定義了:kv應用程式。 在後面章節我們會看到,有些工程一個應用程式也沒定義。

當我們講“工程”時,你應該想到Mix。Mix是管理工程的工具。 它知道如何去編譯、測試你的工程,等等。它還知道如何編譯和啟動你的工程的相關應用程式。

當我們講“應用程式”時,我們討論的是OTP。應用程式是一個實體,它作為一個整體啟動或者停止。 你可以在應用程式模組文件閱讀更多關於應用程式的知識。

5.3 簡單的一對一監督者

我們已經成功定義了我們的監督者,它作為我們應用程式生命週期的一部分自動啟動(和停止)。

回一下,我們的KV.Registryhandle_cast/2回撥中,連結並且監視bucket程式:

{:ok, pid} = KV.Bucket.start_link()
ref = Process.monitor(pid)

連結是雙向的,意味著一個bucket程式掛了會導致登錄檔程式掛掉。儘管現在我們有了監督者,它能保證一旦登錄檔程式掛了還可以重啟, 但是我們儲存在bucket相應程式的資料還是會丟失。

換句話說,我們希望即使bucket程式掛了,登錄檔程式也能夠保持執行。寫個測試:

test "removes bucket on crash", %{registry: registry} do
  KV.Registry.create(registry, "shopping")
  {:ok, bucket} = KV.Registry.lookup(registry, "shopping")

  # Kill the bucket and wait for the notification
  Process.exit(bucket, :shutdown)
  assert_receive {:exit, "shopping", ^bucket}
  assert KV.Registry.lookup(registry, "shopping") == :error
end

這個測試很像之前的“退出時移除bucket”,只是我們的做法更加有點問題。 我們沒有使用Agent.stop/1,而是傳送了一個退出訊號來關閉bucket程式。因為bucket是連結在登錄檔程式的,而登錄檔程式是連線著測試程式,讓bucket掛回導致連測試程式都掛掉:

1) test removes bucket on crash (KV.RegistryTest)
   test/kv/registry_test.exs:52
   ** (EXIT from #PID<0.94.0>) shutdown

一個可行的解決方法是提供KV.Bucket.start/0,讓它執行Agent.start/1。 在登錄檔程式中使用這個方法啟動bucket,從而去掉了它們之間的連結。 但是這不是個好辦法,因為這樣bucket程式就連結不到任何程式。這意味著所有bucket程式即使在不可訪問的狀態下也一直活著。

我們將定義一個新的監督者來解決這個問題。這個新監督者來派生和監督所有的bucket。 有一個簡單的一對一監督策略,叫做:simple_one_for_one,對於此情況是非常適用的: 他允許指定一個工人模板,而後監督基於那個模板的多個孩子。

讓我們定義KV.Bucket.Supervisor

defmodule KV.Bucket.Supervisor do
  use Supervisor

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

  def start_bucket(supervisor) do
    Supervisor.start_child(supervisor, [])
  end

  def init(:ok) do
    children = [
      worker(KV.Bucket, [], restart: :temporary)
    ]

    supervise(children, strategy: :simple_one_for_one)
  end
end

比起之前的,這個監督者有兩點改變。

第一,我們定義了start_bucket/1函式,接受一個監督者id,並且啟動一個bucket程式作為該監督者的孩子。start_bucket/1代替了之前在登錄檔程式中直接呼叫的KV.Bucket.start_link

第二,在init/1回撥中,我們將工人標記為:temporary。意思是如果bucket掛了,它不會重啟! 這是因為我們只是想用監督者將bucket組織起來。建立bucket還得通過登錄檔程式。

執行iex -S mix,試一試我們的新監督者:

iex> {:ok, sup} = KV.Bucket.Supervisor.start_link
{:ok, #PID<0.70.0>}
iex> {:ok, bucket} = KV.Bucket.Supervisor.start_bucket(sup)
{:ok, #PID<0.72.0>}
iex> KV.Bucket.put(bucket, "eggs", 3)
:ok
iex> KV.Bucket.get(bucket, "eggs")
3

現在來對登錄檔程式稍作修改,以配合bucket的監督者。 我們會沿用和處理事件管理器時相同的策略,會顯式地傳遞bucket的監督者的pid給KV.Registery.start_link/3。 讓我們先從修改test/kv/registry_test.exs中的Setup回撥開始:

setup do
  {:ok, sup} = KV.Bucket.Supervisor.start_link
  {:ok, manager} = GenEvent.start_link
  {:ok, registry} = KV.Registry.start_link(manager, sup)

  GenEvent.add_mon_handler(manager, Forwarder, self())
  {:ok, registry: registry}
end

接下來開始修改KV.Registry中的函式,將新監督者投入使用:

## Client API

@doc """
Starts the registry.
"""
def start_link(event_manager, buckets, opts \\ []) do
  # 1. Pass the buckets supevisor as argument
  GenServer.start_link(__MODULE__, {event_manager, buckets}, opts)
end

## Server callbacks

def init({events, buckets}) do
  names = HashDict.new
  refs  = HashDict.new
  # 2. Store the buckets supevisor in the state
  {:ok, %{names: names, refs: refs, events: events, buckets: buckets}}
end

def handle_cast({:create, name}, state) do
  if HashDict.get(state.names, name) do
    {:noreply, state}
  else
    # 3. Use the buckets supervisor instead of starting buckets directly
    {:ok, pid} = KV.Bucket.Supervisor.start_bucket(state.buckets)
    ref = Process.monitor(pid)
    refs = HashDict.put(state.refs, ref, name)
    names = HashDict.put(state.names, name, pid)
    GenEvent.sync_notify(state.events, {:create, name, pid})
    {:noreply, %{state | names: names, refs: refs}}
  end
end

改這些基本上就能讓測試通過了。要完成任務,只需再修改下這個監督者,讓原來bucket的那個監督者也成為它的孩子。

5.4-監督樹

為了使用bucket的監督者,我們要把它作為一個孩子加到KV.Supervisor中去。 注意,我們已經開始用一個監督者去監督另一個監督者了。正式的稱呼是“監督樹”。

開啟lib/kv/supervisor.ex,新增一個新的模組屬性儲存bucket監督者的名字,並且修改init/1

@manager_name KV.EventManager
@registry_name KV.Registry
@bucket_sup_name KV.Bucket.Supervisor

def init(:ok) do
  children = [
    worker(GenEvent, [[name: @manager_name]]),
    supervisor(KV.Bucket.Supervisor, [[name: @bucket_sup_name]]),
    worker(KV.Registry, [@manager_name, @bucket_sup_name, [name: @registry_name]])
  ]

  supervise(children, strategy: :one_for_one)
end

這一次,我們新增了一個監督者作為孩子,並且給了它一個名字KV.Bucket.Supervisor(和它的模組名相同)。 我們還更新了KV.Registry這個工人,使它接受bucket監督者的名字作為引數。

記住,宣告各個孩子的順序是很重要的。因為登錄檔程式依賴於bucket監督者,所以bucket監督者需要在孩子列表中排得靠前一些。

因為我們已為監督者新增了多個孩子,現在就需要考慮使用:one_for_one這個策略還是否正確。 一個顯現的問題就是登錄檔程式和bucket監督者之間的關係。如果登錄檔程式掛了,bucket監督者也必須掛。 因為一旦登錄檔程式掛了,所有關聯bucket名字和其程式的資訊也就丟失了。此時若bucket的監督者還活著,它掌管的眾多bucket根本訪問不到,變成垃圾。

我們可以考慮使用其他的策略,如:one_for_all。這個策略在任何時候,只要有一個孩子掛,它就會停止並且重啟所有孩子程式。 這也許不是最理想的,因為登錄檔程式掛了不應該影響到事件管理器。 事實上,這麼做也是有害的,因為要是事件管理器掛了,所有註冊的事件處理者也都跟著會被移除。

一個可行的方案是再新建一個監督者,讓它來監督登錄檔程式和bucket監督者,使用:one_for_all策略。 讓我們的根監督者監督這個新建的,以及事件管理器這兩個孩子,使用:one_for_one策略。 這個監督樹大概是一下這個樣子:

* root supervisor [one_for_one]
  * event manager
  * supervisor [one_for_all]
    * buckets supervisor [simple_one_for_one]
      * buckets
    * registry

你可以試著構建這個監督樹,但我們不會再對它進行改進了。 因為下一章我們會修改登錄檔程式,讓它可以持久化(persist)它的註冊資訊,使得我們用的:one_for_one策略成為最合適的策略,省得新增監督樹結構或者改變策略了。

記住,還有幾個策略可以傳遞給worker/2supervisor/2supervise/2函式,所以可別忘記閱讀監督者模組的文件.

相關文章