Menü
Zurück zur Blog-Übersichtsseite
Eine von RxJS-Programmierung verwirrte Katze.

Erfolgreich mit RxJS Teil 1 – Ohne den Verstand zu verlieren


Janis Krasemann

10 Minuten

Wer eine moderne, erfolgreiche Webanwendung inklusive asynchroner Kommunikation mit einem Backend bauen möchte, kommt an RxJS eigentlich nicht vorbei. Wenn dabei die Wahl des Frontend Frameworks auf Angular fällt, ist RxJS sogar automatisch gezwungenermaßen Teil der Anwendung, da viele zentrale Features von Angular (wie z.B. der HttpClient) RxJS verwenden.

Wer sich schon einmal eingehender mit dem Thema auseinander gesetzt hat, weiß: RxJS ist komplex. Sehr komplex. Ein bisschen vergleichbar mit regulären Ausdrücken: Sehr mächtig, und die Funktionalität "von Hand" nachzubauen weil man das Werkzeug nicht beherrscht ist ungleich aufwändiger. Entwickler:innen kennen oft nur die absoluten Basics, weswegen sie komplexe, asynchrone Abläufe oft suboptimal umsetzen, indem mehrfach ineinander verschachtelte subscribe Aufrufe verwendet werden:

// User-Daten laden fetchUser$().subscribe((user) => { let cart, favorites; // Warenkorb des aktuellen Users laden fetchShoppingCart$(user.id).subscribe((data) => { // wenn die Favoriten bereits geladen sind, zeige Erfolgsmeldung an if (favorites) { displaySuccessMessage(user, data, favorites); } cart = data; }); // Favoriten des aktuellen Users laden fetchFavorites$(user.id).subscribe((data) => { // wenn der Warenkorb geladen ist, zeige jetzt endlich die Erfolgsmeldung an...? if (cart) { displaySuccessMessage(user, cart, data); } favorites = data; }); });

Im Beispiel wird zunächst der aktuelle User geladen, anschließend der Warenkorb und die Favoriten für die erhaltene User-ID. Da es sich bei allen Operationen um asynchrone REST-Aufrufe handelt, geben die jeweiligen Funktionsaufrufe ein Observable zurück. Im Codebeispiel soll außerdem eine Erfolgsmeldung angezeigt werden, sobald alle Daten vorliegen – in diesem Fall gelöst über zwei lokale Variablen, die dann jeweils im inneren subscribe abgefragt werden. Dadurch wird der Code eher unleserlich, und es ist vielleicht auch nicht 100%ig klar ob es nicht Fälle geben könnte in denen displaySuccessMessage gar nicht aufgerufen wird.

Observables bzw. Funktionen, die Observable zurückgeben, sind oft zu erkennen an dem $ am Ende des Funktions- oder Variablennamen. Das ist aber allerhöchstens eine Konvention und muss demnach nicht immer so sein. In meinen Codebeispielen sind Observables wie beschrieben am $ zu erkennen.

Eine weitere Herausforderung: Gefühlt existieren für jeden Use Case mindestens drei mögliche Operatoren die dann mehr oder weniger das gleiche tun – Stackoverflow ist voll von Fragen wie “rxjs combinelatest vs forkjoin vs merge difference”. Hier kann man schnell den Überblick verlieren – mir selbst ist das in der Vergangenheit einigermaßen häufig passiert. Dagegen hilft dieser Blog Post, der eine Übersicht über die wichtigsten RxJS Operatoren gibt und warum man diese verwenden kann oder sogar sollte. Hierbei unterscheide ich zwischen:

  • Must-Have Operatoren: solche die man sofort und idealerweise ohne Blick in die Doku einsetzen können sollte
  • Must-Know Operatoren: solche die man kennen sollte, die aber nicht so oft zur Anwendung kommen, dass man dafür nicht mal eben in der Doku nachlesen kann

Grundlagen

RxJS wird auf der offziellen Dokumentationsseite als “Lodash für Events” beschrieben. Mit anderen Worten, ist RxJS eine Bibliothek zum Entwickeln asynchroner, Event-basierter Programme. Der Kern von RxJS ist das Observable, also ein “überwachbares” Objekt, welches eine Sequenz von Werten ausgeben kann. Sogenannte Observer reagieren auf neue Werte der besagten Sequenz. Diese können mithilfe von Operatoren, welche teilweise von Array-Methoden inspiriert sind, weiter manipuliert werden.

Subscription

Um eine Aktion auszuführen wenn ein Observable einen Wert ausgibt, wird eine Subscription benötigt. Hierbei wird auf dem Observable die subscribe-Funktion aufgerufen, der gelieferte Callback gibt die Implementierung der Subscription an; heutzutage wird so ein Callback meist als Arrow-Function umgesetzt. Immer wenn die Observable-Sequenz einen Wert ausgibt, erhält jede Subscription diesen Wert und führt den übergebenen Callback aus. Optional können auch Callbacks für Fehlerfälle und die Completion des Observables angegeben werden. Completion bedeutet, dass das Observable "fertig" ist und keine weiteren Werte mehr ausgeben wird. Ein Observable beendet entweder mit dem Fehlerzustand oder wenn es complete ist.

