Tracks
/
Elixir
Elixir
/
Übungen
/
Take-A-Number Deluxe
Take-A-Number Deluxe

Take-A-Number Deluxe

Lernübung

Einführung

GenServer

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.

Note

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.

Beispiel

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

Callbacks

init/1

Ein 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:

  • Akzeptieren ein Modul, das das GenServer-Behaviour implementiert, als erstes Argument.
  • Akzeptieren einen beliebigen Wert als zweites Argument namens init_arg. Wie der Name schon verrät, wird dieses Argument an den Callback init/1 übergeben.
  • Akzeptieren ein optionales drittes Argument mit fortgeschrittenen Optionen für den Prozess, die wir jetzt nicht behandeln.

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/3

Eine 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:

  1. message – der Wert, der als zweites Argument an GenServer.call/2 übergeben wurde.
  2. from – die pid des Prozesses, der GenServer.call/2 aufruft. Meistens kann dieses Argument ignoriert werden.
  3. 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.

Note

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/2

Eine 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.

Note

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.

Sollte ich 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/2

Nachrichten 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.

Timeouts

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.

Anleitung

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:

  • Den Überblick über die aktuell in der Warteschlange stehenden Nummern behalten.
  • Die minimale und die maximale Nummer festlegen. So lassen sich mehrere Deluxe-Take-A-Number-Maschinen einsetzen, um Kunden an verschiedene Abteilungen derselben Einrichtung zu verteilen, und die Abteilungen lassen sich am Nummernbereich unterscheiden.
  • Bestimmten Nummern erlauben, die Warteschlange zu überspringen, um Schwangeren und älteren Menschen eine bevorzugte Bedienung zu bieten.
  • Automatisches Herunterfahren, damit die Maschine nicht versehentlich das ganze Wochenende über eingeschaltet bleibt und Energie verschwendet.

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.

1. Starte die Maschine

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.

2. Melde den Zustand der Maschine

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

3. Neue Nummern in die Warteschlange einreihen

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}

4. Die nächste Nummer aus der Warteschlange bedienen

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}

5. Zustand zurücksetzen

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

6. Automatisches Herunterfahren implementieren

Ä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
Über GitHub bearbeiten Der Link öffnet sich in einem neuen Fenster oder Tab
Elixir Exercism

Bereit, mit Take-A-Number Deluxe zu starten?

Melde dich bei Exercism an, um Elixir mit 58 Konzepte168 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.