Einführung in den Reaktorkern

Einführung in den Reaktorkern

1. Einführung

Reactor Core ist eine Java 8-Bibliothek, die das reaktive Programmiermodell implementiert. Es basiert aufReactive Streams Specification, einem Standard zum Erstellen reaktiver Anwendungen.

Vor dem Hintergrund einer nicht reaktiven Java-Entwicklung kann es eine ziemlich steile Lernkurve sein, reaktiv zu werden. Dies wird schwieriger, wenn man es mit der Java 8Stream-API vergleicht, da sie fälschlicherweise als dieselben Abstraktionen auf hoher Ebene angesehen werden können.

In diesem Artikel werden wir versuchen, dieses Paradigma zu entmystifizieren. Wir werden kleine Schritte durch Reactor machen, bis wir ein Bild davon erstellt haben, wie reaktiver Code erstellt wird, und den Grundstein für fortgeschrittenere Artikel legen, die in einer späteren Reihe erscheinen werden.

2. Spezifikation der reaktiven Ströme

Bevor wir uns Reaktor ansehen, sollten wir uns die Spezifikation der reaktiven Ströme ansehen. Dies ist, was Reactor implementiert, und es legt den Grundstein für die Bibliothek.

Reactive Streams ist im Wesentlichen eine Spezifikation für die asynchrone Stream-Verarbeitung.

Mit anderen Worten, ein System, bei dem viele Ereignisse asynchron erzeugt und konsumiert werden. Denken Sie an einen Strom von Tausenden von Aktienaktualisierungen pro Sekunde, der in eine Finanzanwendung eingeht, und an die Notwendigkeit, rechtzeitig auf diese Aktualisierungen zu reagieren.

Eines der Hauptziele ist es, das Problem des Gegendrucks anzugehen. Wenn wir einen Produzenten haben, der Ereignisse schneller an einen Konsumenten sendet, als er verarbeiten kann, wird der Konsument schließlich mit Ereignissen überfordert sein, denen die Systemressourcen ausgehen. Gegendruck bedeutet, dass unser Verbraucher in der Lage sein sollte, dem Hersteller mitzuteilen, wie viele Daten gesendet werden müssen, um dies zu verhindern. Dies ist in der Spezifikation festgelegt.

3. Maven-Abhängigkeiten

Bevor wir beginnen, fügen wir die Abhängigkeiten vonMavenhinzu:


    io.projectreactor
    reactor-core
    3.0.5.RELEASE



    ch.qos.logback
    logback-classic
    1.1.3

Wir fügen auchLogback als Abhängigkeit hinzu. Dies liegt daran, dass wir die Ausgabe von Reactor protokollieren, um den Datenfluss besser zu verstehen.

4. Erstellen eines Datenstroms

Damit eine Anwendung reaktiv ist, muss sie zunächst einen Datenstrom erzeugen können. Dies könnte etwa das Beispiel für die Aktualisierung der Aktie sein, das wir zuvor angegeben haben. Ohne diese Daten hätten wir nichts zu reagieren, weshalb dies ein logischer erster Schritt ist. Reactive Core bietet uns zwei Datentypen, mit denen wir dies tun können.

4.1. Flux

Der erste Weg, dies zu tun, ist mit einemFlux.. Es ist ein Stream, der0..n Elemente ausgeben kann. Versuchen wir, eine einfache zu erstellen:

Flux just = Flux.just("1", "2", "3");

In diesem Fall haben wir einen statischen Strom von drei Elementen.

4.2. Mono

Der zweite Weg, dies zu tun, ist mit einemMono,, der ein Strom von0..1 Elementen ist. Versuchen wir, eine zu instanziieren:

Mono just = Mono.just("foo");

Dies sieht und verhält sich fast genauso wieFlux, nur dass wir diesmal auf nicht mehr als ein Element beschränkt sind.

4.3. Warum nicht einfach Flussmittel?

Bevor Sie weiter experimentieren, sollten Sie hervorheben, warum wir diese beiden Datentypen haben.

Zunächst sollte angemerkt werden, dass sowohl aFlux als auchMono Implementierungen der Schnittstelle Reactive StreamsPublisher sind. Beide Klassen sind mit der Spezifikation kompatibel, und wir könnten diese Schnittstelle an ihrer Stelle verwenden:

Publisher just = Mono.just("foo");

