Planvoll meditieren mit F# und Sutil
AUTOR MARTIN GROTZ
Vorwort
Für den diesjährigen F# Adventskalender bauen wir eine sehr einfache web-basierte App, um eine Meditations-Session zu timen. Dazu nutzen wir F# und die Bibliothek Sutil.
F# ist eine primär auf's funktionale Programmieren ausgelegte Programmiersprache, die innerhalb des .NET-Ökosystems existiert. Die Sprache ist pragmatisch, kompakt, recht klein und stabil, dabei aber sehr mächtig.
Normalerweise schreibt man eine interaktive Webanwendung mit JavaScript oder TypeScript. Aber man kann dafür auch andere Sprachen benutzen, zum Beispiel eben F#. Der Fable Compiler ermöglicht es uns F#-Code mit ein paar zusätzlichen Annotationen zu versehen und in JavaScript zu übersetzen. Das JavaScript kann dann auf alle gängigen Weisen ausgeführt werden, sei es im Browser, in node.js oder als Electron-App. Auf dem Fable Compiler aufsetzend gibt es verschiedene weitere Bibliotheken, jede mit einem anderen Fokus: Möchte man eine React-App erstellen, so gibt es zum Beispiel Feliz.
Aber im JavaScript-Bereich gibt es eine neue Art von Framework (oder genauer: Compiler), deren aktuell bekanntester Vertreter Svelte sein dürfte: Der Code wird dort vom Svelte-Compiler in reaktiven JavaScript Code übersetzt. Dabei wird zur Laufzeit keine große JavaScript-Laufzeitumgebung benötigt, so dass die Dateigröße viel kleiner ist. Außerdem verzichtet Svelte auf ein Virtual DOM und erreicht dadurch eine höhere Geschwindigkeit. Es werden nur die Seitenteile aktualisiert, die explizit reaktiv an einen Wert gebunden wurden.
Sutil bringt diese Ideen in die F#/Fable-Welt. Die resultierende Anwendung folgt dem Svelte-Modell, nur dass wir F# statt JavaScript oder TypeScript benutzen. Sutil nutzt dabei Teile bestehender Projekte, so stammt zum Beispiel die HTML-Engine aus Feliz. Diese Bauteile werden dann mit eigenen Ideen kombiniert. Sutil ist aktuell im Beta-Stadium: Die Dokumentation ist noch recht rudimentär und einige nützliche Features sind noch nicht implementiert. Ich wollte die Bibliothek trotzdem sehr gerne mal ausprobieren, und der F# Adventskalender ist ein sehr guter Vorwand, es endlich zu tun.
Hinweis: Ich bin kein sehr erfahrener F#-Programmierer, und ich habe bisher nur sehr wenig im Fable-Umfeld experimentiert. Es gibt bestimmt eine Menge Dinge im Folgenden, die sich viel eleganter lösen lassen, als ich es getan habe...
Was bauen wir?
Unser Ziel ist eine kleine Webseite auf der wir eine Meditations-Sitzung planen und begleiten lassen können: Einzelne Schritte werden mit einer Dauer zu einer Liste hinzugefügt, und am Ende jedes Schritts wird einer von zwei möglichen Tönen abgespielt.
Das Endergebnis sieht so aus:
Wir machen kein Styling und beschränken uns auf eine sehr minimale Funktionalität. Außerdem lassen wir die Tests weg (bitte macht das nie in echten Projekten!), um die Artikellänge einigermaßen im Rahmen zu halten.
Ich benutze dafür Windows 10, VSCode mit dem Ionide Plugin, node.js 16 und dotnet5.
Die Architektur
Da ich ein großer Fan der Programmiersprache Elm bin, benutzen wir Sutil auch mit der "Elmish"-Architektur. Diese ist sehr nahe an der offiziellen Elm Architecture. Es gibt speziell für den Einsatz in F# auch noch ein eigenes Online-Buch.
Kurz zusammengefasst: Die Daten und Änderungen in der Applikation fließen immer nur in eine Richtung. Der aktuelle Zustand steckt im Modell (einem F# Record). Dieser wird an die View-Funktion übergeben - eine normale Funktion, die ein SutilElement zurückgibt. Dort können über eine dispatch-Funktion Nachrichten (Messages) ausgelöst werden, die zusammen mit dem aktuellen Modell an die update-Funktion übergeben werden. Diese kann dann eine veränderte Kopie des Modells erzeugen, was wiederum die reaktiven Teile der View aktualisiert. Dieser Kreislauf läuft immer wieder ab. In Sutil wird die gesamte View-Funktion nur einmal aufgerufen, anschließend werden nur noch die Teile aktualisiert, die explizit reaktiv an einen Wert gebunden wurden.
Das leere Projekt, aber schon inklusive aller notwendigen Ressourcen, kann einfach mit npx degit MATHEMA-GmbH/blog-fsharp-sutil-mgrotz#fresh-start blog-fsharp-sutil-mgrotz
geklont werden. Danach installiert der Aufruf von dotnet tool restore && npm install
im Projektordner alle Abhängigkeiten und mit npm run start
wird das Programm gestartet und ist im Browser unter http://localhost:8080/
erreichbar.
Erstes Inkrement: Dauer eingeben
All unsere Entwicklungen werden auf eine ähnliche Art und Weise ablaufen: Zuerst überlegen wir, was sich im Zustand ändern muss (Model), wie es dargestellt werden soll (View), und zuletzt was für Messages generiert werden können und wie diese den Zustand ändern (Update). Das mag ich an der MVU-Architektur: Sie erlaubt ein sehr strukturiertes Vorgehen!
Unser gesamter Quellcode ist in der App.fs
Datei - das Programm ist klein genug, um in eine Datei zu passen!
Ganz oben brauchen wir ein paar Importe:
module App
open Sutil
open Sutil.DOM
open Sutil.Attr
open Sutil.Helpers
Danach definieren wir unser Modell als Record type und eine init-Funktion, die es initial befüllt. Außerdem legen wir für jedes Feld im Record gleich eine Hilfsfunktion an, damit wir später bequemer an den Wert kommen:
type Model = { LastTimerValue : int }
let getLastTimerValue m = m.LastTimerValue
let init () : Model= ({ LastTimerValue = 1 })
Danach brauchen wir eine Funktion, die einen Teil einer View erzeugt. Der Store ist dabei eine Datenstruktur von Sutil, bei dem wir Änderungen abonnieren können, um auf diesem Weg auf neue Werte reagieren zu können. Die dispatch-Funktion brauchen wir, um Messages aus der View heraus auslösen zu können, die dann an die update-Funktion übergeben werden.
let planEditView model dispatch =
Html.ul [
Html.li [
Html.label [Html.text "How many minutes should the next timer last?"]
Html.input [
// we have to use type' here, because type is a reserved word in F#
type' "number"
Attr.value (model |> Store.map getLastTimerValue |> Store.distinct, LastTimerValueChanged >> dispatch)
Attr.min 1
Attr.max 15
Attr.placeholder "How many minutes?"
]
]
]
Der Html-Namespace enthält Funktionen zur Erzeugung von HTML-Elementen. Die Liste, die als Argument übergeben wird, enthält sowohl Attribute als auch Kindelemente.
Attr.value
erzeugt eine reaktive Bindung zwischen Store-Wert und Input-Feld-Wert. Dadurch wird dieser Teil des DOMs immer dann aktualisiert, wenn sich der Wert im Store ändert. Store.map
bildet den Wert im Store (hier unser gesamtes Modell) auf einen bestimmten Wert ab - damit ziehen wir den LastTimerValue
raus. Diesen geben wir dann an Store.distinct
weiter, um unnötige Updates zu vermeiden. Normalerweise wird der reaktive Code auch dann aufgerufen, wenn der Wert im Model identisch zum vorherigen Wert ist. Der zweite Teil des Tupel-Arguments für Attr.value
gibt an, was passiert, wenn der Wert im Input-Feld sich ändert: Dispatche die Message LastTimerValueChanged
.
Dieser neue Message-Fall muss in dem Union Type hinzugefügt werden. Und dann auch noch in der update-Funktion behandelt werden:
type Message =
| LastTimerValueChanged of int
let update (msg : Message) (model : Model) : Model =
match msg with
| LastTimerValueChanged newValue -> { model with LastTimerValue = newValue }
Jetzt können wir unsere neue Sub-View zur Haupt-View hinzufügen:
let view() =
// create the application with The Elm Architecture
let model, dispatch = () |> Store.makeElmishSimple init update ignore
Html.div [
Html.div [
planEditView model dispatch
]
]
Zuletzt muss das gesamte Programm noch zusammengesteckt und gestartet werden. Das erledigt Program.mountElement
am Ende der App.fs
-Datei:
view() |> Program.mountElement "sutil-app"
Sofern die App noch nicht läuft startet sie ein npm run start
, so dass sie anschließend unter http://localhost:8080/
aufrubar wird.
Zweites Inkrement: Schritte hinzufügen
Aber die Eingabe der Minuten alleine bringt uns noch nicht viel. Wir brauchen noch die Möglichkeit, mehrere Schritte nacheinander einzufügen.
Hierzu können wir das gleiche Vorgehen benutzen: Modell erweitern, View erweitern, Message definieren, update-Funktion erweitern.
Das Modell bekommt ein neues Feld, eine neue Hilfsfunktion und eine angepasste init-Funktion:
type Model = {
(...)
TimerStepsReversed : TimerStep list
}
let getTimerSteps m = m.TimerStepsReversed |> List.rev
let init () : Model= ({
LastTimerValue = 1
TimerStepsReversed = []
})
Dazu brauchen wir einen neuen Record-Type für einen einzelnen Schritt und die zwei unterschiedlichen Sounds inklusive ihrer Hilfsfunktionen. Bei der Gelegenheit binden wir auch gleich eine Hilfsfunktion von Sutil ein, um uns aufsteigende IDs zu generieren. Und weil F# immer von oben nach unten geparsed wird, müssen diese vor unserer Modell-Definition stehen:
let idGenerator = makeIdGenerator ()
type Sound =
| SingleShortBell
| SingleLongBell
let soundToString sound =
match sound with
| SingleShortBell -> "A single short bell chime"
| SingleLongBell -> "A single long bell chime"
let soundToFile sound =
match sound with
| SingleShortBell -> "powerUp7.wav"
| SingleLongBell -> "FileLoad.wav"
type TimerStep = {
StepId : int
Minutes : int
Sound : Sound
}
let createStep minutes sound =
{StepId = idGenerator(); Minutes = minutes; Sound = sound;}
Danach erweitern wir unsere bestehende Sub-View um einige Buttons, die nach dem bestehenden Html.li
Element eingefügt werden. Diese fügen entsprechende Schritte bei Klick hinzu:
Html.li [
(...)
]
Html.li [
Html.button [
type' "button"
onClick (fun _ -> dispatch AddSingleShortBell) []
Html.text "Add single short bell"
]
Html.button [
type' "button"
onClick (fun _ -> dispatch AddSingleLongBell) []
Html.text "Add single long bell"
]
]
Jeder Button hat dabei eine eigene Message. Das zweite Argument zu onClick
- bei uns eine leere Liste - ermöglicht Modifikatoren zum Event-Verhalten, also Dinge wie stopPropagation
oder preventDefault
.
Dann brauchen wir natürlich noch die beiden neuen Message-Fälle und eine angepasste update-Funktion:
type Message =
(...)
| AddSingleShortBell
| AddSingleLongBell
let update (msg : Message) (model : Model) : Model =
match msg with
(...)
| AddSingleShortBell ->
let withNewStep = (createStep model.LastTimerValue SingleShortBell) :: model.TimerStepsReversed
{ model with TimerStepsReversed = withNewStep }
| AddSingleLongBell ->
let withNewStep = (createStep model.LastTimerValue SingleLongBell) :: model.TimerStepsReversed
{ model with TimerStepsReversed = withNewStep }
Der neue Schritt wird dabei erst erzeugt und dann vor die bestehenden Schritte gestellt. Das Anfügen vorne an eine Liste ist in F# sehr effizient, deshalb speichern wir die Schritte verkehrt herum ab, und drehen sie erst um, wenn wir sie irgendwo in der richtigen Reihenfolge brauchen.
Übrigens: Einen Zwischenschritt rausloggen kann man über die Nutzung einer Fable-Funktion - aber Achtung: das bricht mit der "Seiteneffekt-Freiheit" der Elm-Architektur in der update-Funktion!
Browser.Dom.console.info(Fable.Core.JS.JSON.stringify withNewStep)
Mit diesen Änderungen können wir jetzt einzelne Schritte zu unserem Meditationsplan hinzufügen. Sutil lädt nach Code-Änderungen die Seite innerhalb weniger Sekunden automatisch mit dem neuesten Stand neu.
Drittes Inkrement: Die Schritte darstellen
Nachdem die Schritte jetzt hinzugefügt werden, müssen wir sie auch irgendwie darstellen. Dafür brauchen wir erstmals reaktive Verknüpfungen ganzer Elemente bzw. von Listen von Elementen. Dafür brauchen wir diesmal keine Anpassungen an Modell oder Update-Funktion, weil sie nur die Darstellung ändert - die MVU-Architektur führt zu einer guten "Separation of Concerns"!
let meditationPlanView (model: IStore<Model>) dispatch =
fragment
[
Html.h2 "Meditation session plan"
Bind.el (model |> Store.map getTimerSteps, fun steps ->
if List.isEmpty steps then
Html.h3 "No steps planned yet"
else
Html.ul
[
Bind.each ((model |> Store.map getTimerSteps), (fun step ->
Html.li [
Html.span $"{soundToString step.Sound} after {step.Minutes} minute(s)"
] )
)
]
)
]
Vier Dinge sind hier spannend:
-
Die Liste von HTML-Elementen muss in
fragment [...]
verpackt werden, sobald wir mehrere Elemente aus einer Funktion zurückgeben wollen, damit Sutil sie korrekt ins DOM einfügen kann. -
Bind.el
erzeugt eine reaktive Bindung: Der erste Teil des Tupels ist der Store-Wert, an den wir binden wollen, und der zweite Teil ist der ausgeführte Code bei jeder Wert-Änderung. In unserem Fall binden wir einen Text, falls die Liste leer ist, oder die Liste der Schritte im DOM an die Liste der Schritte im Modell. -
Bind.each
erzeugt auch eine reaktive Bindung, diesmal aber für eine Liste von Werten. Die übergebene Funktion wird dann für jeden Eintrag aufgerufen und muss jeweils einSutilElement
zurückgeben. -
Die meisten HTML-Element-Funktionen haben eine Kurzschreibweise, wenn nur ein Text eingefügt werden soll: Aus
Html.span [Html.text "our text"]
kann dadurchHtml.span "our text"
werden. Hier nutzen wir auch noch die in F# relativ neue String-Interpolation, um die Daten des Schritts und den Sound in den Text einzufügen via$"{soundToString step.Sound} after {step.Minutes} minute(s)"
.
Jetzt müssen wir die neue Sub-View nur noch in die Haupt-View einfügen, und schon sind die Schritte sichtbar:
let view() =
(...)
Html.div [
Html.div [
(...)
meditationPlanView model dispatch
]
]
Viertes Inkrement: Die Session starten
Jetzt kommt der komplizierteste Teil: Die Sitzung starten, die Zeit vergehen zu lassen, und Schritt für Schritt durch die einzelnen Schritte gehen.
Wie immer fangen wir mit der Erweiterung des Modells an:
type Model = {
(...)
Running : bool
StartTime : Ticks
ElapsedTimeSinceStart : Ticks
PlayPlan : PlayPlan option
}
let getRunning m = m.Running
let getElapsedTime m = m.ElapsedTimeSinceStart
let getPlayPlan m = m.PlayPlan
let init () : Model= ({
(...)
Running = false
StartTime = Ticks 0L // the L marks the 0 as an int64 ("long") in F#
ElapsedTimeSinceStart = Ticks 0L
PlayPlan = None
})
Wir brauchen auch wieder neue Typen: Ticks gibt dem int64 aus der Ticks Eigenschaft von DateTime einen semantischen Namen, und PlayPlan reicht die einzelnen Schritte mit zusätzlichen Daten an:
type Ticks = Ticks of int64
let formatElapsedTime (Ticks elapsedTime) : string =
System.TimeSpan(elapsedTime).ToString()
type StepWithEndTime = {
Step: TimerStep
EndTime: Ticks
}
type PlayPlan = {
CurrentStep : StepWithEndTime
RemainingSteps : StepWithEndTime list
}
In der Hilfsfunktion für die Formatierung der Ticks sieht man, dass auch bestimmte .NET-Funktionen direkt aufgerufen werden können, die dann vom Fable Compiler passend übersetzt werden. Es gibt aber Einschränkungen, zum Beispiel gibt es keine Custom Format Strings für das ToString() von TimeSpan.
Unser PlayPLan besteht aus dem gerade aktiven Schritt und allen, die danach noch kommen. Dadurch stellen wir sicher, dass es während des Ablaufs immer genau einen aktiven Schritt gibt. Der PlayPlan wird erst beim Start bestimmt, daher ist er im Modell optional gekennzeichnet. Dafür gibt es in F# den Option Typ. So vermeiden wir das gefürchtete NULL und können zugleich anschaulich das mögliche Fehlen des Werts ausdrücken.
Zuerst brauchen wir noch einen Knopf für den Start. Der soll nur angezeigt werden, wenn der Timer noch nicht läuft und wir schon mindestens einen Schritt haben. Dafür machen wir wieder eine kleine Sub-View:
let startSessionButton (model: IStore<Model>) dispatch =
Bind.el ((model |> Store.map (fun m -> (m.Running, List.isEmpty m.TimerStepsReversed)) |> Store.distinct),
fun (running, noSteps) ->
if running || noSteps then
Html.text ""
else
Html.button [
type' "button"
onClick (fun _ -> dispatch StartSession) []
Html.text "Start session"
]
)
let showElapsedTime (model: IStore<Model>) dispatch =
Bind.el ((model |> Store.map getElapsedTime), fun elapsedTime ->
Html.div (formatElapsedTime elapsedTime)
)
Diese fügen wir zur Haupt-View hinzu:
let view() =
(...)
Html.div [
Html.div [
(...)
startSessionButton model dispatch
showElapsedTime model dispatch
]
]
Zuletzt müssen wir unseren Message-Typ um das neue StartSession
erweitern, und auch die update-Funktion:
type Message =
(...)
| StartSession
Dabei haben wir jetzt ein Problem: Wir wollen die StartTime bestimmen, wenn die Sitzung startet, und wir wollen, dass jede Sekunde ein Tick stattfindet, um in der Zeit voranzuschreiben. Aber der Zugriff auf die Zeit ist ein Seiteneffekt, und dieser ist in unserer bisherigen Elmish-Architektur nicht vorgesehen!
Dafür brauchen wir einen neuen Datentyp: Cmd
(was für Command steht) - dieser wird von Sutil bereitgestellt. Wir können Sutil sagen, dass wir Cmds als zusätzlichen Return Value der update-Funktion haben wollen.
Dafür müssen wir die Store-Initialisierung abändern:
// old
let model, dispatch = () |> Store.makeElmishSimple init update ignore
// new
let model, dispatch = () |> Store.makeElmish init update ignore
Jetzt gibt uns erstmal der Compiler Bescheid, dass wir an einigen Stellen die Cmds einfügen müssen: Unsere init-Funktion braucht jetzt ein Tupel als Rückgabewert. Möchte man keinen Seiteneffekt haben, nutzt man Cmd.none
.
let init () : Model * Cmd<Message>= ({
(...)
}, Cmd.none)
Danach müssen alle Zweige unserer update-Funktion angepasst werden. Folgend aus Platzgründen nur für einen Zweit gezeigt:
let update (msg : Message) (model : Model) : (Model * Cmd<Message>) =
(...)
| StartSession ->
(model, Cmd.none)
Jetzt können wir die korrekte Startzeit in der update-Funktion verwenden und auch Ticks auslösen. Da die update-Funktion von uns definiert und aufgerufen wird, können wir eine Funktion zum Zeit holen übergeben - dadurch bleibt die Funktion testbar. Achtung: Diese Seiteneffekt-behaftete Funktion im update aufzurufen bricht trotzdem die originale Elm-Architektur. In Elm selbst gibt es dafür sogenannte Subscriptions. Die sind in Sutil (bisher) nicht drin. Zumindest können wir für das Aufrufen der update-Funktion partial application verwenden. Dann gibt's noch ein bisschen Wrapping, um aus dem int64 unseren Ticks Datentyp zu machen.
let update (getNow : unit -> Ticks) (msg : Message) (model : Model) : (Model * Cmd<Message>) =
(...)
| StartSession ->
let now = getNow ()
({model with Running = true; StartTime = now; }, Cmd.none)
let view() =
let model, dispatch = () |> Store.makeElmish init (update (fun () -> Ticks System.DateTime.UtcNow.Ticks)) ignore
(...)
Jetzt müssen wir unseren StartSession Handler noch um die initiale PlayPlan-Berechnung erweitern. Dazu gehen wir mit foldBack
rückwärts durch die Liste der Schritte und berechnen so die jeweilige Endzeit. Wir müssen Ticks-Daten ein- und auspacken. Da das in F# aber genauso einfach ist wie das Erzeugen neuer Typen, lohnt es sich, von der "Primitive Obsession" wegzukommen und die Semantik mit guten Datentypen zu erhöhen! Ich habe sogar für den Interims-Zustand im foldBack
einen eigenen Typ erstellt!
type PlayPlanCalculation = {
PreviousStepEnd : Ticks
CalculatedSteps : StepWithEndTime list
}
let calculateNextTimerStep (timerStep : TimerStep) (state : PlayPlanCalculation) : PlayPlanCalculation =
// let minutesInTicks = (int64)timerStep.Minutes * System.TimeSpan.TicksPerMinute
let minutesInTicks = (int64)timerStep.Minutes * System.TimeSpan.TicksPerMinute / 10L // for DEBUG
// unwrap int64 value
let (Ticks previousStepEnd) = state.PreviousStepEnd
// calculate end and wrap again in Ticks type
let stepEndsAt = previousStepEnd + minutesInTicks |> Ticks
// update the state we thread through each iteration
let withEndTime = {Step = timerStep; EndTime = stepEndsAt}
{ state with PreviousStepEnd = stepEndsAt; CalculatedSteps = withEndTime :: state.CalculatedSteps}
let update getNow msg model : (Model * Cmd<Message>) =
(...)
| StartSession ->
let now = getNow ()
let calculatedSteps =
(List.foldBack calculateNextTimerStep
model.TimerStepsReversed
{ PreviousStepEnd = now; CalculatedSteps = []})
.CalculatedSteps // only take final result
|> List.rev // put them in the right order
// build initial PlayPlan
let playPlan = {
PlayPlan.CurrentStep = calculatedSteps |> List.head
RemainingSteps = calculatedSteps |> List.tail
}
({model with Running = true; StartTime = now; PlayPlan = Some playPlan}, Cmd.none)
Jetzt brauchen wir noch eine neue Message jede Sekunde. Da kommt uns eine der zahlreichen Cmd Hilfsfunktionen Sutils gerade Recht:
// build Cmd to dispatch TimerTick message after approximately 1 second (JavaScript is not exact in its timings)
let timerCmd = Cmd.OfAsync.perform (fun _ -> Async.Sleep 1_000) () (fun _ -> TimerTick)
({model with Running = true; StartTime = now; PlayPlan = Some playPlan}, timerCmd)
Sutil ruft jetzt jede Sekunde unsere update-Funktion auf, nachdem das Cmd ausgeführt und eine passende Nachricht erzeugt wurde.
Den passenden TimerTick-Fall müssen wir noch im Message-Typ ergänzen und vorerst packen wir einen "mache nichts"-Zweig in die update-Funktion. Diesen füllen wir im nächsten Inkrement mit Leben.
type Message =
(...)
| TimerTick
let update getNow msg model : (Model * Cmd<Message>) =
(...)
| TimerTick ->
(model, Cmd.none)
Fünftes Inkrement: Die Zeit vergeht
Und jetzt der schwierige Part: Die Zeit jede Sekunde vergehen lassen, aber nur, wenn der Timer tatsächlich läuft, und überhaupt noch Schritte da sind.
Das alles ist in dem update-Zweig, in dem wir die TimerTick Message verarbeiten. Es ist nicht kompliziert, aber es gibt doch mehrere Fälle, die wir berücksichtigen müssen:
- Der aktuelle Schritt endet, und wir haben keine weiteren Schritte mehr
- Der aktuelle Schritt endet, und wir haben noch mindestens einen weiteren Schritt
- Wir sind mitten in einem Schritt
Für Fall 1 müssen wir den zugehörigen Ton abspielen und den Timer stoppen. Für Fall 2 müssen wir ebenfalls den Ton abspielen, aber auch unseren PlayPlan aktualsiieren und die Zeit weiter vergehen lassen. Für Fall 3 müssen wir nur das Timer-Ticken weiterführen.
Und natürlich soll all das nur passieren, wenn der PlayPlan wirklich existiert - der Option-Typ also im "Some"-Fall ist, statt im "Nothing"-Fall. Wir können hier ausnutzen, dass Option ein Functor ist, und deshalb eine "map"-Funktion bereitstellt, mit der wir den inneren Wert transformieren können, sofern er vorhanden ist. Außerdem packen wir noch einen "mache einfach nichts"-Fallback via Option.defaultValue
mit ran.
let update getNow msg model : (Model * Cmd<Message>) =
(...)
| TimerTick ->
model.PlayPlan
|> Option.map (fun playPlan -> // only do something if the PlayPlan exists
// unwrap all the data
let (Ticks now) = getNow ()
let (Ticks currentStepEndTime) = playPlan.CurrentStep.EndTime
let (Ticks startTime) = model.StartTime
// Case 1: Our final step has just ended
if List.isEmpty playPlan.RemainingSteps && currentStepEndTime < now then
({model with Running = false; PlayingSound = Some playPlan.CurrentStep.Step.Sound}, Cmd.none)
else
// prepare the next tick
let tickCmd = Cmd.OfAsync.perform (fun _ -> Async.Sleep 1_000) () (fun _ -> TimerTick)
// update the elapsed time in the model here to avoid code duplication later
let elapsedTime = now - startTime
let modelWithNewElapsedTime = {model with ElapsedTimeSinceStart = Ticks elapsedTime}
if (currentStepEndTime < now) then
let newPlayPlan =
// Case 2: we have another step we need to move into our CurrentStep property
let newPlayPlan =
{
PlayPlan.CurrentStep = List.head playPlan.RemainingSteps;
RemainingSteps = List.tail playPlan.RemainingSteps
}
({modelWithNewElapsedTime with PlayPlan = Some newPlayPlan; PlayingSound = Some playPlan.CurrentStep.Step.Sound}, tickCmd)
else
// Case 3: just continue with another tick in 1 second
(modelWithNewElapsedTime, tickCmd)
)
// fall back to "do nothing" if the PlayPlan does not exist
|> Option.defaultValue (model, Cmd.none)
Wir fügen dazu noch eine neue Eigenschaft im Modell ein, welcher Sound abgespielt werden soll. Die Auswertung dieses Felds machen wir dann im nächsten Inkrement.
type Model = {
(...)
PlayingSound : Sound option
}
(...)
let getPlayingSound m = m.PlayingSound
let init () : Model * Cmd<Message> = ({
(...)
PlayingSound = None
}, Cmd.none)
Jetzt schreitet die Zeit auch in unserem Programm fort und wir arbeiten uns durch die einzelnen Schritte durch, bis keine mehr übrig sind. Im Code habe ich noch einen Faktor 10 für die Schritt-Dauer eingefügt, damit ich beim Debuggen statt einer Minute nur noch sechs Sekunden warten muss. Das sollte für einen produktive Nutzung entfernt werden, sonst wird die Meditations-Sitzung arg kurz.
Letztes Inkrement: Töne abspielen und Aufräumarbeiten
Das letze fehlende Puzzleteil ist die Audiowiedergabe. Mittlerweile hat HTML dafür ein eigenes Element, das in aktuellen Browsern auch gut unterstützt wird, und auch in Sutil direkt vorhanden ist: Das audio Element.
Das Modell haben wir im vorherigen Schritt schon erweitert, so dass wir das neue Element direkt in einer neuen Sub-View einfügen können:
let audioPlayback (model: IStore<Model>) dispatch =
Bind.el ((model |> Store.map getPlayingSound |> Store.distinct), fun sound ->
match sound with
| None -> Html.text ""
| Some s -> Html.audio [
on "ended" (fun _ -> dispatch SoundEnded) []
Attr.autoPlay true
Attr.src (soundToFile s)
]
)
Hier gibt's einige interessante Dinge: Wir müssen unbedingt den transformierten Wert aus dem Store durch Store.distinct
schieben, denn sonst würde das Element bei jeder Store-Änderung neu erzeugt werden, selbst wenn sich der Wert gar nicht wirklich verändert hat. Und dann beginnt der Sound von neuem, da wir Attr.autoPlay true
setzen!
Hier kommt nun auch die soundToFile
-Funktion zum Einsatz, die wir sehr viel früher erstellt haben, damit wir den korrekten Dateinamen bekommen. Außerdem nutzen wir einen "Custom Event"-Handler, um den Ton aus dem Modell zu entfernen, sobald er zu Ende abgespielt wurde. Wir können mit dieser Lösung zwar immer nur einen Ton gleichzeitig abspielen, aber für unseren Anwendungsfall reicht das völlig aus. Allerdings müssen wir unseren Message-Typ ein letztes Mal um einen neuen Fall ergänzen, um auch SoundEnded
erzeugen und verarbeiten zu können:
type Message =
(...)
| SoundEnded
let update getNow msg model : (Model * Cmd<Message>) =
(...)
| SoundEnded ->
({model with PlayingSound = None}, Cmd.none)
Und dann brauchen wir die neue Sub-View noch in unserer Haupt-View:
let view() =
(...)
Html.div [
Html.div [
(...)
audioPlayback model dispatch
]
]
Jetzt ist unser Meditations-Sitzungstimer vollständig! Wir können Schritte mit unterschiedlichen Längen und Tönen hinzufügen, wir gehen nach dem Start durch den Plan und spielen Töne ab, wenn ein Schritt endet. Wir gehen jetzt noch ein letztes Mal durch den Code für kleinere Aufräumarbeiten:
Teile der Seite sollen nicht dargestellt werden, während eine Sitzung läuft: Dafür nutzen wir zwei tolle Features von F#: Partial application, um eine Funktion erstmal nur mit einem Teil der Argumente zu befüllen, und Typ-Inferenz, um uns bei Funktionen die länglichen Typ-Annotationen zu sparen. Damit können wir eine kleine, praktische Hilfsfunktion schreiben, die die übergebene Sub-View nur dann ausführt, wenn unser Timer gerade nicht läuft:
let ifNotRunning model viewFn =
Bind.el ((model |> Store.map getRunning), fun running ->
if running then
Html.text ""
else
viewFn)
Wir brauchen hier keinerlei Typen ranschreiben, und F# weiß trotzdem Bescheid. Diese Funktion wenden wir in der Haupt-View teilweise an, merken uns die halb-ausgefüllte Funktion und können sie an jeder Stelle, wo wir die Logik brauchen, einsetzen - auch wenn es erstmal nur eine Stelle ist:
let view() =
(...)
let ifNotRunning' = ifNotRunning model
Html.div [
Html.div [
ifNotRunning' (planEditView model dispatch)
(...)
]
]
Jetzt sind wir wirklich fertig! Im letzten Kapitel bauen wir die ganze Applikation noch für's Deployment.
Vorbereitung für's Deployment
Das bei Sutil mitgelieferte Tooling kann uns einen produktiv-tauglichen Build machen, indem wir einmal npm run build
ausführen und ein paar Sekunden warten. Das Kompilat liegt dann als bundle.js
im public
Ordner. Die Konfiguration, was genau gemacht wird, steht in der webpack.config.js
und kann bei Bedarf angepasst oder erweitert werden.
Das JavaScript-File ist 31 KB groß (minifiziert und komprimiert). Das ist nicht ganz so klein wie eine "echte" Svelte-Applikation, aber immernoch voll im Rahmen. Wir können den ganzen public
-Ordner jetzt überall hin hochladen, wo statische Webseiten erlaubt sind, z.B. bei surge.sh oder netlify. Ich hab's auch auf meinem privaten Blog hochgeladen.
Zusammenfassung
Wie immer hat das Arbeiten mit F# jede Menge Spaß gemacht. Es ist eine wirklich schöne Programmiersprache, und das Editor-Tooling mit Ionide hat im letzten Jahr nochmal einen großen Sprung nach vorne gemacht. Ab und zu mal gab's kleinere Aussetzer, so dass ich für ein kommerzielles Projekt wahrscheinlich doch eher JetBrains Rider hernähme.
Sutil sehe ich mit gemischten Gefühlen: Es ist immernoch eine Beta-Version, was sich vor allem durch die unvollständige Dokumentation zeigt. Für mich war es besonders schwierig, weil ich im Fable-Ökosystem nicht fit bin. Ich mag aber natürlich F#, und ich mag die Ideen von Svelte, die Sutil mitbringt. Ich glaube daher, dass Sutil eine gute Ergänzung für den Werkzeugkasten ist, wenn man sein Frontend mit F# schreiben möchte - vor allem, wenn die Doku noch erweitert wird.
Persönlich werde ich F# weiterhin für die Server-Programmierung in meinen kleinen privaten Projekten hernehmen. Im Frontend bleibe ich aber vorerst bei Svelte direkt, oder aber, wenn ich viel UI-Zustand habe und wenig fertige Bibliotheken brauche, gleich bei Elm. Da habe ich dann eine wunderbare Programmiersprache, tolle Fehlermeldungen und alle Garantien der Elm-Architektur ohne Kompromisse. Ich rate aber jedem dazu, sich F# und auch die Frontend-Bibliotheken dafür mal in einem kleinen Spielprojekt (wie diesem hier) anzuschauen, alleine schon, um den Horizont zu erweitern, was es neben den "Mainstream"-Technologien und -paradigmen noch so gibt.
Ich wünsche Euch allen einen schönen und gesunden Dezember, ein Frohes Fest und viele entspannte Meditations-Sitzungen mit unserem in Sutil geschriebenen Meditations-Timer!
Danke
Ein Dankeschön an Sergey Tihon, der seit Jahren den F# Adventskalender organisiert. Und an Patrick und Christian für's Gegenlesen und die vielen Verbesserungsvorschläge. Und an meinen Arbeitgeber, die MATHEMA GmbH, für die Blog-Plattform und die Unterstützung beim Erstellen des Blog-Posts!
Top banner Photo von Zoltan Tasi auf Unsplash.
Über den Autor
Martin Grotz ist Senior Software Engineer bei der MATHEMA GmbH.
Er begann einst mit C#, wechselte dann aber bald in die Web-Entwicklung. Heutzutage nutzt er meistens Angular. Seine Leidenschaft ist die Funktionale Programmierung (naja, und Tee, und Schokolade...) und deshalb liebt er F#, Elixir und Elm.