3-通用服務器(GenServer)
===========
上一章我們用agent實現了buckets。根據第一章所描述的,我們的設計是要給每個bucket賦予名字,
從而可以這么去訪問:
```
CREATE shopping
OK
PUT shopping milk 1
OK
GET shopping milk
1
OK
```
因為agent是進程,每個bucket只有一個進程id(pid)而不是名字。
不過在《入門手冊》中的進程那章中提到過,我們可以給進程注冊名字。
我們貌似可以使用這個方法來給bucket起名:
```elixir
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虛擬機的最大原子數量,從而造成系統崩潰。
比起濫用名字注冊機制,我們可以創建我們自己的 _注冊表進程(registry process)_ 來維護一個字典,
用該字典聯系起每個bucket的名字和進程。
這個注冊表要能夠保證永遠處于最新狀態。如果有一個bucket進程因故崩潰,注冊表必須清除該進程信息,
以防止繼續服務下次查找請求。
在Elixir中,我們描述這種情況會說“該注冊表需要監視(monitor)每個bucket”。
我們將使用[GenServer](http://elixir-lang.org/docs/stable/elixir/GenServer.html)
來創建一個可以監視bucket進程的注冊表進程。
在Elixir和OTP中,GenServer是創建“通用的服務器(generic servers)”的首選抽象物。
## 3.1-第一個GenServer
一個GenServer實現分為兩個部分:客戶端API和服務端回調函數。
這兩部分可以寫在同一個模塊里,也可以分開寫到兩個模塊中。
客戶端和服務端運行于不同進程,依靠調用客戶端函數來與服務端來回傳遞消息。
方便起見,這里我們將這兩部分寫在一個模塊中。
創建文件```lib/kv/registry.ex```,包含以下內容:
```elixir
defmodule KV.Registry do
use GenServer
## Client API
@doc """
Starts the registry.
"""
def start_link() do
GenServer.start_link(__MODULE__, :ok, [])
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, %{}}
end
def handle_call({:lookup, name}, _from, names) do
{:reply, Map.fetch(names, name), names}
end
def handle_cast({:create, name}, names) do
if Map.has_key?(names, name) do
{:noreply, names}
else
{:ok, bucket} = KV.Bucket.start_link()
{:noreply, Map.put(names, name, bucket)}
end
end
end
```
第一個函數是```start_link/0```,它傳遞三個參數啟動了一個新的GenServer:
1. 實現了服務器回調函數的模塊名稱。這里的```__MODULE__```指的是當前模塊
2. 初始參數,這里是```:ok```
3. 一組選項列表,比如可以存放服務器的名字。這里用個空列表
你可以向一個GenServer發送兩種請求:```call```和```cast```。__Call__ 是同步的,
服務器 __必須__ 發送回復給該類請求。__Cast__ 是異步的,服務器 __不會__ 發送回復消息。
再往下的兩個方法,```lookup/2```和```create/2```,它們用來發送這些請求給服務器。
這兩種請求,會被第一個參數所指認的服務器中的```handle_call/3```和```handle_cast/2```
函數處理(因此你的服務器回調函數必須包含這兩個函數)。```GenServer.call/2```
和```GenServer.cast/2```除了指認服務器之外,還告訴服務器它們要發送的請求。
這個請求存儲在元組里,這里即```{:lookup, name}```和```{:create, name}```,
在下面寫相應的回調處理函數時會用到。
這個消息元組第一個元素一般是要服務器做的事兒,后面的元素就是該動作的參數。
在服務器這邊,我們要實現一系列服務器回調函數來實現服務器的啟動、停止以及處理請求等。
回調函數是可選的,我們在這里只實現所關心的那幾個。
第一個是```init/1```回調函數,它接受一個狀態參數(你在用戶API中調用```GenServer.start_link/3```中使用的那個),
返回```{:ok, state}```。這里```state```是一個新建的圖map。
我們現在已經可以觀察到,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/3```和```handle_cast/2```還可以返回其它幾種形式的元組。
還有另外幾種回調函數,如```terminate/2```和```code_change/3```等。
可以參考[完整的GenServer文檔](http://elixir-lang.org/docs/stable/elixir/GenServer.html)來學習相關知識。
現在,來寫幾個測試來保證我們這個GenServer可以執行預期工作。
## 3.2-測試一個GenServer
測試一個GenServer比起測試agent沒有多少區別。我們在測試的setup回調中啟動該服務器進程用以測試。
用以下內容創建測試文件```test/kv/registry_test.exs```:
```elixir
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```函數:
```elixir
## Client API
@doc """
Stops the registry.
"""
def stop(server) do
GenServer.stop(server)
end
```
## 3.3-監控需求
至此我們的注冊表完成的差不多了,剩下的問題就要解決在有bucket崩潰的時候注冊表失去時效的問題。
比如給```KV.RegistryTest```增加一個測試來暴露這個問題:
```elixir
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```:
```elixir
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```回調函數用來保存監視消息。
下面是修改后完整的服務器調用函數:
```elixir
## Server callbacks
def init(:ok) do
names = %{}
refs = %{}
{:ok, {names, refs}}
end
def handle_call({:lookup, name}, _from, {names, _} = state) do
{:reply, Map.fetch(names, name), state}
end
def handle_cast({:create, name}, {names, refs}) do
if Map.has_key?(names, name) do
{:noreply, {names, refs}}
else
{:ok, pid} = KV.Bucket.start_link()
ref = Process.monitor(pid)
refs = Map.put(refs, ref, name)
names = Map.put(names, name, pid)
{:noreply, {names, refs}}
end
end
def handle_info({:DOWN, ref, :process, _pid, _reason}, {names, refs}) do
{name, refs} = Map.pop(refs, ref)
names = Map.delete(names, name)
{:noreply, {names, refs}}
end
def handle_info(_msg, state) do
{:noreply, state}
end
```
看得出來,我們在沒有修改客戶端API情況下修改了服務器的實現。
這就體現出了GenServer將客戶端與服務器隔離開的好處。
最后,不同于其他回調函數,我們定義了一個“捕捉所有消息”的```handle_info/2```的函數子句
(可參考《入門》,其意類似重載的函數的一條實現)。它丟棄那些不知道也用不著的消息。
下面一節來解釋下為啥。
## 3.4-call,cast還是info?
到目前為止,我們已經使用了三個服務器回調函數:```handle_call/3```,```handle_cast/2```
和```handle_info/2```。何時使用哪個,其實很直白:
1. ```handle_call/3```用來處理同步請求。
這是默認的處理方式,因為等待服務器回復是十分有用的“壓力反轉(backpressure,涉及IO優化,請自行搜索)”機制。
2. ```handle_cast/2```用來處理異步請求,當你無所謂要不要個回復時。
一個cast請求甚至不保證服務器收到了該請求,因此請有節制地使用。
例如,我們定義的```create/2```函數應該使用call的,而我們用cast只是為了演示目的。
3. ```handle_info/2```用來接收和處理服務器收到的其它
(既不是```GenServer.call/3```也不是```GenServer.cast/2```)請求。
它可以接受是以普通進程身份通過```send/2```收到的消息或者其它消息。監視器發來的```:DOWN```消息就是個極好的例子。
因為任何消息,包括通過```send/2```發送的消息,回去到```handle_info/2```處理,
因此便會有很多你不需要的消息跑進服務器。如果不定義一個“捕捉所有消息”的函數子句,
這些消息會導致我們的監督者進程(supervisor)崩潰,因為沒有函數子句匹配它們。
我們不需要為```handle_call/3```和```handle_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_link```,```start_link```等)在Elixir和OTP上十分流行。
在講監督者之前,我們首先探索下使用GenEvent進行事件管理以和處理的知識。