Aber es ist wirklich nützlich, diese Kardinalität zu kennen. Dies liegt daran, dass einige Operationen nur für einen der beiden Typen sinnvoll sind und dass sie aussagekräftiger sein können (stellen Sie sichfindOne() in einem Repository vor).

5. Einen Stream abonnieren

Jetzt haben wir einen allgemeinen Überblick darüber, wie ein Datenstrom erzeugt wird. Wir müssen ihn abonnieren, damit er die Elemente ausstrahlt.

5.1. Elemente sammeln

Verwenden wir die Methodesubscribe(), um alle Elemente in einem Stream zu sammeln:

List elements = new ArrayList<>();

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(elements::add);

assertThat(elements).containsExactly(1, 2, 3, 4);

Die Daten fließen erst, wenn wir sie abonnieren. Beachten Sie, dass wir auch einige Protokollierungsfunktionen hinzugefügt haben. Dies ist hilfreich, wenn wir uns ansehen, was hinter den Kulissen passiert.

5.2. Der Fluss der Elemente

Mit der Anmeldung können wir visualisieren, wie die Daten durch unseren Stream fließen:

20:25:19.550 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | request(unbounded)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
20:25:19.553 [main] INFO  reactor.Flux.Array.1 - | onComplete()

Zunächst läuft alles auf dem Hauptthread. Lassen Sie uns hierzu nicht näher darauf eingehen, da wir später in diesem Artikel einen weiteren Blick auf die Parallelität werfen werden. Es macht die Sache jedoch einfach, da wir uns um alles in der richtigen Reihenfolge kümmern können.

Lassen Sie uns nun die Sequenz durchgehen, die wir einzeln protokolliert haben:

  1. onSubscribe() - Dies wird aufgerufen, wenn wir unseren Stream abonnieren

  2. request(unbounded) – Wenn wirsubscribe aufrufen, erstellen wir hinter den Kulissen einSubscription.. Dieses Abonnement fordert Elemente aus dem Stream an. In diesem Fall wird standardmäßigunbounded, verwendet, was bedeutet, dass jedes einzelne verfügbare Element angefordert wird

  3. onNext() – Dies wird für jedes einzelne Element aufgerufen

  4. onComplete() – Dies wird zuletzt nach dem Empfang des letzten Elements aufgerufen. Es gibt tatsächlich auch einonError(), das aufgerufen wird, wenn es eine Ausnahme gibt, in diesem Fall jedoch nicht

Dies ist der Ablauf, der in derSubscriber-Schnittstelle als Teil der Reactive Streams-Spezifikation festgelegt wurde. In Wirklichkeit wurde dies hinter den Kulissen unseres Aufrufs vononSubscribe(). instanziiert. Es ist eine nützliche Methode, aber besser Um zu verstehen, was passiert, stellen wir eineSubscriber-Schnittstelle direkt bereit:

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(new Subscriber() {
    @Override
    public void onSubscribe(Subscription s) {
      s.request(Long.MAX_VALUE);
    }

    @Override
    public void onNext(Integer integer) {
      elements.add(integer);
    }

    @Override
    public void onError(Throwable t) {}

    @Override
    public void onComplete() {}
});

Wir können sehen, dass jede mögliche Stufe im obigen Ablauf einer Methode in der Implementierung vonSubscriberzugeordnet ist. Es kommt einfach vor, dassFlux uns eine Hilfsmethode zur Verfügung gestellt hat, um diese Ausführlichkeit zu reduzieren.

5.3. Vergleich mit Java 8Streams

Es könnte immer noch so aussehen, als hätten wir etwas, das Java 8Streamammelt:

List collected = Stream.of(1, 2, 3, 4)
  .collect(toList());

Nur wir nicht.

Der Hauptunterschied besteht darin, dass Reactive ein Push-Modell ist, während Java 8Streamsein Pull-Modell sind. In reactive approach. events are pushed to the subscribers as they come in.

Das nächste, was zu bemerken ist, ist, dass der Terminaloperator vonStreamsgenau das ist, Terminal, das alle Daten abruft und ein Ergebnis zurückgibt. Mit Reactive könnte ein unendlicher Datenstrom von einer externen Ressource eingehen, an den mehrere Abonnenten ad hoc angehängt und entfernt werden. Wir können auch Dinge tun, wie Ströme kombinieren, Ströme drosseln und Gegendruck anwenden, worauf wir als nächstes eingehen werden.

6. Gegendruck

