Dans toute taverne digne de ce nom, il y a un mur près du comptoir où sont clouées les annonces : primes de chasseurs de têtes, avis de recherche, tournées offertes par un mécène généreux, rumeurs de dragon dans les collines du nord.
Les aventuriers avisés savent une chose : on ne va pas voir le barman toutes les cinq minutes pour lui demander « alors, du nouveau ? ». On s'assoit, on garde un œil sur le panneau, et l'information vient à nous.
C'est très exactement le problème que le polling résout mal et que le SSE (Server-Sent Events) résout bien.
L'aventurier qui n'arrête pas d'interroger le barman
Imaginez un aventurier impatient, installé au comptoir, qui toutes les trois secondes tape sur le zinc pour demander « alors, ce ragoût, il vient ? ».
Le barman finit par répondre à chaque fois, mais ça ne fait pas cuire le ragoût plus vite, ça occupe inutilement ses mains et à la longue, ça agace toute la salle.
C'est très exactement ce que fait un client qui interroge un serveur en boucle pour savoir s'il y a du nouveau : ça fonctionne, mais c'est bruyant, coûteux, et redondant neuf fois sur dix.
Ce qu'on veut, c'est l'inverse : que la cuisine prévienne elle-même quand le plat est servi, que le barman affiche l'annonce sur le panneau dès qu'elle lui parvient, sans qu'on ait à revenir lui tirer la manche.
C'est le principe du push. Et Quarkus, via RESTEasy Reactive et Mutiny, rend ça remarquablement simple à mettre en place.
Le panneau magique
Techniquement, le panneau repose sur un BroadcastProcessor, une sorte de tableau enchanté qui diffuse chaque annonce aux aventuriers actuellement attablés, dès qu'elle est écrite.
Il ne garde pas les vieilles annonces sous le coude pour les retardataires : seuls ceux qui ont déjà les yeux sur le panneau au moment où l'annonce est affichée la voient passer.
@ApplicationScoped
public class PanneauMagique {
// Le processor est le coeur du panneau : il diffuse
// chaque annonce aux aventuriers actuellement abonnés,
// dès qu'elle arrive.
private final BroadcastProcessor<Annonce> flux = BroadcastProcessor.create();
public void afficher(Annonce annonce) {
flux.onNext(annonce);
}
public Multi<Annonce> observer() {
return flux;
}
}
public record Annonce(String auteur, String message, Instant horodatage) {
public static Annonce duBarman(String message) {
return new Annonce("Le Barman", message, Instant.now());
}
}
Et le message que le barman écrit sur le panneau arrive proprement, en JSON plutôt qu'en texte brut (on verra plus bas pourquoi ce choix compte) :
public record CriDuBarman(String message) {
}
Qui écrit, qui lit
Le point d'entrée REST distingue deux rôles bien séparés dans la salle commune : celui qui écrit sur le panneau (le barman, au comptoir), et ceux qui le lisent en continu (les aventuriers, attablés et les yeux levés vers le mur).
@Path("/taverne")
public class SalleCommuneResource {
@Inject
PanneauMagique panneau;
// Le panneau lui-même : les aventuriers s'y abonnent
// et reçoivent les annonces au fil de l'eau, sans jamais
// relancer la conversation avec le barman.
@GET
@Path("/panneau")
@Produces(MediaType.SERVER_SENT_EVENTS)
@RestStreamElementType(MediaType.APPLICATION_JSON)
public Multi<Annonce> suivreLePanneau() {
return panneau.observer();
}
// Le barman crie une annonce depuis le comptoir, en JSON :
// taverne d'à côté, prime sur un dragon, tournée gratuite...
@POST
@Path("/crier")
@Consumes(MediaType.APPLICATION_JSON)
public void crierAnnonce(CriDuBarman cri) {
panneau.afficher(Annonce.duBarman(cri.message()));
}
}
Un Multi<Annonce> n'est pas une réponse qu'on attend puis qu'on referme, comme une commande passée au comptoir. C'est un flux auquel on reste abonné tant qu'on garde sa chaise. C'est toute la différence entre demander et écouter.
Test rapide au comptoir
# Un aventurier s'installe et garde l'oeil sur le panneau
curl -N http://localhost:8080/taverne/panneau
# Le barman lance une annonce depuis un autre terminal
curl -X POST \
-H "Content-Type: application/json" \
-d '{"message": "Une prime de 500 pièces d'\''or vient de tomber sur la tête du Kobold Rouge !"}' \
http://localhost:8080/taverne/crier
Le premier terminal affiche l'annonce en direct. Pas de rafraîchissement, pas de nouvelle requête, pas de page qui retourne demander au barman. Le panneau a parlé de lui-même.
Les runes déformées : une histoire d'encodage
Le panneau fonctionnait déjà.
Restait pourtant une énigme inattendue : certains accents semblaient se déformer lorsqu'on consultait le flux directement dans le navigateur.
Ce qui suit n'est pas une étape obligatoire pour faire fonctionner du SSE avec Quarkus, mais le récit d'une enquête de débogage qui vaut la peine d'être racontée.
Le constat
Une fois le panneau branché et le message passé en JSON pour éviter les soucis de charset côté entrée, tout semblait en ordre dans le terminal. Mais en ouvrant l'URL du panneau directement dans le navigateur, les runes gravées sur le tableau étaient méconnaissables : les accents s'étaient transformés en une suite de symboles absurdes, comme si un scribe distrait avait recopié le message avec un mauvais alphabet.
Première piste : forcer le charset au niveau du serveur
Le premier réflexe a été de déclarer explicitement l'encodage attendu au niveau global de l'application :
quarkus.http.charset=UTF-8
quarkus.default-locale=fr_FR
Cette propriété agit sur le comportement général du serveur, mais elle ne suffisait pas : les runes restaient tordues sur le panneau une fois affichées dans le navigateur. Le charset par défaut de Quarkus, c'est une chose. Le header envoyé sur un flux SSE précis, c'en est une autre.
Deuxième piste : forcer le charset sur l'annotation @Produces
Suspicion suivante : @Produces(MediaType.SERVER_SENT_EVENTS) ne déclare aucun charset dans le Content-Type de la réponse. Un lecteur laissé sans instruction précise finit toujours par deviner, et deviner mal.
@Produces(MediaType.SERVER_SENT_EVENTS + "; charset=UTF-8")
Un pas dans la bonne direction sur le papier, mais l'inspection du header (curl -i) montrait encore un Content-Type: text/event-stream nu, sans le moindre charset. L'annotation seule ne suffisait pas à imposer sa volonté jusqu'au bout de la chaîne.
Troisième piste : un garde posté à la sortie
Puisque l'annotation ne tenait pas sa promesse, l'étape suivante a été de poster un garde à la sortie du panneau, un ContainerResponseFilter chargé de vérifier chaque réponse SSE et d'y inscrire le charset de force avant qu'elle ne quitte la taverne :
@Provider
public class CharsetSseFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) {
Object contentType = responseContext.getHeaders().getFirst("Content-Type");
if (contentType != null && contentType.toString().startsWith(MediaType.SERVER_SENT_EVENTS)) {
responseContext.getHeaders().putSingle("Content-Type", "text/event-stream;charset=UTF-8");
}
}
}
Cette fois, le header sortait bien avec charset=UTF-8 inscrit noir sur blanc. Et pourtant, dans le navigateur, les runes restaient tout aussi déformées qu'avant. À ce stade, il devenait clair que le problème n'était peut-être pas du tout celui qu'on croyait chasser.
Quatrième piste : consigner le flux dans un fichier
Plutôt que de continuer à ajuster des headers à l'aveugle, il fallait vérifier ce qui transitait réellement, en dehors de toute interprétation. Le flux a été capturé brut, octet par octet, dans un fichier :
curl -N http://localhost:8080/taverne/panneau > /tmp/sse.log &
Puis, une fois une annonce crée, une inspection hexadécimale du fichier :
xxd /tmp/sse.log
Verdict : les octets étaient parfaitement corrects. Un è s'y trouvait bien codé sur deux octets UTF-8 propres, sans trace de double encodage. Le panneau, depuis le début, écrivait juste. Le problème n'était donc ni dans les properties, ni dans l'annotation, ni dans le filtre : il n'avait jamais résidé côté serveur.
Le vrai coupable
La faute revenait à la manière dont le panneau était consulté. Ouvrir directement l'URL du flux dans la barre d'adresse ne transforme pas le navigateur en client SSE : il affiche alors le flux comme une simple réponse texte, selon un comportement qui n'est pas représentatif de la consommation réelle d'un flux SSE. Les octets capturés dans le fichier de log étaient corrects, et EventSource les interprétait correctement de son côté : cela suffit à situer le problème dans le mode de consultation, sans qu'il soit nécessaire d'en dire plus sur l'algorithme de repli exact employé par le navigateur.
Consulter le panneau correctement demande la bonne paire de lunettes, un vrai EventSource JavaScript, qui respecte la spécification SSE et décode toujours en UTF-8, quel que soit le charset annoncé :
<script>
const source = new EventSource("http://localhost:8080/taverne/panneau");
source.onmessage = (event) => {
const annonce = JSON.parse(event.data);
const paragraphe = document.createElement("p");
paragraphe.textContent = `${annonce.auteur} : ${annonce.message}`;
document.body.appendChild(paragraphe);
};
</script>
Le message étant envoyé en JSON, event.data est une chaîne JSON brute : on la parse d'abord, puis on affiche le texte avec textContent plutôt qu'en l'injectant directement dans du HTML, histoire de ne pas prendre de mauvaises habitudes même dans un exemple pédagogique.
Avec ce test, les accents s'affichaient enfin correctement, confirmant ce que le fichier de log avait déjà révélé : les runes n'avaient jamais été corrompues. Il avait simplement fallu le bon regard pour les déchiffrer.
Conclusion
Il y a quelque chose d'assez juste, pour un panneau magique, à ce qu'il ne se laisse lire qu'à travers le bon artefact. On a passé du temps à soupçonner le serveur, à ajuster des properties, une annotation, puis à poster un garde à la sortie, avant de comprendre que le panneau n'avait jamais menti. C'est souvent ainsi que se comportent les bugs d'encodage : ils ressemblent à un problème d'écriture, alors qu'ils ne sont, la plupart du temps, qu'un problème de lecture.
Le barman, lui, n'a plus jamais eu à répéter la même annonce cent fois dans la soirée. Le panneau s'en charge désormais tout seul, et les aventuriers, enfin, peuvent rester assis à leur table, l'œil sur le mur, à attendre que l'information vienne à eux.
Tout le code relatif à cet article peut être trouvé juste ici :