GenServer(汎用サーバー)は、Elixirのプロセス間でよくあるクライアントとサーバーのやり取りを抽象化するビヘイビアです。
プロセスについて学んだときの、メッセージを受け取るループを覚えていますか? GenServerビヘイビアは、そうしたループを実装したり、そのようなループを実行しているプロセスとメッセージをやり取りしたりするための抽象化を提供します。これにより、状態を保ったり、非同期のコードを実行したりするのが簡単になります。
GenServerという名前は、複数の意味を背負っているので注意してください。この名前は、GenServerビヘイビアを_使用_する_モジュール_を指すのにも、GenServerビヘイビアを_使用_するモジュールから起動された_プロセス_を指すのにも使われます。
GenServerビヘイビアは、必須のコールバックを1つ、init/1を定義します。また、いくつか興味深い任意のコールバック、handle_call/3、handle_cast/2、handle_info/3も定義します。GenServerを利用する_クライアント_は、これらのコールバックを直接呼び出すことは想定されていません。代わりに、GenServerモジュールが、クライアントがGenServerプロセスとやり取りするために使える関数を提供します。
多くの場合、1つのモジュールが、クライアント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を呼び出すことで起動できます。これらの関数の違いについては、リンクの概念で学びました。
この2つの関数は、次のようになっています。
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は任意の型でかまいません。サーバーはメッセージを受け取るループを開始しません。プロセスは与えられたreasonで終了します。他にももっと高度な可能性がありますが、今は扱いません。
サーバーのメッセージ受信ループが開始されると、GenServer.start/3とGenServer.start_link/3は{:ok, pid}というタプルを返します。そうでなければ、{:error, reason}を返します。
handle_call/3返信を必要とするメッセージは、GenServer.call/2でサーバープロセスに送ることができます。この関数は、第一引数に実行中のサーバープロセスのpidを、第二引数にメッセージを取ります。メッセージは任意の型でかまいません。
handle_call/3コールバックは、同期メッセージを処理して応答する役割を担います。3つの引数を受け取ります。
message - GenServer.call/2の第二引数として渡された値。from - GenServer.call/2を呼び出しているプロセスのpid。ほとんどの場合、この引数は無視できます。state - サーバーの現在の状態。初期値がinit/1コールバックで設定されたことを思い出してください。handle_call/3コールバックは、通常、{:reply, reply, state}という3要素のタプルを返します。これは、タプルの2番目の要素である任意の型のreplyが、呼び出し元に返されることを意味します。タプルの3番目の要素stateは、このメッセージを処理した後のサーバーの新しい状態です。
他にももっと高度な可能性がありますが、今は扱いません。
このコールバックの役割を名前から覚えるには、 誰かに電話を「かける(call)」ことを思い浮かべてください。
相手が電話に出られる状態なら、すぐに(同期的に)返事が返ってきます。
handle_cast/2返信を必要としないメッセージは、GenServer.cast/2でサーバープロセスに送ることができます。その引数はGenServer.call/2と同じです。
handle_cast/2コールバックは、そうしたメッセージを処理する役割を担います。messageとstateという2つの引数を受け取ります。これらは(fromを除いて)handle_call/3コールバックと同じ引数です。
handle_cast/2コールバックは、通常、{:noreply, state}という2要素のタプルを返します。
他にももっと高度な可能性がありますが、今は扱いません。
このコールバックの役割を名前から覚えるには、 「cast」には「投げる(throw)」という意味もあることを思い出してください。
ボトルに入れたメッセージを海に投げ込んだら、 すぐに返事が返ってくることは期待できません。 おそらく永遠に返ってこないかもしれません。
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のすべてを受け止める実装を提供します。このデフォルトの実装を上書きする場合は、必ず自分自身の、すべてを受け止める実装を入れるようにしてください。忘れると、予期しないメッセージを受け取ったときにサーバーがクラッシュします。
上で説明した4つのコールバックはそれぞれ、タプルの要素をもう1つ、タイムアウトを加えて戻り値を拡張できます。たとえば、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が省略可能な3番目の引数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コールバックを実装します。
マシンの現在の状態と、2番目の省略可能な引数priority_numberを渡して、関数TakeANumberDeluxe.State.serve_next_queued_number/2を呼び出します。
関数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コールバックを実装します。
現在の状態のmin_numberとmax_numberを使って、関数TakeANumberDeluxe.State.new/2を呼び出し、新しい状態を作成します。マシンは新しい状態を自身の状態として設定します。呼び出し元には返信しません。
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
マシンの起動処理を変更します。init_argとして渡されたキーワードリストのキー:auto_shutdown_timeoutの値を読み取り、それを3番目の引数として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}にはタイムアウトを追加しないでください。タイムアウトは、サーバーが受信ループを開始した後にのみ適用されます。
指定されたタイムアウト内に他のメッセージを一切受信しなかった場合にマシンへ送られる:timeoutメッセージを処理するGenServerコールバックを実装します。理由: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