Développement Web

Architecture orientée événements avec RabbitMQ et FastAPI

14 دقائق للقراءة
📍 Vue d’ensemble : ce tutoriel s’inscrit dans une série sur l’architecture logicielle. Pour la carte conceptuelle, lisez le guide principal Architecture logicielle moderne : DDD, microservices et event-driven.

Quand un service en appelle directement un autre en HTTP, il en devient prisonnier : si le service de facturation est lent ou en panne, la prise de commande ralentit ou échoue avec lui. L’architecture orientée événements brise ce lien. Au lieu de commander aux autres d’agir, un service annonce qu’un fait s’est produit, et ceux que cela intéresse réagissent à leur rythme. Dans ce tutoriel, vous allez publier l’événement « commande confirmée » depuis une API FastAPI et le faire consommer par deux services indépendants, à travers RabbitMQ.

🎯 Ce que vous allez apprendre

  • Distinguer un événement (un fait passé) d’une commande (un ordre à exécuter).
  • Mettre en place la topologie RabbitMQ : échange, file et liaison.
  • Publier un événement durable depuis une API FastAPI avec aio-pika.
  • Consommer cet événement dans des services découplés qui s’abonnent sans connaître l’émetteur.
  • Rendre un consommateur idempotent et gérer l’acquittement des messages.

🛠️ Ce que vous allez construire

Un service de commandes qui, lorsqu’une commande est confirmée, publie un événement sur RabbitMQ. Deux consommateurs distincts s’y abonnent : l’un simule l’émission d’une facture, l’autre l’envoi d’une notification. Vous pourrez arrêter l’un sans gêner l’autre, et en ajouter un troisième sans toucher au producteur — la preuve vivante du découplage.

Prérequis

  • Python 3.11 ou plus récent, et Docker pour lancer RabbitMQ.
  • Les bibliothèques : pip install fastapi uvicorn aio-pika.
  • Niveau intermédiaire. Test express : si vous savez écrire une route FastAPI et lancer un conteneur Docker, vous êtes prêt.
  • ⏱️ Temps estimé : ~55 minutes.

Étape 1 — Événement ou commande ?

La distinction est subtile mais structurante. Une commande exprime une intention adressée à un destinataire précis : « émets la facture de la commande 4072 ». Elle suppose qu’on sait qui doit agir et quoi attendre en retour. Un événement, lui, constate un fait déjà accompli : « la commande 4072 a été confirmée ». Il ne s’adresse à personne en particulier et n’attend pas de réponse. C’est cette nuance qui fait toute la différence d’architecture.

En publiant un événement plutôt qu’en envoyant une commande, le service de commandes cesse de se soucier de ce qui doit suivre. Il dit son fait au monde et passe à autre chose. La facturation, la notification, les statistiques — chacun décide pour lui-même de réagir ou non. Le nom d’un événement se formule donc toujours au passé : OrderConfirmed, PaymentReceived, OrderShipped. Cette convention n’est pas cosmétique : elle force à penser en termes de faits, pas d’ordres, et c’est ce changement de regard qui découple réellement les services.

Point d’étape — Listez trois choses qui doivent se produire après la confirmation d’une commande. Pour chacune, demandez-vous : le service de commandes a-t-il besoin d’attendre le résultat ? Si non, c’est un parfait candidat à un événement plutôt qu’à un appel direct.

Étape 2 — Lancer RabbitMQ

RabbitMQ jouera le rôle d’intermédiaire : il reçoit les événements du producteur et les distribue aux consommateurs abonnés. On le démarre en une commande grâce à son image Docker officielle, qui inclut une interface d’administration bien utile pour observer ce qui circule.

docker run -d --name rabbitmq \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:4-management

Le port 5672 est celui du protocole de messagerie (AMQP) ; le port 15672 expose la console web, accessible sur http://localhost:15672 avec les identifiants par défaut guest / guest. Au moment d’écrire, la série 4 est la version courante de RabbitMQ. Une fois le conteneur démarré, ouvrez la console : vous y verrez bientôt apparaître l’échange et les files que nous allons créer, ce qui rend le mécanisme tangible.

