Debugging von reaktiven Streams im Frühling 5

Reaktive Streams im Frühjahr 5 debuggen

1. Überblick

Das Debuggen vonreactive streams ist wahrscheinlich eine der größten Herausforderungen, denen wir uns stellen müssen, wenn wir diese Datenstrukturen verwenden.

Angesichts der Tatsache, dass Reactive Streams in den letzten Jahren immer beliebter wurden, ist es eine gute Idee zu wissen, wie wir diese Aufgabe effizient ausführen können.

Beginnen wir mit der Einrichtung eines Projekts mithilfe eines reaktiven Stapels, um festzustellen, warum dies häufig problematisch ist.

2. Szenario mit Bugs

Wir möchten ein reales Szenario simulieren, in dem mehrere asynchrone Prozesse ausgeführt werden und in dem wir einige Fehler im Code eingeführt haben, die schließlich Ausnahmen auslösen.

Um das Gesamtbild zu verstehen, werden wir erwähnen, dass unsere Anwendung Streams von einfachenFoo-Objekten verbraucht und verarbeitet, die nurid,formattedName undquantityenthalten. s Feld. Weitere Informationen finden Sie unteron the project here.

2.1. Analysieren der Protokollausgabe

Lassen Sie uns nun ein Snippet und die Ausgabe untersuchen, die es generiert, wenn ein nicht behandelter Fehler auftritt:

public void processFoo(Flux flux) {
    flux = FooNameHelper.concatFooName(flux);
    flux = FooNameHelper.substringFooName(flux);
    flux = FooReporter.reportResult(flux);
    flux.subscribe();
}

public void processFooInAnotherScenario(Flux flux) {
    flux = FooNameHelper.substringFooName(flux);
    flux = FooQuantityHelper.divideFooQuantity(flux);
    flux.subscribe();
}

Nachdem wir unsere Anwendung einige Sekunden lang ausgeführt haben, werden wir feststellen, dass von Zeit zu Zeit Ausnahmen protokolliert werden.

Wenn wir uns einen der Fehler genauer ansehen, werden wir etwas Ähnliches finden:

Caused by: java.lang.StringIndexOutOfBoundsException: String index out of range: 15
    at j.l.String.substring(String.java:1963)
    at com.example.debugging.consumer.service.FooNameHelper
      .lambda$1(FooNameHelper.java:38)
    at r.c.p.FluxMap$MapSubscriber.onNext(FluxMap.java:100)
    at r.c.p.FluxMap$MapSubscriber.onNext(FluxMap.java:114)
    at r.c.p.FluxConcatMap$ConcatMapImmediate.innerNext(FluxConcatMap.java:275)
    at r.c.p.FluxConcatMap$ConcatMapInner.onNext(FluxConcatMap.java:849)
    at r.c.p.Operators$MonoSubscriber.complete(Operators.java:1476)
    at r.c.p.MonoDelayUntil$DelayUntilCoordinator.signal(MonoDelayUntil.java:211)
    at r.c.p.MonoDelayUntil$DelayUntilTrigger.onComplete(MonoDelayUntil.java:290)
    at r.c.p.MonoDelay$MonoDelayRunnable.run(MonoDelay.java:118)
    at r.c.s.SchedulerTask.call(SchedulerTask.java:50)
    at r.c.s.SchedulerTask.call(SchedulerTask.java:27)
    at j.u.c.FutureTask.run(FutureTask.java:266)
    at j.u.c.ScheduledThreadPoolExecutor$ScheduledFutureTask
      .access$201(ScheduledThreadPoolExecutor.java:180)
    at j.u.c.ScheduledThreadPoolExecutor$ScheduledFutureTask
      .run(ScheduledThreadPoolExecutor.java:293)
    at j.u.c.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at j.u.c.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at j.l.Thread.run(Thread.java:748)

