GenServer(通用伺服器)是一種行為,將 Elixir 行程之間常見的客戶端與伺服器互動抽象化。
還記得我們在學行程時看過的接收迴圈嗎?GenServer 行為提供了抽象,讓你實作這類迴圈,並與執行這類迴圈的行程交換訊息。它讓保存狀態和執行非同步程式碼變得更容易。
請注意,GenServer 這個名稱的含義不只一種。它也可以用來描述一個_使用_ GenServer 行為的_模組_,以及一個從_使用_ GenServer 行為的模組啟動的_行程_。
GenServer 行為定義了一個必要的回呼 init/1,以及幾個有趣的非必要回呼:handle_call/3、handle_cast/2 和 handle_info/3。使用 GenServer 的_客戶端_不應該直接呼叫這些回呼。相反地,GenServer 模組提供了函式,讓客戶端可以用來與 GenServer 行程溝通。
通常,單一模組會同時定義_客戶端 API_(一組函式,讓你 Elixir 應用程式的其他部分可以呼叫,以便與這個 GenServer 行程溝通)和_伺服器回呼實作_(其中包含這個 GenServer 的邏輯)。
我們先來看一個簡單的 GenServer 範例,然後再了解每個回呼的意義。
這是一個範例伺服器,可以在長途公路旅行中回應煩人乘客一再重複的追問,更精確地說,就是這個問題:「我們到了嗎?」。它會記錄這個問題被問了多少次,並回傳愈來愈不耐煩的回應。
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
init/1可以透過呼叫 GenServer.start/3 或 GenServer.start_link/3 來啟動伺服器。我們在連結概念中學過這兩個函式的差異。
這兩個函式:
GenServer 行為的模組。init_arg。顧名思義,這個引數會被傳給 init/1 回呼。透過呼叫 GenServer.start/3 或 GenServer.start_link/3 啟動伺服器時,會以阻塞的方式呼叫 init/1 回呼。init/1 的回傳值決定了伺服器是否能成功啟動。
init/1 回呼通常會回傳下列其中一個值:
{:ok, state}。伺服器會以 state 作為初始狀態,開始它的接收迴圈。state 可以是任何型別。{:stop, reason}。reason 可以是任何型別。伺服器不會開始它的接收迴圈。行程會以指定的原因結束。還有一些更進階的可能性,我們現在不會介紹。
如果伺服器的接收迴圈啟動了,GenServer.start/3 和 GenServer.start_link/3 這兩個函式會回傳 {:ok, pid} 元組。否則它們會回傳 {:error, reason}。
handle_call/3需要回覆的訊息可以透過 GenServer.call/2 傳送給伺服器行程。這個函式預期第一個引數是執行中伺服器行程的 pid,第二個引數是訊息。訊息可以是任何型別。
handle_call/3 回呼負責處理並回應同步訊息。它會收到三個引數:
message:傳給 GenServer.call/2 第二個引數的值。from:呼叫 GenServer.call/2 的行程 pid。這個引數大多數情況下可以忽略。state:伺服器目前的狀態。請記得它的初始值是在 init/1 回呼中設定的。handle_call/3 回呼通常會回傳 {:reply, reply, state} 這個三元組。這代表元組中的第二個元素 reply(可以是任何型別)會被回傳給呼叫方。元組中的第三個元素 state,是伺服器處理完這則訊息後的新狀態。
還有一些更進階的可能性,我們現在不會介紹。
為了記住這個回呼的名稱代表什麼, 把它想成在電話上「呼叫」某人。
如果那個人有空,你就能立即(同步地)收到回覆。
handle_cast/2不需要回覆的訊息可以透過 GenServer.cast/2 傳送給伺服器行程。它的引數和 GenServer.call/2 的引數完全相同。
handle_cast/2 回呼負責處理這些訊息。它會收到兩個引數,message 和 state,與 handle_call/3 回呼的引數相同(除了 from 之外)。
handle_cast/2 回呼通常會回傳 {:noreply, state} 這個二元組。
還有一些更進階的可能性,我們現在不會介紹。
為了記住這個回呼的名稱代表什麼, 記得「cast」也有「丟出去」的意思。
如果你把裝著訊息的瓶子丟進海裡, 你不會預期立刻收到回覆, 或者也許永遠不會。
call 還是 cast?即使你的客戶端程式碼不需要伺服器的回覆,也幾乎總是使用 call。
使用 call 會等待回覆,這可作為一種背壓機制(避免客戶端一次傳送太多訊息)。收到伺服器的回覆,也是唯一能確定伺服器已收到並處理客戶端訊息的方法。
handle_info/2訊息也可能透過呼叫 GenServer.call/2 或 GenServer.cast/2 以外的方式進入伺服器的收件匣,例如呼叫單純的 send/2 函式。
要處理這類訊息,請使用 handle_info/2 回呼。這個回呼的運作方式和 handle_cast/2 完全相同。
GenServer 行為提供了一個涵蓋所有情況的 handle_info/2 實作,會記錄關於非預期訊息的錯誤。如果你覆寫了那個預設實作,請務必加入你自己涵蓋所有情況的實作。如果忘了,伺服器在收到非預期訊息時就會崩潰。
上述四個回呼的回傳值,都可以再增加一個元組元素,也就是逾時。例如,init/1 不回傳 {:ok, state},而是回傳 {:ok, state, timeout}。
逾時可以用來偵測信箱在特定時間內都沒有訊息。如果伺服器從其中一個回呼回傳逾時,且經過指定的毫秒數後仍沒有訊息送達,就會呼叫 handle_info/2,並以 :timeout 作為第一個引數。
基本的 Take-A-Number 取號機賣得非常好,但有些使用者抱怨,比起市面上其他機型,它少了進階功能。
製造商聽取了使用者的意見回饋,決定推出一款功能更多的豪華版機型,而你又再次負責撰寫這台機器的軟體。
豪華版新增的功能包括:
這台機器的商業邏輯已經由你的同事實作完成,可以在 TakeANumberDeluxe.State 模組裡找到。現在你的任務是用 GenServer 把它包裝起來。
在 TakeANumberDeluxe 模組中使用 GenServer 行為。
實作 start_link/1 函式以及所需的 GenServer 回呼。
傳給 start_link/1 的引數是一個關鍵字清單,裡面包含 :min_number 和 :max_number 這兩個鍵。這些鍵對應的值需要傳給 TakeANumberDeluxe.State.new/2 函式。
如果 TakeANumberDeluxe.State.new/2 回傳 {:ok, state} 元組,機器就應該啟動,並以回傳的狀態作為自身的狀態。如果它回傳的是 {:error, error} 元組,機器就應該停止,並以回傳的錯誤作為停止的原因。
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
你可能已經注意到,TakeANumberDeluxe.State.new/2 函式還接受一個選用的第三個引數 auto_shutdown_timeout。我們會在這個練習的最後一個步驟用到它。
實作 report_state/1 函式以及所需的 GenServer 回呼。機器應該以目前的狀態回覆呼叫方。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
實作 queue_new_number/1 函式以及所需的 GenServer 回呼。
它應該以機器目前的狀態呼叫 TakeANumberDeluxe.State.queue_new_number/1 函式。
如果 TakeANumberDeluxe.State.queue_new_number/1 回傳 {:ok, new_number, new_state} 元組,機器應該以新號碼回覆呼叫方,並把新狀態設為自身的狀態。如果它回傳的是 {:error, error} 元組,機器應該以該錯誤回覆呼叫方,並且不改變自身的狀態。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
實作 serve_next_queued_number/2 函式以及所需的 GenServer 回呼。
它應該呼叫 TakeANumberDeluxe.State.serve_next_queued_number/2 函式,並傳入機器目前的狀態和它第二個選用引數 priority_number。
如果 TakeANumberDeluxe.State.serve_next_queued_number/2 回傳 {:ok, next_number, new_state} 元組,機器應該以下一個號碼回覆呼叫方,並把新狀態設為自身的狀態。如果它回傳的是 {:error, error} 元組,機器應該以該錯誤回覆呼叫方,並且不改變自身的狀態。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
實作 reset_state/1 函式以及所需的 GenServer 回呼。
它應該呼叫 TakeANumberDeluxe.State.new/2 函式,用目前狀態的 min_number 和 max_number 建立一個新狀態。機器應該把新狀態設為自身的狀態,但不回覆呼叫方。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
調整啟動機器的部分。它應該讀取以 init_arg 傳入的關鍵字清單中 :auto_shutdown_timeout 這個鍵的值,並將它作為第三個引數傳給 TakeANumberDeluxe.State.new/3。如果沒有提供 :auto_shutdown_timeout,就使用預設值 :infinity。
調整重設機器狀態的部分,讓它也會把 auto_shutdown_timeout 傳給 TakeANumberDeluxe.State.new/3。
調整所有已實作回呼(init/1 以及所有 handle_* 回呼)的回傳值,替它們設定逾時。請使用目前機器狀態中 :auto_shutdown_timeout 這個鍵的值。不要把逾時加到 init/1 的 {:stop, reason} 回傳值上。逾時只有在伺服器開始執行它的接收迴圈之後才適用。
實作一個 GenServer 回呼來處理 :timeout 訊息,當機器在指定的逾時時間內沒有收到其他訊息時,就會收到這個訊息。它應該以 :normal 為原因結束行程。
也別忘了處理任何非預期的訊息,忽略它們就好。
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
# ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
# (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3