Skip to content

5. Internationalisation

Une application internationalisée affiche ses textes dans la langue de l'utilisateur. L'étude de cas est en français et en anglais. Le principe est simple : les vues ne contiennent plus de textes, mais des clés ([MENU.ACCUEIL]), et un dictionnaire par langue associe à chaque clé son texte. La langue choisie est mémorisée dans un cookie.

[ASP.NET Core] a son propre système de traduction (l'interface [IStringLocalizer] et des fichiers de ressources [.resx]). Nous utiliserons plutôt des dictionnaires JSON, lus par une petite classe écrite pour l'occasion : ce sont les mêmes fichiers que ceux de la version [NestJS] de l'étude de cas, et leur format se lit et s'écrit sans outil particulier. En revanche, nous confierons à [ASP.NET Core] le choix de la langue de chaque requête : c'est le rôle de son middleware de localisation.

5.1. Exemple 22 : une application en deux langues

22-i18n/
├── Controllers/
│   ├── LangueController.cs
│   └── PagesController.cs
├── i18n/
│   ├── en.json
│   └── fr.json
├── Traduction/
│   ├── PageVue.cs
│   └── Traducteur.cs
├── Views/
│   ├── Pages/
│   │   ├── Accueil.cshtml
│   │   └── Produits.cshtml
│   ├── Shared/
│   │   ├── _Layout.cshtml
│   │   └── _Navbar.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── wwwroot/
│   └── css/
│       └── styles.css
├── appsettings.json
├── Exemple22.csproj
└── Program.cs

5.1.1. Les dictionnaires

Le fichier [i18n/fr.json] :

{
  "MENU": {
    "ACCUEIL": "Accueil",
    "PRODUITS": "Produits"
  },
  "ACCUEIL": {
    "TITRE": "Bienvenue",
    "BONJOUR": "Bonjour {nom} !",
    "DATE": "Nous sommes le {date}."
  },
  "PRODUITS": {
    "TITRE": "Nos produits",
    "NOM": "Produit",
    "PRIX": "Prix",
    "NOMBRE": "{n} produit(s) au catalogue."
  }
}

Le fichier [i18n/en.json] :

{
  "MENU": {
    "ACCUEIL": "Home",
    "PRODUITS": "Products"
  },
  "ACCUEIL": {
    "TITRE": "Welcome",
    "BONJOUR": "Hello {nom}!",
    "DATE": "Today is {date}."
  },
  "PRODUITS": {
    "TITRE": "Our products",
    "NOM": "Product",
    "PRIX": "Price",
    "NOMBRE": "{n} product(s) in the catalogue."
  }
}
  • un dictionnaire est un fichier JSON, organisé par page ou par thème : la clé [ACCUEIL.BONJOUR] désigne le texte [BONJOUR] de la section [ACCUEIL] ;
  • ligne 8 : un texte peut contenir des paramètres, entre accolades, remplacés au moment de la traduction. L'ordre des mots change d'une langue à l'autre : c'est pourquoi on ne fabrique pas un texte en concaténant des morceaux traduits.

5.1.2. Le service de traduction

Le fichier [Traduction/Traducteur.cs] :

using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Exemple22.Traduction;

// les dictionnaires, lus UNE fois au démarrage : le service est un singleton
public partial class Traducteur
{
    public static readonly string[] Langues = ["fr", "en"];
    public const string LangueParDefaut = "fr";

    // langue -> (clé complète "ACCUEIL.BONJOUR" -> texte)
    private readonly Dictionary<string, Dictionary<string, string>> dictionnaires = [];

    public Traducteur(IWebHostEnvironment environnement)
    {
        foreach (var langue in Langues)
        {
            var chemin = Path.Combine(environnement.ContentRootPath, "i18n", $"{langue}.json");
            using var document = JsonDocument.Parse(File.ReadAllText(chemin));
            var dictionnaire = new Dictionary<string, string>();
            Aplatir(document.RootElement, "", dictionnaire);
            dictionnaires[langue] = dictionnaire;
        }
    }

    // la langue de la requête en cours, fixée par le middleware de localisation
    public static string LangueCourante()
    {
        var langue = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
        return Langues.Contains(langue) ? langue : LangueParDefaut;
    }

    // Traduire("ACCUEIL.BONJOUR", { ["nom"] = "Marie" }) -> "Bonjour Marie !"
    public string Traduire(string? cle, IReadOnlyDictionary<string, string>? parametres = null)
    {
        if (string.IsNullOrEmpty(cle)) return "";
        // une clé absente affiche la clé elle-même : un oubli se voit à l'écran
        var texte = dictionnaires[LangueCourante()].GetValueOrDefault(cle) ?? cle;
        if (parametres is null) return texte;
        // chaque {nom} est remplacé par la valeur du paramètre de même nom
        return Parametre().Replace(texte, m => parametres.TryGetValue(m.Groups[1].Value, out var valeur) ? valeur : m.Value);
    }

    // { "MENU": { "ACCUEIL": "Accueil" } } -> { "MENU.ACCUEIL": "Accueil" }
    private static void Aplatir(JsonElement noeud, string prefixe, Dictionary<string, string> resultat)
    {
        foreach (var propriete in noeud.EnumerateObject())
        {
            var cle = prefixe == "" ? propriete.Name : $"{prefixe}.{propriete.Name}";
            if (propriete.Value.ValueKind == JsonValueKind.Object)
            {
                Aplatir(propriete.Value, cle, resultat);
            }
            else
            {
                resultat[cle] = propriete.Value.GetString() ?? "";
            }
        }
    }

    // l'expression régulière {nom}, compilée par le compilateur C#
    [GeneratedRegex(@"\{(\w+)\}")]
    private static partial Regex Parametre();
}

Commentons ce code :

  • ligne 8 : le mot-clé [partial] est exigé par l'expression régulière de la fin du fichier (cf. plus loin) ;
  • lignes 10-11 : les langues gérées, et la langue par défaut ;
  • ligne 14 : pour chaque langue, un dictionnaire [C#] qui associe à chaque clé complète ([ACCUEIL.BONJOUR]) son texte ;
  • lignes 16-24 : le constructeur lit les fichiers JSON. Le service [IWebHostEnvironment], fourni par [ASP.NET Core], donne le dossier du projet ([ContentRootPath]). [JsonDocument.Parse] analyse le texte JSON ; [using var] libère l'objet à la fin du bloc. Le service sera un singleton (cf. [Program.cs]) : les fichiers sont lus une seule fois ;
  • lignes 29-32 : la langue de la requête en cours. Nous verrons que le middleware de localisation la range dans [CultureInfo.CurrentUICulture]. [TwoLetterISOLanguageName] en donne le code sur deux lettres ([fr], [en]) ;
  • lignes 36-43 : la traduction. La clé est cherchée dans le dictionnaire de la langue courante ; absente, c'est la clé elle-même qui est affichée : un oubli se repère immédiatement à l'écran. L'expression régulière trouve chaque paramètre [{nom}] du texte et le remplace par sa valeur ; un paramètre sans valeur est laissé tel quel ;
  • lignes 47-58 : la fonction « aplatit » le JSON : l'objet ["ACCUEIL": { "BONJOUR": "..." }] donne la clé [ACCUEIL.BONJOUR]. Elle s'appelle elle-même (fonction récursive) pour descendre dans les objets imbriqués ;
  • lignes 64-65 : l'expression régulière [\{(\w+)\}] (une accolade, un ou plusieurs caractères de mot, capturés, une accolade). L'attribut [[GeneratedRegex]] demande au compilateur de produire son code d'analyse une fois pour toutes, à la compilation. La méthode est déclarée [partial] : son corps est écrit par le compilateur.

5.1.3. Traduire dans les vues

Une vue pourrait obtenir le service par la directive [@inject Traducteur Traducteur], puis écrire [@Traducteur.Traduire("MENU.ACCUEIL")]. C'est long, et il faudrait le répéter dans chaque vue. On procède autrement : chaque vue [Razor] est compilée en une classe [C#], qui dérive par défaut de la classe [RazorPage<TModel>] du framework. On peut intercaler une classe à nous, qui donne à toutes les vues les méthodes dont elles ont besoin. Le fichier [Traduction/PageVue.cs] :

using System.Globalization;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Internal;

namespace Exemple22.Traduction;

// la classe mère de toutes les vues (cf. Views/_ViewImports.cshtml : @inherits)
public abstract class PageVue<TModel> : RazorPage<TModel>
{
    // rempli par l'injection de dépendances, comme le ferait @inject dans une vue
    [RazorInject]
    public Traducteur Traducteur { get; set; } = null!;

    // @T("ACCUEIL.BONJOUR", ("nom", "Marie")) : les paramètres sont des couples (nom, valeur)
    public string T(string? cle, params (string Nom, object? Valeur)[] parametres) =>
        Traducteur.Traduire(cle, parametres.ToDictionary(p => p.Nom, p => p.Valeur?.ToString() ?? ""));

    // "fr" ou "en"
    public string Langue => Traducteur.LangueCourante();

    // une date en toutes lettres, dans la langue courante
    public string DateLongue(DateOnly date) => date.ToString("D", CultureInfo.CurrentCulture);

    // un montant en euros, au format de la langue courante
    public string Monnaie(decimal montant)
    {
        var format = (NumberFormatInfo)CultureInfo.CurrentCulture.NumberFormat.Clone();
        format.CurrencySymbol = "€";
        return montant.ToString("C", format);
    }
}

Commentons ce code :

  • ligne 8 : une classe générique : [TModel] est le type du modèle de la vue, différent pour chaque vue. La classe est abstraite : seules les vues en dérivent ;
  • lignes 11-12 : l'attribut [[RazorInject]] demande à [Razor] de remplir la propriété par l'injection de dépendances : c'est l'équivalent de [@inject] pour une classe ;
  • lignes 15-16 : la méthode [T] traduit une clé. Ses paramètres sont des tuples [(nom, valeur)] : une syntaxe compacte pour écrire des couples de valeurs, [("nom", "Marie")]. Le mot-clé [params] permet d'en passer autant qu'on veut, séparés par des virgules. La méthode les range dans un dictionnaire pour le service. Le texte rendu est une chaîne : [Razor] l'échappera, comme toute expression [@] ;
  • ligne 19 : la langue courante, [fr] ou [en] ;
  • lignes 22-29 : dates et montants sont formatés selon la culture courante, fixée elle aussi par le middleware de localisation (cf. exemple 16).

Le fichier [Views/_ViewImports.cshtml] :

1
2
3
4
5
6
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using Exemple22.Controllers
@using Exemple22.Traduction
@* toutes les vues dérivent de PageVue : elles disposent de T(...), Langue... ;
   TModel désigne le type du modèle de chaque vue (@model ...) *@
@inherits PageVue<TModel>
  • ligne 6 : la directive [@inherits] désigne la classe mère des vues. Placée dans [_ViewImports.cshtml], elle vaut pour toutes. Chaque vue peut désormais écrire [@T("MENU.ACCUEIL")].

5.1.4. Le choix de la langue

Le fichier [Program.cs] :

using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Exemple22.Traduction;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// les dictionnaires : une seule instance, créée au premier besoin
builder.Services.AddSingleton<Traducteur>();

var app = builder.Build();

app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

// la langue de chaque requête : celle du cookie, le français sinon
var cultures = Traducteur.Langues.Select(l => new CultureInfo(l)).ToList();
var localisation = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(Traducteur.LangueParDefaut),
    SupportedCultures = cultures,   // les langues des formats (dates, nombres)
    SupportedUICultures = cultures, // les langues des textes
};
// seul le cookie décide (ni la langue du navigateur, ni un paramètre d'URL)
localisation.RequestCultureProviders = [new CookieRequestCultureProvider()];
app.UseRequestLocalization(localisation);

app.MapControllers();
app.Run();

Commentons ce code :

  • ligne 12 : le service de traduction, en un seul exemplaire ;
  • lignes 24-29 : la configuration du middleware de localisation. [.NET] distingue deux notions : la culture ([CurrentCulture]), qui fixe le format des dates et des nombres, et la culture de l'interface ([CurrentUICulture]), qui fixe la langue des textes. Les deux reçoivent ici la même liste de langues ; une langue qui n'est pas dans la liste est refusée ;
  • ligne 32 : où chercher la langue de la requête. Par défaut, le middleware essaie successivement un paramètre de l'URL ([?culture=en]), un cookie, puis l'entête [Accept-Language] envoyé par le navigateur (sa langue). On ne garde que le cookie : c'est l'utilisateur qui choisit, par les boutons FR / EN ;
  • ligne 33 : le middleware est ajouté au pipeline. À chaque requête, il lit le cookie et fixe [CultureInfo.CurrentCulture] et [CultureInfo.CurrentUICulture] pour toute la durée de la requête. Sans cookie, c'est la langue par défaut : le français.

Le fichier [Controllers/LangueController.cs] :

using Exemple22.Traduction;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;

namespace Exemple22.Controllers;

public class LangueController : Controller
{
    // GET /langue/en : mémorise le choix dans un cookie, puis revient à la page précédente
    [HttpGet("langue/{langue}")]
    public IActionResult Changer(string langue)
    {
        if (!Traducteur.Langues.Contains(langue))
        {
            return NotFound();
        }
        Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,                            // le nom du cookie
            CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(langue)), // "c=en|uic=en"
            new CookieOptions { MaxAge = TimeSpan.FromDays(365), SameSite = SameSiteMode.Lax, HttpOnly = true });
        return Redirect(PageDOrigine());
    }

    // la page d'où l'on vient (entête Referer), dont on ne garde que le chemin :
    // rediriger vers une adresse complète venue de la requête permettrait
    // d'envoyer l'utilisateur sur un autre site (faille "open redirect")
    private string PageDOrigine()
    {
        var referer = Request.Headers.Referer.ToString();
        if (!Uri.TryCreate(referer, UriKind.Absolute, out var url))
        {
            return "/";
        }
        return url.PathAndQuery;
    }
}

