Spring AMQP dans les applications réactives

Spring AMQP dans les applications réactives

1. Vue d'ensemble

Ce didacticiel explique comment créer une application réactive au démarrage avec Spring qui s’intègre au serveur de messagerie RabbitMQ, une implémentation répandue du standard de messagerie AMQP.

Nous couvrons les deux scénarios - point à point et publication-abonnement - en utilisant une configuration distribuée qui met en évidence les différences entre les deux modèles.

Notez que nous supposons des connaissances de base sur AMQP, RabbitMQ et Spring Boot, en particulier sur des concepts clés tels que les échanges, les files d'attente, les sujets, etc. Vous trouverez plus d'informations sur ces concepts dans les liens ci-dessous:

2. Configuration du serveur RabbitMQ

Bien que nous puissions configurer un RabbitMQ local localement, dans la pratique, nous sommes plus susceptibles d'utiliser une installation dédiée avec des fonctionnalités supplémentaires telles que la haute disponibilité, la surveillance, la sécurité, etc.

Afin de simuler un tel environnement dans notre machine de développement, nous utiliserons Docker pour créer un serveur que notre application utilisera.

La commande suivante va démarrer un serveur RabbitMQ autonome:

$ docker run -d --name rabbitmq -p 5672:5672 rabbitmq:3

Nous ne déclarons aucun volume persistant, les messages non lus seront donc perdus entre les redémarrages. Le service sera disponible sur le port 5672 sur l'hôte.

Nous pouvons vérifier les journaux du serveur avec la commandedocker logs, qui devrait produire une sortie telle que celle-ci:

$ docker logs rabbitmq
2018-06-09 13:42:29.718 [info] <0.33.0>
  Application lager started on node [email protected]
// ... some lines omitted
2018-06-09 13:42:33.491 [info] <0.226.0>
 Starting RabbitMQ 3.7.5 on Erlang 20.3.5
 Copyright (C) 2007-2018 Pivotal Software, Inc.
 Licensed under the MPL.  See http://www.rabbitmq.com/

  ##  ##
  ##  ##      RabbitMQ 3.7.5. Copyright (C) 2007-2018 Pivotal Software, Inc.
  ##########  Licensed under the MPL.  See http://www.rabbitmq.com/
  ######  ##
  ##########  Logs: 

              Starting broker...
2018-06-09 13:42:33.494 [info] <0.226.0>
 node           : [email protected]
 home dir       : /var/lib/rabbitmq
 config file(s) : /etc/rabbitmq/rabbitmq.conf
 cookie hash    : CY9rzUYh03PK3k6DJie09g==
 log(s)         : 
 database dir   : /var/lib/rabbitmq/mnesia/[email protected]

// ... more log lines

Puisque l'image inclut l'utilitairerabbitmqctl, nous pouvons l'utiliser pour exécuter des tâches administratives dans le contexte de notre image en cours d'exécution.

Par exemple, nous pouvons obtenir des informations sur l'état du serveur avec la commande suivante:

