Eine Anwendung mit nur einer (funktionalen) Programmiersprache entwickeln
(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:
- In der
init
-Funktion - 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 Todo
s. Als Rückgabe erwarten wir wieder eine Liste von Todo
s:
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:
- Den Bezeichner einer Funktion, die einen Promise zurück liefert (in unserem Fall die Funktion
newTodo
). Wichtig: Hier wird nicht der Promise selbst übergeben. - 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. - 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
). - 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ürSystem.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:
- Nimm den Value aus der Komponente des Events,
- packe ihn in eine
DescriptionChanged
-Message - 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:
- Nimm das Model,
- hole von jedem die
Id
, - ermittle davon den größten Wert
- 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 Id
s 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 …
Weitere Beiträge
von Goetz Markgraf
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Goetz Markgraf
Senior Agile Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.