Eine Geschichte über das reaktive Denken

Zum Download

Im Jahr 2014 dachten Max und Simon darüber nach, ein Unternehmen zu gründen, um die Benutzererfahrung mit moderner Webentwicklung zu maximieren. Wenn Kunden mit einer Idee kamen, machten sie alles selbst (einschließlich Forschung, Schätzungen, Design, Entwicklung, Marketing und Buchhaltung). Doch bald erkannten sie, dass sie ihre Struktur ändern mussten, um der zunehmenden Arbeitsbelastung gerecht zu werden. In dieser Geschichte geht es darum, ihr Unternehmen auf reaktive Weise zu modellieren.

Reaktive Begriffe

Bevor wir mit dem Refactoring beginnen, sollten wir im Vorfeld einige Begriffe klären. In der reaktiven Welt gibt es Observables. Diese Observables sind ein Daten Stream. Man kann diesen Datenstrom subscriben und erhält dann alle neuen Elemente, die in den Datenstrom eingegeben werden. Stellen Sie sich vor, Sie schauen fern. Dann erhalten Sie das aktuelle Bild und den aktuellen Ton als Hot Stream. Heißt, dass er auch dann aktiv ist, wenn Sie den Stream nicht sehen oder hören. Wenn Sie hingegen Netflix anschauen, ist der Stream kalt. Er startet nur, wenn Sie auf einen Film klicken und sendet nur diesen Film. Observables sind standardmäßig kalt. Anwendungstechnisch gesehen kann ein heißes Observable ein Stream sein, der Benutzereingaben wie Klicks auf bestimmte Elemente abhört. Ein kaltes Observable kann zum Beispiel eine http-Anforderung an einen Webserver sein. Diesen Observables wird im Code per Konvention ein $-Zeichen vorangestellt. Zusätzlich zu diesem Verhalten sind Observables kettenfähig. Jedes Observable hat eine Pipe-Member-Funktion. Sie kann so genannte operators als ihre Argumente behandeln. Sie können sich einen operator als eine beobachtbare Modifikation vorstellen, aber sehr wichtig: als eine Observable. Das Observable ist eine Klasse, die wie folgt aussieht:

class Observable { producer; constructor(producer) { this.producer = producer; } subscribe(observer) { return this.producer(observer); } pipe(...operators) { return operators.reduce((source, next) => next(source), this); } }

In dieser Klasse können Sie feststellen, dass die Observable nur dann die Produzentenfunktion ausführt, wenn die Subskription ausgeführt wird. Darüber hinaus erhält die Pipe-Funktion eine Liste von operators, die Schritt für Schritt ausgeführt werden, wobei jeder Schritt als Quelle für den nächsten Schritt dient.

Der Kunde, das Unternehmen und die App

Im Sinne der RxJS-Software ist der Kunde ein Observable, der den Umgebungsstrom modifiziert. Wenn sich etwas in der Umgebung ändert, wie z.B. die Marktsituation, erhält der Kunde diese Information automatisch. Auf der Grundlage dieser Informationen baut er eine neue Anwendungsidee auf und ermittelt deren Anforderungen und das verfügbare Budget.