$ docker exec rabbitmq rabbitmqctl status
Status of node [email protected] ...
[{pid,299},
 {running_applications,
     [{rabbit,"RabbitMQ","3.7.5"},
      {rabbit_common,
          "Modules shared by rabbitmq-server and rabbitmq-erlang-client",
          "3.7.5"},
// ... other info omitted for brevity

Les autres commandes utiles incluent:

  • list_exchanges: liste tous les échanges déclarés

  • list_queues: liste toutes les files d'attente déclarées, y compris le nombre de messages non lus

  • list_bindings: List all définit les liaisons entre les échanges et les files d'attente, y compris les clés de routage

3. Configuration du projet AMQP de printemps

Une fois que notre serveur RabbitMQ est opérationnel, nous pouvons passer à la création de notre projet Spring. Cet exemple de projet permettra à tout client REST de publier et / ou de recevoir des messages sur le serveur de messagerie, à l'aide du module Spring AMQP et du démarreur Spring Boot correspondant, afin de communiquer avec ce dernier.

Les principales dépendances que nous devons ajouter à notre fichier de projetpom.xml sont:


    org.springframework.boot
    spring-boot-starter-amqp
    2.0.3.RELEASE


    org.springframework.boot
    spring-boot-starter-webflux
    2.0.2.RELEASE

Lespring-boot-starter-amqp apporte tous les éléments liés à AMQP tandis que lespring-boot-starter-webflux est la dépendance principale utilisée pour implémenter notre serveur REST réactif.

Remarque: vous pouvez consulter la dernière version des modules Spring Boot StarterAMQP etWebflux sur Maven Central.

4. Scénario 1: messagerie point à point

Dans ce premier scénario, nous utiliserons un échange direct, qui est l'entité logique du courtier pour recevoir les messages des clients.

A Direct Exchange will route all incoming messages to one – and only one – queue, à partir duquel il sera disponible pour la consommation par les clients. Plusieurs clients peuvent s'abonner à la même file d'attente, mais un seul recevra un message donné.

4.1. Configuration Exchange et des files d'attente

Dans notre scénario, nous utilisons un objetDestinationInfo qui encapsule le nom d'échange et la clé de routage. Une carte associée au nom de la destination sera utilisée pour stocker toutes les destinations disponibles.

La méthode@PostConstruct uivante sera responsable de cette configuration initiale:

@Autowired
private AmqpAdmin amqpAdmin;

@Autowired
private DestinationsConfig destinationsConfig;

@PostConstruct
public void setupQueueDestinations() {
    destinationsConfig.getQueues()
        .forEach((key, destination) -> {
            Exchange ex = ExchangeBuilder.directExchange(
              destination.getExchange())
              .durable(true)
              .build();
            amqpAdmin.declareExchange(ex);
            Queue q = QueueBuilder.durable(
              destination.getRoutingKey())
              .build();
            amqpAdmin.declareQueue(q);
            Binding b = BindingBuilder.bind(q)
              .to(ex)
              .with(destination.getRoutingKey())
              .noargs();
            amqpAdmin.declareBinding(b);
        });
}

Cette méthode utilise le sbeanadminAmqp créé par Spring pour déclarer les échanges, les files d'attente et les lier à l'aide d'une clé de routage donnée.

Toutes les destinations proviennent d'un sbeanDestinationsConfig , qui est une classe@ConfigurationProperties utilisée dans notre exemple.

Cette classe a une propriété qui est remplie avec les objetsDestinationInfo construits à partir des mappages lus à partir du fichier de configurationapplication.yml.

4.2. Point de terminaison du producteur

Les producteurs enverront des messages en envoyant unHTTP POST à l'emplacement/queue/{name}.

Il s'agit d'un point de terminaison réactif, nous utilisons donc unMono pour renvoyer un simple accusé de réception:

@SpringBootApplication
@EnableConfigurationProperties(DestinationsConfig.class)
@RestController
public class SpringWebfluxAmqpApplication {

    // ... other members omitted

    @Autowired
    private AmqpTemplate amqpTemplate;

    @PostMapping(value = "/queue/{name}")
    public Mono> sendMessageToQueue(
      @PathVariable String name, @RequestBody String payload) {

        DestinationInfo d = destinationsConfig
          .getQueues().get(name);
        if (d == null) {
            return Mono.just(
              ResponseEntity.notFound().build());
        }

        return Mono.fromCallable(() -> {
            amqpTemplate.convertAndSend(
              d.getExchange(),
              d.getRoutingKey(),
              payload);
            return ResponseEntity.accepted().build();
        });
    }

Nous vérifions d'abord si le paramètre de nom correspond à une destination valide et si c'est le cas, nous utilisons l'instanceamqpTemplate autowired pour envoyer réellement la charge utile - un simple messageString - à RabbitMQ.

4.3. MessageListenerContainer Usine

Afin de recevoir des messages de manière asynchrone, Spring AMQP utilise une classe abstraiteMessageContainerListener qui assure la médiation du flux d'informations des files d'attente AMQP et des écouteurs fournis par une application.

Comme nous avons besoin d'une implémentation concrète de cette classe pour attacher nos écouteurs de messages, nous définissons une fabrique qui isole le code du contrôleur de son implémentation réelle.

Dans notre cas, la méthode factory renvoie un nouveauSimpleMessageContainerListener à chaque fois que nous appelons sa méthodecreateMessageListenerContainer:

@Component
public class MessageListenerContainerFactory {

    @Autowired
    private ConnectionFactory connectionFactory;

    public MessageListenerContainerFactory() {}

    public MessageListenerContainer createMessageListenerContainer(String queueName) {
        SimpleMessageListenerContainer mlc = new SimpleMessageListenerContainer(connectionFactory);
        mlc.addQueueNames(queueName);
        return mlc;
    }
}

4.4. Point de terminaison consommateur

Les consommateurs auront accès à la même adresse de point final que celle utilisée par les producteurs (/queue/{name}) pour obtenir les messages.

Ce point de terminaison renvoie un événement sofFlux , où chaque événement correspond à un message reçu:

@Autowired
private MessageListenerContainerFactory messageListenerContainerFactory;

@GetMapping(
  value = "/queue/{name}",
  produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux receiveMessagesFromQueue(@PathVariable String name) {

    DestinationInfo d = destinationsConfig
      .getQueues()
      .get(name);
    if (d == null) {
        return Flux.just(ResponseEntity.notFound()
          .build());
    }

    MessageListenerContainer mlc = messageListenerContainerFactory
      .createMessageListenerContainer(d.getRoutingKey());

    Flux f = Flux. create(emitter -> {
        mlc.setupMessageListener((MessageListener) m -> {
            String payload = new String(m.getBody());
            emitter.next(payload);
        });
        emitter.onRequest(v -> {
            mlc.start();
        });
        emitter.onDispose(() -> {
            mlc.stop();
        });
      });

    return Flux.interval(Duration.ofSeconds(5))
      .map(v -> "No news is good news")
      .mergeWith(f);
}

Après la vérification initiale du nom de la destination, le point de terminaison consommateur créeMessageListenerContainer en utilisant lesMessageListenerContainerFactory et le nom de file d'attente récupéré à partir de notre registre.

Une fois que nous avons nosMessageListenerContainer, nous créons le messageFlux en utilisant l'une de ses méthodes de générateurcreate().

Dans notre cas particulier, nous en utilisons un qui prend un lambda prenant un argumentFluxSink, que nous utilisons ensuite pour relier l'API asynchrone basée sur l'écouteur de Spring AMQP à notre application réactive.

Nous attachons également deux lambdas supplémentaires aux rappels de l'émetteuronRequest() andonDispose() afin que notre analyseMessageListenerContainer alloue / libère ses ressources internes en suivant le cycle de vie deFlux.

Enfin, nous fusionnons leFlux w avec un autre créé avecinterval(), w, ce qui crée un nouvel événement toutes les cinq secondes. Those dummy messages play an important function in our case: sans eux, nous ne détectons une déconnexion client que lors de la réception d'un message et de son échec de l'envoi, ce qui peut prendre du temps en fonction de votre cas d'utilisation particulier.

4.5. Essai

Avec notre configuration de points de terminaison consommateurs et éditeurs, nous pouvons maintenant effectuer quelques tests avec notre exemple d'application.

Nous devons définir les détails de connexion au serveur de RabbitMQ et au moins une destination sur nosapplication.yml, qui devrait ressembler à ceci:

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

destinations:
  queues:
    NYSE:
      exchange: nyse
      routing-key: NYSE

Les propriétésspring.rabbitmq.* définissent les propriétés de base nécessaires pour se connecter à notre serveur RabbitMQ s'exécutant dans un conteneur Docker local. Veuillez noter que l'adresse IP indiquée ci-dessus est juste un exemple et peut être différente dans une configuration particulière.

Les files d'attente sont définies à l'aide dedestinations.queues.<name>.*, où<name> est utilisé comme nom de destination. Ici, nous avons déclaré une destination unique appelée «NYSE» qui enverra des messages à l’échange «nyse» sur RabbitMQ avec une clé de routage «NYSE».

Une fois le serveur démarré via une ligne de commande ou à partir de notre IDE, nous pouvons commencer à envoyer et recevoir des messages. Nous utiliserons l'utilitairecurl, un utilitaire commun disponible pour les systèmes d'exploitation Windows, Mac et Linux.

La liste suivante montre comment envoyer un message à notre destination et la réponse attendue du serveur:

$ curl -v -d "Test message" http://localhost:8080/queue/NYSE
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /queue/NYSE HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
> Content-Length: 12
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 12 out of 12 bytes
< HTTP/1.1 202 Accepted
< content-length: 0
<
* Connection #0 to host localhost left intact

Après avoir exécuté cette commande, nous pouvons vérifier que le message a été reçu par RabbitMQ et qu'il est prêt à être utilisé avec la commande suivante:

$ docker exec rabbitmq rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
NYSE    1

Maintenant, nous pouvons lire les messages avec curl avec la commande suivante:

$ curl -v http://localhost:8080/queue/NYSE
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /queue/NYSE HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: text/event-stream;charset=UTF-8
<
data:Test message

data:No news is good news...

... same message repeating every 5 secs

Comme nous pouvons le constater, nous obtenons d’abord le message précédemment stocké, puis nous commençons à recevoir notre message factice toutes les 5 secondes.

Si nous relançons la commande pour lister les files d'attente, nous pouvons maintenant voir qu'il n'y a pas de messages stockés:

$ docker exec rabbitmq rabbitmqctl list_queues

Timeout: 60.0 seconds ...
Listing queues for vhost / ...
NYSE    0

5. Scénario 2: Publier-S'abonner

Un autre scénario courant pour les applications de messagerie est le modèle Publier-S'abonner, dans lequel un seul message doit être envoyé à plusieurs consommateurs.

RabbitMQ propose deux types d'échanges prenant en charge ces types d'applications: Fan-out et Topic.

La principale différence entre ces deux types réside dans le fait que ce dernier nous permet de filtrer les messages à recevoir en fonction d’un modèle de clé de routage (par ex. “Alarm.mailserver. *”) Fourni au moment de l’enregistrement, tandis que le premier répliquait simplement les messages entrants dans toutes les files d’attente liées.

RabbitMQ prend également en charge les échanges d'en-tête, ce qui permet un filtrage plus complexe des messages, mais son utilisation dépasse le cadre de cet article.

5.1. Configuration des destinations

Nous définissons les destinations Pub / Sub au démarrage avec une autre méthode@PostConstruct , comme nous l'avons fait dans le scénario point à point.

La seule différence est que nous ne créons que lesExchanges, mais pas lesQueues - ceux-ci seront créés à la demande et liés auxExchange plus tard, car nous voulons unQueue exclusif pour chaque client:

@PostConstruct
public void setupTopicDestinations(
    destinationsConfig.getTopics()
      .forEach((key, destination) -> {
          Exchange ex = ExchangeBuilder
            .topicExchange(destination.getExchange())
            .durable(true)
            .build();
            amqpAdmin.declareExchange(ex);
      });
}

5.2. Endpoint de l'éditeur

Les clients utiliseront le point de terminaison de l'éditeur disponible à l'emplacement/topic/{name} afin de publier des messages qui seront envoyés à tous les clients connectés.

Comme dans le scénario précédent, nous utilisons un@PostMapping qui renvoie unMono avec l'état après l'envoi du message:

@PostMapping(value = "/topic/{name}")
public Mono> sendMessageToTopic(
  @PathVariable String name, @RequestBody String payload) {

    DestinationInfo d = destinationsConfig
      .getTopics()
      .get(name);

    if (d == null) {
        return Mono.just(ResponseEntity.notFound().build());
    }

   return Mono.fromCallable(() -> {
       amqpTemplate.convertAndSend(
         d.getExchange(), d.getRoutingKey(),payload);
            return ResponseEntity.accepted().build();
        });
    }

5.3. Endpoint d'abonné

Notre point de terminaison d'abonné sera situé à/topic/{name}, produisant unFlux de messages pour les clients connectés.

Ces messages comprennent à la fois les messages reçus et les messages factices générés toutes les 5 secondes:

@GetMapping(
  value = "/topic/{name}",
  produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux receiveMessagesFromTopic(@PathVariable String name) {
    DestinationInfo d = destinationsConfig.getTopics()
        .get(name);
    if (d == null) {
        return Flux.just(ResponseEntity.notFound()
            .build());
    }
    Queue topicQueue = createTopicQueue(d);
    String qname = topicQueue.getName();
    MessageListenerContainer mlc = messageListenerContainerFactory.createMessageListenerContainer(qname);
    Flux f = Flux. create(emitter -> {
        mlc.setupMessageListener((MessageListener) m -> {
            String payload = new String(m.getBody());
            emitter.next(payload);
        });
        emitter.onRequest(v -> {
            mlc.start();
        });
        emitter.onDispose(() -> {
            amqpAdmin.deleteQueue(qname);
            mlc.stop();
        });
      });

    return Flux.interval(Duration.ofSeconds(5))
        .map(v -> "No news is good news")
        .mergeWith(f);
}

Ce code est fondamentalement le même que celui que nous avons vu dans le cas précédent, avec seulement les différences suivantes: tout d'abord, nous créons un nouveauQueue pour chaque nouvel abonné.

Nous faisons cela par un appel à la méthodecreateTopicQueue(), qui utilise les informations de l'instanceDestinationInfo pour créer une file d'attente exclusive et non durable, que nous lions ensuite auxExchange en utilisant le clé de routage:

private Queue createTopicQueue(DestinationInfo destination) {

    Exchange ex = ExchangeBuilder
      .topicExchange(destination.getExchange())
      .durable(true)
      .build();
    amqpAdmin.declareExchange(ex);
    Queue q = QueueBuilder
      .nonDurable()
      .build();
    amqpAdmin.declareQueue(q);
    Binding b = BindingBuilder.bind(q)
      .to(ex)
      .with(destination.getRoutingKey())
      .noargs();
    amqpAdmin.declareBinding(b);
    return q;
}

Notez que, malgré le fait que nous déclarions à nouveau lesExchange, RabbitMQ n'en créera pas de nouveau, puisque nous l'avons déjà déclaré au démarrage.

La deuxième différence réside dans le lambda que nous passons à la méthodeonDispose(), qui cette fois supprimera également lesQueue lorsque l'abonné se déconnectera.

5.3. Essai

Afin de tester le scénario Pub-Sub, nous devons d'abord définir une destination de rubrique dans outapplication.yml comme ceci:

destinations:
## ... queue destinations omitted
  topics:
    weather:
      exchange: alerts
      routing-key: WEATHER

Ici, nous avons défini un point de terminaison de sujet qui sera disponible à l'emplacement/topic/weather. Ce point final sera utilisé pour envoyer des messages à l’échange «alertes» sur RabbitMQ avec une clé de routage «WEATHER».

Après le démarrage du serveur, nous pouvons vérifier que l'échange a été créé à l'aide de la commanderabbitmqctl:

$ docker exec docker_rabbitmq_1 rabbitmqctl list_exchanges
Listing exchanges for vhost / ...
amq.topic       topic
amq.fanout      fanout
amq.match       headers
amq.headers     headers
        direct
amq.rabbitmq.trace      topic
amq.direct      direct
alerts  topic

Maintenant, si nous émettons la commandelist_bindings, nous pouvons voir qu'il n'y a pas de files d'attente liées à l'échange «alertes»:

$ docker exec rabbitmq rabbitmqctl list_bindings
Listing bindings for vhost /...
        exchange        NYSE    queue   NYSE    []
nyse    exchange        NYSE    queue   NYSE    []

Commençons par un couple d’abonnés qui s’abonneront à notre destination en ouvrant deux shells de commande et en émettant la commande suivante sur chacun d’eux:

$ curl -v http://localhost:8080/topic/weather
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /topic/weather HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: text/event-stream;charset=UTF-8
<
data:No news is good news...

# ... same message repeating indefinitely

Enfin, nous utilisons à nouveau curl pour envoyer des alertes à nos abonnés:

$ curl -v -d "Hurricane approaching!" http://localhost:8080/topic/weather
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /topic/weather HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
> Content-Length: 22
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 22 out of 22 bytes
< HTTP/1.1 202 Accepted
< content-length: 0
<
* Connection #0 to host localhost left intact

Une fois le message envoyé, nous pouvons presque instantanément voir le message «L’ouragan approche!» Sur la coque de chaque abonné.

Si nous vérifions maintenant les liaisons disponibles, nous pouvons voir que nous avons une file d'attente pour chaque abonné:

$ docker exec rabbitmq rabbitmqctl list_bindings
Listing bindings for vhost /...
        exchange        IBOV    queue   IBOV    []
        exchange        NYSE    queue   NYSE    []
        exchange        spring.gen-i0m0pbyKQMqpz2_KFZCd0g
  queue   spring.gen-i0m0pbyKQMqpz2_KFZCd0g       []
        exchange        spring.gen-wCHALTsIS1q11PQbARJ7eQ
  queue   spring.gen-wCHALTsIS1q11PQbARJ7eQ       []
alerts  exchange        spring.gen-i0m0pbyKQMqpz2_KFZCd0g
  queue   WEATHER []
alerts  exchange        spring.gen-wCHALTsIS1q11PQbARJ7eQ
  queue   WEATHER []
ibov    exchange        IBOV    queue   IBOV    []
nyse    exchange        NYSE    queue   NYSE    []
quotes  exchange        NYSE    queue   NYSE    []

Une fois que nous avons appuyé sur Ctrl-C sur le shell de l’abonné, notre passerelle finira par détecter que le client s’est déconnecté et supprimera ces liaisons.

6. Conclusion

Dans cet article, nous avons montré comment créer une application réactive simple qui interagit avec un serveur RabbitMQ à l'aide du modulespring-amqp.

Avec seulement quelques lignes de code, nous avons pu créer une passerelle fonctionnelle HTTP vers AMQP prenant en charge les modèles d'intégration point à point et Publier-Abonnement, que nous pouvons facilement étendre pour ajouter des fonctionnalités supplémentaires, telles que la sécurité par le biais de la sécurité. ajout des fonctionnalités standard de Spring.

Le code présenté dans cet article est disponibleover on Github.