Weboberflächenentwicklung mit Elm | heise online

Weboberflächenentwicklung mit Elm

Sollen sich Clientanwendungen für verteilte Applikation sicher und ohne Laufzeitfehler ausführen lassen, bietet sich die funktionale Programmiersprache Elm an. Eine Beispielanwendung zeigt ihr serverseitiges Zusammenspiel mit Elixir und dem Webserver Phoenix.

vorlesen Druckansicht 50 Kommentare lesen
Weboberflächenentwicklung mit Elm

(Bild: Shutterstock)

Lesezeit: 18 Min.
Von
  • Martin Grotz
Inhaltsverzeichnis

Der Artikel "Skalierbare, robuste Webanwendungen mit Elixir und Phoenix" beschreibt, wie sich mit der funktionalen, auf Erlang-VM basierenden Programmiersprache Elixir und dem zugehörigen Webserver-Framework Phoenix der serverseitige Teil eines kleinen Spiels schreiben lässt, bei dem Spieler eine Zahl zwischen 1 und 100 erraten müssen. Dabei erhält jeder Spieler eine eigene Spielsession in einem eigenen Prozess, die unabhängig von allen anderen Sessions läuft. Phoenix sorgt dafür, dass sich die Serverschnittstellen via HTTP ansprechen lassen.

Nachfolgend wird nun beschrieben, wie sich in der Programmiersprache Elm eine Clientanwendung entwickeln lässt, die die angebotenen APIs nutzt, um Spielern sämtliche Funktionen auf einer Weboberfläche zur Verfügung zu stellen.

Elm ist eine funktionale Programmiersprache zum Erstellen von Web-Frontends, die sich zu JavaScript transpilieren lässt. Die Sprachsyntax ist an Haskell angelehnt, der Sprachumfang aber deutlich geringer. Dadurch lässt sich Elm leichter erlernen und eignet sich auch gut als Einstieg in die funktionale Programmierung. Elm besitzt ein ausdrucksstarkes Typsystem, einen hilfreichen Compiler und eine unidirektionale Datenarchitektur ähnlich wie React Redux.

Die unidirektionale Architektur von Elm (Abb. 1)

Die Daten im Modell beschreiben den aktuellen Zustand der Anwendung, der sich mit der view-Funktion darstellen lässt. Sie löst auch Messages aus, die wiederum zusammen mit dem aktuellen Zustand in der Update-Funktion verarbeitet werden. Dadurch entsteht ein neuer, veränderter Zustand und es kommt gegebenenfalls zum Auslösen von Seiteneffekten.

In Elm werden alle Seiteneffekte als Daten beschrieben und dann an die Runtime übergeben, die sie ausführt und das Ergebnis in Form einer Message inklusive eventueller Daten wiederum an die Update-Funktion übergibt. Da alles entweder Daten oder Funktionen sind, lassen sich an jeder Stelle alle Möglichkeiten der Sprache nutzen. Der Compiler hat Zugriff auf alle Ebenen der Anwendung. Viele Fehler lassen sich dadurch ganz vermeiden oder zumindest frühzeitig entdecken. Zum Entwickeln mit Elm empfiehlt sich beispielsweise Visual Studio Code mit der elm-Extension.

Zum Einbinden von Elm in das Phoenix-Framework sind mehrere Schritte erforderlich. Zuerst muss webpack, das seit Phoenix 1.4 bevorzugte Web Asset Buildtool, um den entsprechenden Loader erweitert werden. Dies geschieht im assets-Unterordner mit cd assets && npm install --save-dev elm-webpack-loader. Als Nächstes ist dort die webpack.config.js anzupassen:

entry: {
'app': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js')),
'elm': ['./js/elm.js']
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js')
},
module: {
rules: [
...
{
test: /\.elm$/,
exclude: [/elm-stuff/, /node_modules/],
use: {
loader: 'elm-webpack-loader',
options: {
debug: options.mode === "development"
}
}
}
]
}
...

Dann sollte in package.json noch der elm-Befehl als Skript hinterlegt werden, um später einfach npm run elm ausführen zu können:

"scripts": {
...
"elm": "elm"
}

Anschließend muss in /assets/js/ eine Datei namens elm.js angelegt werden, die den kompilierten Elm-Code lädt und das Elm-Programm an ein DOM-Element in der Website bindet:

import { Elm } from "../src/Main.elm";

var app = Elm.Main.init({
node: document.getElementById('elm-main')
});

