Aller au contenu
BackJavaquarkusReactive

Le tavernier aux huit bras : la programmation réactive vue depuis le comptoir

Dans cet article, nous allons découvrir les bases de Mutiny en utilisant une métaphore simple : celle d'un tavernier capable de servir plusieurs clients sans rester bloqué devant une seule chope.

Un tavernier très réactif

Dans une taverne ordinaire, il n'y a qu'un tavernier, deux bras, et une seule règle non écrite : on sert un client à la fois. Vous commandez une bière, le tavernier place la chope sous le fût, et il reste là, immobile, le regard vide, jusqu'à ce qu'elle soit pleine. Pendant ce temps, personne d'autre n'est servi. Le client au bout du comptoir attend. Celui qui vient d'entrer attend aussi. Le tavernier n'est pas paresseux, il est simplement occupé à ne rien faire d'utile : il attend.

C'est exactement ce que fait un thread bloquant pendant un appel réseau, une requête de base de données, ou n'importe quelle opération qui prend du temps sans consommer de CPU.

Puis un soir, un tavernier différent prend son service. On raconte qu'il a huit bras. Ce n'est pas tout à fait vrai, il n'a toujours que deux bras, mais il a appris à ne jamais rester figé devant un fût. Il lance la pression, va servir une autre table, encaisse une commande, débarrasse un plateau, et revient exactement quand la chope est pleine, ni avant ni après. Le travail n'a pas changé de nature, il a changé d'organisation : autour d'événements futurs plutôt que d'attentes bloquantes.

C'est la promesse de la programmation réactive, et Quarkus l'implémente avec Mutiny. Deux types dominent cette histoire :

  • Uni<T> : une seule valeur qui arrivera plus tard, comme une chope qu'on vous promet.
  • Multi<T> : un flux de valeurs, comme une file continue de clients qui arrivent au comptoir.

Suivons le tavernier aux huit bras pendant son service.

Le tavernier ordinaire, pour mémoire

Avant de voir le nouveau tavernier à l'œuvre, rappelons à quoi ressemble le service à l'ancienne.

BeerResponse beer = service.pourBeerBlocking("Gimli");
return beer;

Chaque étape attend la précédente. Le thread qui exécute ce code ne peut rien faire d'autre tant que la bière n'est pas servie.
Multipliez ça par cent clients simultanés et vous comprenez pourquoi une taverne à l'ancienne s'écroule vite sous la charge.

Une chope promise : Uni<BeerResponse>

Le premier tour de bras du tavernier réactif consiste à promettre une chope plutôt que de la servir immédiatement sous vos yeux. Voici comment c'est écrit côté service :

public Uni<BeerResponse> pourBeer(String adventurer, long durationMs) {
    String safeAdventurer = normalizeAdventurer(adventurer);
    long safeDurationMs = sanitizeDelay(durationMs);

    return Uni.createFrom()
            .item(() -> new BeerResponse(
                    safeAdventurer,
                    ORDERS.getFirst(),
                    safeDurationMs,
                    "Le tavernier lance la pression, sert une table, puis revient quand la chope est pleine."
            ))
            .onItem()
            .delayIt()
            .by(Duration.ofMillis(safeDurationMs));
}

La méthode pour la promesse d'une bonne bière

Uni.createFrom().item(...) prépare la réponse, et .onItem().delayIt().by(...) retarde uniquement sa livraison. Quarkus n'immobilise pas un thread pendant ce délai simulé : la chope est représentée comme un résultat futur, et le runtime peut utiliser ses ressources pour traiter autre chose au lieu de garder quelqu'un planté devant le fût.

Exposé côté comptoir (la ressource REST), l'appel ressemble à ceci :

@GET
@Path("/pression/{aventurier}")
public Uni<BeerResponse> pourBeer(
        @PathParam("aventurier") String adventurer,
        @QueryParam("durationMs") @DefaultValue("300") long durationMs
) {
    return reactiveTavernService.pourBeer(adventurer, durationMs);
}

