post

RxJS: Push und Pull endlich auseinanderhalten

Wer Producer und Consumer benennt, erkennt sofort, ob Daten gezogen oder geschoben werden, und wählt zwischen Funktion, Iterator, Promise und Observable ohne Raten.

RxJS: Push und Pull endlich auseinanderhalten

In meinen Schulungen mit der Grossweber passiert oft dasselbe. Jemand kennt Promises, hat von Observables gehört und steht jetzt vor map, switchMap und debounceTime. Die Frage lautet dann meist: Welcher Operator ist der richtige? Das ist die falsche erste Frage. Die richtige ist: Wer entscheidet eigentlich, wann ein Wert fließt, der Producer oder der Consumer? Wer das auseinanderhält, versteht RxJS in einer Stunde statt in einer Woche.

Die eine Frage, die alles ordnet

Jede Datenquelle in JavaScript lässt sich über ihre Richtung beschreiben. Es gibt nur zwei.

Bei einem Pull-System holt sich der Consumer die Werte aktiv. Er ruft etwas auf und bekommt eine Antwort. Eine Funktion ist genau das: Du rufst sie, sie liefert. Ein Iterator ist dasselbe in mehreren Schritten, denn iterator.next() zieht den nächsten Wert heraus. Der Consumer bestimmt das Tempo.

Bei einem Push-System dreht sich das um. Jetzt entscheidet der Producer, wann etwas geschickt wird, und der Consumer weiß vorher nicht, wann der nächste Wert kommt. Ein Klick-Event, eine WebSocket-Nachricht, ein Timer: All das schiebt, sobald es so weit ist. Der Consumer reagiert nur noch.

Das klingt akademisch, hat aber direkte Folgen für den Code. In einem Pull-Modell darfst du blockieren und warten, bis du den nächsten Wert abrufst. In einem Push-Modell darfst du das nicht, weil du nie weißt, ob überhaupt noch etwas kommt. Genau hier scheitern viele Migrationen von „Daten abfragen“ zu „auf Daten reagieren“.

Vier Produzenten, zwei Richtungen

Die offizielle RxJS-Dokumentation ordnet die vier wichtigen Produzenten in ein Raster ein. Ich male es in jeder Schulung an die erste Tafel, weil danach die meisten Fragen schon beantwortet sind.

Producer Anzahl Werte Richtung Auswertung
Function genau ein Wert Pull lazy
Iterator / Generator null bis viele Pull lazy
Promise genau ein Wert Push eager
Observable null bis viele Push lazy

Lies die Tabelle als zwei Achsen. Die eine Achse fragt: ein Wert oder mehrere? Die andere fragt: zieht der Consumer oder schiebt der Producer? Das Observable ist die einzige Quelle, die mehrere Werte schiebt und dabei trotzdem faul bleibt. Diese Kombination macht es so mächtig und für Einsteiger so verwirrend, weil sie keine direkte Entsprechung im klassischen JavaScript hat.

Ein Promise ist der nächste Verwandte, deckt aber nur eine Zelle daneben ab: ein Wert, geschoben, sofort gestartet. Wer das verinnerlicht, hört auf, ein Observable wie ein Promise mit mehr Methoden zu behandeln.

Ein Observable ist eine Funktion mit mehr Ausgängen

Am schnellsten wird das im Code klar. Das introduction-rxjs-Material, das ich für die Schulungen pflege, beginnt bewusst bei Observable.create, bevor es Operatoren zeigt. Denn create zwingt dich, Producer und Consumer selbst zu benennen.

import { Observable } from 'rxjs/Observable';

// Der Producer bestimmt selbst, wann Werte fließen.
const ticks$ = Observable.create((observer) => {
  let n = 0;
  const id = setInterval(() => observer.next(n++), 1000);

  // Aufräumfunktion: läuft bei unsubscribe oder bei complete.
  return () => clearInterval(id);
});

// Der Consumer reagiert nur, er fragt nichts aktiv ab.
const sub = ticks$.subscribe({
  next: (v) => console.log('Wert', v),
  error: (e) => console.error('Fehler', e),
  complete: () => console.log('fertig'),
});

Der Block in subscribe ist der Observer mit seinen drei Kanälen: next für Werte, error für den Abbruch mit Fehler, complete für das saubere Ende. Das ist das vollständige Vokabular eines Push-Consumers. Mehr gibt es nicht, und mehr braucht man auch nicht.

Zum Vergleich dieselbe Idee als Pull-Quelle. Hier passiert nichts, solange niemand zieht.

function* naturals() {
  let n = 0;
  while (true) yield n++; // Der Wert entsteht erst beim Abrufen.
}

const it = naturals();
it.next().value; // 0  – der Consumer zieht
it.next().value; // 1  – und bestimmt das Tempo

Beide Quellen liefern eine unendliche Folge natürlicher Zahlen. Der Unterschied liegt allein in der Kontrolle. Beim Generator entscheidest du mit jedem next(), wann die Eins nach der Null kommt. Beim Observable entscheidet der Timer, und du bekommst die Werte einfach zugestellt.

Lazy heißt: ohne subscribe passiert nichts