Im letzten Schritt vor der eigentlichen Einbindung in Phoenix ist noch im assets-Ordner ein Elm-Projekt via npm run elm init zu initialisieren und im neu erzeugten src-Unterordner eine Main.elm-Datei anzulegen, die das spätere Programm enthalten wird. Zunächst reicht hier ein Platzhalter, um zu prüfen, ob die Integration überhaupt funktioniert:

module Main exposing (main)

import Html exposing (text)

main =
text "Elm is working!"

Damit der Elm-Code später auch ausgeführt wird, muss die entsprechende .js-Datei in einem Phoenix-Template angegeben sein. Üblicherweise befindet sich das grundlegende Seitengerüst in der Datei /lib/game_web/templates/layout/app.html.eex. Dateien mit der .eex-Endung sind Phoenix-Templates. In Templates lassen sich HTML und Elixir-Code beliebig kombinieren. Um die spätere Auslieferung zur Laufzeit zu beschleunigen, werden Templates vom Elixir-Compiler so weit wie möglich vorkompiliert. Im Beispielprojekt kann das Grundgerüst unverändert bleiben, aber der Inhalt der Index-Seite in /lib/game_web/templates/page/index.html.eex ist so anzupassen, dass das Zielelement für Elm vorhanden ist und elm.js geladen wird:

<div id="elm-main"></div>

<script type="text/javascript" src="<%= Routes.static_url(@conn, "/js/elm.js") %>"></script>

Text zwischen <%= und %> ist Elixir-Code – hier der Routes-Helper von Phoenix, der eine passende URL für die JavaScript-Datei erzeugt und ins Template einfügt. Anpassungen an der view der Startseite sind nicht erforderlich. Sie dient normalerweise dazu, die Daten aus dem Datenmodell für die Darstellung im Template aufzubereiten. Das ist hier aber nicht nötig.

Beim Aufrufen der Startseite der Applikation unter http://localhost:4000/ sollte dort unterhalb des Phoenix-Headers der Text ″Elm is working!″ erscheinen. Sonst muss gegebenenfalls der Server einmal via Strg+C beendet und mit mix phx.server neu gestartet werden.

Jede Elm-Applikation besteht aus einem Datenmodell und zwei Funktionen: view zur Darstellung des Modells und update zur Verarbeitung von Nachrichten, die in view generiert werden, und zur Erzeugung veränderter Kopien des Modells. Darüber hinaus ist noch etwas Setup-Code nötig, um die Elm-Applikation zu starten und das Datenmodell erstmalig zu initialisieren. Alle weiteren Code-Änderungen erfolgen in der Main.elm-Datei. Ein minimales Elm-Programm sieht dann folgendermaßen aus:

module Main exposing (Model, Msg(..), init, main, update, view)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)

type alias Model =
{}

init : () -> ( Model, Cmd Msg )
init _ =
( {}, Cmd.none )

type Msg
= Todo

update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
( model, Cmd.none )

view : Model -> Html Msg
view model =
div [] [ text "Elm is still working!" ]

main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}

Das Datenmodell ist aktuell noch leer, daher muss auch nichts initialisiert werden. Commands (Cmd) dienen in Elm dazu, der Elm-Runtime den Wunsch eines Seiteneffekts mitzuteilen. Diese führt den Seiteneffekt dann aus und sendet nach Abschluss eine entsprechende Nachricht (Msg) an die update-Funktion. Da kein Seiteneffekt gewünscht wird, ist Cmd.none zu übergeben. Im update wird zunächst nur ein Tupel aus dem unveränderten Datenmodell und dem gewünschten Seiteneffekt – im vorliegenden Fall keiner – zurückgegeben.

In der view wird ein div-Element ins DOM geschrieben, das keine Eigenschaften ([] als leere Liste) und einen einzigen Kindknoten mit Text (zweite Liste) hat. Immer wenn sich etwas im Modell verändert, wird die view-Funktion neu aufgerufen. Die Elm-Runtime bildet dann in einem virtuellen DOM die Differenz zwischen alter und neuer view und wendet nur die tatsächlichen Änderungen an. Daraus resultiert sehr schnelles Ausführen jedweder DOM-Änderungen.

Am Ende der Datei wird dann das eigentliche Programm aus den einzelnen Funktionen zusammengesteckt. Subscriptions dienen zum Benachrichtigen über eintreffende Mitteilungen aus Websockets sowie zur Kommunikation mit JavaScript-Code außerhalb von Elm. Beides findet in der hier behandelten Beispielanwendung nicht statt, daher wird Sub.none übergeben.