Notre premier endpoint

Résultat de la requête

Pour l'instant, un seul aventurier est servi, un seul bras travaille. Ce n'est pas encore ce qui distingue vraiment le tavernier aux huit bras de son prédécesseur. La vraie démonstration commence quand plusieurs aventuriers commandent en même temps.

La preuve par les bras multiples : servir une tournée entière

Voici le moment où la métaphore cesse d'être seulement une image et devient une démonstration observable. Si le tavernier a vraiment appris à ne pas bloquer, alors servir trois aventuriers en même temps ne devrait pas prendre trois fois plus de temps que d'en servir un seul.

/**
 * Sert plusieurs aventuriers en meme temps pour prouver que le tavernier aux huit bras
 * ne bloque jamais un bras pour un seul client : le temps total observe se rapproche du
 * plus long des services, pas de leur somme.
 */
public Uni<List<BeerResponse>> pourTournee(List<String> adventurers, long durationMs) {
    List<String> safeAdventurers = sanitizeAdventurers(adventurers);

    List<Uni<BeerResponse>> pours = safeAdventurers.stream()
            .map(adventurer -> pourBeer(adventurer, durationMs))
            .toList();

    return Uni.join().all(pours).andFailFast();
}

Uni.join().all(pours) prend une liste de chopes promises et les assemble en une seule promesse plus large, qui ne se réalise que lorsque tous les aventuriers sont servis.

L'idée importante n'est pas que trois threads travaillent forcément en même temps, mais que trois attentes ne sont pas empilées les unes derrière les autres.

Les trois résultats futurs avancent de façon concurrente et non bloquante, au lieu d'être servis à la file indienne.

@GET
@Path("/pression/simultanee")
public Uni<List<BeerResponse>> pourTournee(
        @QueryParam("aventuriers") @DefaultValue("Gimli,Legolas,Frodon") String adventurersParam,
        @QueryParam("durationMs") @DefaultValue("300") long durationMs
) {
    List<String> adventurers = Arrays.stream(adventurersParam.split(","))
            .toList();
    return reactiveTavernService.pourTournee(adventurers, durationMs);
}
Résultat de la requête

Le flux ininterrompu des clients : Multi<ClientOrderResponse>

Une chope promise, c'est bien pour un aventurier isolé. Mais un vrai comptoir, un soir de pleine lune, voit défiler des clients en continu. C'est le rôle de Multi :

public Multi<ClientOrderResponse> streamClientOrders(int count, long intervalMs) {
    int safeCount = sanitizeCount(count);
    long safeIntervalMs = sanitizeDelay(intervalMs);

    return Multi.createFrom()
            .ticks()
            .every(Duration.ofMillis(safeIntervalMs))
            .select()
            .first(safeCount)
            .onItem()
            .transform(sequence -> createClientOrder(sequence + 1))
            .onCancellation()
            .invoke(() -> Log.info(
                    "Un client quitte la taverne avant la fin de la tournee, le tavernier passe a autre chose sans se figer."
            ));
}

Le comptoir diffuse ce flux en Server-Sent Events, chaque client apparaissant comme un événement au fil du temps :

@GET
@Path("/clients")
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<ClientOrderResponse> streamClientOrders(
        @QueryParam("count") @DefaultValue("5") int count,
        @QueryParam("intervalMs") @DefaultValue("250") long intervalMs
) {
    return reactiveTavernService.streamClientOrders(count, intervalMs);
}
Résultat de la requête

Le détail le plus révélateur n'est pourtant pas dans ce qui arrive, mais dans ce qui se passe quand un client s'en va avant la fin. Dans une taverne à l'ancienne, un serveur qui attend un client parti risque de continuer à travailler pour rien. Ici, onCancellation() permet de réagir à l'abandon du flux : le tavernier constate que plus personne n'écoute, puis passe à autre chose.