Basierend auf der Grundursache und unter Berücksichtigung der im Stack-Trace genanntenFooNameHelper-Klasse können wir uns vorstellen, dass unsereFoo-Objekte gelegentlich mit einemformattedName -Wert verarbeitet werden, der kürzer als ist erwartet.

Dies ist natürlich nur ein vereinfachter Fall, und die Lösung scheint ziemlich offensichtlich.

Stellen wir uns jedoch vor, dies wäre ein reales Szenario, in dem die Ausnahme selbst uns nicht hilft, das Problem ohne Kontextinformationen zu lösen.

Wurde die Ausnahme als Teil derprocessFoo,- oder derprocessFooInAnotherScenario-Methode ausgelöst?

Haben andere vorherige Schritte das FeldformattedNamebeeinflusst, bevor sie zu diesem Zeitpunkt gelangt sind?

Der Protokolleintrag würde uns nicht helfen, diese Fragen herauszufinden.

Um die Sache noch schlimmer zu machen, wird die Ausnahme manchmal nicht einmal aus unserer Funktionalität heraus ausgelöst.

Stellen Sie sich zum Beispiel vor, wir verlassen uns auf ein reaktives Repository, um die Objekte vonFoobeizubehalten. Wenn zu diesem Zeitpunkt ein Fehler auftritt, wissen wir möglicherweise nicht einmal, wo wir mit dem Debuggen unseres Codes beginnen sollen.

Wir brauchen Tools, um reaktive Streams effizient zu debuggen.

3. Verwenden einer Debugsitzung

Eine Möglichkeit, um herauszufinden, was mit unserer Anwendung los ist, besteht darin, eine Debugging-Sitzung mit unserer bevorzugten IDE zu starten.

Wir müssen einige bedingte Haltepunkte einrichten und den Datenfluss analysieren, wenn jeder Schritt im Stream ausgeführt wird.

Dies kann in der Tat eine mühsame Aufgabe sein, insbesondere wenn viele reaktive Prozesse ausgeführt werden und Ressourcen gemeinsam genutzt werden.

Darüber hinaus gibt es viele Umstände, unter denen wir aus Sicherheitsgründen keine Debugging-Sitzung starten können.

4. Protokollieren von Informationen mit derdoOnError Method oder Verwenden des Subscribe-Parameters

Manchmal sindwe can add useful context information, by providing a Consumer as a second parameter of the subscribe method:

public void processFoo(Flux flux) {

    // ...

    flux.subscribe(foo -> {
        logger.debug("Finished processing Foo with Id {}", foo.getId());
    }, error -> {
        logger.error(
          "The following error happened on processFoo method!",
           error);
    });
}

Hinweis: Es ist erwähnenswert, dass wir diedoOnError-Funktion auf unserem Publisher verketten können, wenn wir keine weitere Verarbeitung dersubscribe-Methode durchführen müssen:

flux.doOnError(error -> {
    logger.error("The following error happened on processFoo method!", error);
}).subscribe();

Jetzt haben wir eine Anleitung, woher der Fehler kommen könnte, obwohl wir noch nicht viele Informationen über das tatsächliche Element haben, das die Ausnahme generiert hat.

5. Aktivieren der globalen Debug-Konfiguration von Reactor

DieReactor-Bibliothek bietet eineHooks-Klasse, mit der wir das Verhalten derFlux- undMono-Operatoren konfigurieren können.

By just adding the following statement, our application will instrument the calls to the to the publishers' methods, wrap the construction of the operator, and capture a stack trace:

Hooks.onOperatorDebug();

Nachdem der Debug-Modus aktiviert wurde, enthalten unsere Ausnahmeprotokolle einige hilfreiche Informationen:

16:06:35.334 [parallel-1] ERROR c.b.d.consumer.service.FooService
  - The following error happened on processFoo method!