Étape 3 — Le producteur : publier l’événement

Côté commandes, on ouvre une connexion robuste à RabbitMQ au démarrage de l’application, et on déclare un échange de type topic. L’échange est le point d’entrée : le producteur ne connaît que lui, jamais les files ni les consommateurs. On utilise le cycle de vie lifespan de FastAPI pour ouvrir et fermer proprement la connexion.

# producer.py — service de commandes (FastAPI)
import json
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from uuid import uuid4

import aio_pika
from fastapi import FastAPI

AMQP_URL = "amqp://guest:guest@localhost/"

@asynccontextmanager
async def lifespan(app: FastAPI):
    connection = await aio_pika.connect_robust(AMQP_URL)
    channel = await connection.channel()
    app.state.exchange = await channel.declare_exchange(
        "shop.events", aio_pika.ExchangeType.TOPIC, durable=True
    )
    yield
    await connection.close()

app = FastAPI(lifespan=lifespan)

@app.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: str):
    event = {
        "type": "OrderConfirmed",
        "orderId": order_id,
        "occurredAt": datetime.now(timezone.utc).isoformat(),
    }
    message = aio_pika.Message(
        body=json.dumps(event).encode(),
        content_type="application/json",
        delivery_mode=aio_pika.DeliveryMode.PERSISTENT,  # survit à un redémarrage
        message_id=str(uuid4()),                          # identifiant unique du message
    )
    await app.state.exchange.publish(message, routing_key="order.confirmed")
    return {"status": "published", "orderId": order_id}

Deux choix méritent attention. Le delivery_mode persistant demande à RabbitMQ d’écrire le message sur disque : il survivra à un redémarrage du serveur. Le message_id unique servira aux consommateurs à détecter les doublons. La clé de routage order.confirmed est l’étiquette du fait ; les consommateurs s’abonneront à ce motif. Remarquez surtout ce que le producteur ne fait pas : il n’appelle aucun service, ne connaît aucun abonné. Il dépose un fait et rend la main immédiatement.

Étape 4 — La topologie : échange, file, liaison

Trois objets structurent la messagerie RabbitMQ, et les confondre est la première source d’erreur. L’échange reçoit les messages du producteur. La file stocke les messages en attendant qu’un consommateur les traite. La liaison connecte une file à un échange selon un motif de clé de routage. Un échange de type topic route un message vers toutes les files dont la liaison correspond à la clé.

Concrètement, notre échange shop.events recevra l’événement avec la clé order.confirmed. Le service de facturation créera une file billing.order-confirmed liée au motif order.confirmed ; le service de notification créera la sienne, notify.order-confirmed, liée au même motif. Résultat : un seul événement publié, deux copies distribuées, chacune dans sa file. Si demain un service d’analyse veut aussi réagir, il crée sa propre file et se lie au motif — sans que le producteur ni les autres consommateurs ne changent d’une ligne.

Pourquoi un échange de type topic plutôt qu’un autre ? RabbitMQ en propose plusieurs, et le choix n’est pas anodin. L’échange direct route vers les files dont la clé correspond exactement, utile quand on a des catégories fixes. L’échange fanout diffuse à toutes les files liées, sans filtrage, comme une radio. L’échange topic, lui, route selon des motifs avec jokers : une file pourrait s’abonner à order.* pour capter tous les événements de commande (confirmée, expédiée, annulée) en une seule liaison. C’est cette flexibilité qui en fait le choix par défaut d’une architecture événementielle évolutive : la structure de vos clés (domaine.fait) devient un langage que les consommateurs interrogent à la granularité qu’ils veulent.

Un autre choix de conception mérite d’être posé tôt : que met-on dans l’événement ? Deux styles s’opposent, bien décrits par Martin Fowler. La notification d’événement ne transporte que le strict minimum — un identifiant — et laisse les consommateurs rappeler le producteur s’ils veulent plus de détails ; elle garde les messages légers mais recrée une dépendance. Le transfert d’état porté par l’événement embarque au contraire toutes les données utiles (ici, le montant, le client, les lignes), de sorte que le consommateur n’a besoin de personne pour agir ; les messages sont plus gros, mais l’autonomie est totale. Pour une architecture qui vise le découplage, ce second style est souvent préférable, à condition de versionner soigneusement la structure des événements.

