Introduction au cœur du réacteur

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

Avant de commencer, ajoutons nos dépendancesMaven:


    io.projectreactor
    reactor-core
    3.0.5.RELEASE



    ch.qos.logback
    logback-classic
    1.1.3

Nous ajoutons égalementLogback en tant que dépendance. En effet, nous enregistrerons la sortie de Reactor afin de mieux comprendre le flux de données.

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:

  1. onSubscribe() - Ceci est appelé lorsque nous nous abonnons à notre flux

  2. 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

  3. onNext() – Ceci est appelé sur chaque élément

  4. 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 publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .publish();


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 publish = Flux.create(fluxSink -> {
    while(true) {
        fluxSink.next(System.currentTimeMillis());
    }
})
  .sample(ofSeconds(2))
  .publish();


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.