In diesem Kapitel wollen wir uns anschauen, wie man den Datentyp Cmd nutzt, den wir bisher ignoriert haben. Wir haben zuvor bereits gelernt, dass Elm eine rein funktionale Programmiersprache ist und man daher keine Seiteneffekte ausführen kann. Einige Teile einer Frontend-Anwendung benötigen aber natürlich Seiteneffekte. Ein Beispiel für einen Seiteneffekt, der in einer Frontend-Anwendung ist wichtig ist, ist das Durchführen von HTTP-Anfragen. Um Seiteneffekte in Elm ausführen zu können und dennoch eine referenziell transparente Anwendung zu behalten, wird die Durchführung von Seiteneffekten von der Elm-Runtime übernommen. Genauer gesagt, teilen wir Elm nur mit, dass wir einen Seiteneffekt durchführen möchten. Elm führt dann diesen Seiteneffekt durch und informiert uns über das Ergebnis. Auch die Kommandos sind wieder ein Beispiel für den deklarativen Ansatz, da man nur beschreibt, dass ein Seiteneffekt durchgeführt werden soll, man beschreibt aber nicht, wie dieser genau ausgeführt wird.

HTTP-Anfragen

Um eine HTTP-Anfrage zu stellen, teilen wir dem System mit, welche Anfrage wir stellen möchten und das System ruft die Funktion update auf, wenn die Anfrage erfolgreich abgeschlossen ist. Im Unterschied zum Erzeugen eines Zufallswertes, kann in diesem Fall aber auch ein Fehler bei der Abarbeitung der Aufgabe auftreten. Um eine HTTP-Anfrage zu senden, müssen wir zunächst mit dem folgenden Kommando eine Bibliothek installieren.

elm install elm/http

Wir wollen eine einfache Anwendung entwickeln, die eine bestehende API anfragt. Die Route https://api.isevenapi.xyz/api/iseven/{number}1 liefert für jede Zahl number, die Information, ob number gerade ist oder nicht. Für die Zahl 3 erhalten wir als Ergebnis zum Beispiel das folgende JSON-Objekt.

{
  "ad" : "Buy isEvenCoin, the hottest new cryptocurrency!",
  "iseven" : false
}

Wir modellieren diese Struktur erst einmal auf Elm-Ebene und definieren eine Funktion, um diese Informationen anzuzeigen. Da die Parität – also ob eine Zahl gerade oder ungerade ist – ein elementarer Bestandteil unserer Anwendung sein wird, nutzen wir für die Modellierung dieser Information einen selbstdefinierten Aufzählungstyp. Obwohl der Umfang der Anwendung, die wir hier entwickeln, sehr gering ist, legen wir ein eigenes Modul für den Datentyp Parity an, um zu illustrieren, wie eine reale Anwendung modularisiert wird. Wir legen ein Verzeichnis Api an, in dem wir Module speichern, die für die Kommunikation mit der Schnittstelle genutzt werden. Bei der Kommunikation nutzen wir möglichst stark getypte Daten. Zum Beispiel könnte es sein, dass eine Schnittstelle Daten in Form eines String zur Verfügung stellt, die wir zur Nutzung in unserer Anwendung in einen Aufzählungstyp überführen.

module Api.Parity exposing (Parity(..), toString)


type Parity
    = Even
    | Odd


toString : Parity -> String
toString : parity =
    case parity of
        Even ->
            "gerade"

        Odd ->
            "ungerade"

Neben dem Datentyp Parity definieren wir noch einen Datentyp ParityInfo, der zusätzlich die Werbung zur Verfügung stellt.

module Api.ParityInfo exposing (ParityInfo)


type alias ParityInfo =
    { parity : Parity
    , advertisement : String
    }

Im Nächsten Schritt entwickeln wir die Logik zur Anzeige unserer Daten in der Anwendung. Die folgende Funktion wird im Hauptmodul der Anwendung definiert.

