Skalierbare, robuste Webanwendungen mit Elixir und Phoenix

Seite 3: Beispielprojekt: Nummer erraten

Inhaltsverzeichnis

Als Beispiel für die Arbeit mit Elixir und das Zusammenspiel mit Elm dient ein einfaches Spiel, bei dem Benutzer auf einer Website eine Zahl zwischen 1 und 100 erraten müssen. Zum Anlegen des Projekts zur Server-Implementierung kommt das wichtigste Tool zur Projektverwaltung mit Elixir zum Einsatz: mix. Damit lassen sich unter anderem Projekte erstellen, der Compiler aufrufen, Code generieren und Abhängigkeiten aktualisieren. Ein Aufruf von mix help zeigt die gesamte Liste der möglichen Befehle, die sich zudem beliebig erweitern lässt – etwa durch Bibliotheken oder eigenen Code.

Das Elixir-Projekt (bzw. Phoenix) lässt sich mit einem einzigen Befehl einrichten. Elixirs standardmäßige Datenzugriffsbibliothek Ecto kommt explizit nicht zum Einsatz, da das Beispielprojekt keine Datenbank benötigt:

mix phx.new guess_the_number --module Game --app game --no-ecto

Die erstellte Ordnerstruktur folgt bestimmten Konventionen, die am Anfang durchaus gewöhnungsbedürftig sind. Sämtlicher Anwendungscode liegt im Unterordner /lib und ist aufgeteilt in die webspezifischen Teile (Controller, Views, Templates, Routing) in /app_name_web/ und die eigentliche Geschäftslogik in den Contexts in /app_name/. Im Ordner /config/ findet sich mit der config.exs die allgemeine Konfiguration sowie in den jeweils anderen Dateien die Einstellungen je nach gewählter Umgebung.

Im /priv/-Ordner sind die Dateien abgelegt, die später unverändert in ein Release-Archiv kopiert werden, zum Beispiel statische Website-Assets oder Datenbank-Migrationsskripte. Alle Tests landen im /test-Ordner. Die Verwaltung von Website-Assets wie Elm-Code spielt sich komplett im /assets-Ordner ab. Hier kommen npm zur Paketverwaltung und webpack als Build-Tool zum Einsatz. Auf oberster Ebene findet sich noch die Datei mix.exs, in die alle Abhängigkeiten sowie die Startup-Konfiguration der Anwendung eingetragen werden.

Der Server setzt sich aus mehreren verschiedenen Teilen zusammen. Der grundlegende Ablauf einer Spielsitzung beginnt mit dem expliziten Start der Sitzung. Der dabei erzeugte Prozess und dessen Prozess-ID (PID) werden zusammen mit einer laufenden Sitzungsnummer gespeichert. Mit der Sitzungsnummer kann der Client genau seine Sitzung ansprechen und seine Rate-Versuche übermitteln. Am Ende des Spiels wird die Sitzung, und damit auch der Prozess, beendet.

Jede Spielsitzung ist also ein eigener Prozess und dadurch von allen anderen Sitzungen entkoppelt – auch ein eventueller Absturz betrifft nur genau diese Sitzung. Elixir kennt verschiedene Abstraktionen zur Ausgestaltung von Prozessen. Die beiden wichtigsten sind GenServer ("generic server process") für zustandsbehaftete Server-Prozesse und Agents als reine Zustandsspeicher. In beiden Fällen ist der vorgehaltene Zustand nicht persistent – endet der jeweilige Prozess, so ist auch der Zustand unrettbar verloren.

Der Session-GenServer wird in der Datei /lib/game/session.ex implementiert. In jedem GenServer müssen mindestens zwei Funktionen vorhanden sein, die zum Starten des Prozesses notwendig sind:

defmodule Game.Session do
use GenServer

alias Game.GameState

def init(:no_args) do
{:ok, %GameState{number: Enum.random(1..100)}}
end

def start_link(_) do
GenServer.start_link(__MODULE__, :no_args)
end
end

