Skalierbare, robuste Webanwendungen mit Elixir und Phoenix

Seite 2: Elixir: funktional, skalierbar und robust

Inhaltsverzeichnis

Elixir ist eine funktionale Programmiersprache, deren Schwerpunkt im Erstellen skalierbarer, robuster und nebenläufiger Programme liegt. Sie nutzt die über Jahrzehnte hinweg erprobte und weiterentwickelte Erlang-VM, denn der Elixir-Compiler erzeugt Erlang-kompatiblen Bytecode. Die grundlegende Syntax ist an Ruby angelehnt und lässt sich über Makros erweitern. Obwohl Elixir noch verhältnismäßig jung ist – 2014 erschien Versionsnummer 1.0 – gibt es eine umfangreiche Tool-Unterstützung, ein ausreichend großes Ökosystem an Bibliotheken und eine hilfsbereite, offene und aktive Community.

Die Installation unterscheidet sich je nach gewähltem Betriebssystem – Elixir steht für macOS, Linux und Windows sowie den Raspberry Pi, aber auch als Docker-Image zur Verfügung. Für Windows gibt es ein All-inclusive-Installationspaket. Aber auch für die anderen Betriebssysteme existieren vorgefertigte Pakete, die alles mitbringen. Eine detaillierte Anleitung findet sich auf der Elixir-Homepage.

Nach der Elixir-Installation lässt sich deren Erfolg am einfachsten auf der Kommandozeile durch die Eingabe von elixir --version überprüfen. Im folgenden Projekt kommt Version 1.6.5 samt einer interaktiven Elixir-Konsole zum Einsatz, die nach Ausführung von iex startet. Leider funktioniert unter Windows in iex die Autovervollständigung von Modul- und Funktionsnamen via Tabulator-Taste nicht – unter Linux und macOS gibt es damit keine Probleme. Die interaktive Konsole führt jeden Befehl sofort aus, sofern dieser ein abgeschlossenes Elixir-Kommando ist. Besondere Endmarkierungen wie ;; oder eine spezielle Tastenkombination sind nicht nötig.

Zusätzlich zu den üblichen Datentypen wie Integer, Float und String kennt Elixir noch zwei, die ihren Ursprung in der Erlang-Welt haben: PID als Referenz auf Erlang-Prozesse auf dem lokalen oder einem entfernten System und atoms als Konstanten, mit denen sich bestimmte Werte oder Zustände markieren lassen. Jedes atom beginnt mit einem Doppelpunkt, gefolgt von einer Zeichenkette. So gibt es zum Beispiel Funktionen zum Dateizugriff, die nach erfolgreicher Ausführung einen mit :ok markierten Wert zurückliefern, oder andernfalls eine mit :error markierte Fehlermeldung.

Der letzte wichtige Basis-Datentyp ist die Map als Sammlung von Key-Value-Pärchen: Sie wird über die Syntax %{key1 => value1, key2 => value2} oder %{atom1: value, atom2: value} erzeugt. Die Datentypen der jeweiligen Schlüsselwerte müssen nicht alle vom gleichen Datentyp sein – häufig kommen aber atoms oder Strings zum Einsatz, ohne zu mischen. Der Zugriff erfolgt dann entweder über die Punktnotation, sofern die Schlüssel alle atoms sind, oder aber mit eckigen Klammern. Hauptunterschied: Bei eckigen Klammern wird beim Zugriff auf einen nicht existierenden Key der konstante Wert nil zurückgeliefert, bei der Punktnotation erscheint stattdessen ein KeyError.

Das Pattern Matching spielt in Elixir eine zentrale Rolle. Genau genommen gibt es nicht einmal eine einfache Zuweisung, da das Versehen eines Werts mit einem Bezeichner bereits dazu führt, dass Elixir versucht, die linke und rechte Seite des Gleichheitszeichens zur Übereinstimmung zu bringen. Dies lässt sich in iex sehr einfach zeigen:

iex> x = 3
iex> 2 = x
** (MatchError) no match of right hand side value: 3

Prinzipiell lässt sich die gleiche linke Seite aber mit verschiedenen rechten Seiten gleichsetzen, wobei jeweils der Wert des letzten Vorgangs gilt. Verhindern kann man dieses Verhalten mit dem Pin-Operator ^:

iex> x = 3
iex> x = 4
iex> x
4
iex> ^x = 5
** (MatchError) no match of right hand side value: 5

Pattern Matching funktioniert mit praktisch jedem Elixir-Datentyp. Es wird häufig bei der rekursiven Verarbeitung von Listen verwendet und zur Extraktion von Daten aus Tupeln oder Maps. Außerdem kann es in Kombination mit dem Schlüsselwort case zur Fallunterscheidung innerhalb von Funktionen dienen.

Da Elixir eine funktionale Programmiersprache ist, sind Funktionen ein zentraler Bestandteil jedes Programms. Hierbei entstehen vor allem pure Funktionen geringen Umfangs, deren Ausgabedaten nur von den explizit übergebenen Argumenten abhängen. Idealerweise kombiniert man mehrere solcher kleinen Transformationsfunktionen zu größeren Funktionalitäten, sodass am Ende ein gut test- und wartbares Gesamtsystem entsteht.

Jede Funktion ist dabei über ihren Namen und die Anzahl der Parameter (die sogenannte "Stelligkeit" bzw. im Englischen "arity") eindeutig festgelegt. Im Funktionskopf kann bereits ein Pattern Matching erfolgen. Nur wenn das Matching erfolgreich erfüllt ist, wird die jeweilige Funktionsdefinition ausgeführt. Normalerweise gibt es mehrere Definitionen, die jeweils unterschiedliche Fälle behandeln. Daher muss man nur selten auf if-else oder Fallunterscheidungen via case zurückgreifen. Gibt es keine Funktionsdefinition, in die sich die Argumente erfolgreich einpassen lassen, erhält man eine Fehlermeldung.

Es gibt verschiedene Arten der Funktionsdefinition:

# Funktion in einem Modul
def function_name(param1, param2) do
...
end
# Anonyme Funktion
anon_fn = fn param1, param2 -> param1 + param2 end
# Kurzschreibweise für anonyme Funktion
anon_fn = &(&1 + &2)

Das grundlegende Programmiermodell in Erlang, und damit auch in Elixir, beruht auf Aktoren. Hierbei tauschen voneinander unabhängige Aktoren asynchron Nachrichten aus. Diese werden jeweils in einer Mailbox pro Aktor abgelegt, bis der jeweilige Aktor sie explizit verarbeitet. Die Verarbeitung innerhalb eines Aktors erfolgt synchron, jedwede Kommunikation zwischen Aktoren aber asynchron und ausschließlich nachrichtenbasiert.

Das Aktor-Modell von Erlang und Elixir (Abb.1)

In Erlang ist jeder Aktor ein eigener Erlang-Prozess, wobei diese Prozesse nicht mit „schwergewichtigen“ Betriebssystemprozessen zu verwechseln sind. Erlang selbst läuft auf modernen Betriebssystemen in einem einzigen Prozess, der wiederum intern Threads verwendet, um mit einem auf Nebenläufigkeit optimierten präemptiven Scheduler die Erlang-Prozesse möglichst effizient mit CPU-Zeit zu versorgen.

Durch die Kombination von Aktorenmodell und Erlang-Scheduler lässt sich eine optimale Auslastung beliebig vieler CPU-Kerne erreichen, ohne dass man bei der Entwicklung der Software besondere Rücksicht auf die Synchronisation der einzelnen Aktoren nehmen muss. Dies ist eine deutliche Erleichterung gegenüber der expliziten (und fehleranfälligen) Synchronisation in anderen Programmiermodellen beziehungsweise -sprachen.