viewParityInfo : ParityInfo -> Html msg
viewParityInfo info =
    div []
        [ p [] [ text ("Die Zahl ist " ++ Api.Parity.toString info.parity ++ ".") ]
        , p [] [ text info.advertisement ]
        ]

Nachdem wir das Resultat eines Requests modelliert haben, wollen wir einen Request durchführen. Die Funktion

get : { url : String, expect : Expect msg } -> Cmd msg

aus dem Modul Http kann genutzt werden, um ein Kommando zu erzeugen, das eine get-Anfrage durchführt. Dazu wird eine URL und ein Wert vom Typ Expect msg angegeben, mit dem wir spezifizieren, welche Art von Ergebnis wir als Resultat von der Anfrage erwarten. Das Modul Http stellt zum Beispiel die Funktion

expectJson : (Result Error a -> msg) -> Decoder a -> Expect msg

zur Verfügung, um JSON zu verarbeiten, das von einer Anfrage zurückgeliefert wird. Dazu müssen wir zum einen einen Decoder angeben, der die JSON-Struktur in eine Elm-Datenstruktur umwandelt. Außerdem müssen wir eine Funktion angeben, die das Resultat des Decoders in eine Nachricht umwandeln kann. Hierbei ist allerdings zu beachten, dass die Anfrage auch fehlschlagen kann. Daher muss die Funktion auch in der Lage sein, einen möglichen Fehler zu verarbeiten.

Wir definieren im Datentyp Msg einfach einen Konstruktor, der später als erstes Argument von expectJson genutzt wird. Neben diesem Konstruktor fügen wir noch Nachrichten hinzu, um einen Zähler hoch- und runterzuzählen. Die Anwendung wird später für den aktuellen Wert des Zählers die API anfragen, um zu prüfen, ob die Zahl gerade ist oder nicht.

type Msg
    = Number Change
    | ReceivedResult (Result Http.Error ParityInfo)


type Change
    = Increase
    | Decrease

Wir definieren nun zuerst einen Decoder, um die JSON-Struktur, die wir vom Server erhalten, in den Record ParityInfo umzuwandeln. Dazu definieren wir die folgenden beiden decoder in den jeweiligen Modulen.

decoder : Decoder Parity
decoder =
    Decoder.map
        (\b ->
            if b then
                Even

            else
                Odd
        )
        Decoder.bool
decoder : Decoder ParityInfo
decoder =
    Decoder.map2 ParityInfo
        (Decoder.field "iseven" Parity.decoder)
        (Decoder.field "ad" Decoder.string)

Als nächsten wollen wir ein Kommando definieren, das eine Anfrage an die API durchführt. Statt die URL string-basiert zusammenzusetzen, nutzen wir die Funktionen aus dem Paket elm/url. Daher installieren wir dieses Paket zunächst mithilfe des folgenden Kommandos.

elm install elm/url

Wir importieren dann das Modul Url.Builder. Dieses Modul stellt eine Funktion

crossOrigin : String -> List String -> List QueryParameter -> String

zur Verfügung.2 Mit dieser Funktion können wir eine URL bauen. Das erste Argument der Funktion crossOrigin ist die Basis-URL der Anfrage. Um solche Informationen zu speichern, legen wir ein Modul Api.Config an. In diesem Modul können wir später zum Beispiel auch Informationen wie API-Schlüssel hinterlegen. In einem realen Projekt würden wir dieses Modul nicht zur Versionskontrolle hinzufügen. Auf diese Weise können wir auf dem Produktiv-Server eine andere Konfiguration verwenden als in unserer Entwicklungsumgebung. Als weiteren Benefit erhalten wir durch die Nutzung eines Elm-Moduls einen Fehler vom Compiler, wenn die Datei nicht existiert. Das heißt, es kann nicht passieren, dass unsere Anwendung abstürzt, da die entsprechende Konfigurationsdatei fehlt.

module Api.Config exposing (baseURL)


baseURL : String
baseURL =
    "https://api.isevenapi.xyz/api"

