Introduction au noyau du réacteur
1. introduction
Reactor Core est une bibliothèque Java 8 qui implémente le modèle de programmation réactive. Il s’appuie sur lesReactive Streams Specification, une norme pour la création d’applications réactives.
Dans le contexte du développement Java non réactif, la réactivité peut être une courbe d'apprentissage assez abrupte. Cela devient plus difficile en le comparant à l'API Java 8Stream, car ils pourraient être confondus avec les mêmes abstractions de haut niveau.
Dans cet article, nous tenterons de démystifier ce paradigme. Nous allons faire de petits pas dans Reactor jusqu'à ce que nous ayons créé une image de la façon de composer du code réactif, jetant ainsi les bases d'articles plus avancés à venir dans une série ultérieure.
2. Spécification des flux réactifs
Avant de nous pencher sur Reactor, nous devrions nous pencher sur la spécification des flux réactifs. C’est ce que Reactor met en œuvre et jette les bases de la bibliothèque.
Essentiellement, Reactive Streams est une spécification pour le traitement de flux asynchrone.
En d'autres termes, un système dans lequel de nombreux événements sont générés et consommés de manière asynchrone. Pensez à un flux de milliers de mises à jour de stock par seconde entrant dans une application financière et obligeant celle-ci à réagir rapidement à ces mises à jour.
L'un des principaux objectifs est de résoudre le problème de la pression de retour. Si nous avons un producteur qui émet des événements à un consommateur plus rapidement qu'il ne peut les traiter, il finira par être submergé par les événements et à court de ressources système. Contrepression signifie que notre consommateur devrait être en mesure d'indiquer au producteur la quantité de données à envoyer pour éviter cela, et c'est ce qui est défini dans le cahier des charges.
3. Dépendances Maven
4. Produire un flux de données
Pour qu'une application soit réactive, la première chose à faire est de produire un flux de données. Cela pourrait être quelque chose comme l'exemple de mise à jour des stocks que nous avons donné plus tôt. Sans ces données, nous n’aurions rien à réagir, c’est pourquoi il s’agit d’une première étape logique. Le noyau réactif nous donne deux types de données qui nous permettent de le faire.
4.1. Flux
La première façon de faire est d'utiliser unFlux.. C'est un flux qui peut émettre des éléments0..n. Essayons d'en créer un simple:
Flux just = Flux.just("1", "2", "3");
Dans ce cas, nous avons un flux statique de trois éléments.
4.2. Mono
La deuxième façon de faire est d'utiliser unMono, qui est un flux d'éléments0..1. Essayons d'en instancier une:
Mono just = Mono.just("foo");
Cela ressemble et se comporte presque exactement comme lesFlux, mais cette fois nous sommes limités à pas plus d'un élément.
4.3. Pourquoi pas seulement Flux?
Avant d’expérimenter plus avant, il convient de souligner pourquoi nous avons ces deux types de données.
Tout d'abord, il convient de noter que les deuxFlux etMono sont des implémentations de l'interface Reactive StreamsPublisher. Les deux classes sont conformes à la spécification et nous pourrions utiliser cette interface à leur place:
Publisher just = Mono.just("foo");
Mais vraiment, connaître cette cardinalité est utile. C'est parce que quelques opérations n'ont de sens que pour l'un des deux types, et parce qu'elles peuvent être plus expressives (imaginezfindOne() dans un référentiel).
5. S'abonner à un flux
Maintenant que nous avons une vue d'ensemble de haut niveau sur la manière de produire un flux de données, nous devons nous y abonner pour qu'il puisse émettre les éléments.
5.1. Collecter des éléments
Utilisons la méthodesubscribe() pour collecter tous les éléments d'un flux:
List elements = new ArrayList<>();
Flux.just(1, 2, 3, 4)
.log()
.subscribe(elements::add);
assertThat(elements).containsExactly(1, 2, 3, 4);
Les données ne commenceront pas à circuler tant que nous ne nous abonnerons pas. Notez que nous avons également ajouté une journalisation, cela sera utile lorsque nous examinerons ce qui se passe dans les coulisses.
5.2. Le flux des éléments
Avec la connexion en place, nous pouvons l’utiliser pour visualiser la façon dont les données circulent dans notre flux:
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()
Tout d’abord, tout tourne sur le thread principal. N'entrons pas dans les détails à ce sujet, car nous examinerons plus en détail la concurrence plus tard dans cet article. Cela simplifie les choses, cependant, car nous pouvons tout régler dans l’ordre.
Passons maintenant à la séquence que nous avons enregistrée un par un:
-
onSubscribe() - Ceci est appelé lorsque nous nous abonnons à notre flux
-
request(unbounded) – Lorsque nous appelonssubscribe, en coulisses, nous créons unSubscription. Cet abonnement demande des éléments du flux. Dans ce cas, il est par défautunbounded,, ce qui signifie qu'il demande chaque élément disponible
-
onNext() – Ceci est appelé sur chaque élément
-
onComplete() – Ceci est appelé en dernier, après avoir reçu le dernier élément. Il y a en fait unonError() aussi, qui serait appelé s'il y avait une exception, mais dans ce cas, il n'y en a pas
C'est le flux présenté dans l'interfaceSubscriber dans le cadre de la spécification Reactive Streams, et en réalité, c'est ce qui a été instancié dans les coulisses de notre appel àonSubscribe(). C'est une méthode utile, mais pour mieux comprenons ce qui se passe, fournissons directement une interfaceSubscriber:
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() {}
});
Nous pouvons voir que chaque étape possible dans le flux ci-dessus correspond à une méthode dans l'implémentation deSubscriber. Il se trouve que leFlux nous a fourni une méthode d'aide pour réduire cette verbosité.
5.3. Comparaison avec Java 8Streams
Il peut toujours sembler que nous avons aussi quelque chose pour un Java 8Stream en train de collecter:
List collected = Stream.of(1, 2, 3, 4)
.collect(toList());
Seulement nous ne le faisons pas.
La principale différence est que Reactive est un modèle push, tandis que les Java 8Streamsont un modèle pull. In reactive approach. events are pushed to the subscribers as they come in.
La prochaine chose à noter est qu'un opérateur de terminalStreams est juste que, terminal, tirant toutes les données et renvoyant un résultat. Avec Reactive, nous pourrions avoir un flux infini provenant d'une ressource externe, avec plusieurs abonnés connectés et supprimés de manière ad hoc. Nous pouvons également faire des choses comme combiner des flux, des flux d'étranglement et appliquer une contre-pression, que nous couvrirons ensuite.
6. Contre-pression
La prochaine chose que nous devrions considérer est la contre-pression. Dans notre exemple, l’abonné demande au producteur de pousser chaque élément à la fois. Cela pourrait finir par devenir accablant pour l'abonné, consommant toutes ses ressources.
Backpressure is when a downstream can tell an upstream to send it fewer data in order to prevent it from being overwhelmed.
Nous pouvons modifier notre implémentationSubscriber pour appliquer une contre-pression. Disons à l'amont de n'envoyer que deux éléments à la fois en utilisantrequest():
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() {}
});
Maintenant, si nous exécutons à nouveau notre code, nous verrons que lerequest(2) est appelé, suivi de deux appelsonNext(), puis de nouveaurequest(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()
Il s’agit essentiellement d’une pression de retour réactive. Nous demandons à l'amont de ne pousser qu'un certain nombre d'éléments, et seulement lorsque nous sommes prêts. Si nous imaginons que les tweets de Twitter sont diffusés en continu, il appartiendra alors à l’amont de décider quoi faire. Si des tweets entraient mais qu'il n'y avait pas de demandes en aval, ceux en amont pourraient alors supprimer des éléments, les stocker dans une mémoire tampon ou une autre stratégie.
7. Fonctionnement sur un flux
Nous pouvons également effectuer des opérations sur les données de notre flux, en répondant aux événements à notre guise.
7.1. Mappage de données dans un flux
Une opération simple que nous pouvons effectuer applique une transformation. Dans ce cas, doublons simplement tous les chiffres de notre flux:
Flux.just(1, 2, 3, 4)
.log()
.map(i -> i * 2)
.subscribe(elements::add);
map() sera appliqué lorsqueonNext() est appelé.
7.2. Combinaison de deux flux
Nous pouvons alors rendre les choses plus intéressantes en combinant un autre flux avec celui-ci. Essayons ceci en utilisant la fonctionzip():
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");
Ici, nous créons un autreFlux qui continue de s'incrémenter de un et de le diffuser avec notre original. Nous pouvons voir comment ceux-ci fonctionnent ensemble en inspectant les journaux:
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()
Notez comment nous avons maintenant un abonnement parFlux. Les appels deonNext() sont également alternés, donc l'index de chaque élément du flux correspondra lorsque nous appliquerons la fonctionzip().
8. Flux chauds
Actuellement, nous nous concentrons principalement sur les courants froids. Ce sont des flux statiques, de longueur fixe, faciles à traiter. Un cas d'utilisation plus réaliste de réactif pourrait être quelque chose qui se passe à l'infini.
Par exemple, nous pourrions avoir un flux de mouvements de souris auquel il faut constamment réagir ou un flux Twitter. Ces types de flux sont appelés flux chauds, car ils sont toujours en cours d'exécution et peuvent être abonnés à tout moment, en omettant le début des données.
8.1. Créer unConnectableFlux
Une façon de créer un flux chaud consiste à convertir un flux froid en un flux. Créons unFlux qui dure indéfiniment, en produisant les résultats sur la console, qui simulerait un flux infini de données provenant d'une ressource externe:
ConnectableFlux
En appelantpublish(), on nous donne unConnectableFlux. Cela signifie que l'appel desubscribe() ne le fera pas commencer à émettre, ce qui nous permet d'ajouter plusieurs abonnements:
publish.subscribe(System.out::println);
publish.subscribe(System.out::println);
Si nous essayons d'exécuter ce code, rien ne se passera. Ce n’est que lorsque nous appelonsconnect(), que lesFlux commenceront à émettre. Peu importe que nous nous abonnions ou non.
8.2. Étranglement
Si nous exécutons notre code, notre console sera submergée de journalisation. Cela simule une situation dans laquelle trop de données sont transmises à nos consommateurs. Essayons de contourner ce problème avec la limitation:
ConnectableFlux
Ici, nous avons introduit une méthodesample() avec un intervalle de deux secondes. Désormais, les valeurs ne seront transmises à notre abonné que toutes les deux secondes, ce qui signifie que la console sera beaucoup moins agitée.
Bien sûr, il existe plusieurs stratégies pour réduire la quantité de données envoyées en aval, telles que le fenêtrage et la mise en mémoire tampon, mais elles seront laissées hors de portée de cet article.
9. Simultanéité
Tous nos exemples ci-dessus ont été exécutés sur le thread principal. Cependant, nous pouvons contrôler le thread sur lequel notre code s'exécute si nous le voulons. L'interfaceScheduler fournit une abstraction autour du code asynchrone, pour lequel de nombreuses implémentations nous sont fournies. Essayons de vous abonner à un fil de discussion différent de celui principal:
Flux.just(1, 2, 3, 4)
.log()
.map(i -> i * 2)
.subscribeOn(Schedulers.parallel())
.subscribe(elements::add);
Le planificateur deParallel fera exécuter notre abonnement sur un thread différent, ce que nous pouvons prouver en regardant les journaux:
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()
La concurrence devient plus intéressante que cela, et cela vaudra la peine de l'explorer dans un autre article.
10. Conclusion
Dans cet article, nous avons présenté une vue d'ensemble de haut niveau et de bout en bout de Reactive Core. Nous avons expliqué comment nous pouvons publier et nous abonner à des flux, appliquer une contre-pression, fonctionner sur des flux et également gérer des données de manière asynchrone. J'espère que cela devrait nous permettre de rédiger des applications réactives.
Les articles suivants de cette série couvriront des concepts plus avancés de concurrence et de réactivité. Il existe également un autre article couvrant lesReactor with Spring.
Le code source de notre application est disponible surover on GitHub; c'est un projet Maven qui devrait pouvoir fonctionner tel quel.