GenServer (generischer Server) ist ein Behaviour, das häufige Client-Server-Interaktionen zwischen Elixir-Prozessen abstrahiert.
Erinnerst du dich an die Empfangsschleife aus dem Kapitel über Prozesse? Das GenServer-Behaviour bietet Abstraktionen, um solche Schleifen zu implementieren und um Nachrichten mit einem Prozess auszutauschen, der eine solche Schleife ausführt. Damit lässt sich der Zustand leichter verwalten und asynchroner Code leichter ausführen.
Sei gewarnt: Der Name GenServer ist mehrdeutig. Er bezeichnet auch ein Modul, das das GenServer-Behaviour verwendet, sowie einen Prozess, der von einem Modul gestartet wurde, das das GenServer-Behaviour verwendet.
Das GenServer-Behaviour definiert einen erforderlichen Callback, init/1, und ein paar interessante optionale Callbacks: handle_call/3, handle_cast/2 und handle_info/3. Die Clients, die einen GenServer verwenden, sollen diese Callbacks nicht direkt aufrufen. Stattdessen stellt das Modul GenServer Funktionen bereit, mit denen Clients mit einem GenServer-Prozess kommunizieren können.
Häufig definiert ein einzelnes Modul sowohl eine Client-API, also eine Reihe von Funktionen, die andere Teile deiner Elixir-App aufrufen können, um mit diesem GenServer-Prozess zu kommunizieren, als auch Server-Callback-Implementierungen, die die Logik dieses GenServer enthalten.
Schauen wir uns zuerst ein einfaches Beispiel für einen GenServer an und lernen dann, was die einzelnen Callbacks bedeuten.
Dies ist ein Beispielserver, der auf die wiederholten Fragen nerviger Mitfahrer während einer langen Autofahrt reagieren kann, genauer gesagt auf die Frage: „Sind wir schon da?". Er zählt mit, wie oft diese Frage gestellt wurde, und gibt zunehmend genervtere Antworten zurück.
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/1Ein Server kann gestartet werden, indem man GenServer.start/3 oder GenServer.start_link/3 aufruft. Den Unterschied zwischen diesen Funktionen haben wir im Links-Konzept kennengelernt.
Diese beiden Funktionen:
GenServer-Behaviour implementiert, als erstes Argument.init_arg. Wie der Name schon verrät, wird dieses Argument an den Callback init/1 übergeben.Wenn du einen Server mit GenServer.start/3 oder GenServer.start_link/3 startest, wird der Callback init/1 blockierend aufgerufen. Der Rückgabewert von init/1 entscheidet, ob der Server erfolgreich gestartet werden kann.
Der Callback init/1 gibt normalerweise einen der folgenden Werte zurück:
{:ok, state}. Der Server startet seine Empfangsschleife und verwendet state als Anfangszustand. state kann von beliebigem Typ sein.{:stop, reason}. reason kann von beliebigem Typ sein. Der Server startet seine Empfangsschleife nicht. Der Prozess beendet sich mit dem angegebenen Grund.Es gibt auch noch fortgeschrittenere Möglichkeiten, die wir jetzt nicht behandeln.
Wenn die Empfangsschleife des Servers startet, geben die Funktionen GenServer.start/3 und GenServer.start_link/3 ein {:ok, pid}-Tupel zurück. Andernfalls geben sie {:error, reason} zurück.
handle_call/3Eine Nachricht, die eine Antwort erfordert, kann mit GenServer.call/2 an einen Serverprozess gesendet werden. Diese Funktion erwartet die pid eines laufenden Serverprozesses als erstes Argument und die Nachricht als zweites Argument. Die Nachricht kann von beliebigem Typ sein.
Der Callback handle_call/3 ist dafür zuständig, synchrone Nachrichten zu verarbeiten und zu beantworten. Er erhält drei Argumente:
message – der Wert, der als zweites Argument an GenServer.call/2 übergeben wurde.from – die pid des Prozesses, der GenServer.call/2 aufruft. Meistens kann dieses Argument ignoriert werden.state – der aktuelle Zustand des Servers. Denk daran, dass sein Anfangswert im Callback init/1 gesetzt wurde.Der Callback handle_call/3 gibt normalerweise ein 3er-Tupel {:reply, reply, state} zurück. Das bedeutet, dass das zweite Element des Tupels, eine reply von beliebigem Typ, an den Aufrufer zurückgeschickt wird. Das dritte Element des Tupels, state, ist der neue Zustand des Servers nach der Verarbeitung dieser Nachricht.
Es gibt auch noch fortgeschrittenere Möglichkeiten, die wir jetzt nicht behandeln.
Um dir anhand des Namens zu merken, was dieser Callback tut, stell dir vor, du „rufst" jemanden am Telefon an.
Wenn diese Person verfügbar ist, bekommst du sofort (synchron) eine Antwort.
handle_cast/2Eine Nachricht, die keine Antwort erfordert, kann mit GenServer.cast/2 an einen Serverprozess gesendet werden. Ihre Argumente sind identisch mit denen von GenServer.call/2.
Der Callback handle_cast/2 ist dafür zuständig, diese Nachrichten zu verarbeiten. Er erhält zwei Argumente, message und state, dieselben Argumente wie beim Callback handle_call/3 (bis auf from).
Der Callback handle_cast/2 gibt normalerweise ein 2er-Tupel {:noreply, state} zurück.
Es gibt auch noch fortgeschrittenere Möglichkeiten, die wir jetzt nicht behandeln.
Um dir anhand des Namens zu merken, was dieser Callback tut, denk daran, dass „to cast" auch „werfen" bedeutet.
Wenn du eine Flaschenpost ins Meer wirfst, erwartest du keine sofortige Antwort, vielleicht nie eine.
call oder cast verwenden?Verwende fast immer call, auch wenn dein Client-Code die Antwort des Servers nicht braucht.
Bei call wartest du auf die Antwort, die als Backpressure-Mechanismus dient (um zu verhindern, dass Clients zu viele Nachrichten auf einmal senden). Eine Antwort vom Server zu erhalten ist außerdem der einzige Weg, sicher zu sein, dass der Server die Nachricht des Clients empfangen und verarbeitet hat.
handle_info/2Nachrichten können auch auf anderem Weg als über GenServer.call/2 oder GenServer.cast/2 im Postfach des Servers landen, zum Beispiel durch einen Aufruf der einfachen Funktion send/2.
Um solche Nachrichten zu verarbeiten, verwende den Callback handle_info/2. Dieser Callback funktioniert genauso wie handle_cast/2.
Das GenServer-Behaviour bietet eine Catch-all-Implementierung von handle_info/2, die Fehler über unerwartete Nachrichten protokolliert. Wenn du diese Standardimplementierung überschreibst, achte darauf, immer eine eigene Catch-all-Implementierung anzugeben. Wenn du das vergisst, stürzt der Server ab, sobald er eine unerwartete Nachricht erhält.
Der Rückgabewert jedes der vier oben beschriebenen Callbacks kann um ein weiteres Tupel-Element erweitert werden, ein Timeout. Statt zum Beispiel aus init/1 {:ok, state} zurückzugeben, gibst du {:ok, state, timeout} zurück.
Mit dem Timeout kannst du feststellen, dass über einen bestimmten Zeitraum keine Nachrichten im Postfach eingehen. Wenn der Server aus einem seiner Callbacks ein Timeout zurückgibt und die angegebene Anzahl Millisekunden verstrichen ist, ohne dass eine Nachricht eintrifft, wird handle_info/2 mit :timeout als erstem Argument aufgerufen.
Die einfache Take-A-Number-Maschine verkaufte sich sehr gut, aber einige Nutzer beschwerten sich über die fehlenden erweiterten Funktionen im Vergleich zu anderen auf dem Markt erhältlichen Modellen.
Der Hersteller hörte auf das Feedback der Nutzer und beschloss, ein Deluxe-Modell mit mehr Funktionen auf den Markt zu bringen. Du wurdest erneut damit beauftragt, die Software für diese Maschine zu schreiben.
Zu den neuen Funktionen des Deluxe-Modells gehören:
Die Geschäftslogik der Maschine wurde bereits von deinem Kollegen implementiert und befindet sich im Modul TakeANumberDeluxe.State. Jetzt ist es deine Aufgabe, sie in einen GenServer zu verpacken.
Verwende das GenServer-Verhalten im Modul TakeANumberDeluxe.
Implementiere die Funktion start_link/1 und den nötigen GenServer-Callback.
Das an start_link/1 übergebene Argument ist eine Keyword-Liste. Sie enthält die Schlüssel :min_number und :max_number. Die Werte unter diesen Schlüsseln müssen an die Funktion TakeANumberDeluxe.State.new/2 übergeben werden.
Wenn TakeANumberDeluxe.State.new/2 ein Tupel {:ok, state} zurückgibt, soll die Maschine starten und den zurückgegebenen Zustand als ihren Zustand verwenden. Gibt sie stattdessen ein Tupel {:error, error} zurück, soll die Maschine stoppen und den zurückgegebenen Fehler als Grund für das Stoppen angeben.
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}
Du hast vielleicht bemerkt, dass die Funktion TakeANumberDeluxe.State.new/2 auch ein optionales drittes Argument entgegennimmt, auto_shutdown_timeout. Wir werden es im letzten Schritt dieser Übung verwenden.
Implementiere die Funktion report_state/1 und den nötigen GenServer-Callback. Die Maschine soll dem Aufrufer ihren aktuellen Zustand zurückgeben.
{: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,
# }
Implementiere die Funktion queue_new_number/1 und den nötigen GenServer-Callback.
Sie soll die Funktion TakeANumberDeluxe.State.queue_new_number/1 mit dem aktuellen Zustand der Maschine aufrufen.
Wenn TakeANumberDeluxe.State.queue_new_number/1 ein Tupel {:ok, new_number, new_state} zurückgibt, soll die Maschine dem Aufrufer die neue Nummer zurückgeben und den neuen Zustand als ihren Zustand setzen. Gibt sie stattdessen ein Tupel {:error, error} zurück, soll die Maschine dem Aufrufer den Fehler zurückgeben und ihren Zustand nicht ändern.
{: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}
Implementiere die Funktion serve_next_queued_number/2 und den nötigen GenServer-Callback.
Sie soll die Funktion TakeANumberDeluxe.State.serve_next_queued_number/2 mit dem aktuellen Zustand der Maschine und ihrem zweiten optionalen Argument priority_number aufrufen.
Wenn TakeANumberDeluxe.State.serve_next_queued_number/2 ein Tupel {:ok, next_number, new_state} zurückgibt, soll die Maschine dem Aufrufer die nächste Nummer zurückgeben und den neuen Zustand als ihren Zustand setzen. Gibt sie stattdessen ein Tupel {:error, error} zurück, soll die Maschine dem Aufrufer den Fehler zurückgeben und ihren Zustand nicht ändern.
{: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}
Implementiere die Funktion reset_state/1 und den nötigen GenServer-Callback.
Sie soll die Funktion TakeANumberDeluxe.State.new/2 aufrufen, um mit min_number und max_number des aktuellen Zustands einen neuen Zustand zu erzeugen. Die Maschine soll den neuen Zustand als ihren Zustand setzen. Sie soll dem Aufrufer nichts zurückgeben.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
Ändere das Starten der Maschine. Sie soll den Wert unter dem Schlüssel :auto_shutdown_timeout in der als init_arg übergebenen Keyword-Liste auslesen und ihn als drittes Argument an TakeANumberDeluxe.State.new/3 übergeben. Verwende den Standardwert :infinity, wenn :auto_shutdown_timeout nicht angegeben wurde.
Ändere das Zurücksetzen des Maschinenzustands so, dass auto_shutdown_timeout ebenfalls an TakeANumberDeluxe.State.new/3 übergeben wird.
Ändere die Rückgabewerte aller implementierten Callbacks (init/1 und alle handle_*-Callbacks) so, dass sie ein Timeout setzen. Verwende den Wert unter dem Schlüssel :auto_shutdown_timeout im aktuellen Zustand der Maschine. Füge das Timeout nicht zum Rückgabewert {:stop, reason} von init/1 hinzu. Timeouts gelten erst, nachdem der Server seine Empfangsschleife gestartet hat.
Implementiere einen GenServer-Callback, der die :timeout-Nachricht behandelt, die an die Maschine gesendet wird, wenn sie innerhalb des angegebenen Timeouts keine anderen Nachrichten empfängt. Er soll den Prozess mit dem Grund :normal beenden.
Stelle außerdem sicher, dass du unerwartete Nachrichten behandelst, indem du sie ignorierst.
{: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
Melde dich bei Exercism an, um Elixir mit 58 Konzepte168 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.