GenServer(汎用サーバー)は、Elixirのプロセス間でよく行われるクライアントとサーバーのやり取りを抽象化するビヘイビアです。
プロセスについて学んだときの受信ループを覚えていますか? GenServerビヘイビアは、こうしたループを実装したり、そのようなループを実行するプロセスとメッセージをやり取りしたりするための抽象化を提供します。状態を保持したり、非同期コードを実行したりするのが簡単になります。
GenServerという名前は、いくつもの意味で使われるので注意してください。GenServerビヘイビアを_使う_モジュールを指すこともあれば、GenServerビヘイビアを_使う_モジュールから起動された_プロセス_を指すこともあります。
GenServerビヘイビアは、必須のコールバックである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要素のタプルを返します。これは、タプルの第二要素であるreply(どんな型でもかまいません)が呼び出し元に返されることを意味します。タプルの第三要素であるstateは、このメッセージを処理したあとのサーバーの新しい状態です。
ここでは扱わない、より高度な方法もあります。
このコールバックの動作を名前から覚えるには、電話で誰かを「呼び出す(call)」ことを思い浮かべてください。
相手が電話に出られるなら、すぐに(同期的に)返事が返ってきます。
handle_cast/2返信を必要としないメッセージは、GenServer.cast/2でサーバープロセスに送ることができます。引数はGenServer.call/2と同じです。
handle_cast/2コールバックは、こうしたメッセージを処理する役割を担います。messageとstateの2つの引数を受け取りますが、これはhandle_call/3コールバックと同じ引数です(fromを除く)。
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を渡して呼び出されます。