Étape 5 — Le consommateur : s’abonner et réagir

Chaque consommateur est un programme indépendant. Il déclare la même topologie (l’opération est idempotente : déclarer un échange qui existe déjà ne fait rien), lie sa file, et traite les messages au fil de l’eau. Voici le service de facturation.

# billing_consumer.py — service de facturation
import asyncio
import json
import aio_pika

PROCESSED: set[str] = set()  # mémoire de démo ; en production, un store persistant

async def on_message(message: aio_pika.abc.AbstractIncomingMessage) -> None:
    # process(requeue=True) acquitte le message si le bloc se termine sans
    # exception, et le remet en file en cas d'erreur pour une nouvelle tentative.
    async with message.process(requeue=True):
        if message.message_id in PROCESSED:
            print(f"Doublon ignoré : {message.message_id}")
            return
        event = json.loads(message.body)
        print(f"Facture émise pour la commande {event['orderId']}")
        PROCESSED.add(message.message_id)

async def main() -> None:
    connection = await aio_pika.connect_robust("amqp://guest:guest@localhost/")
    channel = await connection.channel()
    await channel.set_qos(prefetch_count=10)  # 10 messages en vol au maximum

    exchange = await channel.declare_exchange(
        "shop.events", aio_pika.ExchangeType.TOPIC, durable=True
    )
    queue = await channel.declare_queue("billing.order-confirmed", durable=True)
    await queue.bind(exchange, routing_key="order.confirmed")

    await queue.consume(on_message)
    print("Facturation : en attente d'événements…")
    await asyncio.Future()  # bloque indéfiniment

if __name__ == "__main__":
    asyncio.run(main())

Le service de notification serait identique, à deux différences près : le nom de sa file (notify.order-confirmed) et l’action effectuée (envoyer un message au lieu d’émettre une facture). C’est tout l’intérêt : ajouter une réaction au système revient à écrire un petit programme autonome, sans jamais modifier le producteur. Le prefetch_count limite le nombre de messages qu’un consommateur traite en parallèle, ce qui évite qu’un seul instance ne se voie submergée pendant que d’autres restent oisives.

Point d’étape — Démarrez le producteur (uvicorn producer:app) et le consommateur, puis appelez curl -X POST http://localhost:8000/orders/4072/confirm. Le consommateur doit afficher « Facture émise pour la commande 4072 ». Dans la console RabbitMQ, la file billing.order-confirmed doit montrer un message entré puis acquitté.

Étape 6 — Idempotence et acquittement

La messagerie offre une garantie précieuse — « au moins une fois » — mais qui a un revers : un même message peut être livré deux fois, par exemple si un consommateur plante juste après avoir agi mais avant d’avoir acquitté. Concevoir des consommateurs idempotents, c’est-à-dire dont le retraitement d’un même événement ne produit aucun effet supplémentaire, n’est donc pas une option : c’est une obligation. Émettre deux fois la même facture serait une faute métier grave.

Notre code s’en protège par le message_id : on mémorise les identifiants déjà traités et on ignore les doublons. L’ensemble PROCESSED en mémoire suffit pour la démonstration, mais en production il faut un stockage durable — une table avec une contrainte d’unicité sur l’identifiant fait parfaitement l’affaire. Quant à l’acquittement, le bloc async with message.process() s’en charge : si le traitement réussit, le message est acquitté et retiré de la file ; s’il lève une exception, il est remis en file pour une nouvelle tentative. Cette mécanique, combinée à l’idempotence, est ce qui rend l’architecture événementielle fiable malgré les pannes.

Étape 7 — Vérification du découplage

Le vrai test n’est pas qu’un message passe, mais que les services soient réellement indépendants. Faites l’expérience suivante : arrêtez le consommateur de facturation, puis confirmez trois commandes. Le producteur répond normalement, sans erreur — il ignore que personne n’écoute. RabbitMQ conserve les trois messages dans la file billing.order-confirmed, que vous voyez s’accumuler dans la console. Redémarrez le consommateur : il rattrape aussitôt les trois messages en attente et émet les trois factures.

