Отладка реактивных потоков в Spring 5
1. обзор
Отладкаreactive streams, вероятно, является одной из основных проблем, с которыми нам придется столкнуться, когда мы начнем использовать эти структуры данных.
И учитывая, что реактивные потоки набирают популярность в последние годы, неплохо было бы узнать, как мы можем эффективно выполнить эту задачу.
Давайте начнем с настройки проекта с использованием реактивного стека, чтобы понять, почему это часто вызывает проблемы.
2. Сценарий с ошибками
Мы хотим смоделировать реальный сценарий, когда выполняется несколько асинхронных процессов, и в котором мы внесли некоторые дефекты в код, которые в конечном итоге вызовут исключения.
Чтобы понять общую картину, мы упомянем, что наше приложение будет потреблять и обрабатывать потоки простых объектовFoo, которые содержат толькоid, aformattedName иquantityполе s. Для более подробной информации посмотритеon the project here.
2.1. Анализируя вывод журнала
Теперь давайте рассмотрим фрагмент и результат, который он генерирует при обнаружении необработанной ошибки:
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();
}
Запустив наше приложение на несколько секунд, мы поймем, что время от времени оно регистрирует исключения.
Внимательно изучив одну из ошибок, мы обнаружим что-то похожее на это:
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)
Основываясь на основной причине и замечая классFooNameHelper, упомянутый в трассировке стека, мы можем представить, что в некоторых случаях наши объектыFoo обрабатываются со значениемformattedName , которое короче, чем ожидается.
Конечно, это просто упрощенный случай, и решение кажется довольно очевидным.
Но давайте представим, что это был реальный сценарий, когда само исключение не помогает нам решить проблему без некоторой контекстной информации.
Было ли исключение инициировано как часть методаprocessFoo, илиprocessFooInAnotherScenario?
Повлияли ли другие предыдущие шаги на полеformattedName до перехода на этот этап?
Запись в журнале не поможет нам разобраться в этих вопросах.
Что еще хуже, иногда исключение даже не создается внутри нашей функциональности.
Например, представьте, что мы полагаемся на реактивный репозиторий для сохранения наших объектовFoo. Если в этот момент возникает ошибка, мы можем даже не догадываться, с чего начать отладку нашего кода.
Нам нужны инструменты для эффективной отладки реактивных потоков.
3. Использование сеанса отладки
Один из вариантов выяснить, что происходит с нашим приложением, - это запустить сеанс отладки с помощью нашей любимой IDE.
Нам нужно будет установить несколько условных точек останова и проанализировать поток данных при выполнении каждого шага в потоке.
В самом деле, это может быть обременительной задачей, особенно когда у нас работает много реактивных процессов и совместно используют ресурсы.
Кроме того, во многих случаях мы не можем запустить сеанс отладки по соображениям безопасности.
4. Регистрация информации с помощью методаdoOnError или использования параметра подписки
Иногдаwe 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);
});
}
Примечание. Стоит упомянуть, что если нам не нужно выполнять дальнейшую обработку методаsubscribe, мы можем связать функциюdoOnError с нашим издателем:
flux.doOnError(error -> {
logger.error("The following error happened on processFoo method!", error);
}).subscribe();
Теперь у нас будет несколько указаний относительно того, откуда могла взяться ошибка, хотя у нас по-прежнему мало информации о фактическом элементе, вызвавшем исключение.
5. Активация глобальной конфигурации отладки Reactor
БиблиотекаReactor предоставляет классHooks, который позволяет нам настраивать поведение операторовFlux иMono.
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();
После активации режима отладки наши журналы исключений будут содержать некоторую полезную информацию:
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)
Как мы видим, первый раздел остается относительно тем же, но следующие разделы предоставляют информацию о:
-
Трассировка сборки издателя - здесь мы можем подтвердить, что ошибка была впервые сгенерирована в методеprocessFoo.
-
Операторы, которые наблюдали ошибку после ее первого запуска, с классом пользователя, где они были связаны.
Примечание. В этом примере, чтобы наглядно это увидеть, мы добавляем операции для разных классов.
Мы можем включить или выключить режим отладки в любое время, но это не повлияет на объектыFlux иMono, для которых уже созданы экземпляры.
5.1. Выполнение операторов в разных потоках
Еще один аспект, о котором следует помнить, это то, что трассировка сборки генерируется правильно, даже если в потоке работают разные потоки.
Давайте посмотрим на следующий пример:
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();
}
Теперь, если мы проверим журналы, мы поймем, что в этом случае первый раздел может немного измениться, но последние два останутся практически такими же.
Первая часть - это трассировка стека потока, поэтому она показывает только операции, выполняемые конкретным потоком.
Как мы видели, это не самый важный раздел при отладке приложения, поэтому это изменение приемлемо.
6. Активация вывода отладки для одного процесса
Инструментарий и генерация трассировки стека в каждом реактивном процессе являются дорогостоящими.
Таким образом,we should implement the former approach only in critical cases.
Во всяком случае,Reactor provides a way to enable the debug mode on single crucial processes, which is less memory-consuming.
Мы имеем в виду операторcheckpoint:
public void processFoo(Flux flux) {
// ...
flux = flux.checkpoint("Observed error on processFoo", true);
flux.subscribe();
}
Обратите внимание, что таким образом трассировка сборки будет регистрироваться на этапе контрольной точки:
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)
Мы должны реализовать методcheckpoint ближе к концу реактивной цепочки.
В противном случае оператор не сможет наблюдать ошибки, возникающие ниже по потоку.
Также отметим, что библиотека предлагает перегруженный метод. Мы можем избежать:
-
указание описания обнаруженной ошибки, если мы используем опцию no-args
-
генерирование трассировки заполненного стека (что является наиболее дорогостоящей операцией), предоставляя только пользовательское описание
7. Регистрация последовательности элементов
Наконец, издатели Reactor предлагают еще один метод, который в некоторых случаях может пригодиться.
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.
Давайте попробуем это на нашем примере:
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();
}
И проверьте логи:
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!
...
Мы можем легко увидеть состояние каждого объектаFoo на этом этапе и то, как фреймворк отменяет поток при возникновении исключения.
Конечно, этот подход также требует больших затрат, и мы должны использовать его умеренно.
8. Заключение
Мы можем потратить много времени и усилий на устранение неполадок, если не знаем инструментов и механизмов для правильной отладки нашего приложения.
Это особенно верно, если мы не привыкли обрабатывать реактивные и асинхронные структуры данных и нам нужна дополнительная помощь, чтобы понять, как все работает.
Как всегда, полный пример доступен наGitHub repo.