Commentons ce code :

  • lignes 10-21 : [GET /langue/en] mémorise le choix dans un cookie valable un an, puis redirige vers la page d'où l'on vient. Une langue inconnue provoque une 404 ;
  • lignes 17-20 : [Response.Cookies.Append(nom, valeur, options)] dépose un cookie : le navigateur le renverra à chaque requête suivante. Le nom et le format de sa valeur sont ceux qu'attend le middleware de localisation : on les obtient de la classe [CookieRequestCultureProvider], sans les écrire nous-mêmes. Options : [MaxAge] (durée de vie), [HttpOnly] (le [JavaScript] de la page ne peut pas le lire), [SameSite = Lax] (il n'est pas envoyé par les requêtes POST venues d'autres sites) ;
  • lignes 27-34 : la page d'origine est donnée par l'entête [Referer], que le navigateur ajoute quand on clique sur un lien. On n'en garde que le chemin et les paramètres ([PathAndQuery]) : rediriger vers une adresse complète venue de la requête permettrait à un site malveillant de fabriquer un lien qui, après être passé par notre site, envoie l'utilisateur chez lui (faille dite [open redirect]).

5.1.5. Les vues

Le fichier [Views/Shared/_Layout.cshtml] :

<!doctype html>
<html lang="@Langue">
<head>
  <meta charset="utf-8">
  @* le titre est une CLÉ, déposée par la vue, traduite par le gabarit *@
  <title>@T(ViewData["Titre"] as string)</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3">
    <partial name="_Navbar" />
    @RenderBody()
  </div>