java.lang.StringIndexOutOfBoundsException: String index out of range: 15
    at j.l.String.substring(String.java:1963)
    at c.d.b.c.s.FooNameHelper.lambda$1(FooNameHelper.java:38)
    ...
    at j.l.Thread.run(Thread.java:748)
    Suppressed: r.c.p.FluxOnAssembly$OnAssemblyException:
Assembly trace from producer [reactor.core.publisher.FluxMapFuseable] :
    reactor.core.publisher.Flux.map(Flux.java:5653)
    c.d.b.c.s.FooNameHelper.substringFooName(FooNameHelper.java:32)
    c.d.b.c.s.FooService.processFoo(FooService.java:24)
    c.d.b.c.c.ChronJobs.consumeInfiniteFlux(ChronJobs.java:46)
    o.s.s.s.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:84)
    o.s.s.s.DelegatingErrorHandlingRunnable
      .run(DelegatingErrorHandlingRunnable.java:54)
    o.u.c.Executors$RunnableAdapter.call(Executors.java:511)
    o.u.c.FutureTask.runAndReset(FutureTask.java:308)
Error has been observed by the following operator(s):
    |_    Flux.map ⇢ c.d.b.c.s.FooNameHelper
            .substringFooName(FooNameHelper.java:32)
    |_    Flux.map ⇢ c.d.b.c.s.FooReporter.reportResult(FooReporter.java:15)

Wie wir sehen können, bleibt der erste Abschnitt relativ gleich, aber die folgenden Abschnitte enthalten Informationen über:

  1. Die Assembly-Ablaufverfolgung des Herausgebers - hier können wir bestätigen, dass der Fehler zuerst in der MethodeprocessFoo generiert wurde.

  2. Die Operatoren, die den Fehler nach seiner ersten Auslösung beobachtet haben, mit der Benutzerklasse, in der sie verkettet wurden.

Hinweis: In diesem Beispiel fügen wir die Operationen für verschiedene Klassen hinzu, um dies klar zu sehen.

Wir können den Debug-Modus jederzeit ein- oder ausschalten, aber er wirkt sich nicht aufFlux undMono Objekte aus, die bereits instanziiert wurden.

5.1. Ausführen von Operatoren auf verschiedenen Threads

Ein weiterer Aspekt, den Sie berücksichtigen sollten, ist, dass die Assembly-Ablaufverfolgung auch dann ordnungsgemäß generiert wird, wenn im Stream verschiedene Threads ausgeführt werden.

Schauen wir uns das folgende Beispiel an:

public void processFoo(Flux flux) {
    flux = flux.publishOn(Schedulers.newSingle("foo-thread"));
    // ...

    flux = flux.publishOn(Schedulers.newSingle("bar-thread"));
    flux = FooReporter.reportResult(flux);
    flux.subscribeOn(Schedulers.newSingle("starter-thread"))
      .subscribe();
}

Wenn wir nun die Protokolle überprüfen, werden wir erkennen, dass sich in diesem Fall der erste Abschnitt möglicherweise ein wenig ändert, die letzten beiden jedoch ziemlich gleich bleiben.

Der erste Teil ist der Thread-Stack-Trace. Daher werden nur die von einem bestimmten Thread ausgeführten Vorgänge angezeigt.

Wie wir gesehen haben, ist dies nicht der wichtigste Abschnitt beim Debuggen der Anwendung. Daher ist diese Änderung akzeptabel.

6. Aktivieren der Debug-Ausgabe für einen einzelnen Prozess

Das Instrumentieren und Erzeugen einer Stapelverfolgung in jedem einzelnen reaktiven Prozess ist kostspielig.

Somit istwe should implement the former approach only in critical cases.

JedenfallsReactor provides a way to enable the debug mode on single crucial processes, which is less memory-consuming.

Wir beziehen uns auf den Operatorcheckpoint:

public void processFoo(Flux flux) {

    // ...

    flux = flux.checkpoint("Observed error on processFoo", true);
    flux.subscribe();
}

