Accueil Nos publications Blog [ASP.NET Core] Source de localisation personnalisée

[ASP.NET Core] Source de localisation personnalisée

ASP.NET logoDans ce billet, je vous propose de découvrir une implémentation d’une source de données de localisation alternative à celle basée sur les fichiers RESX. La mise en œuvre d’une telle implémentation est, bien sûr, prétexte à la découverte des points d’extensibilité de la librairie Localization.

Attention, cet article a été écrit à partir de la rc2 d’ASP.NET Core. Les APIs décrites ici sont encore sujettes à changement d’ici la release finale de la plate-forme.

Le mécanisme de localisation proposé par ASP.NET Core est très intéressant. Contrairement aux solutions historiques utilisables dans des applications ASP.NET, le nouveau fonctionnement est pensé de manière très générique. Ainsi, même si l’implémentation de base proposée par Microsoft utilise les fichiers de ressources .NET RESX, il est facilement possible de remplacer cette implémentation, basée sur un autre type de sources de données de localisation.

Ici, je vous propose d’analyser comment le mécanisme est extensible en vous expliquant une implémentation complète basée sur des fichiers JSON.

Pour commencer, voici les différentes dépendances que j’exprime dans mon fichier project.json.

"dependencies": {
    "Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc2-*",
    "Microsoft.AspNet.Server.Kestrel": "1.0.0-rc2-*",
    "Microsoft.Extensions.Localization.Abstractions": "1.0.0-rc2-*",
    "Microsoft.Extensions.Configuration.Json": "1.0.0-rc2-*",
    "Microsoft.Extensions.OptionsModel" :  "1.0.0-rc2-*"
},

La première classe que je commence à définir est la suivante. Elle représente l’objet de configuration de ma librairie. Notez que cela ressemble fort au type LocalizationOptions mais que ce dernier est présent dans le paquet contenant la logique d’utilisation des RESX en tant que source pour la localisation. Or, je ne veux pas utiliser de RESX, donc je ne veux pas référencer ce paquet.

public class JsonConfigurationLocalizationOptions
{
    public string BasePath { get; set; }
}

Je passe ensuite à l’écriture de ma propre implémentation de l’interface IStringLocalizerFactory. Mon but ici est d’écrire une implémentation de la méthode Create qui prend en paramètre un type. L’autre surcharge de la méthode Create ne semble pas être appelée par l’implémentation standard de IStringLocalizer<>.

Par le constructeur de ma classe JsonConfigurationStringLocalizerFactory, je reçois une implémentation de IApplicationEnvironment et un accesseur vers mon type d’option JsonConfigurationLocalizationOptions. Le but pour moi est d’obtenir tout ce dont j’ai besoin pour être en mesure de localiser et d’accéder à mes fichiers de ressources.

Dans la méthode Create, je vais générer une instance de la classe JsonConfigurationStringLocalizer. L’implémentation de cette dernière est présentée un peu plus loin dans ce billet. Pour la générer, je vais lui passer le chemin de base où doivent être stockés mes fichiers de ressources ainsi que le nom du type pour qui un IStringLocalizer est demandé. Ainsi, si dans mon application je désire me faire injecter une implémentation de IStringLocalizer, alors il me faudra un fichier Foo.json dans le répertoire de base de mon application, combiné au nom de répertoire définie par la classe JsonConfigurationLocalizationOptions.

public class JsonConfigurationStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly IApplicationEnvironment _applicationEnvironment;
    private readonly IOptions<JsonConfigurationLocalizationOptions> _jsonConfigurationLocalizationOptions;

    public JsonConfigurationStringLocalizerFactory(IApplicationEnvironment applicationEnvironment,
                                                   IOptions<JsonConfigurationLocalizationOptions> jsonConfigurationLocalizationOptions)
    {
        _applicationEnvironment = applicationEnvironment;
        _jsonConfigurationLocalizationOptions = jsonConfigurationLocalizationOptions;
    }

    public IStringLocalizer Create(Type resourceSource)
    {
        var resourceTypeName = resourceSource.Name;

        return new JsonConfigurationStringLocalizer(Path.Combine(_applicationEnvironment.ApplicationBasePath,
                                                                 _jsonConfigurationLocalizationOptions.Value.BasePath),
                                                    resourceTypeName);
    }

    public IStringLocalizer Create(string baseName, string location)
    {
        throw new NotImplementedException();
    }
}

La prochaine classe est nommée JsonConfigurationStringLocalizer, soit mon implémentation de l’interface IStringLocalizer.

Tout d’abord, je me sers des éléments reçus par le constructeur pour rechercher le bon fichier JSON et l’ajouter en tant que source de configuration à une instance de ConfigurationBuilder. Notez que pour le moment, je ne prends pas en compte la culture courante, je reviendrai sur cette étape un peu plus tard.