Zum Verbinden und Trennen von Spielsitzungen sind einige Stellen im Elm-Programm zu erweitern. Im Datenmodell gilt es zu unterscheiden, ob der Client gerade verbunden ist oder nicht, da davon abhängig unterschiedliche Web-Elemente anzuzeigen sind. Außerdem muss das Modell ein Feld für die empfangene Session-ID enthalten. Da diese zu Beginn noch nicht vorhanden ist, bekommt das Feld den Datentyp Maybe Int, um auch den Wert Nothing annehmen zu können. So kann auch der Elm-Compiler sicherstellen, dass bei jedem Zugriff darauf beide Fälle – Wert vorhanden oder nicht – behandelt werden. Ein versehentlicher Zugriff auf null oder undefined ist dadurch ausgeschlossen.

type State = NotConnected | Playing

type alias Model =
{ state : State
, sessionId : Maybe Int
}

init : () -> ( Model, Cmd Msg )
init _ =
( { state = NotConnected, sessionId = Nothing }, Cmd.none )

Notwendig sind außerdem entsprechende Nachrichten, die alle zugehörigen Interaktionen und Ereignisse in der Applikation beschreiben. Dafür ist der Msg-Datentyp zu erweitern. Die später abgesetzten HTTP-Anfragen können auch fehlschlagen. Die entsprechenden "Anfrage abgeschlossen"-Nachrichten müssen daher ein Result enthalten, mit dem sich Erfolg und Misserfolg unterscheiden lassen.

In Result sind sowohl der Fehlerfall als auch der Fall, in dem alles korrekt ist, explizit anzugeben und später auch immer explizit zu behandeln:

type Msg
= Connect
| Connected (Result Http.Error Int)
| Disconnect
| Disconnected (Result Http.Error ())

Die view-Funktion wird jetzt noch um zwei entsprechende views erweitert, die je nach Zustand der Anwendung angezeigt werden. Da view in Elm eine Funktion wie jede andere ist, lässt sich der volle Sprachumfang nutzen und es gibt keinen Bruch zwischen Logik-Code und Template, bei dem sich Fehler einschleichen können oder die Compiler-Unterstützung wegfällt.

view model =
let
playview =
div [] [ button [ onClick Disconnect ] [ text "Disconnect" ] ]

notConnectedview =
div [] [ button [ onClick Connect ] [ text "Connect" ] ]

shownview =
case model.state of
NotConnected ->
notConnectedview

Playing ->
playview
in
div [] [ shownview ]

Im abschließenden Schritt ist nun die update-Funktion um die Behandlung der verschiedenen Nachrichtentypen zu ergänzen:

update message model =
case message of
Connect ->
let
requestUrl =
Url.Builder.relative [ "api", "session", "connect" ] []

getCmd =
Http.get { url = requestUrl, expect = Http.expectJson Connected sessionIdDecoder }
in
( model, getCmd )

Connected result ->
let
newModel =
case result of
Ok sessionId ->
{ model | sessionId = Just sessionId, state = Playing }

Err err ->
{ model | sessionId = Nothing, state = NotConnected }
in
( newModel, Cmd.none )

Disconnect ->
let
disconnectCmd =
case model.sessionId of
Nothing ->
Cmd.none

Just sId ->
let
requestUrl =
Url.Builder.relative [ "api", "session", "disconnect" ] [ Url.Builder.int "sessionId" sId ]
in
Http.get { url = requestUrl, expect = Http.expectWhatever Disconnected }
in
( model, disconnectCmd )

Disconnected result ->
init ()

Mit dem Url.Builder-Modul werden für die verschiedenen Anfragen entsprechende URLs erzeugt und anschließend die notwendigen Commands erstellt. Hierbei ist auch immer anzugeben, welche Nachricht bei Abschluss des Commands an die update-Funktion zu senden ist (Connected oder Disconnected) und wie die empfangenen Daten zu behandeln sind (sessionIdDecoder).

Beim Empfang der Connected-Nachricht muss zwischen einem Fehlerfall, in dem alle bisherigen Daten zurückgesetzt werden, oder konkret empfangener Session-ID, die dann im Modell abgelegt wird, unterschieden werden.

Nach Beenden der Verbindung wird das Datenmodell via init ()-Aufruf in den Ursprungszustand zurückversetzt.

