Voici comment nous nous sommes simplifié la publication/consommation d’événements en utilisant RabbitMQ, Spring Boot, et un peu d’huile de coude.

Le mécanisme de “pub/sub” (publish/subscribe) nous sert à gérer la synchronisation entre différents contextes/modèles métiers (différents services ou découplage au sein d’un même service), ou juste parfois à forcer un traitement asynchrone. Par exemple, quand un freelance change son nom sur Hopwork, son profil va être réindexé pour que ce changement soit visible lors d’une recherche.

Pour ce faire, nous utilisons RabbitMQ avec des exchanges de type Fanout. Cette solution nous offre quelques possibilités intéressantes telles la gestion des erreurs avec ré-essai, ou la mise en attente des événements si aucun consommateur n’est présent.

Gestion des erreurs

Pour bien comprendre la suite, voici en image la configuration nous permettant de retraiter les messages en cas d’erreur :

Diagramme d'état montrant notre configuration : Toute erreur lors du traitement d'un message replace ce message dans l'exchange "dead.letter.exchange". La classe DeadLetterListener est en charge de republier le message dans sa queue d'origine, tout en augmentant le délai nécessaire avant le prochain essai pour le cas où le traitement échouerait à nouveau.

Toute erreur lors du traitement d’un message replace ce message dans l’exchange “dead.letter.exchange”. La classe DeadLetterListener est en charge de republier (indirectement) le message dans sa queue d’origine, tout en augmentant le délai nécessaire avant le prochain essai pour le cas où le traitement échouerait à nouveau. Passons à présent à l’implémentation !

Notre point de départ

Pour la mise en place, comme nous utilisons Spring Boot un peu partout nous n’avons pas dérogé à notre habitude. L’outillage est sympa, mais au bout d’un moment nous avons tout de même trouvé qu’ajouter un type d’événement (enfin, de message Rabbit) était trop verbeux.

En effet il faut déclarer indépendamment : l’exchange, les queues, les bindings entre ceux-ci, et enfin les consommateurs (listeners) des queues. Ensuite, Spring s’occupe de créer ces éléments dans RabbitMQ s’ils n’existent pas déjà. Il se charge également de marshaller et unmarshaller nos messages (en JSON dans notre cas, en utilisant Jackson).

Nous allons nous baser sur un exemple : quand un utilisateur met à jour son adresse email, nous voulons propager ce changement à la représentation de cet utilisateur dans MailChimp (cet outil nous sert à gérer nos campagnes d’emailing). Avec nos besoins, configurer un exchange “email.updated.exchange” et brancher une queue “email.updated.mailchimp.queue” dessus pour transmettre un événement EmailUpdated ressemblait à ceci :

Ouf ! Outre tout le code boiler-plate à écrire, un problème avec cette solution est de trouver la bonne constante à utiliser quand on désire consommer un événement en particulier.

Notre objectif

  1. Ajouter un type d’événement ne devrait consister qu’en la définition de ce type.
  2. Un producteur de cet événement ne devrait avoir qu’à appeler une méthode générique fire.
  3. Un consommateur ne devrait avoir qu’à déclarer son intérêt pour un type d’événement.

Le reste de cet article liste les étapes nous ayant permis d’arriver à ce résultat. On verra par la suite les avantages gagnés en terme d’expérience de développement, de compréhension du parcours d’un événement, mais aussi de documentation et de testabilité.

Si la mise en place détaillée ne vous intéresse pas, vous pouvez directement lire le résultat et/ou sauter aux avantages 😉

Première étape : création de tous les beans en une ligne

Notre premier mouvement a été de supprimer la création manuelle de tous les @Bean nécessaires, via une méthode utilitaire utilisant l’API de de Spring :

Deuxième étape : découverte automatique des queues et bindings via notre propre annotation

L’étape précédente est clairement un plus, mais il nous faut encore déclarer une queue dans la configuration, puis la référencer dans notre listener. À présent que la déclaration est devenu une formalité, pourquoi ne pourrait-on faire en sorte que la queue soit créée et bindée à l’exchange dans le même morceau de logique qui permet à Spring de traiter nos méthodes annotées @RabbitListener ?