Dans un second temps, notez mon implémentation de l’indexeur permettant d’accéder à une valeur de localisation. Par défaut, si une clé n’est pas présente dans le fichier de configuration, alors je vais faire un fallback automatique sur la clé elle-même. Cela se rapproche du comportement utilisé par l’implémentation de la localisation par RESX. Notez cependant que ce comportement est sujet à changements, notamment en raison de la discussion suivante.

public class JsonConfigurationStringLocalizer : IStringLocalizer
{
    private readonly IConfiguration _configuration;

    public JsonConfigurationStringLocalizer(string basePath, string resourceTypeName)
    {
        var configurationBuilder = new ConfigurationBuilder();
        configurationBuilder.SetBasePath(basePath);

        configurationBuilder.AddJsonFile($"{resourceTypeName}.json");

        _configuration = configurationBuilder.Build();
    }

    public LocalizedString this[string name]
    {
        get
        {
            return this[name, new object[0]];
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var value = _configuration[name];

            return new LocalizedString(name, string.Format(value ?? name, arguments), value == null);
        }
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
    {
        throw new NotImplementedException();
    }

    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Enfin, l’implémentation très naïve de mon exemple directement dans la classe Startup. Ici, je branche mes différents éléments dans le conteneur IoC, puis j’injecte une implémentation de IStringLocalizer. Cet exemple va donc chercher un fichier Startup.json dans un dossier Resources lui-même situé dans le dossier de mon application.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IStringLocalizerFactory, JsonConfigurationStringLocalizerFactory>();
        services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));

        services.Configure<JsonConfigurationLocalizationOptions>(config =>
        {
            config.BasePath = "Resources";
        });

        services.AddOptions();
    }

    public void Configure(IApplicationBuilder app, IStringLocalizer<Startup> startupStringLocalizer)
    {
        app.UseIISPlatformHandler();

        app.Run(async (context) =>
        {
            await context.Response.WriteAsync(startupStringLocalizer["Hello"]);
        });
    }
}

Enfin, pour référence, un exemple de fichier Startup.json.

{
  "Hello" :  "Salut"
}

Démarrez l’application, et là, comme par magie, c’est bien la chaîne de caractères Salut qui s’affiche dans votre navigateur internet.

C’est un premier contact avec le mécanisme de localisation mais il nous manque la composant essentielle de la localisation : la prise en compte de la culture courante de l’utilisateur. Souvenez-vous, celle-ci peut être définie automatiquement pour chaque requête grâce au middleware Localization (situé dans le paquet Microsoft.AspNet.Localization).

Voici maintenant mon fichier de dépendances mis à jour. Notez que j’ai ici uniquement ajouté une référence à ASP.NET MVC 6.

"dependencies": {
    "Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc2-*",
    "Microsoft.AspNet.Server.Kestrel": "1.0.0-rc2-*",
    "Microsoft.AspNet.Localization": "1.0.0-rc2-*",
    "Microsoft.Extensions.Configuration.Json": "1.0.0-rc2-*",
    "Microsoft.Extensions.OptionsModel": "1.0.0-rc2-*",
    "Microsoft.AspNet.Mvc": "6.0.0-rc2-*"
},

Ensuite, je vais mettre à jour mes différents fichiers de ressources. Dans un premier temps, le contenu ci-dessous est celui d’un fichier Startup.json.

{
  "Hello" :  "Hello"
}

Puis, le fichier ci-dessous est un fichier de surcharge pour les français de France. Il s’agit du contenu d’un fichier Startup.fr-FR.json.

{
  "Hello" :  "Salut"
}

Enfin, un troisième fichier pour les espagnols d’Espagne Startup.es-ES.json. Il est volontairement vide puisqu’il va me permettre de tester le cas de fallback automatique sur le fichier par défaut.

{
}

Ci-dessous, un exemple de controller qui consomme la clé Helo depuis une implémentation de IStringLocalizer.

public class HomeController
{
    private readonly IStringLocalizer<Startup> _startupStringLocalizer;

    public HomeController(IStringLocalizer<Startup> startupStringLocalizer)
    {
        _startupStringLocalizer = startupStringLocalizer;
    }

    public string Index()
    {
        return _startupStringLocalizer["Hello"].ToString();
    }
}

