學習軌道
/
Elixir
Elixir
/
練習
/
抽號碼牌(豪華版)
抽號碼牌(豪華版)

抽號碼牌(豪華版)

學習練習

簡介

GenServer

GenServer(通用伺服器)是一種行為,將 Elixir 行程之間常見的客戶端與伺服器互動抽象化。

還記得我們在學行程時看過的接收迴圈嗎?GenServer 行為提供了抽象,讓你實作這類迴圈,並與執行這類迴圈的行程交換訊息。它讓保存狀態和執行非同步程式碼變得更容易。

Note

請注意,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 回呼負責處理並回應同步訊息。它會收到三個引數:

  1. message:傳給 GenServer.call/2 第二個引數的值。
  2. from:呼叫 GenServer.call/2 的行程 pid。這個引數大多數情況下可以忽略。
  3. state:伺服器目前的狀態。請記得它的初始值是在 init/1 回呼中設定的。

handle_call/3 回呼通常會回傳 {:reply, reply, state} 這個三元組。這代表元組中的第二個元素 reply(可以是任何型別)會被回傳給呼叫方。元組中的第三個元素 state,是伺服器處理完這則訊息後的新狀態。

還有一些更進階的可能性,我們現在不會介紹。

Note

為了記住這個回呼的名稱代表什麼, 把它想成在電話上「呼叫」某人。

如果那個人有空,你就能立即(同步地)收到回覆。

handle_cast/2

不需要回覆的訊息可以透過 GenServer.cast/2 傳送給伺服器行程。它的引數和 GenServer.call/2 的引數完全相同。

handle_cast/2 回呼負責處理這些訊息。它會收到兩個引數,message 和 state,與 handle_call/3 回呼的引數相同(除了 from 之外)。

handle_cast/2 回呼通常會回傳 {:noreply, state} 這個二元組。

還有一些更進階的可能性,我們現在不會介紹。

Note

為了記住這個回呼的名稱代表什麼, 記得「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 取號機賣得非常好,但有些使用者抱怨,比起市面上其他機型,它少了進階功能。

製造商聽取了使用者的意見回饋,決定推出一款功能更多的豪華版機型,而你又再次負責撰寫這台機器的軟體。

豪華版新增的功能包括:

  • 記錄目前排隊中的號碼。
  • 設定號碼的下限與上限。這樣就能在同一機構內使用多台豪華版 Take-A-Number 取號機,為不同部門的顧客取號,並透過號碼範圍區分不同部門。
  • 允許特定號碼跳過排隊,為孕婦和長者提供優先服務。
  • 自動關機,避免不小心讓機器開著整個週末而浪費能源。

這台機器的商業邏輯已經由你的同事實作完成,可以在 TakeANumberDeluxe.State 模組裡找到。現在你的任務是用 GenServer 把它包裝起來。

1. 啟動機器

在 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。我們會在這個練習的最後一個步驟用到它。

2. 回報機器狀態

實作 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,
#    }

3. 將新號碼排入佇列

實作 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}

4. 服務下一個排隊號碼

實作 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}

5. 重設狀態

實作 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

6. 實作自動關機

調整啟動機器的部分。它應該讀取以 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
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
Elixir Exercism

準備好開始 抽號碼牌(豪華版) 了嗎?

註冊 Exercism,透過 58 個概念168 個練習 和真人引導來學習並精通 Elixir,全部免費。