RxJS

Wir sollten auch dringend einmal über RxJS reden. Immerhin hätten wir Konzepte davon bereits in den anderen Artikeln gebrauchen können.

RxJS beschreibt sich selbst als

Kombination aus Observer und Iterator Pattern mit einem Schuss funktionaler Programmierung

und es hat sich auf die Fahnen geschrieben, sich um eine vernünftige Eventbehandlung zu kümmern.

Observable

Und hierfür wurde das Konzept der Observables eingeführt, das geht in etwa so:

const demo = Observable.create(
  (numberEvent: Observer<number>) => {
    numberEvent.next(Math.random());
    numberEvent.complete()
  }
);

Wir erstellen ein Observable (frei übersetzt also irgendwas, das wir auf Änderungen hin überwachen). Dieses stellt dann einen Zufallszahlenwert bereit, mit dem wir etwas machen wollen. Und dann verwerfen wir es wieder.

Was nun, wenn ich an der Zufallszahl interessiert bin? Ich registriere mich auf das entsprechende Event und erhalte Änderungen von diesem gepusht:

demo.subscribe(
  (value) => { console.log('neu ', value); },
  (error) => { console.log('oops'); },
  () => { console.log('Beobachtung beendet.'); }
);

Natürlich gibt es nicht immer Sinn, im Observable.create() auch gleich die complete-Methode aufzurufen und alle Subscriber zwangsweise abzumelden. Deswegen gibt uns die subscribe-Methode eine Subscription-Instanz zurück, von der wir uns auch dann mittels unsubscribe() selbst, wenn nötig, wieder abmelden können.

Fassen wir zusammen:

  • ein Observable verspricht uns, im Laufe der Zeit neue Werte zur Verfügung zu stellen
  • ein Observer wartet auf diesen Moment und reagiert dann auf Änderungen
  • die Subscription stellt diese Verbindung dar und steht für ein ausgeführtes, laufendes Observable

Subject

Besitzt ein Observable mehrere Subscriber, die auf die gleichen Daten angewiesen sind, dann bietet es sich an, daraus ein Subject zu machen, denn:

Registrieren sich mehrere Observer einzeln mittels Observable.subscribe(), dann erhält jeder Observer einen eigenen Kontext - in unserem Zufallszahlen-Beispiel würde dies bedeuten, dass jeder Subscriber andere Zahlenwerte erhält.

Benutzt man stattdessen Subject.subscribe() teilen sich alle einen Kontext und jeder Observer erhält die gleichen Werte (Multicast).

Promises

Wirklich neu ist an dem eben geschriebenen eigentlich nix. Früher hieß das banal Event (“irgendwas passiert gerade”) und dann irgendwann einmal Promise (“irgendwas wird passieren”). Während das Event über den Status Quo informierte und mittels Callbacks funktionierte, sorgte die Verwendung von Promises für ein komfortableres Arbeiten.

Stellt sich die Frage: was ist an Observables anders als an Promises? Ganz einfach: Observables können mehrere Werte zur Verfügung stellen.

Beispiel asynchroner Webservice-Aufruf:

Ein Promise stünde für einen Aufruf und verspricht, Werte vom Server zu laden. Nach dem Aufruf ist das Versprechen eingelöst und das Promise an sich erfolgreich abgearbeitet und damit beendet.

Angenommen aber, ich bin auf die Ergebnisse eines Server-Aufrufs angewiesen und habe mehrere Quellen, die für eine Aktualisierung dieser Daten in Frage kommen und für die diese Änderungen relevant sind, dann sorgt ein Observable dafür, dass alle abhängigen Elemente immer auf dem aktuellen Stand sind.

DataStores

Jetzt wo die Grundlagen sitzen, schauen wir auf ein Konzept, das hierauf aufbaut: DataStores.

Ein möglicher Aufbau ist die Aufteilung in einen Controller, eine View, einen Service und einen DataStore. Letzterer übernimmt dabei die Koordination der View.

Der Service holt Daten, um beim Beispiel von weiter oben zu bleiben, über einen Webservice. Traditionell gäbe es jetzt mehrere Controller-Methoden, die den Service benutzen, um Daten zu aktualisieren oder zu verändern.

Wir bauen das aber etwas anders auf und ziehen noch eine Schicht ein:

  • der Controller verwaltet seine Daten über ein Observable
  • der Service wartet nurmehr auf Aktionen, die er verarbeitet
  • je nach Aktion verändert der Service die Daten im Store und führt damit Änderungen am Observable herbei

Damit kapseln wir die eigentlichen Daten, Logik, die sich aus Aktionen ableitet und die Anwendungslogik selbst. Und RxJS sorgt für ein reibungsloses Zusammenspiel dieser Elemente.

Konkret könnte es wie folgt aussehen (eine Model-Klasse namens Element vorausgesetzt):

export const LOAD = 'LOAD';
export const REMOVE = 'REMOVE';
// ...

export class DataStore {
  private elements:Element[] = [];
  elementList$ = new BehaviorSubject<Element[]>([]);

  dispatch(action) {
    // Elemente aufgrund der Aktion bereitstellen:
    this.elements = this.reduce(this.elements, action);
    // Subscriber über Änderungen informieren:
    this.elementList$.next(this.elements);
  }

  reduce(elements: Element[], action) {
    switch (action.type) {
      case LOAD:
        // https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Operators/Spread_operator
        return [..action.data];
      case REMOVE:
        return elements.filter(
          element => element.id != action.data.id
        );
      // ...
      default:
        return elements;
    }
  }
}

Das RxJS-BehaviorSubject sorgt dafür, dass neuen Subscribern automatisch der letzte Stand mitgeteilt wird. Neu zur Laufzeit hinzugekommene Subscriber startet also nicht bei nichts und aktualisieren sich mit der nächsten Änderung, sondern erhalten den durch die letzte Änderung erzeugten Stand.

Die Daten liegen also alle zentral im Store vor. Hier werden Daten nur gelesen, Änderungen führen zu einer neuen Instanz des Speicherinhalts.

Änderungen am Store werden ausschließlich über Aktionen ausgelöst (im Beispiel oben LOAD und REMOVE). Es gibt keinen direkten Schreibzugriff.

Eine Reduce-Methode übernimmt die Umwandlung von alten zu neuen Daten.

Ein Aufruf aus dem zugehörigen Service sähe aus wie folgt:

loadElements(order = 'ASC') {
  // connect to server
  // ...
  this.dataStore.dispatch({ type: LOAD, data: elementsfromServer };)
}

Die Registrierung der Angular-Komponente an das nötige Observable erfolgt im einfachsten Fall über die ngOnInit-Methode.

Ein weiteres Beispiel findet sich hier.