Das nächste, was wir berücksichtigen sollten, ist der Gegendruck. In unserem Beispiel weist der Abonnent den Produzenten an, jedes einzelne Element auf einmal zu verschieben. Dies könnte für den Abonnenten überwältigend werden und all seine Ressourcen verbrauchen.

Backpressure is when a downstream can tell an upstream to send it fewer data in order to prevent it from being overwhelmed.

Wir können die Implementierung vonSubscriberändern, um Gegendruck anzuwenden. Lassen Sie uns den Upstream anweisen, nur zwei Elemente gleichzeitig zu senden, indem Sierequest() verwenden:

Flux.just(1, 2, 3, 4)
  .log()
  .subscribe(new Subscriber() {
    private Subscription s;
    int onNextAmount;

    @Override
    public void onSubscribe(Subscription s) {
        this.s = s;
        s.request(2);
    }

    @Override
    public void onNext(Integer integer) {
        elements.add(integer);
        onNextAmount++;
        if (onNextAmount % 2 == 0) {
            s.request(2);
        }
    }

    @Override
    public void onError(Throwable t) {}

    @Override
    public void onComplete() {}
});

Wenn wir unseren Code erneut ausführen, wirdrequest(2) aufgerufen, gefolgt von zweionNext()-Aufrufen undrequest(2).

23:31:15.395 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
23:31:15.397 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.397 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | request(2)
23:31:15.398 [main] INFO  reactor.Flux.Array.1 - | onComplete()

Im Wesentlichen handelt es sich dabei um einen reaktiven Gegendruck. Wir fordern den Upstream auf, nur eine bestimmte Anzahl von Elementen zu pushen, und zwar nur dann, wenn wir bereit sind. Wenn wir uns vorstellen, wir würden Tweets von Twitter streamen, wäre es Sache des Upstreams, zu entscheiden, was zu tun ist. Wenn Tweets eingehen, aber keine Anforderungen vom Downstream vorliegen, kann der Upstream Elemente ablegen, sie in einem Puffer speichern oder eine andere Strategie anwenden.

7. Betrieb an einem Stream

Wir können auch Operationen an den Daten in unserem Stream ausführen und auf Ereignisse reagieren, die wir für richtig halten.

7.1. Zuordnen von Daten in einem Stream

Eine einfache Operation, die wir ausführen können, ist das Anwenden einer Transformation. In diesem Fall verdoppeln wir einfach alle Zahlen in unserem Stream:

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .subscribe(elements::add);

map() wird angewendet, wennonNext() aufgerufen wird.

7.2. Zwei Streams kombinieren

Wir können dann die Dinge interessanter machen, indem wir einen anderen Stream mit diesem kombinieren. Versuchen wir dies mit derzip()-Funktion:

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .zipWith(Flux.range(0, Integer.MAX_VALUE),
    (one, two) -> String.format("First Flux: %d, Second Flux: %d", one, two))
  .subscribe(elements::add);

assertThat(elements).containsExactly(
  "First Flux: 2, Second Flux: 0",
  "First Flux: 4, Second Flux: 1",
  "First Flux: 6, Second Flux: 2",
  "First Flux: 8, Second Flux: 3");

Hier erstellen wir ein weiteresFlux, das ständig um eins erhöht und zusammen mit unserem ursprünglichen gestreamt wird. Wir können sehen, wie diese zusammenarbeiten, indem wir die Protokolle untersuchen:

20:04:38.064 [main] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:04:38.065 [main] INFO  reactor.Flux.Array.1 - | onNext(1)
20:04:38.066 [main] INFO  reactor.Flux.Range.2 - | onSubscribe([Synchronous Fuseable] FluxRange.RangeSubscription)
20:04:38.066 [main] INFO  reactor.Flux.Range.2 - | onNext(0)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(2)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(1)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(3)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(2)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onNext(4)
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | onNext(3)
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | onComplete()
20:04:38.067 [main] INFO  reactor.Flux.Array.1 - | cancel()
20:04:38.067 [main] INFO  reactor.Flux.Range.2 - | cancel()

Beachten Sie, dass wir jetzt ein Abonnement proFluxhaben. Die Aufrufe vononNext()wechseln sich ebenfalls ab, sodass der Index jedes Elements im Stream übereinstimmt, wenn wir die Funktionzip()anwenden.

8. Heiße Streams