Wir definieren das Kommando, das genutzt wird, um eine Anfrage durchzuführen in einem Modul Api.ParityInfo. In diesem Modul kennen wir den Nachrichtendatentyp unserer Anwendung nicht. Wir möchten an sich auch nicht, dass dieses Modul das Modul importiert, dass den Nachrichtentyp definiert, da wir dann eine Abhängigkeit zu diesem Modul einführen würden. Daher übergeben wir an die Funktion get eine Funktion als Argument, die später dafür zuständig ist, die Daten in den Nachrichtendatentyp einzupacken. Durch die Verwendung einer Funktion höherer Ordnung und von Polymorphismus erreichen wir also, dass das Modul Api.ParityInfo keine Abhängigkeit zum Nachrichtendatentyp hat.

get : { number : Int, onResponse : Result Http.Error ParityInfo -> msg } -> Cmd msg
get { number, onResponse } =
    Http.get
        { url = Url.Builder.crossOrigin Api.Config.baseURL [ "iseven", String.fromInt number ] []
        , expect = Http.expectJson onResponse decoder
        }
Wichtig

Ein solches Vorgehen wird auch als Dependency Injection bezeichnet.

Bei einer Dependency Injection wird einem Modul von außen eine Abhängigkeit zu einem anderen Modul injiziert. In vielen imperativen Programmiersprachen werden aufwendige Dependency Injection Frameworks genutzt, um eine Abhängigkeit auf ein anderes Modul im Nachhinein in ein Modul einzusetzen. Ein ganz ähnliches Ergebnis lässt sich aber wie hier durch eine Funktion höherer Ordnung und Polymorphismus erreichen.

Wir nennen die oben definierte Funktion get, da sie das Kommando liefert, um eine GET-Anfrage durchzuführen. Entsprechend würden wir eine Funktion post nennen, wenn sie das entsprechende Kommando für eine POST-Anfrage liefert. Für GET-Anfragen, die eine Liste von Daten liefern, nutzen wir einen Namen wie getAll. Falls die Anfrage nicht alle Daten liefert, sondern nur eine Teilmenge, können wir dies entsprechend in dem Suffix hinter get ausdrücken.

Wir nutzen zur Modellierung des internen Zustands unserer Anwendung den folgenden Datentyp.

type alias Model =
    { number : Int
    , apiData : Api.Data ParityInfo
    }

Der Datentyp Api.Data wird dabei genutzt, um die verschiedenen Zustände beim Ausführen einer HTTP-Anfrage zu modellieren. Der Datentyp wird hier in ein Modul Api.Data geschrieben.

type Data value
    = Loading
    | Failure Http.Error
    | Success value

Für den Datentyp Data nutzen wir außerdem die folgende Funktion.

dataFromResult : Result Http.Error a -> Data a
dataFromResult result =
    case result of
        Err error ->
            Failure error

        Ok value ->
            Success value

Nun haben wir alle Komponenten zusammen, um die Funktion update für unsere Anwendung zu definieren. Im Fall Number führen wir eine Anfrage an die API durch. Im Fall ReceivedResult aktualisieren wir unser Modell mit den Daten der API.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Number change ->
            let newNumber = updateNumber change model.number
            in
            ( { model | number = newNumber, apiData = Api.Loading }
            , Api.ParityInfo.get { number = newNumber, onResponse = ReceivedResult } )

        ReceivedResult result ->
            ( { model | apiData = Api.dataFromResult result }
            , Cmd.none )


updateNumber : Change -> Int -> Int
updateNumber change number =
    case change of
        Decrease ->
            number - 1

        Increase ->
            number + 1

Zu guter Letzt müssen wir nur noch eine Funktion schreiben, die abhängig vom aktuellen Zustand eine entsprechende HTML-Seite erzeugt. Außerdem stellen wir Knöpfe für die verschiedenen Aktionen zur Verfügung.

view : Model -> Html Msg
view { number, apiData } =
    div []
        [ div []
            [ button [ onClick (Number Decrease) ] [ text "-" ]
            , text (String.fromInt number)
            , button [ onClick (Number Increase) ] [ text "+" ]
            ]
        , viewApiData apiData
        ]