Operator

RxJS unterscheidet zwischen Pipeable Operators und Creation Operators. Pipeable Operators sind pure Funktionen, die ein Observable als Input erhalten und auch wieder ein Observable als Output herausgeben. Sie werden beispielsweise verwendet, um Werte herauszufiltern, zu modifizieren oder Seiteneffekte auszuführen.

Dafür wird die pipe-Funktion eines Observables ausgeführt, in das dann ein oder mehrere Operatoren hineingegeben werden. Ähnlich der Pipe in Unix Betriebssystemen können mehrere Operatoren hintereinander geschaltet werden, um die herausgegebenen Werte des Observables, auf das pipe aufgerufen wurde, nach und nach weiter zu modifizieren. Pipeable Operators spielen eine große Rolle bei der Verwendung von APIs großer Bibliotheken wie NGRX, NGXS und natürlich Angular.

Creation Operators hingegen werden verwendet, um ein Observable zu erzeugen. Wir werden sowohl Operatoren besprochen, welche Observables aus nicht-Observables erzeugen, als auch solche, welche aus einem oder mehreren Observables ein neues Observable kombinieren.

Must-Have Pipeable Operators

Hier kommen die Operatoren die alle Entwickler:innen mit RxJS-Bezug kennen und ohne Probleme anwenden können sollten:

map / mapTo

map funktioniert so wie man es auch von der gleichnamigen Array-Funktion erwartet: Es nimmt einen Wert aus der Sequenz des Observables entgegen und gibt einen neue Wert zurück (mit dem ein folgender Operator dann weiterarbeiten kann).

this.httpClient .put(baseUrl + '/shopping-cart', { id: 4711, amount: 2, }) .pipe(map((response) => response.statusCode === 201)) .subscribe((success) => { if (success) { console.log('Der Warenkorb wurde erfolgreich aktualisiert!'); } else { console.log('Das hat leider nicht geklappt'); } });

Dieses Beispiel aktualisiert den Warenkorb mit dem Produkt mit ID 4711; das Produkt wurde zwei Mal in den Warenkorb gelegt. Anschließend wird aus der Response der Status ausgelesen. Wir erwarten in diesem Fall einen Status Code 201, ansonsten ist etwas schief gegangen. Hier hätte man sich den map Aufruf auch theoretisch sparen können, bei komplexeren Operationen die nicht nur ein Property auslesen ist ein map Aufruf aber immer zu bevorzugen, da es expliziter ist und deutlich macht, dass der Rest des Objekts (bzw. in diesem Fall der Response) für den weiteren Verlauf nicht mehr benötigt wird.

Manchmal interessiert uns der eingehende Wert gar nicht. Man kann den Parameter von map dann theoretisch einfach ignorieren, sprechender ist in so einem Fall aber, wenn man stattdessen mapTo verwendet.

tap

Um innerhalb der Observable-Kette einen Seiteneffekt auszuführen, kann tap verwendet werden. In diesem Codebeispiel wird via tap der Request gecached:

const url = `${baseUrl}/shopping-cart/${shoppingCartId}`; if (this.cacheService.has(url)) { return this.cacheService.get(url); } this.httpClient .get(url) .pipe( tap((response) => this.cacheService.put(url, response)), map((response) => response.data.shoppingCart) ) .subscribe((shoppingCart) => console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`) );

tap ist auch zum Debuggen ab und an sehr nützlich, da man bei komplexen asynchronen Abläufen nicht immer ausschließlich mit Breakpoints arbeiten kann (ich finde da verliert man schnell den Überblick was wann passiert). Ein einfaches tap(console.log) vor oder nach einem Operator kann dann Abhilfe schaffen.

mergeMap

Im Gegensatz zu tap und map, welche meiner Meinung nach relativ intuitiv anwendbar sind, ist mergeMap ein Operator, mit dessen Anwendung viele Entwickler*:innen Probleme haben. Das liegt wahrscheinlich daran, dass im mentalen Modell oft das Vorhaben lautet "ich möchte den Wert des Observables auf den Wert eines anderen Observables mappen" - also wird map verwendet.

Stellen wir uns aus dem Beispiel oben also vor, es soll anschließend der aktualisierte Warenkorb geladen und angezeigt werden. Man will ja den Wert aus dem initialen Observable auf einen Wert eines weiteren Requests mappen, also könnte ein erster Entwurf wie folgt aussehen:

const fetchShoppingCart$ = (shoppingCartId) => this.httpClient.get( `${baseUrl}/shopping-cart/${response.data.shoppingCartId}` ); this.httpClient .put(baseUrl + '/shopping-cart', { id: 4711, amount: 2, }) .pipe(map((response) => fetchShoppingCart$(response.data.shoppingCartId))) .subscribe((shoppingCart) => { console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`); });