Jetzt fehlt nur der Decoder für die empfangenen JSON-Daten mit der Session-ID:

sessionIdDecoder : Json.Decode.Decoder Int
sessionIdDecoder =
Json.Decode.field "sessionId" Json.Decode.int

Für die Kommunikation via JSON oder auch mit externem JavaScript benötigt Elm immer Decoder beziehungsweise Encoder. Sie sorgen dafür, dass die Daten innerhalb des Elm-Kosmos genau festgelegt und ″wohlgeformt″ sind.

Zuletzt sind am Anfang der Datei noch die Importe um die notwendigen Module zu erweitern:

import Http
import Url.Builder
import Json.Decode
import Json.Encode

Bevor das Elm-Programm aber tatsächlich kompiliert werden kann, müssen noch die entsprechenden Module im assets-Ordner installiert werden:

npm run elm install elm/url && npm run elm install elm/http && npm run elm install elm/json

Sofern der Phoenix-Server noch läuft, sollte jetzt die bisher entwickelte Anwendung sichtbar und bereit für einen Testlauf mit Aufbauen und Beenden von Sessions sein.

Connect/Disconnect von Spielsitzungen auf dem Phoenix-Server (Abb. 2)

Nachdem sich nun Sessions starten und stoppen lassen, geht es ans eigentliche Nummern-Raten. Dazu sind ein Eingabefeld sowie ein Senden-Button nötig, die beide zusammen mit etwas CSS-Code in der view-Funktion ergänzt werden:

playview =
div [ style "display" "flex", style "flex-direction" "column" ]
[ input [ type_ "number", onInput GuessChanged, Html.Attributes.min "1", Html.Attributes.max "100", required True, placeholder "Guess a number between 1 and 100" ] []
, button [ onClick SendGuess ] [ text "Send guess" ]
, button [ onClick Disconnect ] [ text "Disconnect" ]
]

Die beiden neuen Nachrichten sowie die Nachricht über das Empfangen des gesendeten Rateversuchs werden anschließend ebenfalls ergänzt:

type Msg =
...
| GuessChanged String
| SendGuess
| GuessResultReceived (Result Http.Error String)

Jetzt sind noch Felder im Datenmodell anzulegen und mit einem Startwert zu initialisieren:

type alias Model =
{ ...
, guess : String
, result : Maybe String
}


init _ =
( { ..., guess = "", result = Nothing }, Cmd.none )

Dann müssen alle neuen Nachrichtentypen auch in der update-Funktion behandelt werden. Da nach jeder Erweiterung der view der Compiler immer anmerkt, was noch fehlt, nennt man dieses Vorgehen des sukzessiven Implementierens der einzelnen notwendigen Änderungen gelegentlich auch ″Compiler-driven Development″. Sozusagen als Belohnung funktioniert ein Elm-Programm dann auch auf Anhieb richtig und ohne Laufzeitfehler, sobald der Compiler zufriedengestellt ist.

GuessChanged guess ->
( { model | guess = guess }, Cmd.none )

SendGuess ->
if String.isEmpty model.guess then
( model, Cmd.none )

else
case model.sessionId of
Nothing ->
( model, Cmd.none )

Just sId ->
let
requestUrl =
Url.Builder.relative [ "api", "guess" ] [ Url.Builder.string "guess" model.guess, Url.Builder.int "sessionId" sId ]

getCmd =
Http.get { url = requestUrl, expect = Http.expectJson GuessResultReceived resultDecoder }
in
( model, getCmd )

GuessResultReceived result ->
let
extractedResult =
case result of
Ok r ->
r

Err _ ->
"fatal_error"
in
( { model | result = Just extractedResult }, Cmd.none )

Nun fehlt nur der entsprechende Decoder für das empfangene JSON:

resultDecoder : Json.Decode.Decoder String
resultDecoder =
Json.Decode.field "result" Json.Decode.string

Damit kann das Ratespiel bereits vollständig gespielt werden. Allerdings tappen Spieler noch im Dunkeln darüber, ob ihre geratene Zahl richtig oder falsch ist, denn es fehlt noch die Darstellung des Ergebnisses.

Die Hauptarbeit für die Ergebnisdarstellung ist die Übersetzung der empfangenen Werte aus dem Server in besser lesbare Texte. Dafür bietet sich eine Hilfsfunktion an:

humanReadableResult : String -> String
humanReadableResult result =
case result of
"fatal_error" ->
"A fatal error occured. Sorry!"

