Wir haben zu Anfang gelernt, dass Elm eine rein funktionale Programmiersprache ist und man daher keine Seiteneffekte ausführen kann. Genauer gesagt sind Programme in Elm referentiell transparent.

Wichtig

Ein Ausdruck ist referenziell transparent, wenn der Wert des Ausdrucks nur von den Werten seiner Teilausdrücke abhängt.

In Programmiersprachen, die uns nicht dazu zwingen, solche Programme zu schreiben, ist es aber auch guter Stil, diese Eigenschaft an möglichst vielen Stellen zu gewährleisten. Man kann sich leicht vorstellen, dass es recht schwierig ist, Fehler zu finden, wenn wiederholte Aufrufe der gleichen Methode mit identischen Argumenten immer wieder andere Ergebnisse liefern. Daher versucht man auch in anderen Programmiersprachen den Teil der Anwendung, der nicht referentiell transparent ist, möglichst von dem Teil zu trennen, der referentiell transparent ist.

Einige Teile einer Frontend-Anwendung benötigen aber natürlich Seiteneffekte. Ein Beispiel für einen Seiteneffekt, der in einer Frontend-Anwendung 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. Dieser Prozess wird durch Kommandos und den zugehörigen Datentyp Cmd modelliert. 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.

Grundlagen

Als wir die Elm-Architektur besprochen haben, haben wir das Programm mithilfe der Funktion sandbox erstellt.

sandbox :
    { init : model
    , view : model -> Html msg
    , update : msg -> model -> model
    }
    -> Program () model msg

Neben dieser Funktion gibt es auch eine Funktion element, die den folgenden Typ hat.

element :
    { init : flags -> ( model, Cmd msg )
    , view : model -> Html msg
    , update : msg -> model -> ( model, Cmd msg )
    , subscriptions : model -> Sub msg
    }
    -> Program flags model msg

Diese Funktion nimmt als initialen Wert eine Funktion. Die Funktion, die das initiale Modell erzeugt, erhält als Argument einen Wert vom Typ flags. Es handelt sich dabei um Informationen, die das JavaScript-Programm, das die Elm-Anwendung startet, an die Elm-Anwendung übergeben kann. Das initiale Modell besteht im Vergleich zur Sandbox außerdem nicht nur aus einem Modell sondern noch aus einem Kommando in Form eines Wertes vom Typ Cmd msg. Die Funktion update liefert in diesem Fall auch nicht nur ein Modell als Ergebnis, sondern ein Modell und ein Kommando. Außerdem ist ein neues Feld hinzugekommen, das subscriptions heißt. Das heißt, wir können einen Seiteneffekt ausführen, wenn die Anwendung gestartet wird. Außerdem können wir einen Seiteneffekt durchführen, wenn eine Nachricht an die Anwendung geschickt wird. Das Feld subscriptions wird hier nicht näher behandelt. Das Konzept der Kommandos wird dafür genutzt, um einmalig eine Aktion durchzuführen. Mithilfe von subscriptions kann man sich dagegen wiederholt informieren lassen. Dieses Konzept wird zum Beispiel genutzt, um Tastendrücke oder Mausbewegungen zu verarbeiten.

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. 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(..), toText)


type Parity
    = Even
    | Odd


toText : Parity -> String
toText 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.toText info.parity ++ ".") ]
        , p [] [ text info.advertisement ]
        ]

Nachdem wir das Resultat einer Anfrage modelliert haben, wollen wir die Anfrage 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ächstes 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, das 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
        }

Statt die Funktion onResponse an die Funktion get zu übergeben, könnten wir an dieser Stelle auch die Funktion Cmd.map : (a -> b) -> Cmd a -> Cmd b nutzen. Diese Art der Entkopplung haben wir im Abschnitt Unnötige Abhängigkeiten genutzt, um den Typ der Nachrichten in einer HTML-Struktur umzuwandeln. Beide Ansätze verfolgen das Ziel, die Abhängigkeit vom Typ der Nachrichten zu verhindern.

Wichtig

Man kann diese Ansätze als eine Form von Dependency Injection bezeichnen.

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
    , parityData : RemoteData ParityInfo
    }

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

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

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

fromResult : Result Http.Error a -> RemoteData a
fromResult 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. Die Konstante none aus dem Modul Cmd hat den Typ Cmd msg und kann genutzt werden, wenn kein Kommando durchgeführt werden soll.

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

        ReceivedResult result ->
            ( { model | parityData = RemoteData.fromResult 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
        ]


viewParityData : RemoteData ParityInfo -> Html msg
viewParityData data =
    case data of
        RemoteData.Loading ->
            text "Lade Daten ..."

        RemoteData.Success info ->
            viewParityInfo info

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


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

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

  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.