Nous avons donc adapté ce que fait Spring, en substituant notre annotation @RabbitEventListener à la sienne (@RabbitListener) et en surchargeant son RabbitListenerAnnotationBeanPostProcessor :

Dernière étape : on s’occupe de l’exchange !

Au final on publie toujours un type d’événement sur un exchange donné, et on ne peut consommer qu’un type d’événement donné en consommant les queues liées à cet exchange*. Devoir fournir à la fois le type de l’événement à publier/consommer et l’exchange sur lequel publier/écouter est redondant !

On aimerait donc à présent :

  1. ne plus avoir à créer l’exchange dans la configuration ;
  2. ne plus avoir à overloader EventsService.fire(...) pour donner l’exchange correspondant à l’événement que nous souhaitons publier.

On va faire en sorte d’extraire le nom de l’exchange du type de l’événement. Nous avons une préférence pour un nom clairement configurable, donc nous en avons fait une constante attachée au type d’événement, plutôt que de le générer automatiquement.

Et voilà !

Note : l’exchange devant être obtenu à partir d’une classe et non d’une instance d’Event, nous n’avons pu définir getExchangeName comme une méthode d’instance. À la place nous nous reposons sur un peu d’introspection et de conventions. Quelques tests unitaires permettent de s’assurer que ces conventions sont bien respectées. Pour avoir tous les détails, voir le gist de la solution complète.

* Bien qu’il soit possible de binder une queue sur plusieurs exchanges et donc de consommer plusieurs types de messages différents sur cette queue, ce cas d’utilisation ne nous intéresse pas.

Résultat : lisibilité et traçabilité

Nous avons atteint le but recherché : le type d’événement ainsi que ses producteurs et consommateurs s’écrivent — et surtout se lisent — désormais sans peine :

Et c’est tout.

Mieux : il est possible de suivre la chaîne production/consommation en faisant une simple recherche sur le type de l’événement, mais cette fois-ci sans faire apparaître aucun code intermédiaire indésirable. Par exemple, sous IntelliJ on utilisera le raccourci Alt+F7 pour cela :

Class usages in IntelliJ

Et les détails qui peuvent être nécessaires sont tout près aussi au besoin, à savoir le nom de l’exchange et le nom de la (des) queue(s). On pourrait arguer qu’il s’agit là d’un très fort couplage à l’implémentation, et ça l’est, mais en pratique cela nous aide énormément à suivre le fil et à éviter les ennuis.

Le petit plus : des tests simples

Ce qui est bien avec notre mécanisme de scan des listeners, c’est que la technique est réutilisable très simplement pour faire une implémentation capable d’appeler ces listeners de manière synchrone au sein d’un même programme, sans passer par RabbitMQ.

C’est très pratique pour reconstruire un ensemble donné de producteurs/consommateurs au sein d’un test. Notre implémentation JvmSynchronizedEventsService sert exactement à ça. Voici un exemple illustrant son utilisation au sein d’un test JUnit/Spring :

Le gros plus : une (vraie) documentation automatique

En suivant les excellents conseils prodigués par Cyril Martraire dans Living Documentation, nous avons utilisé QDox pour générer une petite documentation en ligne de nos événements, décrivant chaque événement et listant ses producteurs et consommateurs :

Capture d'écran annotée de notre documentation des événements

Cela permet d’avoir une vue d’ensemble un peu plus clair que la recherche via l’IDE. Pour les curieux, le code source est disponible ici.

That’s all folks!

Nous avons mis beaucoup de chose dans cet article, il est temps de souffler un peu. Vous commencez à en avoir l’habitude, nous ne vous aurons épargné aucun détail (en fait si :-P), mais nous espérons évidemment que cela vous servira ! Pour notre part, nous utilisons Spring Boot et RabbitMQ depuis maintenant 3 ans, et la solution présentée depuis 9 mois, et nous sommes clairement très satisfaits du résultat.

Leave a Reply

Your email address will not be published. Required fields are marked *