Derzeit konzentrieren wir uns hauptsächlich auf kalte Ströme. Dies sind statische Streams mit fester Länge, die einfach zu handhaben sind. Ein realistischerer Anwendungsfall für reaktive Ereignisse kann unendlich sein.

Zum Beispiel könnten wir einen Strom von Mausbewegungen haben, auf den ständig reagiert werden muss, oder einen Twitter-Feed. Diese Arten von Streams werden als Hot-Streams bezeichnet, da sie immer ausgeführt werden und zu jedem Zeitpunkt abonniert werden können, ohne dass der Beginn der Daten fehlt.

8.1. ConnectableFlux erstellen

Eine Möglichkeit, einen heißen Stream zu erstellen, besteht darin, einen kalten Stream in einen umzuwandeln. Erstellen wir einFlux, das für immer gültig ist, und geben die Ergebnisse an die Konsole aus, wodurch ein unendlicher Datenstrom simuliert wird, der von einer externen Ressource stammt:

ConnectableFlux publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .publish();


Durch das Aufrufen vonpublish() erhalten wirConnectableFlux.. Dies bedeutet, dass das Aufrufen vonsubscribe() nicht dazu führt, dass es ausgegeben wird, sodass wir mehrere Abonnements hinzufügen können:

publish.subscribe(System.out::println);
publish.subscribe(System.out::println);

Wenn wir versuchen, diesen Code auszuführen, passiert nichts. Erst wenn wirconnect(), aufrufen, werden dieFlux ausgegeben. Es spielt keine Rolle, ob wir abonnieren oder nicht.

8.2. Drosselung

Wenn wir unseren Code ausführen, ist unsere Konsole mit der Protokollierung überfordert. Dies simuliert eine Situation, in der zu viele Daten an unsere Verbraucher weitergegeben werden. Versuchen wir, dies mit Drosselung zu umgehen:

ConnectableFlux publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .sample(ofSeconds(2))
  .publish();


Hier haben wir einesample()-Methode mit einem Intervall von zwei Sekunden eingeführt. Jetzt werden die Werte nur alle zwei Sekunden an unseren Abonnenten gesendet, wodurch die Konsole deutlich weniger hektisch wird.

Natürlich gibt es mehrere Strategien, um die nachgelagerte Datenmenge zu reduzieren, z. B. Fensterung und Pufferung, aber sie werden für diesen Artikel nicht berücksichtigt.

9. Parallelität

Alle unsere obigen Beispiele wurden derzeit auf dem Haupt-Thread ausgeführt. Wir können jedoch steuern, auf welchem ​​Thread unser Code ausgeführt wird, wenn wir möchten. DieScheduler-Schnittstelle bietet eine Abstraktion um asynchronen Code, für die viele Implementierungen für uns bereitgestellt werden. Versuchen wir, einen anderen Thread als main zu abonnieren:

Flux.just(1, 2, 3, 4)
  .log()
  .map(i -> i * 2)
  .subscribeOn(Schedulers.parallel())
  .subscribe(elements::add);

Der Scheduler vonParallelbewirkt, dass unser Abonnement auf einem anderen Thread ausgeführt wird. Dies können wir anhand der Protokolle nachweisen:

20:03:27.505 [main] DEBUG reactor.util.Loggers$LoggerFactory - Using Slf4j logging framework
20:03:27.529 [parallel-1] INFO  reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | request(unbounded)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(1)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(2)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(3)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onNext(4)
20:03:27.531 [parallel-1] INFO  reactor.Flux.Array.1 - | onComplete()

Parallelität wird interessanter als dies, und es lohnt sich, sie in einem anderen Artikel zu untersuchen.

10. Fazit

In diesem Artikel haben wir einen umfassenden Überblick über Reactive Core gegeben. Wir haben erklärt, wie wir Streams veröffentlichen und abonnieren, Gegendruck anwenden, Streams bearbeiten und Daten auch asynchron verarbeiten können. Dies sollte hoffentlich den Grundstein für die Erstellung reaktiver Anwendungen legen.

Spätere Artikel in dieser Reihe befassen sich mit weiter fortgeschrittenen Konzepten für Parallelität und andere reaktive Prozesse. Es gibt auch einen anderen Artikel überReactor with Spring.

Der Quellcode für unsere Anwendung ist aufover on GitHub verfügbar. Dies ist ein Maven-Projekt, das so ausgeführt werden sollte, wie es ist.