Wenn wir als Programmierer ein neues Projekt auf den Schreibtisch bekommen, erhalten wir meist ein Klick-Dummy-Design und ein mündliches Briefing vom verantwortlichen Projektmanager. Dabei geht es um die Anwendungsanforderungen, also um die Frage, wie die App funktionieren und verwendet werden soll. Klassischerweise passiert dies mit imperativen Formulierungen, die etwa so klingen:
“Wenn du den Knopf hier drückst, dann soll Modal A angezeigt werden. Außer, der Nutzer hat den Haken dort gesetzt, dann soll Modal B gezeigt werden. Und der Eintrag in der Datenbank müsste aktualisiert werden, wenn der Nutzer etwas in diesem Eingabefeld eingetragen hat. Und das muss dann auch in seinem Nutzerprofil angezeigt werden. Außer… “.
Diese Formulierungen und die damit verbundene Denkweise werden dann direkt in der eigentlichen Programmierung angewendet. Und finden sich dann auch im Programmcode wieder. Wir versuchen dabei natürlich, alle Grenz- und Spezialfälle mitzudenken. Jedoch kann es vorkommen, dass sich bei den ersten Tests herausstellt, dass ein Sonderfall nicht bedacht wurde. Und dieser nachträglich ergänzt werden muss. Dabei kann ein beachtlicher Mehraufwand entstehen.
Anstelle dieser imperativen Denkweise wollen wir den Prozess nun reaktiv betrachten. Dafür stellen wir die Frage: Welche Daten werden sich verändern? Und welche Daten werden dabei von den Komponenten benötigt? Aus einem “Was muss die App leisten?” wird ein “Was kann sich in der App verändern?”
Meistens sind das Eingaben des Users (Mausklick, Inputfeld, etc). Diese Änderungen wandelt man nun gedanklich in eine Sequenz von Daten um, welche zeitlich sortiert eintreffen. Und schon ist man bei der Definition eines Streams angelangt. Die Formulierung "Stream" verdeutlicht auch schon einen wesentlichen Vorteil sowie die Einsatzmöglichkeit: die Komplexitätsreduzierung eigentlich asynchroner Events.
Um Streams einfach darzustellen und erklären zu können, eignen sich sogenannte Marble Diagramme (Marble Diagram Generator) wie im oberen Bild.
Jede Kugel entspricht dabei einem Ereignis (bspw. Mausklick), das in der zeitlichen Reihenfolge -A-B-C-> (ASCII Variante des Marble Diagrams) stattgefunden hat und auf den Stream gelegt wurde. Dieser Stream kann nun subscribed werden. Jeder Wert, der auf den Stream gelegt wird, wird dann der Subscription mitgeteilt und kann weiter bearbeitet werden.
Asynchrone Events verlieren durch die Verwendung von Streams deutlich an Komplexität. Der produzierte Code wird eleganter.
Ein praktisches Beispiel:
Stellen wir uns vor, unsere neue Aufgabe ist die Programmierung einer einfachen Anwendung, bei der ein User eine Kugel innerhalb eines Containers per Drag & Drop verschieben kann. Dabei soll die Kugel den Rahmen nicht verlassen können. Außerdem sollen die X- und Y-Positionen angezeigt werden. Ein Klick innerhalb des Containers mit einer Mausbewegung soll die Kugel zum Mauszeiger springen lassen und die Bewegung der Maus im Container nachfahren.
Dafür werden drei Events benötigt:
Für die Umsetzung werden entsprechend drei verschiedene Streams benötigt:
Das “|”-Symbol kennzeichnet, dass ein Stream zu diesem Zeitpunkt beendet ist. Ein “$”-Zeichen symbolisiert, dass die Variable ein Stream ist. Die Streams sind voneinander abhängig. Nachdem ein Wert auf click$ gelegt wurde, sollen Mausbewegungen auf den move$ gelegt werden. Schließlich soll die Aufnahme der Bewegungen durch einen Wert im release$ beendet werden.
Ein großer Vorteil von Streams ist, dass die Operatoren pipeable (leitbar) sind. Dabei handelt es sich um Operatoren, die einen Stream annehmen und wieder zurückgeben. Dazwischen können bspw. mit filter Werte aussortiert werden. Wir benötigen für den obigen Anwendungsfall einen Operator, der es ermöglicht, Streams zu kombinieren. Hier kommt bspw. mergeMap in Frage. MergeMap projiziert jeden Quellwert auf einen inneren Stream und subscribed diesen. Sobald der innere Stream einen Wert emittiert, werden diese direkt auf den resultierenden Stream gelegt:
Der move$ startet mit dem ersten Klick, mit mergeMap werden die erhaltenen Werte ausgelesen.
Da wird es elegant und kurz:
In der inneren Map-Position können weitere Operationen – bspw. die Begrenzung auf den Container mit einigen kleineren Berechnungen– vorgenommen werden. Der imgMove$ kann nun subscribed werden, um die Positionsdaten als Objekt mit den jeweiligen x- und y-Werten zu erhalten:
Das Beispiel kann unter folgendem Stackblitz-Link getestet und bearbeitet werden.
... schon designed für asynchrone Ereignisse. Und man hat sich schon an die Programmierung mit Promises gewöhnt. Warum also etwas Neues, wenn man sich bereits die Mühe gemacht hat, Promises zu lernen? Ganz generell ist ein Promise ein Wert, der zu einem Zeitpunkt in der Zukunft den Zustand resolved oder rejected einnimmt (heißt: Der Wert erfüllt sich oder wird zurückgewiesen). Der Wert des Promises ist undefiniert, solange der Zustand auf pending (wartend) ist. Es handelt sich um ein einzelnes Ereignis und stellt keine Methode bereit, die das Promise abbrechen kann. Dies kann selbst implementiert werden, was aber direkt zu verschiedenen States innerhalb des Promises führen kann. Und auch verschiedene Zeitpunkte innerhalb des Promises abgreifen muss (vor, während und nach der Ausführung; bei mehrfacher Ausführung muss auch eine multiple Abbruchmöglichkeit implementiert werden).
In short: Promises sind hervorragend geeignet, um einzelne asynchrone Events zu bearbeiten. Oder wenn ein Wert in der Zukunft auf jeden Fall gebraucht wird. Der große Unterschied und Bonuspunkt, den Streams haben ist aber, dass sie keinen oder beliebig viele Werte abarbeiten können. Außerdem können die Werte auf einem Stream durch ein unsubscribe abgebrochen werden. Wird z.B. eine HTTP-Anfrage nicht mehr benötigt (ein Nutzer hat sich bei einer Auswahlmöglichkeit anders entschieden), kann der Wert der Anfrage über die Subscription ignoriert werden. Ein Promise wird für die Anfrage, die uns zu diesem Zeitpunkt nicht mehr interessiert, den Wert bzw. Status resolved oder rejected einnehmen.
Ein weiterer Vorteil: Streams sind "faul", also lazy. Das bedeutet, dass die Auswertung erst mit einem subscribe Aufruf startet. Dies bringt uns den Vorteil, dass wir die Werte über die pipeable Operatoren bearbeiten können, bevor sie mit einem subscribe tatsächlich ausgeführt werden. Das führt zu einer deklarativen Programmierung. Außerdem kann ein Stream auch Promises zurückgeben.
Vorteile:
+ Asynchrone Events können leichter beschrieben werden und verlieren deutlich an Komplexität
+ Der resultierende Code wird leichter lesbar, kürzer und leichter wartbar, higher ordered Streams (die bspw. bei mergeMap entstehen) reduzieren die Komplexität noch weiter
+ Reaktive statt imperative Denkweise erspart viele Sonder- und Grenzfälle bereits in der Datenverarbeitung (durch pipeable Operatoren)
+ Ereignisse können ignoriert werden (bspw. ein nicht mehr benötigter HTTP-Request auf der Clientseite)
+ Ausführliche Dokumentation (RxJS) und viele Anwendungsbeispiele im Netz
Mögliche Nachteile:
- Steile Lernkurve, kann durch die Marble-Diagramme verkürzt werden
- Durch die schiere Größe der vorhandenen Bibliothek und vielen ähnlichen Operatoren gibt es oftmals mehrere Lösungen für ein Problem (bspw. könnte man im Beispiel statt mergeMap auch switchMap verwenden)
- verschiedene Benennung für den gleichen Sachverhalt (Stream, Observables, ..) kann verwirrend sein
Unterm Strich: Das Arbeiten mit Streams ist eine interessante und oftmals empfehlenswerte Vorgehensweise bei asynchronen Events. Ob und wann sich dieser Weg oder ein anderer empfiehlt, muss natürlich jeder im Einzelfall entscheiden.
Dynamisch und kompetent setzen wir uns für Ihren nachhaltigen digitalen Erfolg ein. Kontaktieren Sie uns.