viewApiData : Api.Data ParityInfo -> Html msg
viewApiData apiData =
    case apiData of
        Api.Loading ->
            text "Lade Daten ..."

        Api.Success info ->
            viewParityInfo info

        Api.Failure error ->
            text ("Der folgende Fehler ist aufgetreten:\n" ++ Debug.toString error)


main : Program () Model Msg
main =
    Browser.element
        { init = \() -> ( { number = 0, apiData = Api.Loading }
                       , Api.ParityInfo.get { number = 0, onResponse = ReceivedResult } )
        , subscriptions = \_ -> Sub.none
        , view = view
        , update = update
        }

Die Funktion viewApiData nutzt zur Vereinfachung die Funktion Debug.toString.

Wichtig

Die Funktion Debug.toString kann einen beliebigen Elm-Wert in einen String umwandeln und ist eigentlich nur zum Debugging einer Anwendung gedacht.

Diese Funktion sollte in einer fertigen Anwendung nicht genutzt werden. Wir nutzen in dieser Anwendung auch die Möglichkeit, direkt beim Start der Anwendung ein Kommando durchzuführen. Zu diesem Zweck liefert die Funktion init in der zweiten Komponente des Paares ein Kommando für eine entsprechende HTTP-Anfrage.

Zufall

Als zweiten Anwendungsfall von Kommandos wollen wir die Generierung von Zufallszahlen anschauen. Wir wollen eine Anwendung schreiben, mit der man einen Würfel werfen kann. Zuerst installieren wir das Paket elm/random. Als nächstes modellieren wir die möglichen Ergebnisse eines Würfels.

type Side
    = One
    | Two
    | Three
    | Four
    | Five
    | Six

Als nächstes definieren wir ein Modell für unsere Anwendung.

type alias Model =
    Maybe Side

Wir nutzen Maybe, da wir gern modellieren wollen, dass der Würfel noch nicht geworfen wurde.

Nun definieren wir einen initialen Zustand. Initial haben wir kein Würfelergebnis, daher ist der initiale Zustand unserer Anwendung Nothing.

init : Model
init =
    Nothing

Als nächstes definieren wir die Nachrichten, die unsere Anwendung verarbeiten kann. Die Anwendung soll nur in der Lage sein, einen Würfel zu würfeln, daher benötigen wir nur eine einzige Nachricht.

type Msg
    = RollDie

Mithilfe eines Knopfes können wir diese Nachricht an die Anwendung schicken.

view : Model -> Html Msg
view model =
    div []
        [ case model of
              Nothing ->
                  text "Please roll the die!"

              Just side ->
                  text (toString side)
        , button [ onClick RollDie ] [ text "Roll" ]
        ]

Als nächstes benötigen wir die update-Funktion. Diese liefert neben dem neuen Modell auch ein Kommando, das als nächstes ausgeführt werden soll. Um dieses Kommando zu konstruieren, verwenden wir im Fall des Zufalls die vordefinierte Funktion

generate : (a -> msg) -> Generator a -> Cmd msg

aus dem Modul Random. Diese Funktion nimmt einen Zufallsgenerator, eine Funktion, die das Ergebnis des Zufallsgenerators in eine Nachricht verpackt, und liefert ein Kommando. Wir benötigen also noch eine weitere Nachricht und erweitern daher unseren Datentyp Msg wie folgt.

type Msg
    = RollDie
    | Rolled Side

Außerdem benötigen wir einen Generator, der zufällig eine Würfelseite liefert. Wir nutzen dafür die Funktion uniform : a -> List a -> Generator a aus dem Modul Random.

die : Random.Generator Side
die =
    Random.uniform One [ Two, Three, Four, Five, Six ]

