Tutorial: F# mit SAFE-Stack – Teil 2

Keine Kommentare

Eine Anwendung mit nur einer (funktionalen) Programmiersprache entwickeln

Spielsteine und Hände
(https://unsplash.com/photos/rMm0dChKUaI)

Willkommen zurück!

Im ersten Teil der Serie haben wir unser Grundgerüst für eine einfache To-do-Anwendung gebaut. Der Code der Anwendung findet sich dabei im Wesentlichen in drei Dateien, eine für das Frontend, eine für das Backend und eine, die in beiden Schichten benutzt wird.

Der Code ist vollständig in F# geschrieben und benutzt die Bibliotheken des SAFE-Stacks, insbesondere:

  • Giraffe (für die Bearbeitung von HTTP-Requests)
  • Fable (für den F# nach Javascript-Transpiler und die React-UI-Elemente)
  • Elmish (für das Model-Update-View-Pattern)

Bisher kann die Anwendung nur eine fixe Liste von To-dos, die im Backend initialisiert sind, in der GUI anzeigen. Heute erweitern wir sie um eine wichtige neue Funktionalität:

Wir wollen neue Aufgaben hinzufügen können.

Den Stand des Codes vom letzten Teil findet ihr hier.

Den vollständigen Code von heute findet ihr hier.

Steigen wir direkt ein. Los geht’s diesmal mit dem Frontend:

Index.fs

Um eine neue Aufgabe hinzuzufügen, verwenden wir eine Eingabezeile und einen Button. In der Eingabezeile gibt der Benutzer an, um welche Aufgabe es geht und der Button legt das To-do an.

Da im Model-Update-View-Pattern der vollständige Zustand einer Anwendung im Model hinterlegt ist, müssen wir die Datenstruktur für das Model zunächst um ein Attribut für den Inhalt der Eingabezeile erweitern:

type Model =
    {
        Todos: Todo list
        Error: string
        Description: string  // <- das hier ist neu 
    }

Des weiteren benötigen wir zwei neue Messages: Eine, wenn der Inhalt des Eingabefeldes geändert wird, und die andere, um das neue To-do anzulegen:

type Msg =

    ... // die bisherigen Messages

    | DescriptionChanged of string
    | AddTodo

Jetzt dürfen wir nicht vergessen, in der Init-Funktion auch die Description auf einen leeren String zu setzen (der Compiler meckert bereits, dass da etwas fehlen würde!).

let init() =
    { Todos = []; Error = ""; Description = "" }, Cmd.ofMsg Load

Soweit so einfach. Allerdings meckert der Compiler immer noch, und zwar darüber, dass der match-Ausdruck in der Funktion update nicht vollständig sei – womit er völlig recht hat. Ergänzen wir also den Code für die Behandlung der beiden fehlenden Messages:

let update msg model =
    match msg with

    ... // die bisherigen Messages

    | DescriptionChanged s ->
        { model with Description = s}, Cmd.none
    | AddTodo ->
        let newTodo description = Fetch.post<string, Todo list> (Route.todos, description)
        let cmd = Cmd.OfPromise.either newTodo model.Description Refresh Error
        { model with Description = "" } , cmd

Die Behandlung für DescriptionChanged ist vermutlich selbsterklärend, der neue Text wird in das Model eingefügt.

Spannender ist AddTodo. Und an dieser Stelle möchte ich euch wie versprochen die merkwürdige Konstruktion mit dem Fetch und dem Cmd erklären.

Cmd ist der Name einer Datenstruktur, die eine Message hält, also ein Container für eine Message.Cmd-Container werden an zwei Stellen verwendet:

  1. In der init-Funktion
  2. In der update-Funktion

In beiden Fällen wird ein Tupel aus dem neuen Model und einem Cmd zurückgegeben.

Wozu benötigen wir das? Können wir nicht einfach eine Message zurückgeben?

Nein, können wir nicht. Denn in F# gibt es keinen null-Ausdruck, also können wir nicht einfach keine Message zurückgeben. Wir benötigen schon allein daher einen Container.

Es gibt aber noch einen zweiten Grund, und den sehen wir hier in der Behandlung von AddTodo – ebenso, wie bei Load aus dem ersten Teil:

    | Load ->
        let loadTodos() = Fetch.get<unit, Todo list> Route.todos
        let cmd = Cmd.OfPromise.either loadTodos () Refresh Error
        model, cmd

Die Kommunikation mit dem Backend erfolgt nämlich asynchron über einen Promise.

Fetch stammt aus der Bibliothek Thoth, die ebenfalls Teil des SAFE-Stacks ist.
Fetch.get bzw. Fetch.post rufen dabei nicht direkt das Backend auf, sondern erzeugen einen Promise.

Hierbei müssen wir übrigens den Typ für den Parameter zum Backend und dem Rückgabewert angeben, den kann der Compiler wirklich nicht ermitteln. Der GET-Aufruf hat wie bei einem GET üblich keinen Body, daher geben wir als ersten Typ unit an. Der GET liefert eine Liste von To-dos zurück, die wir als zweiten Typ angeben:

Fetch.get<unit, Todo list> Route.todos

Der POST-Aufruf sieht ähnlich aus. Hier geben wir auch Daten an das Backend, nämlich den string mit der Beschreibung des neuen Todos. Als Rückgabe erwarten wir wieder eine Liste von Todos:

Fetch.post<string, Todo list> (Route.todos, description)

(Die Klammern sind wichtig, da alle Parameter als ein Tupel an Fetch.get bzw. post übergeben werden und nicht als einzelne Parameter.

Verwendet wird das Ganze als Parameter für Cmd.OfPromise.either. Schauen wir uns den Aufruf einmal im Beispiel unseres POST-Requests an:

Cmd.OfPromise.either newTodo model.Description Refresh Error

Die Funktion either nimmt vier Parameter:

  1. Den Bezeichner einer Funktion, die einen Promise zurück liefert (in unserem Fall die Funktion newTodo). Wichtig: Hier wird nicht der Promise selbst übergeben.
  2. Der Parameter, der an die Funktion aus 1. übergeben werden soll. In unserem Fall die Description aus dem Model. Bei dem GET-Request steht dort (), also nix.
  3. Eine Message, die verwendet werden soll, wenn der Promise fertig ist. Die Message muss dabei genau den Datentyp aufnehmen können, die auch das Ergebnis des Promises ist (hier: Todo list).
  4. Eine Message, die verwendet werden soll, wenn bei der Bearbeitung des Promises ein Fehler auftritt. Der Parameter dieser Message ist vom Typ exn (eine Abkürzung für System.Exception).

Schaut euch den Aufruf des GET an. Ist jetzt klar, warum zwischen loadTodos und () definitiv ein Leerzeichen stehen muss?

Der so erstellte Cmd ist (neben einem unveränderten Model) der Rückgabewert dieser Funktion. Das Elmish-Framework erzeugt dann den Promise, führt ihn aus und erzeugt je nach Ergebnis asynchron eine der beiden Messages.

Anpassung der UI

Als letztes müssen wir die view-Funktion anpassen. Um diese nicht unendlich groß werden zu lassen, teilen wir sie in einzelne Funktionen auf, die man als Komponenten bezeichnen könnte – zumindest um die UI einer Komponente.

Die eigentliche view-Funktion kann dadurch recht kurz werden:

let view model dispatch =
    div [ Style [ TextAlign TextAlignOptions.Center; Padding 40 ] ] [
        div [] [
            img [ Src "favicon.png" ]
            h1 [] [ str (sprintf "Todos: %i" model.Todos.Length) ]
            
            descriptionView model dispatch
            errorView model
            todosView model dispatch            
        ]
    ]

Neben dem Header werden drei weitere Funktionen aufgerufen (die natürlich vor dieser Funktion im Code stehen müssen, da der F#-Compiler nur von vorne nach hinten liest).

Hier gibt es die neue Eingabezeile:

let descriptionView model dispatch =
    div [] [
        input [
            Placeholder "What is to be done?"
            Value (model.Description)
            OnChange (fun ev -> !!ev.target?value |> DescriptionChanged |> dispatch)
        ]
        button [
            OnClick (fun _ -> AddTodo |> dispatch)
        ] [ str "Add" ]
    ]

Interessant ist das OnChange-Property. Dieses erhält als Parameter eine Funktion mit dem Event als Parameter. Mit der Konstruktion !!ev.target?value können wir auf den Inhalt des Textfeldes, das das Event ausgelöst hat, zugreifen. Damit das geht müsst ihr vorher noch ein weiteres Fable-Modul einbinden:

open Fable.Core.JsInterop

Mit !! greifen wir dann auf JavaScript-Objekte zu, und mit ? auf Elemente eines JS-Objektes, natürlich ohne jede Typ-Prüfung (also: aufpassen!). Wir erhalten damit den Inhalt des Textfeldes und geben diesen an eine Message weiter.

Die ganze Zeile:

!!ev.target?value |> DescriptionChanged |> dispatch

Ist dabei eine – wie ich finde – elegante Schreibweise für:

dispatch (DescriptionChanged (!!ev.target?value))

Das funktioniert auch, aber ich bevorzuge die Pipeline-Darstellung, da sie sich einfacher von vorne nach hinten lesen lässt:

  1. Nimm den Value aus der Komponente des Events,
  2. packe ihn in eine DescriptionChanged-Message
  3. und gibt diese als Parameter an die dispatch-Funktion.

dispatch ist – wenig überraschend – die Funktion, die eine Message aufnimmt und diese im nächsten Zyklus an die update-Funktion reicht. Hier benötigen wir keinen Cmd-Container, da wir ja stets eine Message an dispatch übergeben können – ansonsten würden wir es halt nicht aufrufen.

Den Inhalt der anderen beiden Funktionen kennen wir schon, er stand vorher genauso in der view-Funktion.

let errorView model =
    match model.Error with
    | "" -> div [] []
    | s -> p [ ] [ str s ]

let todosView model dispatch =
    div [] ( model.Todos |> List.map (fun each ->
        p [ ] [str each.Description]))

Damit ist unser Frontend fertig.

Server.fs

Ab jetzt darf unsere Pseudo-»Datenbank« nicht mehr unveränderlich sein. Wir werden Funktionen benötigen, die deren Inhalt manipulieren. Zur besseren Übersicht packen wir die komplette Funktionalität in ein eigenes Modul mit dem Namen Database:

module Database =

    let mutable private database: Todo list = []

Das Schlüsselword mutable weist F# an, dass der Inhalt von database geändert werden kann.

Wichtig! Dadurch wird nicht die Liste selbst änderbar, diese bleibt read only. Aber ich kann die eine Liste durch eine andere ersetzen und wieder in database ablegen.

Initial ist database jetzt also eine leere Liste. Um trotzdem mit etwas starten zu können, schreiben wir eine Initialisierungs-Funktion:

    let init() =
        database <- [
        {
            Id = 1
            Description = "Read all todos"
            Completed = true
        }
        {
            Id = 2
            Description = "Add a new todo"
            Completed = true
        }
        {
            Id = 3
            Description = "Toggle State"
            Completed = false
        }
    ]

Damit niemand direkt auf database zugreift (wir haben es ja extra mit private gekennzeichnet), brauchen wir Zugriffsfunktionen, dabei insbesondere eine, die direkt nach Id sortiert.

    let getAll () =
        database

    let getAllSorted () =
        database |> List.sortBy (fun each -> each.Id)

Als letztes benötigen wir noch eine Funktion, um die »Datenbank« auf einen neuen Stand zu bringen:

    let save model =
        database <- model

Dabei steht <- für die Zuweisung an einen mutable Bezeichner (wie auch schon in der init-Funktion). Die Syntax sieht hier extra ein sehr besonderes Zeichen vor, zusammen mit dem Schlüsselwort mutable, damit niemand leichtfertig so etwas macht. Eine bestehende Zuweisung zu ändern bricht nämlich mit dem funktionalen Paradigma. In dieser Beispiel-Anwendung können wir das machen, grundsätzlich solltet ihr so etwas, wenn es irgendwie geht, vermeiden.

Als letztes rufen wir noch die init-Funktion auf, damit unsere Datenbank auch gefüllt ist. Dieser Code wird ausgeführt, sobald das Modul Database geladen und initialisiert wird.

    do init()

Wichtig! Alle diese Funktionen müssen eingerückt werden, damit sie zu dem oben deklarierten Modul gehören.

Business Logik

Jetzt brauchen wir noch die Business Logik unserer Anwendung. Diese ist in unserem Beispiel sehr überschaubar, denn unser Backend kann lediglich ein neues To-do anlegen.

Auch diese Logik kapseln wir in einem eigenen Modul. (Ihr könnt beide Module auch gerne in eigene Dateien auslagern, ich habe mir das gespart):

module Todos =

    let newId model =
        model
            |> List.map (fun each -> each.Id)
            |> List.max
            |> (+) 1

    let addTodo model description =
        let id = newId model
        let newTodo = { Description = description; Completed = false; Id = id }
        newTodo :: model

Die erste Funktion (newId) ermittelt eine neue, eindeutige Id für ein neues Todo. Wieder einmal fällt die elegante-Pipeline-Syntax auf:

  1. Nimm das Model,
  2. hole von jedem die Id,
  3. ermittle davon den größten Wert
  4. und erhöhe ihn um 1

Dagegen sieht addTodo schon fast langweilig normal aus. Übrigens hängen wir das neue To-do vorne an, was für unveränderliche Listen der empfohlene, weil performanteste Weg ist.

So, wir sind fast fertig. Wir müssen nur noch unseren router ergänzen, damit er den POST-Request auch verarbeiten kann.

let webApp =
    router {

        ... // die bisherige Behandlung des GET-Requests

       post Route.todos (fun next ctx -> 
            task {
                let! description = ctx.BindModelAsync<string>()
                let model = Todos.addTodo (Database.getAll()) description
                Database.save model
                return! json (Database.getAllSorted()) next ctx
            })
    }

Ich hatte euch versprochen, dass wir diesmal wenigstens den Parameter ctx benötigen werden: Damit greifen wir auf den Body des Requests zu. Auch das geschieht asynchron und muss daher in einem task gekapselt werden. Damit Tasks genutzt werden können, benötigen wir Zugriff auf das entsprechende Modul:

open FSharp.Control.Tasks.V2.ContextInsensitive

Die Ausrufezeichen (!) bei let und return sind dafür da, um auf das Ergebnis der asynchronen Verarbeitung zu warten, bzw. um uns wieder mit der Umwelt zu synchronisieren.

Beim Zugriff auf den Body des POST-Requests müssen wir angeben, welchen Datentypen wir erwarten, in diesem Fall <string>.

Damit die neu hinzugefügte Aufgabe im Frontend nicht als erstes Element auftaucht, liefern wir die Liste nach Ids aufsteigend sortiert zurück.

Wir hätten natürlich auch im Frontend sortieren können. Vielleicht wäre es sinnvoll, solche Funktion in Shared zu platzieren, dann hätten sowohl Front- als auch Backend darauf Zugriff.

Shared.fs

Nix zu tun. Wir verwenden weiterhin denselben Pfad, nur diesmal mit POST und nicht mit GET.

That’s it

Die neue Anwendung verfügt jetzt über die erwähnte Eingabezeile. Wenn eine neue Aufgabe erstellt wird, wird diese zu der Liste der Anwendungen hinzugefügt und die Eingabezeile wieder geleert.

Zusammenfassung

Diesmal haben wir asynchrone Verarbeitung im Frontend mit Hilfe von Promises kennengelernt. Außerdem haben wir die dispatch-Funktion zum erstellen von Messages aufgrund von Benutzereingaben benutzt, sowie unser Frontend in kleinere »Komponenten« aufgeteilt.

Tatsächlich kennen wir im Frontend jetzt so ziemlich alles, was man wissen muss, um die Funktionalität eines User Interface zu programmieren.

Im Backend haben wir Business Logik eingefügt und eine Pseudo-Datenbank erzeugt.

Im nächsten Teil der Serie werden wir bestehende To-dos verändern, wir wollen sie nämlich als erledigt kennzeichnen können.

Stay tuned …

Goetz Markgraf

Goetz hat Wirtschaftsinformatik studiert und viele Jahre als Softwareentwickler und Project Manager gearbeitet. Sein Fokus liegt dabei immer auf dem Verständnis für die Situation und Herausforderungen der Kunden und darin, dies für Entwickler verständlich zu machen.
Seit 2018 ist er Consultant bei der codecentric AG.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien. Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

* Hiermit willige ich in die Erhebung und Verarbeitung der vorstehenden Daten für das Empfangen des monatlichen Newsletters der codecentric AG per E-Mail ein. Ihre Einwilligung können Sie per E-Mail an datenschutz@codecentric.de, in der Informations-E-Mail selbst per Link oder an die im Impressum genannten Kontaktdaten jederzeit widerrufen. Von der Datenschutzerklärung der codecentric AG habe ich Kenntnis genommen und bestätige dies mit Absendung des Formulars.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.