2026-07-28 17:34:47,097 INFO  [fr.eletutour.tavern.reactive.service.ReactiveTavernService] (vert.x-eventloop-thread-0) Un client quitte la taverne avant la fin de la tournee, le tavernier passe a autre chose sans se figer.

Le même comptoir, deux façons de le lire

Le flux SSE est parfait pour un client qui veut voir les événements arriver un par un. Mais parfois, on veut simplement la liste complète, d'un coup, pour un test ou un script :

public Uni<List<ClientOrderResponse>> collectClientOrders(int count, long intervalMs) {
    return streamClientOrders(count, intervalMs)
            .collect()
            .asList();
}

Rien de nouveau ici sur le plan conceptuel, c'est le même Multi qu'on collecte simplement dans une Uni<List<...>>. C'est le même tavernier, le même service, juste une façon différente de recevoir la tournée : par petites gorgées ou d'un seul plateau.

Cette route est aussi utile pour les tests automatisés. Tester un flux SSE demande de lire plusieurs événements dans le temps, alors qu'une liste JSON se vérifie en quelques assertions. Le modèle réactif reste présent, mais on adapte la forme de sortie au besoin du client.

Le mot du tavernier

Demandez au tavernier aux huit bras de résumer sa philosophie, et voici ce qu'il répond :

public ProgrammingStyleResponse compareStyles() {
    return new ProgrammingStyleResponse(
            "Imperatif : le tavernier reste devant le fut jusqu'a ce que la biere soit servie. Le thread attend.",
            "Reactif : le tavernier declare ce qui arrivera quand la biere sera prete. Le thread peut servir ailleurs.",
            "Uni : une seule chope promise, livree une fois quand elle est pleine.",
            "Multi : une file de clients, recue commande apres commande tant que le comptoir vit."
    );
}

Rien à ajouter, la métaphore et le code disent exactement la même chose.

Ce que le tavernier aux huit bras ne promet pas

Il faut quand même éviter un malentendu : la programmation réactive ne rend pas tout plus rapide par enchantement.

Elle est particulièrement intéressante quand l'application passe beaucoup de temps à attendre quelque chose : une base de données, une API distante, un fichier, un message, un timer, ou un client qui lit lentement une réponse. Dans ces cas-là, bloquer un thread revient à immobiliser un tavernier devant un fût qui coule tout seul.

En revanche, si le tavernier doit couper cent kilos de légumes, c'est du calcul pur. Le réactif ne transforme pas deux bras en huit processeurs. Pour une tâche CPU intensive, il faut surtout penser découpage du travail, parallélisme contrôlé, files d'exécution adaptées, ou délégation à des workers. Le réactif aide à ne pas gaspiller l'attente ; il ne supprime pas le coût réel du travail.

C'est pour ça que la métaphore du comptoir fonctionne bien : Mutiny ne promet pas un tavernier plus musclé, mais un tavernier mieux organisé.

Ce qu'il faut retenir en quittant la taverne

  • Un Uni<T> promet une seule valeur future, comme une chope qu'on vous sert quand elle est prête.
  • Un Multi<T> promet un flux de valeurs, comme une file de clients qui n'arrête pas de commander.
  • Le gain n'est pas magique : c'est une organisation différente du travail, autour d'événements plutôt que d'attentes bloquantes.
  • Le chronomètre rend l'intuition visible : trois chopes servies de façon non bloquante ne prennent pas nécessairement trois fois plus de temps qu'une seule.
  • Quand un client s'en va avant la fin, le tavernier peut arrêter de servir un flux que plus personne ne consomme.
  • Le réactif brille surtout sur l'attente et l'I/O. Pour du calcul pur, il faut d'autres outils.

Si vous commandez une tournée un soir de pleine lune dans cette taverne, gardez l'œil ouvert sur la carte : certaines potions n'y figurent pas par hasard.


Tout le code relatif à cet article peut être consulté ici :

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier