Aller au contenu
BackJavaquarkusi18nl10n

La taverne multilingue

Dans une taverne bondée, servir vite ne suffit plus : il faut comprendre chaque aventurier. À l'image d'une API moderne, notre comptoir doit parler le bon dialecte, garder des routes claires et tenir le choc. Découvrons comment allier i18n, lisibilité REST et résilience.

Quand on commande, on espère avoir été compris pour obtenir ce que l'on attendait

Un soir d'orage, la salle est pleine. Il y a un nain venu de Khazad qui parle allemand, une magicienne andalouse, un barde de Waterdeep, et un vieux lettré qui répond en latin.

Ils veulent tous la même chose : être compris. Pourtant, la taverne répond toujours en français, comme si le monde entier partageait le même dialecte.

La taverne sait servir vite. Elle ne sait pas encore parler juste.

Dans cet article, on va faire en sorte qu'elle parle la langue de chaque aventurier, tout en gardant ses mécanismes de résilience. Et comme dans une vraie soirée chargée, on va aussi clarifier les routes REST pour que la carte soit lisible.

Le comptoir ne comprend qu'un dialecte

Quand tout est en dur, l'API ressemble à un tavernier fatigué qui répète sa formule :

return "Tiens, une bonne pinte bien fraîche !";

réponse simple et efficace, peut-être trop simple

Cette approche fonctionne pour une démo de cinq minutes, mais elle casse dès qu'on veut supporter plusieurs langues, faire évoluer les messages sans toucher au code métier, ou garder une réponse cohérente quand la langue demandée n'est pas disponible.

Le premier chantier consiste donc à sortir les phrases du code, comme on sortirait les recettes du cuisinier pour les ranger dans un grimoire partagé.

Le grimoire des messages : @MessageBundle

Dans Quarkus, ce grimoire prend la forme d'un Message Bundle typé :

@MessageBundle("TavernMessages")
public interface TavernMessages {

    @Message("Tiens, une bonne pinte bien fraîche !")
    String biereFraiche();

    @Message("Holà l'ami ! Laisse-moi le temps de tirer ta bière, le tonneau n'est pas infini !")
    String biereRateLimit();

    @Message("Bienvenue dans la Taverne du Dragon Boiteux, {nom} !")
    String bienvenue(String nom);
}

une méthode = une clé de message

Le détail important ici, c'est le nom "TavernMessages" : il doit correspondre aux fichiers de traduction, placés au bon endroit :

  • src/main/resources/messages/TavernMessages_fr.properties
  • src/main/resources/messages/TavernMessages_en.properties
  • src/main/resources/messages/TavernMessages_de.properties
  • src/main/resources/messages/TavernMessages_es.properties
  • src/main/resources/messages/TavernMessages_la.properties

Autrement dit, le tavernier a enfin un dictionnaire multi-langue, et chaque clé (biereFraiche, bienvenue, et ainsi de suite) devient une entrée stable du contrat API. Renommer une clé sans mettre à jour les fichiers de traduction, et le compilateur le signale immédiatement.

# =============================================================
# TavernMessages_fr.properties — Français (locale par défaut)
# l10n : traductions françaises de tous les messages de la taverne
# =============================================================

# Comptoir
biereFraiche=Tiens, une bonne pinte bien fraîche !
biereRateLimit=Holà l'ami ! Laisse-moi le temps de tirer ta bière, le tonneau n'est pas infini !

# Cave
caveSucces=Voilà votre prestigieux Vin Elfique millésimé !
caveTrebuche=Oups ! Le tavernier a glissé sur une marche vers la cave ! (Tentative {tentative})
caveReset=Cave réinitialisée, le tavernier reprend ses esprits.

# Cuisine
platDuJour=Voici un délicieux ragoût de sanglier !
platFallback=Désolé l'ami, la marmite est vide... Tiens, un morceau de pain dur et du fromage sec en lot de consolation.

# Général
bienvenue=Bienvenue dans la Taverne de la Baleine qui tombe, {nom} !
prix=Le {article} vous coûtera {prix}.
affluence=Il y a actuellement {compte} {libelle} dans la salle.

les messages en français

C'est l'avantage du bundle typé sur un simple ResourceBundle : les régressions de messages deviennent des erreurs de compilation.

Écouter l'aventurier : Accept-Language et résolution de locale

À l'entrée, l'aventurier ne dit pas "je veux le fichier _de.properties". Il envoie un header HTTP : Accept-Language.

On centralise cette logique dans un helper :

public Locale resolveLocale(HttpHeaders headers) {
    List<Locale> acceptedLocales = headers.getAcceptableLanguages();
    if (acceptedLocales == null || acceptedLocales.isEmpty()) {
        return Locale.FRENCH;
    }
    Locale requested = acceptedLocales.get(0);
    if ("*".equals(requested.getLanguage())) {
        return Locale.FRENCH;
    }
    return requested;
}

on détermine la locale de l'utilisateur

Narrativement, c'est le videur de la taverne : il regarde l'accent du client et informe le personnel de la langue à utiliser. Techniquement, ça évite de dupliquer la lecture de headers dans chaque endpoint, et ça offre un seul endroit où gérer les cas limites — absence de header, wildcard *, locale non supportée.

Lors des tests de charge Gatling, tous les scénarios utilisaient text/plain comme acceptHeader, sans préciser de langue. Ce sont exactement les cas où ce fallback français entre en jeu.

Servir en plusieurs langues, sans perdre la robustesse

La taverne n'est pas qu'un traducteur. C'est aussi un lieu sous pression, et les mécanismes de résilience testés lors du siège restent intacts.
Seuls les messages changent de robe.

POST /taverne/commandes : rate limit au comptoir

Le tavernier sert la bière, mais refuse poliment après trop de demandes en rafale. Les mille nains du test Gatling ont reçu leurs rejets en français — désormais, le nain de Khazad recevra le sien en allemand.

@RateLimit(value = 3, window = 10, windowUnit = ChronoUnit.SECONDS)
public String orderBeer(HttpHeaders headers) {
    Locale locale = localeHelper.resolveLocale(headers);
    return resolveMessages(locale).biereFraiche();
}

Si la limite est dépassée, un ExceptionMapper renvoie un 429 localisé au lieu d'une erreur opaque.

GET /taverne/approvisionnements-cave : retry dans les escaliers

Descendre à la cave est risqué. Le mécanisme de retry — probabiliste depuis l'article Gatling pour tenir la concurrence — reste inchangé. Ce qui change, c'est que chaque tentative échouée produit désormais un avertissement dans la bonne langue :

@Retry(maxRetries = 3, delay = 200)
public String fetchFromCellar(HttpHeaders headers) {
    // échec aléatoire + message localisé
}

GET /taverne/plats-du-jour : fallback quand la marmite est vide

Les cent cinquante affamés du test de charge ont tous reçu leur pain sec en français. Désormais, la magicienne andalouse obtient le sien en espagnol, et le lettré en latin. Le fallback ne disparaît pas — il parle mieux.

@Fallback(fallbackMethod = "serveLeftovers")
public String orderPlatDuJour(boolean empty, HttpHeaders headers) {
    if (empty) throw new RuntimeException("Plus aucun ragoût !");
    return resolveMessages(locale).platDuJour();
}

L'histoire continue même en cas d'échec technique : la cuisine ne s'effondre pas, elle s'adapte — et elle s'adresse à chacun dans sa langue.

Documenter comme on raconte : OpenAPI côté resource

Chaque endpoint est annoté avec @Tag pour le domaine, @Operation pour le résumé et la description, @APIResponse pour les codes de retour, et @Parameter pour les query params.

@POST
@Path("/commandes")
@Operation(summary = "Commander une bière",
    description = "Retourne un message localisé selon Accept-Language.")
@APIResponses({
    @APIResponse(responseCode = "200", description = "Bière servie"),
    @APIResponse(responseCode = "429", description = "Trop de commandes")
})
public Response orderBeer(@Context HttpHeaders headers) { ... }

On ne documente pas juste du HTTP : on documente le comportement de la taverne pour ceux qui la consomment. Un aventurier qui lit la Swagger UI doit comprendre ce qui l'attend au comptoir, pas seulement le code de retour.

La grammaire des foules : pluriels et monnaie

Une API multilingue ne se limite pas aux phrases. Il faut aussi localiser les formats, c'est la partie l10n qui reste souvent dans l'angle mort, alors qu'elle est immédiatement visible côté utilisateur.

Pluriels

Pour l'affluence, les règles varient selon la langue. Le français distingue singulier et pluriel à partir de deux, l'anglais dès deux également, et le latin ajoute une déclinaison morphologique propre à la deuxième déclinaison :

  • 1 aventurier, 0 aventurier
  • 2 adventurers
  • 1 viator, 2 viatores

Ces règles ne peuvent pas venir des fichiers .properties seuls — elles demandent une logique dans le code, dans un helper dédié pour ne pas polluer le service métier.

Monnaie

Pour /taverne/tarifs, on formate le montant selon la locale. NumberFormat.getCurrencyInstance() gère la plupart des cas : 3,50 € en français, $3.50 en anglais, 3,50 € en allemand.

Le latin est le cas limite qui révèle toute la richesse du problème : pas de devise ISO moderne, pas de format standard. On adopte une convention narrative empruntée à l'univers DnD — le denarius — et on sort du mécanisme standard pour l'implémenter explicitement. C'est précisément ce genre de cas qui justifie d'isoler la l10n dans un helper plutôt que de la disperser dans les services.

Vérifier que l'histoire tient : tests

On valide à deux niveaux : des tests unitaires sur LocaleHelper pour les pluriels et le format latin, et des tests d'intégration Quarkus sur les endpoints REST.

curl -H "Accept-Language: de-DE" -X POST "http://localhost:8080/taverne/commandes"
curl -H "Accept-Language: en-US" "http://localhost:8080/taverne/salutations?nom=Thorin"
curl -H "Accept-Language: la-LA" "http://localhost:8080/taverne/tarifs?article=potio&montant=3.5"
curl -H "Accept-Language: fr-FR" "http://localhost:8080/taverne/affluence?nombre=0"

Quelques checks représentatifs :

  • GET /taverne/affluence?nombre=0 avec fr-FR contient 0 aventuriers.
  • GET /taverne/tarifs avec la-LA contient 3,50 dn.
  • POST /taverne/commandes avec de-DE renvoie bien un message allemand.

Conclusion

La taverne a changé de niveau.

Quand Gatling avait lâché ses mille aventuriers, la question était : est-ce que ça tient ? La réponse était oui : au sens technique du terme.
Les mécanismes tenaient. Les assertions passaient. Le rapport était propre.

Mais tenir la charge et tenir la conversation, ce sont deux choses différentes. Une API qui répond à mille requêtes par seconde dans une seule langue n'est robuste qu'à moitié.

Désormais, la taverne ne se contente plus de répondre : elle comprend qui lui parle, adapte ses messages, garde son calme sous la pression, et documente clairement son contrat.
Le nain de Khazad est rejeté au comptoir en allemand. La magicienne andalouse reçoit son pain sec en espagnol. Le lettré apprend le prix de sa potion en denarii.

La prochaine fois que mille aventuriers poussent la porte, le tavernier ne paniquera pas.
Il les accueillera chacun dans leur langue, avec le bon prix, le bon pluriel, et une bière servie dans les règles.

Dernier