Beachten Sie, dass der Assembly-Trace auf diese Weise in der Checkpoint-Phase protokolliert wird:

Caused by: java.lang.StringIndexOutOfBoundsException: String index out of range: 15
    ...
Assembly trace from producer [reactor.core.publisher.FluxMap],
  described as [Observed error on processFoo] :
    r.c.p.Flux.checkpoint(Flux.java:3096)
    c.b.d.c.s.FooService.processFoo(FooService.java:26)
    c.b.d.c.c.ChronJobs.consumeInfiniteFlux(ChronJobs.java:46)
    o.s.s.s.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:84)
    o.s.s.s.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
    j.u.c.Executors$RunnableAdapter.call(Executors.java:511)
    j.u.c.FutureTask.runAndReset(FutureTask.java:308)
Error has been observed by the following operator(s):
    |_    Flux.checkpoint ⇢ c.b.d.c.s.FooService.processFoo(FooService.java:26)

Wir sollten diecheckpoint-Methode gegen Ende der reaktiven Kette implementieren.

Andernfalls kann der Bediener keine nachgeschalteten Fehler beobachten.

Beachten Sie außerdem, dass die Bibliothek eine überladene Methode bietet. Wir können vermeiden:

  • Angabe einer Beschreibung für den beobachteten Fehler, wenn die Option no-args verwendet wird

  • Generieren eines gefüllten Stack-Trace (was die teuerste Operation ist), indem nur die benutzerdefinierte Beschreibung angegeben wird

7. Eine Folge von Elementen protokollieren

Schließlich bieten die Reactor-Verlage eine weitere Methode an, die sich in einigen Fällen als nützlich erweisen könnte.

By calling the log method in our reactive chain, the application will log each element in the flow with the state that it has at that stage.

Probieren wir es in unserem Beispiel aus:

public void processFoo(Flux flux) {
    flux = FooNameHelper.concatFooName(flux);
    flux = FooNameHelper.substringFooName(flux);
    flux = flux.log();
    flux = FooReporter.reportResult(flux);
    flux = flux.doOnError(error -> {
        logger.error("The following error happened on processFoo method!", error);
    });
    flux.subscribe();
}

Und überprüfen Sie die Protokolle:

INFO  reactor.Flux.Map.1 - onSubscribe(FluxMap.MapSubscriber)
INFO  reactor.Flux.Map.1 - request(unbounded)
INFO  reactor.Flux.Map.1 - onNext(Foo(id=0, formattedName=theFo, quantity=8))
INFO  reactor.Flux.Map.1 - onNext(Foo(id=1, formattedName=theFo, quantity=3))
INFO  reactor.Flux.Map.1 - onNext(Foo(id=2, formattedName=theFo, quantity=5))
INFO  reactor.Flux.Map.1 - onNext(Foo(id=3, formattedName=theFo, quantity=6))
INFO  reactor.Flux.Map.1 - onNext(Foo(id=4, formattedName=theFo, quantity=6))
INFO  reactor.Flux.Map.1 - cancel()
ERROR c.b.d.consumer.service.FooService
  - The following error happened on processFoo method!
...

In diesem Stadium können wir leicht den Status jedesFoo-Objekts sehen und sehen, wie das Framework den Fluss abbricht, wenn eine Ausnahme auftritt.

Natürlich ist dieser Ansatz auch kostspielig und wir müssen ihn mit Mäßigung anwenden.

8. Fazit

Wir können viel Zeit und Mühe aufwenden, um Probleme zu beheben, wenn wir die Tools und Mechanismen zum ordnungsgemäßen Debuggen unserer Anwendung nicht kennen.

Dies gilt insbesondere dann, wenn wir nicht an den Umgang mit reaktiven und asynchronen Datenstrukturen gewöhnt sind und zusätzliche Hilfe benötigen, um herauszufinden, wie die Dinge funktionieren.

Wie immer ist das vollständige Beispiel fürGitHub repo verfügbar.