Das Problem: shoppingCart ist dann nicht - wie vielleicht zunächst erwartet - das Objekt, das direkt den Warenkorb repräsentiert, sondern ein Observable, welches einen Warenkorb ausgibt. Hier ist nochmal ein bisschen Terminologie wichtig:

  • das Observable, welches durch httpClient.put erzeug wird, ist ein outer Observable
  • das Observable aus fetchShoppingCart$() ist ein inner Observable, also ein Observable, welches innerhalb der Pipe in einem Operator zurückgegeben wird
  • das daraus entstehende Observable (d.h. das, worauf anschließend subscribe ausgeführt wird) ist ein sogenanntes Higher Order Observable, da es wiederum selbst ein Observable zurück gibt

Da wir aber eigentlich mit dem eigentlichen Wert aus dem fetchShoppingCart$()-Aufruf weiter arbeiten wollen (wie das console.log ja auch schon suggeriert), ist map nicht der richtige Operator. Um den Wert des inneren Observables zu erhalten, kommt deshalb stattdessen mergeMap zum Einsatz. Man spricht hier auch von flattening (zu deutsch etwa "flach klopfen"). Der Rest des Codes bleibt gleich:

this.httpClient .put(baseUrl + '/shopping-cart', { id: 4711, amount: 2, }) .pipe( mergeMap((response) => this.httpClient.get( `${baseUrl}/shopping-cart/${response.data.shoppingCartId}` ) ) ) .subscribe((shoppingCart) => { console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`); });

Wenn ihr eine einzige Information aus diesem Artikel mitnehmt, dann am besten diesen Unterschied zwischen map und mergeMap. Dazu noch eine Faustregel: Wenn im Callback von map ein weiteres inneres Observable zurückgegeben wird, sollte in den meisten Fällen stattdessen mergeMap angewendet werden.

Weitere Ressourcen

Wie angesprochen, muss man bei der RxJs-Programmierung oft mal in die Dokumentation schauen. Folgende Ressourcen haben mir in meiner täglichen Arbeit dabei geholfen:

Offizielle Dokumentation

Die offizielle RxJS-Dokumentation bietet einen Überblick über die zentralen Konzepte sowie selbstverständlich eine Referenz über alle existierenden Operatoren. Desweiteren finden sich hier natürlich ein Getting Started sowie Installationshinweise und Informationen über Breaking Changes in der API bei Versionssprüngen. Die Referenz der Operatoren kann an manchen Stellen etwas unübersichtlich sein, weswegen ich meistens auf andere Ressourcen zurückgreife.

Learn RxJS

Die Seite Learn RxJS von Brian Troncone ist meine erste Anlaufstelle, wenn ich nachlesen will, was ein bestimmter Operator tut oder den richtigen Operator für meinen Use Case suche. Die Aufteilung der Operatoren nach Kategorie sowie die Markierung der meistgenutzten Operatoren mit einem Stern sind extrem hilfreich. Hinweise bei einzelnen Operatoren verweisen zudem auf verwandte Operatoren und geben Tipps, wann diese evtl. die bessere Alternative wären.

RxMarbles

Marble-Diagramme sind die perfekte Visualisierungsart für RxJS-Operatoren. RxMarbles nutzt diesen Diagrammtyp für interaktive, animierte Erklärungen aller Operatoren. Auf die eigentliche Dokumentation der Operatoren wird hier verzichtet, die muss man dann weiterhin von anderen Quellen beziehen.

Rx Visualizer

Wenn die einzelnen Beispiele aus RxMarbles nicht reichen, kann man via Rx Visualizer den Verlauf eines angegebenen Observables visualisieren. Kann sehr nützlich sein, um ein vorliegendes oder in der Entwicklung befindliches Observable besser zu verstehen. Oft muss der Code hierfür aber noch deutlich angepasst werden, z.B. indem zusätzliche Delays eingebaut werden.

Code-Kommentare

Hiermit sind die eigenen Code-Kommentare gemeint. Ist der richtige Operator gefunden und der RxJS-Code geschrieben, kann sehr sauberer Code enstehen. Durch die hohe Einstiegshürde bei RxJS und die vielen verschiedenen Operatoren haben Entwickler:innen aber oft Probleme, diesen Code zu lesen, geschweige denn zu warten. Wenn ihr die Kommentare nicht für die Junior-Entwickler:innen in eurem Team schreibt, dann schreibt sie wenigstens für euer Zukunfts-Ich! (Das explizite Aufschreiben der Intention hilft außerdem oft, eigene Fehler zu finden.)

Abschluss

Ich hoffe, ich konnte ein bisschen Klarheit bezüglich der Grundlagen von RxJs und der ersten besprochenen Operatoren geben. mergeMap war hier sicherlich der wichtigste und komplizierteste. Im Laufe der nächsten Wochen wird es noch einen zweiten Teil geben, in dem noch einige weitere wichtige Operatoren näher beleuchtet werden.

Hier geht es weiter zu "Erfolgreich mit RxJS Teil 2".

Dienstag, 11.10.2022

Diesen Artikel teilen über:




enpit GmbH & Co. KG

Marienplatz 11a
33098 Paderborn
+49 5251 2027791
© 2024 – enpit GmbH & Co. KGDatenschutzerklärung | Impressum