3-GenServer

StraightDave發表於2014-10-23

3-GenServer

本章講述的GenServer不論在Erlang還是Elixir中都是非常核心的內容。 GenServer是OTP提供的一個抽象物,對OTP程式設計中經常用到的一個事物---伺服器模型其中的通用部分進行了封裝,從而程式設計師不需要重複實現這些通用部分,而是實現關鍵的行為程式碼。該行為內碼表分兩部分,使用者API和操作該GenServer的回撥方法。詳細內容請開始閱讀。

GenServer是一個Elixir模組,對應著Erlang/OTP裡面的gen_server,在使用時用use匯入。關於use的使用,之後還會推出《Elixir超程式設計》的一些內容加以描述。

第一個GenServer
測試一個GenServer
需要監控
call,cast還是info?
監視器還是連結?

上一章我們用agent實現了buckets,而根據第一章所描述的,我們的設計是給每個bucket賦予一個名字:

CREATE shopping
OK

PUT shopping milk 1
OK

GET shopping milk
1
OK

因為agent是個程式,因而每個bucket首先有一個程式的id(pid)而不是名字。 回憶一下《入門》中的“程式”那章,我們學習過給程式註冊一個名字。 鑑於此,我們可以使用這個方法來給bucket起名:

iex> Agent.start_link(fn -> [] end, name: :shopping)
{:ok, #PID<0.43.0>}
iex> KV.Bucket.put(:shopping, "milk", 1)
:ok
iex> KV.Bucket.get(:shopping, "milk")
1

這可是個很差的主意!在Elixir中,這些名字都會儲存為原子。這意味著我們從外部客戶端輸入的bucket名字,都會被轉換成原子。
記住,絕對不要把使用者輸入轉換為原子。這是因為原子是不會被垃圾收集器收集。一旦原子被建立,它就不會被撤下(你也沒法主動釋放一個原子,對吧)。使用使用者輸入生成原子就意味著使用者可以插入足夠不同的名字來耗盡系統記憶體空間! 在實際操作中,在它用完記憶體之前會先觸及Erland虛擬機器的最大原子數量,從而造成系統崩潰。

比起濫用名字序號產生器制,我們可以建立我們自己的登錄檔程式來維護一個字典,用該字典聯絡起每個bucket的名字和程式。

這個登錄檔要能夠保證永遠處於最新狀態。如果有一個bucket程式因故崩潰,登錄檔必須清除該程式資訊,以防止繼續服務下次查詢請求。 在Elixir中,我們描述這種情況會說“該登錄檔程式需要監視(monitor)每個bucket程式”。

我們將使用一個GenServer來建立一個登錄檔程式,用來派生和監視bucket程式。 在Elixir和OTP中,GenServer是建立這中程式的首選抽象物。

3.1-第一個GenServer

一個GenServer實現分為兩個部分:使用者API和伺服器回撥函式。這兩部分都要寫在同一個模組裡。 下面我們建立檔案lib/kv/registry.ex,包含以下內容:

defmodule KV.Registry do
  use GenServer

  ## Client API

  @doc """
  Starts the registry.
  """
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  @doc """
  Looks up the bucket pid for `name` stored in `server`.

  Returns `{:ok, pid}` if the bucket exists, `:error` otherwise.
  """
  def lookup(server, name) do
    GenServer.call(server, {:lookup, name})
  end

  @doc """
  Ensures there is a bucket associated to the given `name` in `server`.
  """
  def create(server, name) do
    GenServer.cast(server, {:create, name})
  end

  ## Server Callbacks

  def init(:ok) do
    {:ok, HashDict.new}
  end

  def handle_call({:lookup, name}, _from, names) do
     {:reply, HashDict.fetch(names, name), names}
  end

  def handle_cast({:create, name}, names) do
    if HashDict.get(names, name) do
      {:noreply, names}
    else
      {:ok, bucket} = KV.Bucket.start_link()
      {:noreply, HashDict.put(names, name, bucket)}
    end
  end
end

第一個函式是start_link/1,它啟動了一個新的GenServer。 其呼叫GenServer模組的start_link/3函式所使用的三個引數:

  1. 定義和實現了伺服器回撥函式的模組名稱。這裡的__MODULE__是當前模組名
  2. 初始引數,這裡是:ok
  3. 一個選項列表,它可以存放伺服器的名字等

你可以向一個GenServer傳送兩種請求。Call是同步的,伺服器必須傳送回覆給該類請求。 Cast是非同步的,伺服器不會傳送回覆訊息。

再往下的兩個方法,lookup/2create/2,它們用了兩種不同方式傳送請求給伺服器。 這兩種請求,會被第一個引數所指認的伺服器中的handle_call/3handle_cast/2函式處理(因此這裡你的伺服器回撥函式必須包含這兩個函式)。GenServer.call/2GenServer.cast/2除了指認伺服器之外,還告訴伺服器它們要傳送的請求。
這個請求儲存在元組裡,這裡即{:lookup, name}{:create, name},在下面寫相應的回撥處理函式時會用到。 這個訊息元組第一個元素一般是要伺服器做的事兒,後面的元素就是該動作的引數。

在伺服器這邊,我們要實現一系列伺服器回撥函式來實現伺服器的啟動、停止以及處理請求等。 回撥函式是可選的,我們在這裡只實現所關係的那幾個。

第一個是init/1回撥函式,它接受一個狀態引數(你在使用者API中呼叫GenServer.start_link/3中使用的那個),返回{:ok, state}。這裡state是一個新建的HashDict。 我們現在已經可以觀察到,GenServer的API中,客戶端和伺服器之間的界限十分明顯。start_link/3在客戶端發生。 而其對應的init/1在伺服器端執行。

對於call請求,我們在伺服器端必須實現handle_call/3回撥函式。引數:接收某請求(那個元組)、請求來源(_from)以及當前伺服器狀態(names)。handle_call/3函式返回一個{:reply, reply, new_state}形式的元組。其中,reply是你要回復給客戶端的東西,而new_statue是新的伺服器狀態。

對於cast請求,我們必須實現一個handle_cast/2回撥函式,接受引數:request以及當前伺服器狀態(names)。這個函式返回{:noreply, new_state}形式的元組。

這兩個回撥函式,handle_call/3handle_cast/2還可以返回其它幾種形式的元組。還有另外幾種回撥函式,如terminate/2code_change/3等。可以參考完整的GenServer文件來學習相關知識。

現在,來寫幾個測試來保證我們這個GenServer可以執行預期工作。

3.2-測試一個GenServer

測試一個GenServer和測試agent比沒有多少不同。我們在測試的setup回撥中啟動該伺服器程式用以測試。 用以下內容建立測試檔案test/kv/registry_test.exs

defmodule KV.RegistryTest do
  use ExUnit.Case, async: true

  setup do
    {:ok, registry} = KV.Registry.start_link
    {:ok, registry: registry}
  end

  test "spawns buckets", %{registry: registry} do
    assert KV.Registry.lookup(registry, "shopping") == :error

    KV.Registry.create(registry, "shopping")
    assert {:ok, bucket} = KV.Registry.lookup(registry, "shopping")

    KV.Bucket.put(bucket, "milk", 1)
    assert KV.Bucket.get(bucket, "milk") == 1
  end
end

哈,居然都過了!

關閉這個登錄檔程式,我們只需在測試結束時簡單地傳送:shutdown訊號給該程式(別忘記,GenServer也是個程式而已,傳送結束程式訊號就可以粗暴地停止它)。這個方法對於測試是還好啦,只是,最好你還是在GenServer的處理邏輯里加上關於停止的方法。 比如,定義一個stop/1函式來傳送要求停止的call請求,就是極好的:

## Client API

@doc """
Stops the registry.
"""
def stop(server) do
  GenServer.call(server, :stop)
end

## Server Callbacks

def handle_call(:stop, _from, state) do
  {:stop, :normal, :ok, state}
end

上面程式碼中,新的handle_call/3回撥函式專門處理:stop請求。它返回的元組包括一個原子:stop,後面跟著原因:normal,然後是:ok和伺服器狀態。

3.3-需要監控

至此,我們的登錄檔完成的差不多了。剩下的問題是這個登錄檔在有bucket崩潰的時候會失去時效。 比如增加一個一下測試來暴露這個問題:

test "removes buckets on exit", %{registry: registry} do
  KV.Registry.create(registry, "shopping")
  {:ok, bucket} = KV.Registry.lookup(registry, "shopping")
  Agent.stop(bucket)
  assert KV.Registry.lookup(registry, "shopping") == :error
end

這個測試會在最後一個斷言處失敗。因為當我們停止了bucket程式後,該bucket名字還存在於登錄檔中。

為了解決這個bug,我們需要登錄檔能夠監視它派生出的每一個bucket程式。一旦我們建立了監視器,登錄檔將收到每個bucket退出的通知。 這樣它就可以清理bucket對映字典了。

我們先在命令列中玩弄一下監視機制。啟動iex -S mix

iex> {:ok, pid} = KV.Bucket.start_link
{:ok, #PID<0.66.0>}
iex> Process.monitor(pid)
#Reference<0.0.0.551>
iex> Agent.stop(pid)
:ok
iex> flush()
{:DOWN, #Reference<0.0.0.551>, :process, #PID<0.66.0>, :normal}

注意Process.monitor(pid)返回一個唯一的引用,使我們可以通過這個引用找到其指代的監視器發來的訊息。 在我們停止agent之後,我們可以用flush()函式重新整理所有訊息,此時會收到一個:DOWN訊息,內含一個監視器返回的引用。它表示有個bucket程式退出,原因是:normal

現在讓我們重新實現下伺服器回撥函式。 首先,將GenServer的狀態改成兩個字典:一個用來儲存name->pid對映關係,另一個儲存ref->name關係。 然後在handle_cast/2中加入監視器,並且實現一個handle_info/2回撥函式用來儲存監視訊息。 下面是修改後完整的伺服器呼叫函式:

## Server callbacks

def init(:ok) do
  names = HashDict.new
  refs  = HashDict.new
  {:ok, {names, refs}}
end

def handle_call({:lookup, name}, _from, {names, _} = state) do
  {:reply, HashDict.fetch(names, name), state}
end

def handle_call(:stop, _from, state) do
  {:stop, :normal, :ok, state}
end

def handle_cast({:create, name}, {names, refs}) do
  if HashDict.get(names, name) do
    {:noreply, {names, refs}}
  else
    {:ok, pid} = KV.Bucket.start_link()
    ref = Process.monitor(pid)
    refs = HashDict.put(refs, ref, name)
    names = HashDict.put(names, name, pid)
    {:noreply, {names, refs}}
  end
end

def handle_info({:DOWN, ref, :process, _pid, _reason}, {names, refs}) do
  {name, refs} = HashDict.pop(refs, ref)
  names = HashDict.delete(names, name)
  {:noreply, {names, refs}}
end

def handle_info(_msg, state) do
  {:noreply, state}
end

我們沒有修改任何客戶端API而是稍微修改了伺服器的實現。這就體現出了GenServer將客戶端與伺服器隔離開的好處。

最後,不同於其他回撥函式,我們定義了一個“捕捉所有訊息”的handle_info/2的函式子句(可參考《入門》,其意類似過載的函式的一條實現)。它丟棄那些不知道也用不著的訊息。下面一節來解釋下WHY。

3.4-call,cast還是info?

到目前為止,我們已經使用了三個伺服器回撥函式:handle_call/3handle_cast/2handle_info/2。何時使用哪個,其實很直白:

  1. handle_call/3用來處理同步請求。這是預設的處理方式,因為等待伺服器回覆是十分有用的“反向壓力(backpressure,涉及IO優化,請自行搜尋)”機制。
  2. handle_cast/2用來處理非同步請求,當你無所謂要不要個回覆時。一個cast請求甚至不保證伺服器收到了該請求,因此請有節制地使用。例如,我們定義的create/2函式應該使用call的,而我們用cast只是為了演示目的。
  3. handle_info用來接收和處理伺服器收到的既不是GenServer.call/3也不是GenServer.cast/2的請求。它可以接受是以普通程式身份通過send/2收到的訊息或者其它訊息。監視器發來的:DOWN訊息就是個極好的例子。

因為任何訊息,包括通過send/2傳送的訊息,回去到handle_info/2處理,因此便會有很多你不需要的訊息跑進伺服器。 如果不定義一個“捕捉所有訊息”的函式子句,這些訊息會導致我們的監督者程式(supervisor)崩潰,因為沒有函式子句匹配它們。

我們不需要為handle_call/3handle_cast/2擔心這個情況,因為它們能接受的請求都是通過GenServer的API傳送的,要是出了毛病就是程式設計師自己犯錯。

3.5-監視器還是連結?

我們之前在程式那章裡的學習過連結(links)。現在,隨著登錄檔的完工,你也許會問:我們啥時候用監控器,啥時候用連結呢?

連結是雙向的。你將兩個程式連結起來,其中一個掛了,另一個也會掛(除非它處理了該異常,改變了行為)。 而監視機制是單向的:只有監視別人的程式會收到被監視的程式的訊息。 簡單說,當你想讓某些程式一掛都掛時,使用連結;而想要得到程式退出或掛了等事件的訊息通知,使用監視。

回到我們handle_cast/2的實現,你可以看到登錄檔是同時連結著且監視著派生出的bucket: elixir {:ok, pid} = KV.Bucket.start_link() ref = Process.monitor(pid)

這是個壞主意。我們不想登錄檔程式因為某個bucket程式掛而一同掛掉!我們將在講解監督者(supervisor)時探索更好的解決方法。 一句話概括,我們將不直接建立新的程式,而是將把這個責任委託給監督者。 就像我們即將看到的那樣,監督者同連結工作在一起,這就解釋了為啥基於連結的API(如spawn_linkstart_link等)在Elixir和OTP上十分流行。

在講監督者之前,我們首先探索下使用GenEvent進行事件管理以和處理的知識。