Cette démonstration résume tout le bénéfice de l’approche. Une panne d’un consommateur ne se propage pas à l’émetteur ; les messages patientent sans être perdus ; le système se rééquilibre dès que le service revient. C’est la cohérence à terme évoquée dans le guide principal : pendant un instant, la commande existait sans sa facture, mais le système a convergé vers un état cohérent dès que possible. Accepter et borner ce délai est le prix — modeste — de la résilience gagnée.

🐞 Pièges fréquents

Symptôme Cause probable Correctif
Les messages disparaissent au redémarrage Échange ou file non durables, message non persistant durable=True partout et DeliveryMode.PERSISTENT à la publication
Une facture émise deux fois Consommateur non idempotent Mémoriser le message_id traité dans un store durable
Le consommateur ne reçoit rien File non liée, ou clé de routage différente Vérifier queue.bind et la concordance des clés
Un consommateur sature, les autres chôment Pas de limite de préchargement Régler prefetch_count via set_qos
Messages bloqués « unacked » Exception silencieuse sans acquittement Utiliser async with message.process() qui gère ack et rejet

✅ Récapitulatif

Vous avez relié des services par des faits, non par des appels. Le producteur publie un événement durable sur un échange RabbitMQ sans connaître ses abonnés ; les consommateurs lient leurs files au même motif et réagissent indépendamment ; l’idempotence et l’acquittement automatique rendent l’ensemble fiable malgré les pannes. Vous avez surtout vérifié, expérience à l’appui, que l’arrêt d’un consommateur n’affecte ni l’émetteur ni les autres. C’est cette autonomie qui permet à un système distribué de grandir sans se transformer en château de cartes.

🧾 Aide-mémoire

Concept Rôle
Échange (topic) Point d’entrée ; route selon la clé vers les files liées
File Stocke les messages en attente de traitement
Liaison Connecte une file à un échange selon un motif
Clé de routage Étiquette du fait, ex. order.confirmed
durable / PERSISTENT Survie des objets et messages à un redémarrage
Idempotence Retraiter un message ne produit aucun effet en plus

💪 À vous de jouer

Ajoutez un troisième consommateur « analytics » qui compte les commandes confirmées, sans modifier le producteur ni les autres consommateurs. Donnez-lui sa propre file et liez-la au motif order.confirmed.

Voir une solution
# analytics_consumer.py
queue = await channel.declare_queue("analytics.order-confirmed", durable=True)
await queue.bind(exchange, routing_key="order.confirmed")
# dans on_message : un compteur global incrémenté à chaque événement non dupliqué

Le simple fait de pouvoir ajouter ce service sans toucher au reste prouve que le découplage fonctionne.

Tutoriels liés

Pour aller plus loin

Questions fréquentes

RabbitMQ ou Kafka ?
RabbitMQ excelle dans le routage flexible de messages entre services et la distribution de tâches ; Kafka brille pour les journaux d’événements à très haut débit qu’on rejoue et conserve longtemps. Pour la communication événementielle entre services applicatifs comme ici, RabbitMQ est souvent le choix le plus simple et le plus rapide à mettre en œuvre.

Que se passe-t-il si un message échoue indéfiniment ?
On configure une file de lettres mortes (dead-letter queue) : après un certain nombre de tentatives, le message y est déplacé pour analyse, au lieu de bloquer la file en boucle. C’est l’étape suivante naturelle une fois les bases en place.

Faut-il un schéma pour les événements ?
Oui, dès que plusieurs équipes consomment vos événements. Documenter et versionner la structure d’un événement (ses champs, leurs types) évite qu’un changement côté producteur ne casse silencieusement les consommateurs. Un simple document partagé suffit pour démarrer.

Mots-clés : architecture orientée événements, event-driven, RabbitMQ, FastAPI, aio-pika, messagerie asynchrone, idempotence, cohérence à terme.

Service ITSkillsCenter

Application mobile Android et iOS

Création d'application mobile Android et iOS. À partir de 350 000 FCFA.

Démarrer mon projet
Publicité