"correct" ->
"You have won!"

"wrong_higher" ->
"You guessed too low. Try a higher number."

"wrong_lower" ->
"You guessed too high - try a lower number."

"already_guessed" ->
"You already tried that number."

_ ->
"Invalid response received."

Da potentiell unendlich viele String-Kombinationen möglich sind, benötigt Elm beim Pattern Matching gegen Strings immer einen Default-Case.

Die neue Funktion ist in der view so einzubinden, dass der entsprechende Text nur dann sichtbar ist, wenn zuvor schon ein Ergebnis empfangen wurde. Dazu lässt sich auch in der view der volle Sprachumfang von Elm nutzen:

view model =
let
playview =
div [ ...
, case model.result of
Nothing ->
text ""

Just r ->
p [] [ text (humanReadableResult r) ]
]
...

Wird ein Spiel komplett gespielt, also bis gewonnen wurde, zeigt sich noch eine Lücke im bisherigen Programm: Spieler könnten weiterhin Zahlen an den Server schicken, die dieser auch brav weiterhin verarbeiten und beantworten wird. Daher sollte der Senden-Button nur dann sichtbar sein, wenn das Spiel noch nicht beendet ist:

case model.result of
Nothing ->
button [ onClick SendGuess ] [ text "Send guess" ]

Just r ->
if r == "correct" then
text ""

else
button [ onClick SendGuess ] [ text "Send guess" ]

Da button auch nur eine Funktion ist, sollte dessen Erzeugung anschließend noch in eine Funktion verpackt werden, die dann an beiden Stellen aufgerufen wird. Ebenso lässt sich auch das gesamte case-Statement in eine Funktion auslagern, damit der Code der eigentlichen view übersichtlicher bleibt. Da der Compiler für solche Refactorings aussagekräftige Unterstützung liefert, ist es kein Problem, derlei Änderungen erst dann durchzuführen, wenn sie nötig sind.

Das gesamte Spiel ist damit fertig: Der Server erzeugt und verwaltet die voneinander unabhängigen Sessions und der Client kann eine Session anfordern, sie durchspielen und anschließend beenden.

Bei der Umsetzung des Ratespiels zeigt sich bereits die an Ruby beziehungsweise Rails erinnernde Einfachheit von Elixir und Phoenix. In dieser ersten Version des Spiels finden alle Serverprozesse noch auf einem einzigen lokalen Knoten statt. Da Elixir aber auf Erlang basiert, spricht nichts dagegen, die einzelnen Teile der Anwendung auf unterschiedliche Knoten zu verteilen und unterschiedlich vertikal oder horizontal zu skalieren. Die gut durchdachten Abstraktionen und die hier nur ansatzweise vorgestellten Tools des Elixir-Ökosystems helfen dabei, reibungslos vom ersten Prototypen bis zur verteilten, fehlertoleranten Anwendung zu wachsen.

Hinzu kommt mit Elm eine Programmiersprache, die den Client durch garantiertes Vermeiden von Laufzeitfehlern sehr robust macht. Die Elm-Architektur unterstützt durch ihren klar nachvollziehbaren Datenfluss und der Elm-Compiler sorgt dafür, dass Programmierfehler schon vor dem Ausführen entdeckt werden. Wächst das Elm-Programm durch neue Funktionen im Laufe der Zeit, so bleibt die grundlegende Architektur überall erhalten. Der Evolution vom ersten Prototypen hin zur komplexen Client-Anwendung steht daher nichts im Wege.

In jedem Fall lohnt sich als Horizonterweiterung eine Beschäftigung sowohl mit Elixir und dem dort eingesetzten Aktormodell als auch mit Elm als Einstieg in die funktionale Programmierung und das unidirektionale Datenmodell. Denn beides findet sich auch an anderer Stelle, in Programmiersprachen, Toolkits und Librarys wie beispielsweise Akka und React Redux. Die wertvollen Tools gehören ins Handwerkszeug eines jeden Entwicklers.

Martin Grotz
beschäftigt sich seit dem Aufkommen von Single Page Applications als Full-Stack-Entwickler mit Architektur, Umsetzung und dem Betrieb komplexer Web-Anwendungen. Fest davon überzeugt, dass es immer noch besser gehen kann, hat er sich mittlerweile verstärkt der funktionalen Programmierung zugewandt und dabei unter anderem die Programmiersprachen Elm und Elixir für sich entdeckt. (map)