</body>
</html>
  • ligne 2 : l'attribut [lang] de la page suit la langue choisie (utile aux lecteurs d'écran et aux correcteurs orthographiques) ;
  • ligne 6 : le titre de la page est une clé, déposée par la vue dans [ViewData], et traduite par le gabarit. [as string] convertit la valeur (de type [object]) en chaîne, ou en [null] si ce n'en est pas une.

Le fichier [Views/Shared/_Navbar.cshtml] :

<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    <a class="nav-link" href="/">@T("MENU.ACCUEIL")</a>
    <a class="nav-link" href="/produits">@T("MENU.PRODUITS")</a>
  </div>
  @* la langue courante est mise en évidence *@
  <div class="btn-group btn-group-sm">
    <a class="btn @(Langue == "fr" ? "btn-primary" : "btn-outline-primary")" href="/langue/fr">FR</a>
    <a class="btn @(Langue == "en" ? "btn-primary" : "btn-outline-primary")" href="/langue/en">EN</a>
  </div>
</nav>
  • lignes 8-9 : deux liens (changer de langue ne modifie aucune donnée de l'application : un GET convient), la langue courante étant mise en évidence.

Le fichier [Views/Pages/Accueil.cshtml] :

1
2
3
4
5
6
7
@model AccueilModele
@{
    ViewData["Titre"] = "ACCUEIL.TITRE";
}
<h1>@T("ACCUEIL.TITRE")</h1>
<p>@T("ACCUEIL.BONJOUR", ("nom", Model.Nom))</p>
<p>@T("ACCUEIL.DATE", ("date", DateLongue(Model.Aujourdhui)))</p>
  • ligne 6 : un paramètre, passé sous forme de tuple ;
  • ligne 7 : la date est d'abord formatée par [DateLongue], puis passée comme paramètre [date] à [T].

Le fichier [Views/Pages/Produits.cshtml] :

@model List<Produit>
@{
    ViewData["Titre"] = "PRODUITS.TITRE";
}
<h1>@T("PRODUITS.TITRE")</h1>
<table class="table table-sm w-auto">
  <thead><tr><th>@T("PRODUITS.NOM")</th><th class="text-end">@T("PRODUITS.PRIX")</th></tr></thead>
  <tbody>
    @foreach (var produit in Model)
    {
      <tr><td>@produit.Nom</td><td class="text-end">@Monnaie(produit.Prix)</td></tr>
    }
  </tbody>
</table>
<p>@T("PRODUITS.NOMBRE", ("n", Model.Count))</p>
  • ligne 11 : le montant, formaté selon la langue ;
  • ligne 15 : un paramètre numérique : [T] le convertit en chaîne.

Le fichier [Controllers/PagesController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple22.Controllers;

public record Produit(string Nom, decimal Prix);

public record AccueilModele(string Nom, DateOnly Aujourdhui);

public class PagesController : Controller
{
    [HttpGet("/")]
    public IActionResult Accueil()
    {
        return View(new AccueilModele("Marie", new DateOnly(2026, 9, 23)));
    }

    [HttpGet("produits")]
    public IActionResult Produits()
    {
        List<Produit> produits =
        [
            new Produit("Clavier", 25m),
            new Produit("Souris", 12.5m),
            new Produit("Écran", 1149m),
        ];
        return View(produits);
    }
}

Les actions ne savent rien de la langue : elles fournissent des données, les vues les traduisent et les formatent.

5.1.6. Exécution

En français (pas encore de cookie), aux URL http://localhost:5000/ et http://localhost:5000/produits :

Image

Image

Un clic sur EN, simulé avec [curl] (l'option [-H] ajoute l'entête [Referer] qu'aurait envoyé le navigateur) :

curl -i -H "Referer: http://localhost:5000/produits" http://localhost:5000/langue/en
1
2
3
4
HTTP/1.1 302 Found
Content-Length: 0
Location: /produits
Set-Cookie: .AspNetCore.Culture=c%3Den%7Cuic%3Den; max-age=31536000; path=/; samesite=lax; httponly

Le serveur dépose le cookie [.AspNetCore.Culture], dont la valeur codée est [c=en|uic=en] (la culture et la culture de l'interface), et renvoie à la page d'origine, désormais en anglais :

Image

Image

Une langue non gérée :

curl -i http://localhost:5000/langue/de
HTTP/1.1 404 Not Found
Content-Length: 0

L'étude de cas procède exactement ainsi, avec des dictionnaires plus fournis ([i18n/fr.json] et [i18n/en.json]) : même service [Traducteur], même classe mère des vues (avec quelques propriétés de plus), même contrôleur [/langue/{langue}], même cookie. Une seule différence : les messages d'erreur produits par les couches métier et web sont eux aussi des clés, traduites au dernier moment par la vue.