1. Chemin de messagerie minimal
La connexion à RabbitMQ et au bean Queue en elle-même ne prouve encore rien. Tant que le message n'a pas parcouru le chemin de l'action HTTP au journal du destinataire, le câblage reste une belle hypothèse. C'est pourquoi dans ce cours nous allons délibérément construire un chemin de message aussi petit que possible mais honnête : une file d'attente, une chaîne et un destinataire qui écrit dans le journal.
Le scénario ressemblera à ceci (et c'est vraiment toute « l'architecture » pour aujourd'hui) :
sequenceDiagram
participant C as "Client HTTP (curl / Postman / .http)"
participant A as "Service Catalogue (Spring Boot)"
participant MQ as RabbitMQ
C->>A: POST /api/catalog/items
A->>MQ: envoyer "ITEM_CREATED:42:SKU-42"
MQ-->>A: délivrer le message à l'écouteur
A-->>A: journal "Reçu de catalog.events: ITEM_CREATED:42:SKU-42"
Notez une subtilité : l'éditeur et le consommateur vivent dans la même application. Dans la « vraie » vie, ce sont souvent deux services différents, mais pour un objectif pédagogique, c'est même un avantage. Nous voyons tout le chemin dans les journaux d'un seul conteneur et nous n'avons pas à nous laisser distraire par les débats inter-services, mieux réservés à d'autres cours.
Pour ne pas se perdre dans la terminologie, fixons un mini-plan sous forme de tableau. Il n'est pas destiné à la théorie, mais pour que vous puissiez ouvrir les journaux la semaine prochaine et comprendre où « le fil s'est rompu ».
| Rôle | Ce que c'est dans notre projet | Quel code/objet le fait |
|---|---|---|
| Éditeur | celui qui envoie le message | AmqpTemplate.convertAndSend(...) |
| File d'attente | « boîte aux lettres » avec un nom | Queue bean, par exemple "catalog.events" |
| Courtier | le service qui stocke et distribue les messages | conteneur rabbitmq dans Compose |
| Consommateur | celui qui reçoit le message | méthode @RabbitListener(...) |
2. Format du message : une chaîne
Je vais être honnête tout de suite : « chaîne » – ce n'est pas parce que c'est « comme en 2005 », mais parce que c'est le meilleur format pour un premier pas, quand vous apprenez à voir la chaîne de cause à effet. Dès que nous ferons du message un objet, nous aurons un axe de complexité supplémentaire : classe DTO, sérialisation, convertisseurs, compatibilité, champs, versions de contrat. Tout cela est utile, mais maintenant cela ne fait que gêner la compréhension de ce qui se passe réellement.
C'est pourquoi le contrat du message sera délibérément simple aujourd'hui : une seule chaîne contenant le type d'événement et une paire de valeurs. La chaîne doit être telle que vous puissiez ouvrir docker compose logs et la lire comme un humain normal, et non comme un archéologue d'artefacts binaires.
Par exemple, pour l'événement « élément du catalogue créé », on peut faire un message de la forme :
ITEM_CREATED:<id>:<sku>
Pour éviter d'éparpiller le format dans le code et de se retrouver avec « oh, ici deux-points, là un tiret », il est pratique de sortir la construction du message dans une petite classe utilitaire. Ce n'est pas « une architecture pour l'architecture », mais simplement une façon de garder le format en un seul endroit.
public final class CatalogEventMessages {
private CatalogEventMessages() {}
public static String itemCreated(long itemId, String sku) {
// Construisons un contrat de message lisible : TYPE:<id>:<sku>
return "ITEM_CREATED:%d:%s".formatted(itemId, sku);
}
}
Le bonus agréable ici est que formatted(...) reste lisible même pour un débutant : nous substituons id et sku et obtenons une chaîne prête. Et oui, si vous pensez maintenant « et si sku contient un deux-points ? » — c'est une bonne idée. Dans un contrat réel, vous choisiriez le format plus soigneusement, mais pour un scénario de test de fumée pédagogique, cette discipline est suffisante.
Le nom de la file d'attente est déjà fixé dans CatalogQueues.CATALOG_EVENTS, donc il n'y a pas de second point de configuration ici. Cela fait aussi partie de la lisibilité : une file, un nom, et l'expéditeur et l'écouteur pointent exactement sur celle-ci.
3. Éditeur : envoi via AmqpTemplate
L'erreur de débutant la plus courante dans les intégrations de messagerie est d'essayer « d'intégrer RabbitMQ dans la logique métier si étroitement que rien ne fonctionne sans lui ». Dans notre projet, il existe déjà une règle : les profils (standalone, postgres, cache, messaging) doivent modifier l'environnement d'infrastructure, et non casser les cas d'utilisation principaux de l'application. Par conséquent, le code de publication d'événement doit pouvoir être activé et désactivé avec soin.
Nous avons déjà une abstraction : CatalogItemService dépend de CatalogEventPublisher, sans messaging fonctionne NoOpCatalogEventPublisher, et avec messaging Spring injecte RabbitCatalogEventPublisher. Maintenant, il ne reste plus à cet éditeur qu'à faire une seule chose concrète : assembler la chaîne et l'envoyer dans CatalogQueues.CATALOG_EVENTS.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Profile("messaging")
@Component
public class RabbitCatalogEventPublisher implements CatalogEventPublisher {
private static final Logger log = LoggerFactory.getLogger(RabbitCatalogEventPublisher.class);
private final AmqpTemplate amqpTemplate;
public RabbitCatalogEventPublisher(AmqpTemplate amqpTemplate) {
this.amqpTemplate = amqpTemplate;
}
@Override
public void itemCreated(long itemId, String sku) {
String message = CatalogEventMessages.itemCreated(itemId, sku);
amqpTemplate.convertAndSend(CatalogQueues.CATALOG_EVENTS, message);
log.info("Envoyé à {} : {}", CatalogQueues.CATALOG_EVENTS, message);
}
}
Il y a ici un point important mais sympathique : convertAndSend(destination, message) sous cette forme utilise le chemin « simple », où destination est le nom de la file d'attente. RabbitMQ en coulisse peut remettre le message à la file d'attente par son nom via le courtier par défaut. Cela nous suffit amplement.
Le journal de marquage après l'envoi n'est pas pour la beauté, mais pour le diagnostic. Quand vous regarderez ensuite docker compose logs app, la chaîne Envoyé à catalog.events : ... répondra rapidement à la question : « l'envoi a-t-il eu lieu ou suis-je en train de chercher l'erreur ailleurs ? ».
4. Consommateur : @RabbitListener et journal
Du côté du destinataire, nous avons besoin d'exactement un effet : la preuve que le message ne s'est pas « évaporé dans l'espace », mais qu'il est vraiment arrivé au gestionnaire. Pas de logique métier, pas de modifications de base de données, pas de « et si on recalculait le cache tout de suite » — sinon nous perdrons la transparence. Que le consommateur soit aujourd'hui comme un caissier de supermarché : « il a bipé l'article — il a dit "bip" — c'est tout ».
Dans Spring Boot, il suffit d'ajouter un composant avec une méthode annotée @RabbitListener. Spring élèvera automatiquement l'infrastructure des écouteurs, se connectera au courtier et commencera à récupérer les messages de la file d'attente spécifiée. Nous acceptons volontairement String pour ne pas discuter de sérialisation.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Profile("messaging")
@Component
public class CatalogEventListener {
private static final Logger log = LoggerFactory.getLogger(CatalogEventListener.class);
// Écoutons une file d'attente spécifique — c'est notre « bout du fil » pour le test de fumée
@RabbitListener(queues = CatalogQueues.CATALOG_EVENTS)
public void onMessage(String message) {
// Le journal est notre preuve que le message est bien arrivé au gestionnaire
log.info("Reçu de {} : {}", CatalogQueues.CATALOG_EVENTS, message);
}
}
Pourquoi l'écouteur est-il aussi sous @Profile("messaging")? Parce que sans le profil messaging, il n'est pas nécessaire et même nuisible : dans les modes sans RabbitMQ, il essaiera de se connecter à un courtier qui n'existe pas et cassera le démarrage de l'application. Le profil rend le comportement prévisible : vous activez messaging — vous obtenez à la fois l'éditeur et le consommateur ; vous ne l'activez pas — l'application vit sans files d'attente, comme prévu.
Notons au passage : nous écrivons le journal via slf4j et non via System.out.println(...), car c'est le langage naturel d'une application Spring Boot. Dans le conteneur, ce journal sera de toute façon dans stdout/stderr, donc docker logs et docker compose logs le verront sans problème.
5. Point d'appel de l'éditeur
Maintenant, la question pratique principale : qui doit appeler l'éditeur ? RabbitMQ lui-même n'est pas télépathe, il ne sait pas que vous avez créé un CatalogItem. Il doit donc y avoir un moment dans le code où l'action du domaine s'est terminée avec succès et nous décidons « oui, il est temps d'envoyer l'événement ».
L'option la plus évidente (et méthodologiquement correcte pour notre cours) est d'appeler l'éditeur dans le même service où l'élément du catalogue est créé. Cela donne une courte chaîne de cause à effet : « appel du service → sauvegarde → envoi du message ». Pour un projet pédagogique, c'est idéal : vous trouvez facilement l'endroit d'envoi et pouvez le relier rapidement au point de terminaison HTTP.
Voici à quoi peut ressembler un extrait de la méthode de création :
// 1) D'abord nous sauvegardons (pour obtenir l'id de manière garantie)
CatalogItem saved = repository.save(item);
// 2) Ensuite nous publions l'événement avec les données déjà connues
eventPublisher.itemCreated(saved.getId(), saved.getSku());
return saved;
Pour que cela fonctionne dans tous les profils, CatalogItemService doit dépendre non pas de RabbitCatalogEventPublisher, mais de l'interface CatalogEventPublisher. Ainsi Spring injectera automatiquement l'implémentation appropriée en fonction des profils actifs. Vous avez activé messaging — vous obtenez l'envoi vers RabbitMQ. Vous n'avez pas activé messaging — vous obtenez une implémentation NoOp et le service continue de vivre normalement.
C'est une discipline très importante pour un projet « prêt pour le conteneur » : les dépendances externes sont ajoutées de manière à ce que l'application ne se transforme pas en château de cartes fragile. Nous avons déjà vu cela avec PostgreSQL et Redis, et maintenant nous répétons le même style avec RabbitMQ.
6. Test de fumée dans Compose
Une fois le code prêt, on souhaite effectuer un test de fumée court et honnête. Pas « j'ai ouvert l'interface d'administration et quelque chose clignote là-bas », mais « j'ai effectué une action du domaine et j'ai vu dans les journaux : envoyé et reçu ». C'est le moment où le système cesse d'être un schéma sur papier et devient un environnement de travail.
La logique de vérification est très simple. Vous démarrez la pile Compose avec les profils postgres,cache,messaging, vous effectuez un POST /api/catalog/items et vous vous attendez à voir dans les journaux de l'application deux lignes de marquage : Envoyé à catalog.events : ... et Reçu de catalog.events : .... Si vous voyez les deux, alors la chaîne app → courtier → app fonctionne vraiment.
Pour la requête, vous pouvez utiliser un fichier .http ou curl. Exemple pour .http (il est petit et se place bien dans le dépôt sous requests/) :
POST http://localhost:8080/api/catalog/items
Content-Type: application/json
{"sku":"SKU-42","title":"Docker Mug","price":12.50}
Et ensuite — le plus agréable : vous ne « devinez » pas ce qui s'est passé, mais vous lisez ce que le système vous a dit dans les journaux. Si les deux lignes sont présentes, vous avez déjà une base de travail : rabbitmq est monté comme service, l'application démarre avec postgres,cache,messaging, SPRING_RABBITMQ_* pointent sur rabbitmq:5672, la file catalog.events existe, et l'éditeur et l'écouteur réalisent réellement le chemin du message.
7. Erreurs typiques lors de la messagerie minimale
Dans un scénario de messagerie minimale, les erreurs sont particulièrement instructives car il n'y a presque rien à casser… et pourtant ça casse. C'est normal : un débutant en programmation pense souvent que si le sujet est simple, il devrait « simplement fonctionner ». En pratique, les sujets simples sont utiles car ils montrent rapidement exactement où vous avez fait une erreur et vous donnent une chance d'apprendre à lire le système plutôt que de compter sur la chance.
Erreur n°1 : le service dépend directement d'une classe Rabbit, et l'application plante sans le profil messaging.
C'est un classique : vous avez injecté RabbitCatalogEventPublisher directement dans CatalogItemService, puis vous lancez le mode sans RabbitMQ — et Spring ne peut pas créer le bean. Cela se soigne par la discipline : le service doit dépendre de l'interface CatalogEventPublisher, et les implémentations activables se font via @Profile.
Erreur n°2 : noms de file d'attente différents dans l'éditeur et le consommateur.
Dans l'expéditeur vous avez écrit "catalog.events", et dans l'écouteur vous avez écrit "catalog.event" ou "catalog-events". Le système peut paraître « vivant », RabbitMQ est monté, l'application ne plante pas, mais l'écouteur reste silencieux. La solution est banale mais efficace : sortir le nom de la file dans une constante (CatalogQueues.CATALOG_EVENTS) et l'utiliser partout.
Erreur n°3 : le consommateur fonctionne toujours, même lorsque le profil messaging est désactivé.
Si on oublie @Profile("messaging") sur l'écouteur, alors dans les modes sans RabbitMQ, l'application commencera à essayer de se connecter au courtier et échouera au démarrage ou restera bloquée avec des erreurs de connexion. Il est important de se rappeler : un écouteur n'est pas seulement une méthode, c'est une partie « active » de l'application qui ne vit que là où l'infrastructure est présente.
Erreur n°4 : l'événement est envoyé « avant que nous ayons réellement créé l'entité », et les données dans le message sont étranges.
Par exemple, vous avez envoyé le message avant la sauvegarde, et id est encore null, ou vous avez envoyé le message au milieu de la logique où une erreur peut encore se produire. Pour un scénario pédagogique, la règle la plus simple est : d'abord sauvegarder, obtenir l'id, puis envoyer. Dans la vie « adulte », le sujet des transactions et de la livraison garantie est bien plus profond, mais aujourd'hui nous avons besoin d'une causalité claire.
Erreur n°5 : vous essayez de faire du message un objet tout de suite, et au lieu d'étudier RabbitMQ, vous étudiez la sérialisation.
Ce n'est pas « une mauvaise idée pour toujours », mais c'est une mauvaise idée aujourd'hui. Dès que vous passez à un objet, vous vous posez la question : « comment ça se transforme en octets ? », « quel encodage ? », « quel convertisseur ? ». Si l'objectif de la journée est de vérifier le câblage de l'environnement, la chaîne gagne, car elle peut être lue dans les journaux sans préparation supplémentaire.
GO TO FULL VERSION