Evitar magic string al internacionalizar una aplicación ASPNet Core

Introducción

En el post sobre la internacionalización de una aplicación ASPNet Core uno de los puntos más negativos que encontré es el uso de magic strings a la hora de localizar los recursos y en este post/ píldora quiero comentar la aproximación que se me ha ocurrido para evitarlo.

¿Qué son las “magic strings”?

Son valores de cadenas que se especifican directamente dentro del código, y el tufo suele saltar cuando especificas esa “magic string” en más un sitio. Uno de los mecanismos que existen para evitarlo es el uso de constantes.

Poniendonos en situación

Si nos centramos en el ejemplo de querer localizar algún recurso en el controlador tenemos la opción de inyectar IStringLocalizer/ IHtmlLocalizer y para solicitar al localizador un recurso debemos utilizar una clave. Esa clave es nuestra “magic string”.

public HomeController(IStringLocalizer<SharedResource> localizer)
{
    var localizedValue = localizer["Home"];
}
  • ¿Qué ocurre si queremos utilizar la clave Home en otro controlador? ¿La repetimos en el resto de controladores?

  • ¿Qué ocurre si le queremos cambiar el nombre a la clave mientras refactorizamos? Usaremos la técnica de buscar y reemplazar que tan buen resultado da.

Evitando las “magic strings” con el uso de constantes

El mecanismo que suelo utilizar es el uso de una clase estática con constantes públicas y gracias al uso de nameof evito tener que escribir el literal como tal de la clave.

public static class SharedResourceKeys
{
    public const string Title = nameof(Title);
    public const string Home = nameof(Home);
}

Queremos asegurarnos que todas las claves tienen un recurso

Aunque sabemos que con ASPNet Core y el middleware de localización si solicitamos al localizador una clave que no existe nos duelve el mismo valor solicitado (clave), nosotros queremos asegurarnos que todas las constantes tienen su correspondiente recurso, al menos en la cultura por defecto y para ello hemos creado un test.

En dicho test, recurrimos a reflexión para recorrernos las propiedades de nuestra clase estática con los flags Public y Static y por cada uno de ellos llamar al localizador y comparar la propiedad ResourceNotFound. Esta propiedad la tenemos ya que el tipo que nos devuelve el localizador al localizar un recurso no es un string sino LocalizedString.

public class StringLocalizerTest
{
    private readonly TestServer _server;
    private readonly HttpClient _client;

    public StringLocalizerTest()
    {
        var webHostBuilder = new WebHostBuilder()
            .UseStartup<Startup>();
        _server = new TestServer(webHostBuilder);
        _client = _server.CreateClient();
    }

    [Fact]
    public void All_Keys_Exists_In_Resources()
    {
        var localizer = _server.Host.Services.GetService<IStringLocalizer<SharedResource>>();
        var properties = typeof(SharedResourceKeys).GetFields(BindingFlags.Public | BindingFlags.Static);

        foreach (var property in properties)
        {
            Assert.False(localizer[property.Name].ResourceNotFound);
        }
    }
}

¿Quién es el encargado de proporcionarnos esta clave?

La clase llamada ResourceManagerStringLocalizer es la implementación por defecto de IStringLocalizer que se encarga de obtener los recursos de archivos resx.

Queremos asegurarnos que todos los recursos tienen una clave

Igual que nos gusta asegurarnos que todas nuestras claves tengan un recurso al menos en la cultura por defecto, también nos interesa (a nosotros) asegurarnos que todos los recursos tienen una clave (gracias Sergio Navarro) de tal forma que exista una correlacción 1:1 entre nuestro almacén único de recursos y nuestra clase que almacen las claves.

Los “localizadores” implementan un método llamado GetAllStrings que devuelve un IEnumerable de LocalizedString por lo que podemos hacer un test similar al anterior para asegurarnos de la correlación 1:1.

[Fact]
public void All_Resources_Have_a_Key()
{
    var properties = typeof(SharedResourceKeys).GetFields(BindingFlags.Public | BindingFlags.Static);

    var localizer = _server.Host.Services.GetService<IStringLocalizer<SharedResource>>();
    var items = localizer.GetAllStrings();

    foreach (var item in items)
    {
        Assert.Contains(item.Name, properties.Select(p => p.Name));
    }
}

Ahora bien, si ejecutas este test puede ser que salte la excepción System.Resources.MissingManifestResourceException : No manifests exist for the current culture. dependiendo de la versión de Microsoft.Extensions.Localization que estés utilizando.

Es un bug abierto que en principio estará resuelto en la versión 2.1.0. Puede leer más información sobre él en esta issue del repo de Github.

System.Resources.MissingManifestResourceException : No manifests exist for the current culture.
   at Microsoft.Extensions.Localization.ResourceManagerStringLocalizer.GetResourceNamesFromCultureHierarchy(CultureInfo startingCulture)
   at Microsoft.Extensions.Localization.ResourceManagerStringLocalizer.<GetAllStrings>d__15.MoveNext()
   at MyWebApi.Test.StringLocalizerTest.All_Resources_Have_a_Key() in line 52