Die Funktion uniform erhält einen Wert und eine Liste von Werten und liefert mit gleicher Wahrscheinlichkeit den Wert oder eines der Elemente der Liste. An sich könnte die Funktion auch nur eine Liste erhalten. In diesem Fall könnten wir die Funktion aber mit einer leeren Liste aufrufen. Wenn wir an uniform eine leere Liste übergeben, kann der Generator aber keinen Wert erzeugen, da wir ihm gar keinen möglichen Wert zur Verfügung gestellt haben. Daher erhält uniform noch ein zusätzliches Argument, um zu gewährleisten, dass die Funktion immer mindestens einen möglichen Wert erhält. Alternativ hätte man der Funktion uniform auch nur eine nicht-leeren Liste übergeben können.

Mithilfe des Generators, der gleichverteilt Würfelseiten liefern kann, können wir nun die Funktion update wie folgt definieren.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        RollDie ->
            ( model, Random.generate Rolled die )

        Rolled side ->
            ( Just side, Cmd.none )

Wenn der Benutzer auf den Knopf drückt, erhält die Anwendung die Nachricht RollDie. In diesem Fall lassen wir das Modell einfach wie es ist und fordern die Laufzeitumgebung auf, einen zufälligen Wert mit unserem Generator zu erzeugen. Wenn dieser Wert erzeugt wurde, wird die Funktion update wieder aufgerufen, dieses Mal aber mit der Nachricht Rolled. Der Konstruktor enthält die Seite, die gewürfelt wurde. Wenn wir diese Nachricht erhalten, ersetzen wir den alten Zustand durch unsere neue Würfelseite und geben an, dass wir kein Kommando ausführen wollen.

Zu guter Letzt müssen wir unsere Anwendung nur noch wie folgt zusammenbauen. Da wir zur Verwendung von Kommandos die Funktion Browser.element verwenden müssen, müssen wir dem Record auch ein Feld subscriptions übergeben. Da wir in dieser Anwendung nicht über Ereignisse informiert werden möchten, nutzen wir die Konstante Sub.none, um zu signalisieren, dass wir keine Abonnements nutzen möchten.

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

Das Modul Random stellt ähnliche Funktionen zur Verfügung wie das Modul Json.Decode für die Definition von Decodern. Zum Einen stellt das Modul Random die Funktion map : (a -> b) -> Generator a -> Generator b zur Verfügung. Mithilfe dieser Funktion können wir die Ergebnisse eines Generators abändern. Nehmen wir an, wir benötigen einen Zufallsgenerator, der Zahlen liefert anstelle des Datentyps Side. In diesem Fall können wir wie folgt einen Generator definieren.

pips : Random.Generator Int
pips =
    let
        toPips side =
            case side of
                One ->
                    1

                Two ->
                    2

                Three ->
                    3

                Four ->
                    4

                Five ->
                    5

                Six ->
                    6
    in
    Random.map toPips die

Das Modul Random stellt außerdem eine Funktion

map2 : (a -> b -> c) -> Generator a -> Generator b -> Generator c

zur Verfügung, mit der wir zwei Generatoren zu einem Generator kombinieren können. Wir können zum Beispiel wie folgt einen Generator definieren, der zufällig zwei Würfel würfelt und die Summe der Augenzahlen liefert.

dice : Random.Generator Int
dice =
    Random.map2 (+) pips pips

Die Schreibweise (+) ist im Endeffekt eine Kurzform von \x y -> x + y.

Wichtig

Wenn man einen Infixoperator mit Klammern umschließt, kann man den eigentlich infix verwendeten Operator präfix schreiben.

Zum Beispiel kann man statt 1 + 2 auch (+) 1 2 schreiben. Das heißt, statt Random.map2 (\x y -> x + y) pips pips können wir auch Random.map2 (\x y -> (+) x y) pips pips schreiben. Mittels zwei Anwendungen von Eta-Reduktion können wir diesen Ausdruck dann zu Random.map2 (+) pips pips vereinfachen.

  1. https://github.com/public-apis/public-apis#science--math 

  2. Das Modul Url.Builder stellt auch Funktionen string : String -> String -> QueryParameter und int : String -> Int -> QueryParameter zur Verfügung, mit denen QueryParameter gebaut werden können.