Da der Prozess keine Start-Argumente übergeben bekommt, ist das Atom :no_args zu verwenden. Um eine Zufallszahl zwischen 1 und 100 zu erzeugen, kommt in der init-Funktion die Enum.random()-Funktion zusammen mit einer Range zum Einsatz. Die init-Funktion muss als Ergebnis den initialen Zustand des GenServers zurückgeben. Im vorliegenden Fall ist das eine Map mit festgelegten Keys, was in Elixir einem struct entspricht und in der Datei game_state.ex zusammen mit Standardwerten wie folgt definiert wird:

defmodule Game.GameState do
defstruct number: nil, guesses: []
end

Der Bequemlichkeit halber lassen sich Module via alias-Anweisung in anderen Modulen mit verkürztem Namen verwenden.

Die nach außen hin sichtbare API des Session-GenServers wird über einfache Funktionen beschrieben, die dann intern den eigentlichen GenServer-Aufruf absetzen. Das Ratespiel benötigt nur eine einzige Funktion:

def guess(session_id, guess) do
send_guess(guess, Game.SessionStore.id_to_pid(session_id))
end

Um die verschiedenen möglichen Antworten des später noch genauer beschriebenen SessionStore abhandeln zu können, bietet es sich an, direkt im Funktionskopf ein Pattern Matching auszuführen. Dafür muss man die send_guess-Funktion zweimal ausführen:

defp send_guess(_guess, :error) do
:fatal_error
end

defp send_guess(guess, {:ok, pid}) do
GenServer.call(pid, {:guess, guess})
end

Im Fehlerfall wird ein entsprechendes Atom zurückgegeben, im Fall, dass es geklappt hat, wird eine Anfrage an die übergebene Prozess-ID gesendet. Die Nachricht ist hierbei das zweite Argument für GenServer.call: ein Tupel aus einem Atom für den Nachrichtentyp und den Nutzdaten, also der geratenen Zahl. Der Aufruf via call erfolgt synchron, das heißt, der jeweilige Prozess wartet auf die Antwort und ist solange blockiert. Im Fall eines cast wird nicht gewartet, sondern der aufrufende Code läuft sofort mit der nächsten Anweisung weiter.

Passend zu call und cast gibt es die tatsächlichen Message-Handler handle_call und handle_cast. Diese sind für jede Nachrichtentyp- und Nutzdatenkombination einzeln zu implementieren. Die Spielsitzung benötigt nur einen Handler, der die geratene Zahl entgegennimmt, prüft, ob diese bereits geraten wurde, und falls nicht noch mit der gesuchten Zahl vergleicht.

Die als Ergebnis zurückgelieferten Atoms variieren je nach beschrittenem Pfad. Am Ende jedes handle_call muss der aktuelle, gegebenenfalls veränderte Zustand mit zurückgegeben werden, um ihn beim nächsten Aufruf wieder an handle_call übergeben zu können. Die eigentliche Zustandsverwaltung erfolgt hierbei transparent im Hintergrund. Im konkreten Fall wird die Liste der bisher geratenen Zahlen um den neuesten Versuch erweitert, eine entsprechend veränderte Kopie des bisherigen Zustands erzeugt und zusammen mit dem Rate-Ergebnis zurückgeliefert:

 def handle_call({:guess, guess}, _from, state) do
result =
case Enum.member?(state.guesses, guess) do
true ->
:already_guessed

false ->
case guess === state.number do
true ->
:correct

false ->
if guess < state.number do
:wrong_higher
else
:wrong_lower
end
end
end

{:reply, result, %GameState{state | guesses: [guess | state.guesses]}}
end

Zur Verwaltung von laufender Session-ID und zugehöriger Prozess-ID dient ein Agent als einfache Abstraktion zur Zustandsverwaltung. Er wird in der Datei session_store.ex abgelegt. Jeder Agent muss jeweils wieder eine Callback-Funktion zum Erzeugen des Prozesses implementieren. Da das Beispielprojekt nur einen einzigen SessionStore benötigt, kann dieser mit einem Namen versehen und so später leichter angesprochen werden:

defmodule Game.SessionStore do
use Agent

@me SessionStore

def start_link(_opts) do
Agent.start_link(fn -> %{next_id: 1} end, name: @me)
end
end

In start_link muss eine Funktion angegeben werden, die den initialen Zustand zurückliefert – eine Map mit einem Key next_id, unter dem die jeweils nächste Session-ID zu finden ist.