Dans ma classe Startup, j’ajoute le middleware Localization grâce à la méthode UseRequestLocalization. En paramètre de celle-ci, je précise les différentes cultures que j’ai choisi de supporter dans mon application : en-US, fr-FR, es-ES*. Notez que je branche également le middleware ASP.NET MVC avec sa route par défaut.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IStringLocalizerFactory, JsonConfigurationStringLocalizerFactory>();
        services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));

        services.Configure<JsonConfigurationLocalizationOptions>(config =>
        {
            config.BasePath = "Resources";
        });

        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseIISPlatformHandler();

        app.UseRequestLocalization(new RequestLocalizationOptions()
        {
            SupportedCultures = new List<CultureInfo>
            {
                new CultureInfo("en-US"),
                new CultureInfo("fr-FR"),
                new CultureInfo("es-ES")
            },
            SupportedUICultures = new List<CultureInfo>
            {
                new CultureInfo("en-US"),
                new CultureInfo("fr-FR"),
                new CultureInfo("es-ES")
            }
        }, new RequestCulture("en-US"));

        app.UseMvcWithDefaultRoute();
    }
}

Enfin, la nouvelle implémentation de la classe JsonConfigurationStringLocalizer. Notez qu’ici, j’ai simplement modifié le constructeur de la classe en ajoutant une nouvelle source de configuration. Celle-ci recherche un fichier de configuration du nom du type, suffixé par le nom de la culture courante. Cette recherche de fichier de culture est optionnelle. J’ai laissé la recherche du fichier racine, c’est-à-dire celui sans suffixe de culture, afin qu’il serve automatiquement de fallback.

public class JsonConfigurationStringLocalizer : IStringLocalizer
{
    private readonly IConfiguration _configuration;

    public JsonConfigurationStringLocalizer(string basePath, string resourceTypeName)
    {
        var configurationBuilder = new ConfigurationBuilder();
        configurationBuilder.SetBasePath(basePath);

        configurationBuilder.AddJsonFile($"{resourceTypeName}.json");
        configurationBuilder.AddJsonFile($"{resourceTypeName}.{CultureInfo.CurrentUICulture.Name}.json", true);

        _configuration = configurationBuilder.Build();
    }

    public LocalizedString this[string name]
    {
        get
        {
            return this[name, new object[0]];
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var value = _configuration[name];

            return new LocalizedString(name, string.Format(value ?? name, arguments), value == null);
        }
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
    {
        throw new NotImplementedException();
    }

    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Lancez à nouveau l’application et jouez un peu avec les différentes cultures grâce à l’entête HTTP Accept-Language. Vous devriez voir le message localisé en français ou alors le fallback automatique si vous utilisez la culture espagnole.

Enfin, je vous propose une nouvelle mise à jour de la classe JsonConfigurationStringLocalizer. Ici, j’ai ajouté une méthode EnumCultures. Le but est de lister les différents niveaux de cultures contenues dans une chaîne de caractères telle que fr-FR-Bretagne par exemple. Dans ce cas précis, les chaînes suivantes seront retournées : fr, fr-FR, fr-FR-Bretagne.

Je consomme cette méthode dans le constructeur de la classe afin d’essayer de charger plusieurs fichiers de configuration en cascade, du plus générique au plus spécifique. Par exemple, dans le cas de ma culture bretonne, les fichiers suivants seraient recherchés :

  • Startup.json
  • Startup.fr.json
  • Startup.fr-FR.json
  • Startup.fr-FR-Bretagne.json

Ce genre d’approche permet de gérer plus facilement le cas de spécialisation des cultures par pays, zone géographique ou n’importe quel autre critère personnalisé.

public class JsonConfigurationStringLocalizer : IStringLocalizer
{
    private readonly IConfiguration _configuration;

    public JsonConfigurationStringLocalizer(string basePath, string resourceTypeName)
    {
        var configurationBuilder = new ConfigurationBuilder();
        configurationBuilder.SetBasePath(basePath);

        configurationBuilder.AddJsonFile($"{resourceTypeName}.json");

        foreach(var culture in EnumCultures(CultureInfo.CurrentUICulture.Name))
        {
            configurationBuilder.AddJsonFile($"{resourceTypeName}.{culture}.json", true);
        }

        _configuration = configurationBuilder.Build();
    }

    private IEnumerable<string> EnumCultures(string input)
    {
        for (int i = 0; i < input.Length; i++)
        {
            if (input[i] == '-')
            {
                yield return input.Substring(0, i);
            }
        }

        yield return input;
    }

    public LocalizedString this[string name]
    {
        get
        {
            return this[name, new object[0]];
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var value = _configuration[name];

            return new LocalizedString(name, string.Format(value ?? name, arguments), value == null);
        }
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeAncestorCultures)
    {
        throw new NotImplementedException();
    }

    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

J’espère que ce billet vous a permis de mieux comprendre comment fonctionne le mécanisme de localisation mais aussi de vous rendre compte qu’il est facilement extensible. N’oubliez pas qu’il est toujours très important d’abstraire le plus possible le code d’une application en utilisant des éléments les plus standards et génériques possibles. Dans le cas précis de la localisation, ce sont les interfaces IStringLocalizer et IHtmlLocalizer que vous devriez toujours utiliser.