const client$ = environment$.pipe( map(environment => { // analyze the market // find a new idea based on the market knowledge // return {idea, requirements, budget} })

Die map Funktion übernimmt eine Abbildungsfunktion. Diese Funktion bildet die Umgebung auf der Grundlage der Anforderungen des Entwicklers auf andere Eigenschaften ab. Die Firma von Max und Simon sieht derzeit wie folgt aus. Sie sind nur zwei Personen und müssen jede Aufgabe allein bewältigen. Dies führt zu einer riesigen Liste von Aufgaben und das kann sehr chaotisch werden.

const company$ = zip( clients$, environment$ ).pipe( map(([{ requirements, idea, budget }, environment]) => { // reject or accept the idea based on // environment, budget and requirements // transform the idea to a PoC // estimate risks and time schedule // destructure problem to small tickets // implement designs and software // return an application }) );

Wenn die Anmeldung abgeschlossen ist, wird sie an die Tester weitergeleitet, bevor sie im Playstore veröffentlicht wird. Die Tester erhalten die Anmeldung und die Anforderungen des Kunden und prüfen, ob jeder Teil anspruchsvoll ist. Ist dies nicht der Fall, lehnen sie die Bewerbung ab und geben sie nicht an weitere Subscriptions weiter.

const requirements$ = clients$.pipe(pluck('requirements')); const testers$ = zip( requirements$, company$ ).pipe( filter(([requirements, application]) => application.matches(requirements) ) );

Die Filterfunktion nimmt eine Funktion ein, die prüft, ob die ausgesendeten Werte (Anforderungen und Anwendung) den Stream auf der Grundlage richtiger oder falscher Werte fortsetzen sollen. Der Playstore sollte neue Anwendungen (oder Versionen) im Playstore hinzufügen (oder überschreiben). Darüber hinaus sollten alle Kunden, die im Appstore browsen (den Store abonnieren), den gleichen Wert erhalten, unabhängig davon, wie viele Personen den Store gerade beobachten.

const playstore$ = testers$.pipe( scan((allApps, app) => ({...allApps, [app.id]: app}), {}), shareReplay(1) );

Dies ist der vollständige Prozess der einer reaktiven Art der Programmierung. Bei einer Änderung der Umgebung$ werden die Daten reaktiv alle diese Schritte durchlaufen. Um die Idee zu vervollständigen, müssen wir wissen, dass jeder Prozessschritt nur dann Sinn macht, wenn es mindestens einen Abonnenten für das Geschäft gibt, d.h. es gibt mindestens einen Abonnenten für jedes Observable in der Observable Kette. Andernfalls würden nicht alle Teile des Prozesses ausgeführt werden. Dieser Subscription Prozess ist ein sehr wichtiges Muster in der reaktiven Programmierung und wird im folgenden Abschnitt ausführlich besprochen. Dabei sollte man bedenken, dass dieser Prozess möglicherweise nicht ein tatsächliches Szenario aus der realen Welt widerspiegelt.

Die Schritte der Subscription

Bevor wir unseren entkoppelten Stream weiter ausbauen, ist es Zeit für einen kleinen Exkurs. Üblicherweise wird das nicht sehr oft erwähnt. Wie Subscriptions funktionieren: Stellen Sie sich vor, dass environments$ ein http-Abruf an eine API ist, der angefordert werden kann, um Einzelheiten über Unternehmen, Interessen und finanzielle Lage zu erhalten, und der dann abgeschlossen werden kann. Ein benutzerdefiniertes Observable, das eine XHR-Abfrage stellt, würde folgendermaßen aussehen:

function get(url: string): Observable { const producer = (observer: Observer) => { const xhr = new XMLHttpRequest(); xhr.addEventListener('load', () => { if (xhr.status === 200 && xhr.readyState === 4) { observer.next(JSON.parse(xhr.responseText)); observer.complete(); } }); xhr.open('GET', url); xhr.send(); return () => xhr.abort(); } return new Observable(producer); }

Wir können dieses Observable in der RxJS-Methode verwenden, indem wir einen Observer mit der nächsten, fehlerhaften und vollständigen Funktion definieren:

environments$ = get(`https://publicapi.com`).subscribe({ next: (data) => console.log(data), error: (err) => console.log(err), complete: () => console.log('The stream has completed'); });

natürlich mit einer imaginären URL. Wenn wir dieses Observable subscriben, führt es die genannte Producer-Funktion aus, die in diesem Fall der XMLHttpRequest ist und die Daten an den Stream mit der nächsten Observer-Funktion zurückgibt, und schließt dann ab. Die Subskriptionsmethode erhält entweder einen Observer oder drei Rückrufe in genau der gleichen Reihenfolge wie oben. Werfen wir einen Blick auf die operators und erstellen wir einen benutzerdefinierten operator.

function map(mapOperation) { return (source) => { const producer = (observer) => source.subscribe({ next: (value) => observer.next(mapOperation(value)) }); return new Observable(producer); } }

In diesem Beispiel gibt Map eine Funktion zurück, die ein Quellargument hat und eine neue Observable zurückgibt. Die Funktion Observables Producer subscript die beobachtbare Quelle und gibt jeden Wert der Observable Quelle mit dem angewandten mapFn zurück. Sie können einen Blick auf den ursprünglichen RxJS-Map-Betreiber werfen und Sie werden die Ähnlichkeiten sehen. Jetzt können wir die Pipe-Funktion verwenden, um dieses Observable mit dem http-Observable zu verketten.

const currentTechnologies$ = get('https://publicapi.com').pipe( map((environment) => environment.currentTechnologies) );currentTechnologies$.subscribe({ next: (technologies) => console.log(technologies), complete: () => console.log('Stream has completed') })

Was bewirkt die Subscription? Sie können sich jeden Eintrag in einer Pipe als ein weiteres Observable vorstellen. Es subscribt zuerst das Observable, das vom Map Betreiber zurückgegeben wurde, und danach dieses Observable das http-Observable. Dann wird der eigentliche http-Prozess ausgeführt (weil er ein kaltes Observable ist und nur auf Abonnement ausgeführt wird). Wenn die Daten eintreffen, werden sie innerhalb des Map Betreibers verarbeitet und sind danach in der Subscription zugänglich. Darüber hinaus vervollständigt und sendet das http-Request Observable den vollständigen Zustand an die Map und kappt die Subscription zwischen diesen beiden. Dann gibt die Map den kompletten Zustand aus und die komplette Funktion innerhalb der Subscription feuert ab. Die Subscription zwischen der Subscription und der Map ist nun ebenfalls weg. Das Subscriptionsverfahren ist ein Schlüsselkonzept, das wir für den Rest des Artikels und immer, wenn wir mit RxJS arbeiten werden, im Auge behalten sollten. Fahren wir mit der Erstellung der Subscription fort. Es ist an der Zeit, mehr Menschen einzubeziehen.

Time to hire

Entwickler:innen

Als die Zeit verging und immer mehr Kunden Dienstleistungen nachfragten, dachten die Gründer Max und Simon, es wäre schön, einen echten Experten für Technologie zu haben, die sie für die meisten ihrer Projekte auswählen wollen. Sie dachten auch, dass es gut wäre, einen strukturierteren Weg zu haben. Die einzigen Voraussetzung, die erfüllt werden muss, ist, dass die Firma Software-Ideen und Zahlungen vom Kunden erhält und im Gegenzug Software liefert. Also beschlossen die beiden, Kris, einen Experten für Angular-Entwicklung, einzustellen.

const founders$ = zip( client$, environment$ ).pipe( map(([{idea, payments}, env]) => // Todo // reject or accept // transform the idea based on the environment to // a proof of concept // estimate risks // return parts of the payments, prototype and estimation ) )

Wie Sie sehen können, haben die beiden Gründer Max und Simon ihre Arbeitsbelastung erheblich reduziert und können sich auf Buchhaltung, Kundenakquise und Business Development konzentrieren. Von nun an müssen sie nicht mehr die kompletten Entwicklungsschritte bis ind Detail kennen. Denn an dieser Stelle kommt Kris ins Spiel.

const developers = ['Kris']; const speedOfDevelopment = developers.length / 1000;const developers$ = founders$.pipe( filter(({payments}) => payments > 0), map(({payments, prototype, estimation}) => // Todo // create subtasks // implement designs // implement application // return application ), delay(1 / speedOfDevelopment) )

Er ist verantwortlich für die Softwareentwicklung und auch für Designs. Da immer mehr Projekte dazukommen, beschließen die Gründer, die für die Fertigstellung eines Projekts benötigte Zeit zu verkürzen, indem sie mehr Entwickler einstellen. Aber es reichte nicht aus, immer mehr Entwickler einzustellen.

const developers = ['Kris', 'Anna', 'Gustav', 'Yens', 'Maria', 'Lennard'];

Also beschlossen Max und Simon, nach Projektleitern zu suchen, die Projekte verwalten, den Zeitplan schätzen und die Aufgaben in kleinere Teilaufgaben unterteilen. Kris wünschte sich auch, dass Designer die Designs in Angriff nehmen sollten. Was haben Sie neben der Einstellung der Entwickler erkannt? Wir haben unseren Code von einem Observable in kleinere Teile entkoppelt, die viel einfacher zu handhaben und weniger fehleranfällig sind als der große Code. Und das ist eine Kernannahme der reaktiven Softwareentwicklung: Man teilt größere Teile des Programms in kleinere Teile, die unabhängig voneinander agieren. Genau das wollen wir in unserem Beispiel auch mit den Projektleitern tun.

Projektleiter:innen

Die Entwickler haben eine sehr gute Arbeit geleistet, aber sie waren ein wenig unorganisiert, und manchmal fanden sie sich dabei wieder, sich bei der Suche nach einer perfekten Lösung zu verzetteln, anstatt sich auf die wichtigsten Parts zu fokussieren. Hier kamen die Projektleiter ins Spiel. Sie sind dafür zuständig, einen Überblick über alle Projekte und für jede Aufgabe kleine Tickets zu erstellen. Wenn es neue Entwickler gibt, die den Projektleiter subscriben, sollten sie außerdem die neuesten Tickets erhalten.

Die Implementierung des Entwicklers muss nun überarbeitet werden, um die Tickets statt der vollständigen Anwendung zu erhalten.

const projectLeads = ['Emma', 'Markus'];const projectLeads$ = founders$.pipe( scan(({ payments, prototype, estimation }) => // Todo // Append application to either Emma's or Markus' // application stack // Append prototype and estimation to the project // leads internal projects list // Check if project payment ressources are covered , {}), map(({ projects }) => // Todo // Create small tickets for the developers and // (later on) designers // Estimate a time for each ticket // Return tickets ), shareReplay(1) );
const developers$ = projectLeads$.pipe( map(ticket => // Todo // Implement designs // Implement application // Return application ) );

Designer:innen

Zu diesem Zeitpunkt sind die Anwendungen technisch erstklassig, reaktiv und nutzen Best Practices und neueste Technologien. Max und Simon sind mit den Ergebnissen recht zufrieden, aber es fehlt noch ein wichtiger Punkt. Die Anwendungen sehen nicht besonders gut aus und, sondern wie jede andere reine Entwickleranwendung auch. Meistens mit einer Titelleiste, einer Seitennavigation, einigen Karten und einer Tabelle. Also beschließen die Projektleiter, einige Designer einzustellen, um die Benutzerfreundlichkeit auf die nächste Stufe zu heben.

const designers = ['Agathe', 'Annabelle', 'David'];const designers$ = this.projectLead$.pipe( filter(ticket => ticket.isForDesigners), map(ticket => //Todo // Create design based on tickets // Return design ) );

Die Entwickler:innen können diese visuell ansprechenden und benutzerfreundlichen Designs nun nutzen, um erstklassige Anwendungen zu implementieren.

const developers$ = this.projectLeads$.pipe( filter(ticket => ticket.isForDevelopers), withLatestFrom(this.designers$), map(([ticket, designs]) => // Todo // Implement features/application // Return application ) );

Entdecken Sie hier den reaktiven Spielplatz. Sie können sehen, dass, wenn Sie den Prozess durch Klicken auf die "Neue Umgebung" auslösen, der gesamte Stream erneut verarbeitet wird. Dies deckt einen Teil des gesamten Unternehmens ab. Mit all den neuen Teammitgliedern an Bord wurde die interne Verwaltung für die Gründer viel zeitaufwändiger.

Daher mussten zwei Organisatoren eingestellt werden, um die Firma zu vervollständigen. Zu ihren Aufgaben gehören die Lohnabrechnung, die Umsetzung von Marketingstrategien und die Verwaltung von Urlaubsanträgen. Wie würden Sie also auf der Grundlage dessen, was Sie bisher gelernt haben, const organizers = ['Lea', 'Karol', 'Ricarda'] umsetzen?

Wenn Sie die Antwort nicht wissen, zögern Sie nicht, uns für einen Lösungsvorschlag zu kontaktieren. Wir bei interfacewerk entwickeln High-End Enterprise Web-Anwendungen und HMIs und beraten zu Angular-, RxJS- und UX-Themen. Also zögern Sie nicht, uns zu kontaktieren, wenn Sie Fragen zu diesen Themen haben.

Schlussfolgerung

Einer meiner Lieblingspunkte bei der Verwendung reaktiver Muster: Unabhängige Anwendungsteile bringen den Code in eine neue Dimension der Lesbarkeit. Stellen Sie sich vor, die Gründer hätten für immer alles selbst gemacht; das hätte zu einem völligen Durcheinander führen können. Die Definition mehrerer geteilter Unternehmensteile und die Übertragung von Aufgaben und Verantwortlichkeiten macht die Arbeit des Unternehmens effizient und produktiv und gewährleistet die vom Kunden geforderte Qualität. Für den Code bedeutet dies, mehrere Teile für jeden Observable Bereich zu definieren, um den Code sauber zu halten. Mit RxJS und reaktiver Programmierung erreichen Sie dasselbe wie Max und Simon, indem Sie die Verantwortlichkeiten teilen und die Entscheidungsfindung dezentralisieren. Sie sind in der Lage, in Bausteinen zu denken und auf Benutzeraktionen, http-Antworten oder was immer Sie hören möchten, zu reagieren. Sie ermöglicht es dem Entwickler, Teile der Anwendung völlig unabhängig zu machen. Denken Sie daran: Als wir die Firmenbox aufbrachen, um sie detaillierter zu gestalten, haben wir den Rest des Prozesses nicht berührt. Dies ist ein Grund dafür, dass das Refactoring und die Anforderung zusätzlicher Funktionen viel einfacher und schneller zu implementieren sind.

Viele Entwickler vergessen, dass es mindestens einen Abonnenten des (kalten) Observables geben muss, damit es funktioniert. Eine häufige Aufgabe ist die Verwendung der http.get(url)-Funktion von Angular, die eine Observable (einen Strom der Antwort) zurückgibt. Wenn es jedoch keinen Abonnenten dafür gibt, wird die Anfrage überhaupt nicht verarbeitet. Die Subscription kann in Form der Verwendung der Observables-Subscribe-Methode oder in Angular vorzugsweise unter Verwendung der eingebauten Async-Pipe erfolgen. In dem Artikel haben wir den Subskriptionsprozess im Detail gesehen. Darüber hinaus haben wir die Observable Implementierung gesehen und wie sie die Verkettung mit Hilfe der Pipe-Funktion handhaben kann.

Wie geht es weiter?

Wie Sie in den Codeschnipseln gesehen haben, gibt es jede Menge operators, mit denen Sie Ihr Observable modifizieren können. Das ist sowohl eine große Stärke als auch eine Schwäche von RxJS. Mit mehr als 100 operator sind neue Entwickler überfordert, verwirrt und wissen nicht, wie sie anfangen sollen. Eine Empfehlung ist, mit nur wenigen zu beginnen und nur mit verschachtelten Observables (und switchMap, mergeMap usw.) zu beginnen, wenn Sie die normalen verstanden haben. Es ist auch nicht nötig, am Anfang sogenannte window operators zu berühren. Eine vollständige Liste aller operators finden Sie auf rxjs.dev und auch ein praktisches Werkzeug namens Entscheidungsbaum steht zur Verfügung. Dieser Entscheidungsbaum empfiehlt auf der Grundlage Ihrer Beschreibung oder Situation bestimmte operators.

Wie in der Einleitung erwähnt, wurde RxJS zusammen mit Angular zu Beginn implementiert. Angular verwendet massiv die reaktiven Muster, weshalb es sich absolut lohnt, mit reaktiver Programmierung vertraut zu sein. Neben diesen Vorteilen gibt es auch eine große Auswirkung auf die Leistung, wenn RxJS richtig eingesetzt wird. Sie können in vielen Situationen die so genannte Änderungserkennung vermeiden und Änderungen nur dann vornehmen, wenn das Observable einen neuen Wert ausgibt. Insbesondere beim Rendern riesiger Listen auf dem DOM ist RxJS der effizienteste Weg, damit umzugehen.

Join our next webinar!

Register now
Zum Download
8
Minuten Lesezeit
Geschrieben von:
Felix