Введение в ядро реактора
1. Вступление
Reactor Core - это библиотека Java 8, которая реализует модель реактивного программирования. Он построен на основеReactive Streams Specification, стандарта для создания реактивных приложений.
На фоне нереактивной разработки Java переход к реактивной может быть довольно крутой кривой обучения. Это становится более сложной задачей при сравнении его с API Java 8Stream, поскольку они могут быть ошибочно приняты за те же абстракции высокого уровня.
В этой статье мы попытаемся развенчать эту парадигму. Мы будем делать небольшие шаги через Reactor, пока не построим картину того, как составлять реактивный код, заложив основу для более сложных статей, которые появятся в следующих сериях.
2. Спецификация реактивных потоков
Прежде чем мы рассмотрим Reactor, мы должны взглянуть на спецификацию Reactive Streams. Это то, что реализует Reactor, и это закладывает основу для библиотеки.
По сути, Reactive Streams - это спецификация для обработки асинхронных потоков.
Другими словами, система, в которой множество событий создается и потребляется асинхронно. Подумайте о потоке тысяч обновлений в секунду, поступающих в финансовое приложение, и о том, что оно должно своевременно реагировать на эти обновления.
Одной из основных целей этого является решение проблемы противодействия. Если у нас есть производитель, который отправляет события потребителю быстрее, чем он может их обработать, то в конечном итоге потребитель будет перегружен событиями, исчерпывающими системные ресурсы. Противодавление означает, что наш потребитель должен иметь возможность сообщить производителю, сколько данных отправлять, чтобы предотвратить это, и это именно то, что изложено в спецификации.
3. Maven Зависимости
4. Создание потока данных
Чтобы приложение могло реагировать, первое, что оно должно сделать, это создать поток данных. Это может быть что-то наподобие примера обновления запаса, который мы привели ранее. Без этих данных нам не на что было бы реагировать, поэтому это первый логичный шаг. Reactive Core предоставляет нам два типа данных, которые позволяют нам это делать.
4.1. Flux
Первый способ сделать это - использоватьFlux.. Это поток, который может испускать элементы0..n. Попробуем создать простой:
Flux just = Flux.just("1", "2", "3");
В этом случае у нас есть статический поток из трех элементов.
4.2. Mono
Второй способ сделать это - использоватьMono,, который представляет собой поток элементов0..1. Давайте попробуем создать один экземпляр:
Mono just = Mono.just("foo");
Он выглядит и ведет себя почти так же, какFlux, только на этот раз мы ограничены не более чем одним элементом.
4.3. Почему не просто Flux?
Прежде чем продолжить эксперименты, стоит подчеркнуть, почему у нас есть эти два типа данных.
Во-первых, следует отметить, что какFlux, так иMono являются реализациями интерфейса Reactive StreamsPublisher. Оба класса соответствуют спецификации, и мы могли бы использовать этот интерфейс вместо них:
Publisher just = Mono.just("foo");
Но на самом деле, знание этой мощности полезно. Это связано с тем, что несколько операций имеют смысл только для одного из двух типов и потому, что они могут быть более выразительными (представьте себеfindOne() в репозитории).
5. Подписка на поток
Теперь у нас есть общий обзор того, как создать поток данных, нам нужно подписаться на него, чтобы он излучал элементы.
5.1. Сбор элементов
Давайте воспользуемся методомsubscribe(), чтобы собрать все элементы в потоке:
List elements = new ArrayList<>();
Flux.just(1, 2, 3, 4)
.log()
.subscribe(elements::add);
assertThat(elements).containsExactly(1, 2, 3, 4);
Данные не начнут поступать, пока мы не подпишемся. Обратите внимание, что мы также добавили ведение журнала, это будет полезно, когда мы посмотрим, что происходит за кулисами.
5.2. Поток элементов
С регистрацией на месте мы можем использовать ее для визуализации того, как данные проходят через наш поток:
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()
Прежде всего, все работает в основном потоке. Не будем вдаваться в подробности об этом, так как позже в этой статье мы подробнее рассмотрим параллелизм. Это делает вещи простыми, так как мы можем разобраться со всем по порядку.
Теперь давайте пройдемся по порядку, который мы записали в журнал:
-
onSubscribe() - это вызывается, когда мы подписываемся на наш поток
-
request(unbounded) – Когда мы вызываемsubscribe, за кулисами мы создаемSubscription. Эта подписка запрашивает элементы из потока. В этом случае по умолчанию используетсяunbounded,, что означает, что он запрашивает каждый доступный элемент.
-
onNext() – Это вызывается для каждого отдельного элемента
-
onComplete() – Вызывается последним после получения последнего элемента. На самом деле есть такжеonError(), который будет вызываться, если есть исключение, но в этом случае нет
Это поток, изложенный в интерфейсеSubscriber как часть спецификации реактивных потоков, и на самом деле это то, что было создано за кулисами в нашем вызовеonSubscribe().. Это полезный метод, но лучше разобраться, что происходит, давайте напрямую предоставим интерфейсSubscriber:
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() {}
});
Мы можем видеть, что каждый возможный этап в приведенном выше потоке отображается на метод в реализацииSubscriber. Так получилось, чтоFlux предоставил нам вспомогательный метод для уменьшения этой многословности.
5.3. Сравнение с Java 8Streams
По-прежнему может показаться, что у нас есть что-то синонимическое для Java 8Stream, выполняющего сбор:
List collected = Stream.of(1, 2, 3, 4)
.collect(toList());
Только мы этого не делаем.
Основное отличие состоит в том, что Reactive - это модель push, тогда как Java 8Streams - модель pull. In reactive approach. events are pushed to the subscribers as they come in.
Следующее, на что следует обратить внимание, это оператор терминалаStreams - это просто терминал, который извлекает все данные и возвращает результат. С Reactive у нас может быть бесконечный поток, поступающий с внешнего ресурса, с несколькими подписчиками, подключенными и удаленными на разовой основе. Мы также можем сделать такие вещи, как объединить потоки, потоки газа и применить противодавление, которое мы рассмотрим далее.
6. Обратное давление
Следующее, что мы должны рассмотреть, это противодавление. В нашем примере подписчик говорит производителю нажать каждый элемент сразу. Это может в конечном итоге стать подавляющим для абонента, потребляя все его ресурсы.
Backpressure is when a downstream can tell an upstream to send it fewer data in order to prevent it from being overwhelmed.
Мы можем изменить нашу реализациюSubscriber, чтобы применить противодавление. Давайте укажем восходящему потоку отправлять только два элемента за раз, используяrequest():
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() {}
});
Теперь, если мы снова запустим наш код, мы увидим, что вызываетсяrequest(2), за которым следуют два вызоваonNext(), а затем сноваrequest(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()
По сути, это реактивное обратное противодавление. Мы просим апстрим только подтолкнуть определенное количество элементов, и только когда мы будем готовы. Если мы представим, что нас транслируют в твиттере, тогда дело за тем, чтобы решить, что делать. Если поступают твиты, но нет запросов от нисходящего потока, то восходящий поток может отбрасывать элементы, сохранять их в буфере или какой-либо другой стратегии.
7. Работа с потоком
Мы также можем выполнять операции с данными в нашем потоке, реагируя на события по своему усмотрению.
7.1. Отображение данных в потоке
Простая операция, которую мы можем выполнить - применить преобразование. В этом случае давайте просто удвоим все числа в нашем потоке:
Flux.just(1, 2, 3, 4)
.log()
.map(i -> i * 2)
.subscribe(elements::add);
map() будет применяться при вызовеonNext().
7.2. Объединение двух потоков
Затем мы можем сделать вещи более интересными, комбинируя другой поток с этим. Давайте попробуем это, используя функциюzip():
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");
Здесь мы создаем еще одинFlux, который продолжает увеличиваться на единицу, и транслируем его вместе с нашим исходным. Мы можем видеть, как они работают вместе, проверяя журналы:
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()
Обратите внимание, что теперь у нас есть одна подписка наFlux. ВызовыonNext() также чередуются, поэтому индекс каждого элемента в потоке будет совпадать, когда мы применим функциюzip().
8. Горячие потоки
В настоящее время мы уделяем основное внимание холодным потокам. Это статические потоки фиксированной длины, с которыми легко иметь дело. Более реалистичным вариантом использования реактивного может быть то, что происходит бесконечно.
Например, у нас может быть поток движений мыши, на которые нужно постоянно реагировать, или твиттер. Эти типы потоков называются горячими потоками, так как они всегда работают и на них можно подписаться в любой момент времени, пропуская начало данных.
8.1. СозданиеConnectableFlux
Один из способов создать горячий поток - это преобразовать холодный поток в один. Давайте создадимFlux, который будет длиться вечно, выводя результаты на консоль, которая будет имитировать бесконечный поток данных, поступающий из внешнего ресурса:
ConnectableFlux
Вызываяpublish(), мы получаемConnectableFlux.. Это означает, что вызовsubscribe() не приведет к тому, что он начнет излучать, что позволяет нам добавить несколько подписок:
publish.subscribe(System.out::println);
publish.subscribe(System.out::println);
Если мы попробуем запустить этот код, ничего не произойдет. Только когда мы вызовемconnect(),,Flux начнет излучать. Неважно, подписываемся мы или нет.
8.2. Дроссельный
Если мы запустим наш код, наша консоль будет перегружена журналированием. Это имитирует ситуацию, когда нашим потребителям передается слишком много данных. Попробуем обойти это с помощью троттлинга:
ConnectableFlux
Здесь мы ввели методsample() с интервалом в две секунды. Теперь значения будут передаваться нашему подписчику только каждые две секунды, что означает, что консоль будет намного менее беспокойной.
Конечно, есть несколько стратегий для уменьшения объема данных, отправляемых в нисходящем потоке, такие как управление окнами и буферизация, но они будут оставлены за рамками данной статьи.
9. совпадение
Все приведенные выше примеры в настоящее время выполняются в главном потоке. Однако мы можем контролировать, в каком потоке работает наш код, если мы этого хотим. ИнтерфейсScheduler обеспечивает абстракцию асинхронного кода, для которого нам предоставлено множество реализаций. Давайте попробуем подписаться на другую ветку main:
Flux.just(1, 2, 3, 4)
.log()
.map(i -> i * 2)
.subscribeOn(Schedulers.parallel())
.subscribe(elements::add);
ПланировщикParallel заставит нашу подписку запускаться в другом потоке, что мы можем проверить, просмотрев журналы:
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()
Параллелизм становится более интересным, и его стоит рассмотреть в другой статье.
10. Заключение
В этой статье мы представили подробный обзор Reactive Core на высоком уровне. Мы объяснили, как мы можем публиковать потоки и подписываться на них, применять обратное давление, работать с потоками, а также обрабатывать данные асинхронно. Мы надеемся, что это послужит основой для написания реактивных приложений.
В последующих статьях этой серии будут рассмотрены более сложные параллелизм и другие реактивные концепции. Есть еще одна статья, посвященнаяReactor with Spring.
Исходный код нашего приложения доступен наover on GitHub; это проект Maven, который должен работать как есть.