Die wichtigste These dieses Textes ist die Spalte „Auswertung“ in der Tabelle. Ein Observable ist faul. Es führt seinen Producer-Code erst aus, wenn subscribe aufgerufen wird, und für jedes subscribe von vorne. Ein Promise ist gierig. Es startet seine Arbeit in dem Moment, in dem es erzeugt wird, ganz ohne then.

Dieser Unterschied ist kein Detail, sondern eine häufige Fehlerquelle.

// eager: Die Anfrage geht sofort raus, auch ohne then.
const preise = fetch('/api/preise');

// lazy: Es passiert gar nichts, bis jemand subscribe aufruft.
const preise$ = Observable.create((observer) => {
  fetch('/api/preise')
    .then((res) => res.json())
    .then((data) => {
      observer.next(data);
      observer.complete();
    })
    .catch((err) => observer.error(err));
});

Jetzt der reale Fehlerfall, den ich regelmäßig sehe. Ein Team baut eine Suche um. Vorher gab es ein geteiltes Promise, das einmal lief und dessen Ergebnis sich mehrere Komponenten teilten. Nach der Umstellung auf ein Observable abonnieren drei Komponenten denselben Stream. Plötzlich gehen drei HTTP-Anfragen raus statt einer. Niemand hat einen Fehler gemacht im klassischen Sinn, das Observable verhält sich exakt nach Definition: lazy und kalt, also pro Subscription eine eigene Ausführung. Wer das Promise-Verhalten zurück will, muss den Stream mit publishReplay und refCount teilen oder ein Subject davorsetzen. Aber das ist eine bewusste Entscheidung, kein Automatismus. Erst wenn man Push, Lazy und Cold getrennt benennt, sieht man, warum hier drei Requests entstehen.

Zeit sichtbar machen mit dem Marble-Diagramm

Push-Systeme leben in der Zeit, und genau das macht sie schwer zu lesen. Ein Marble-Diagramm zeichnet diese Zeit auf eine Linie. Jede Murmel ist ein gepushter Wert, das | ist complete, das X wäre ein error.

subscribe                         unsubscribe
   |                                   |
   v                                   v
ticks$: ---0----1----2----3----4----5--|
              (alle 1000 ms schiebt der Producer)

Die Linie macht zwei Dinge sofort sichtbar, die im Text untergehen. Erstens: Die Werte erscheinen nicht auf Abruf, sondern nach der inneren Logik des Producers, hier dem Intervall. Zweitens: Es gibt zwei Ränder, die Subscription und die Unsubscription. Zwischen diesen Rändern lebt der Stream, davor und danach existiert er nicht. Wer Marble-Diagramme liest, hört auf, über einzelne Werte nachzudenken, und beginnt über die Lebensdauer des Streams nachzudenken.

Für die Richtung selbst genügt ein kleines Schema. Es zeigt, wer wen antreibt.

flowchart LR
  subgraph Pull
    C1[Consumer] -- ruft next ab --> P1[Producer]
  end
  subgraph Push
    P2[Producer] -- schiebt next --> C2[Consumer]
  end

Der Fehler, den fast jeder einmal baut

Aus der Lazy-Eigenschaft folgt eine Pflicht, die im Material ein eigenes Kapitel hat, nämlich Disposable. Wenn ein subscribe einen Producer startet, dann muss irgendwer ihn auch wieder beenden. Beim ticks$ oben ist das offensichtlich: Der setInterval läuft ewig weiter, solange niemand unsubscribe aufruft.

const sub = ticks$.subscribe((v) => console.log(v));

// Ohne diese Zeile tickt der Timer für immer,
// auch wenn die Komponente langst weg ist.
sub.unsubscribe();

In einer Single-Page-Anwendung wird daraus ein echtes Leck. Eine Komponente abonniert einen Event-Stream, der Nutzer navigiert weg, die Komponente verschwindet aus dem DOM, aber das Subscription-Objekt hält den Event-Listener und alle darüber referenzierten Objekte am Leben. Beim nächsten Öffnen kommt ein zweites Abo dazu, dann ein drittes. Die Symptome sind doppelt ausgeführte Nebenwirkungen und ein Speicherverbrauch, der nur steigt. Die Ursache ist immer dieselbe: Ein Push-Producer wurde gestartet, aber nie sauber beendet. Die Subscription ist die Grenze, an der ein Stream Besitz und Lebensdauer bekommt. Wer sie ignoriert, baut globalen Zustand mit anderem Namen.

Was bleibt

Push und Pull ist keine Trivia für Vorstellungsgespräche, sondern das Raster, an dem du jede Entscheidung in RxJS aufhängst. Brauche ich einen oder mehrere Werte? Zieht mein Consumer oder schiebt mein Producer? Startet die Arbeit sofort oder erst beim Abonnieren? Und wer beendet den Stream wieder? Diese vier Fragen klären die meisten Operator-Diskussionen, bevor sie entstehen.

Die Reihenfolge im Schulungsmaterial folgt genau dieser Logik. Erst Observable, Observer und Notification, also das Push-Vokabular. Dann Scheduler und Disposable, also Zeit und Lebensdauer. Operatoren kommen zuletzt, weil sie nur Werkzeuge auf diesem Fundament sind. Wer mit der Operatorliste anfängt, lernt Vokabeln ohne Grammatik.

Weiterführende Quellen

Kommentare

Kommentar schreiben