Der SessionStore bietet nach außen hin drei Funktionen an, über die sich Session-Prozess-Zuordnungen anlegen, abfragen und wieder löschen lassen:

def add(pid) do
Agent.get_and_update(@me, fn store ->
{next_id, store} =
Map.get_and_update(store, :next_id, fn old_id -> {old_id, old_id + 1} end)

store = Map.put(store, next_id, pid)

{next_id, store}
end)
end

def remove(session_id) do
Agent.update(@me, &Map.delete(&1, session_id))
end

def id_to_pid(session_id) do
Agent.get(@me, &Map.fetch(&1, session_id))
end

Das erste Argument jeder Agent-Funktion ist der jeweilige Prozess. Hier ist das ein lokaler Prozess mit einem Namen. In einem verteilten System kann es aber auch ein Prozess auf einem anderen Rechner sein. In get_and_update werden die nächste Session-ID aus der Zustands-Map geholt und der Wert anschließend aktualisiert. Unter der gerade ermittelten Session-ID ist dann noch die zugehörige Prozess-ID abzulegen und der Rückgabewert – ein Tupel aus dem tatsächlich zum Aufrufer der add-Funktion zurückgelieferten Wert und dem neuen Zustand – zu konstruieren. Das Ergebnis des letzten Ausdrucks in einer Funktion verwendet Elixir automatisch als Rückgabewert der Funktion, sodass kein return nötig ist.

remove und id_to_pid nutzen jeweils Standardfunktionen der Elixir-Map. Durch die &-Kurzschreibweise bei Funktionen lassen sich anonyme Funktionen ad-hoc erzeugen. Die übergebenen Argumente lassen sich dann in dieser Funktion mit &1, &2, ... ansprechen. &Map.fetch(&1, session_id) entspricht also ausgeschrieben fn store -> Map.fetch(store, session_id) end.

Als letzter Baustein des Servers fehlt jetzt noch der Verwalter aller Sessions: der SessionSupervisor. Dieser Prozess erzeugt und beendet dynamisch die Session-Prozesse. Daher kommt die von Elixir bereitgestellte DynamicSupervisor-Abstraktion in der Datei session_supervisor.ex zum Einsatz:

defmodule Game.SessionSupervisor do
use DynamicSupervisor

@me SessionSupervisor

def start_link(_) do
DynamicSupervisor.start_link(__MODULE__, :no_args, name: @me)
end

def init(:no_args) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

Als API werden zwei Funktionen angeboten:

def connect() do
{:ok, pid} = DynamicSupervisor.start_child(@me, Game.Session)
Game.SessionStore.add(pid)
end

def disconnect(session_id) do
terminate(session_id, Game.SessionStore.id_to_pid(session_id))
end

In connect werden ein neuer Session-Prozess gestartet und dessen PID im SessionStore hinterlegt. Da das Ergebnis des letzten Ausdrucks als Rückgabewert dient und die add-Funktion des SessionStore die neue Session-ID zurückgibt, bekommt der Aufrufer von connect auch eben diese ID als Ergebnis.

Die in disconnect aufgerufene terminate-Funktion nutzt wiederum ein Pattern Matching direkt im Funktionskopf zur Unterscheidung der möglichen Rückgaben von id_to_pid, wobei Fehler im Beispiel einfach ignoriert werden:

defp terminate(session_id, {:ok, pid}) do
DynamicSupervisor.terminate_child(@me, pid)
Game.SessionStore.remove(session_id)
end

defp terminate(_session_id, :error) do
# NOOP
end

Damit die beiden Prozesse SessionStore und SessionSupervisor überhaupt starten, sind sie in der application.ex in die Liste der zu startenden Prozesse einzutragen:

def start(_type, _args) do
children = [
GameWeb.Endpoint,
Game.SessionStore,
Game.SessionSupervisor
]
...

Damit ist der Server komplett und lässt sich nach Eingabe von iex -S mix in der Kommandozeile im Projektordner ausprobieren:

iex> session_id = Game.SessionSupervisor.connect()
iex> Game.Session.guess(session_id, 50)
iex> Game.SessionSupervisor.disconnect(session_id)