keyboard_arrow_left

Globalización y localización en ASpNet Core

Cuando empecé en el mundo de .Net allá por 2007, me acuerdo de que ya me chirrió la manera de traducir las aplicaciones a otros idiomas.

Los que hayáis trabajado con globalización y localización en el ecosistema .Net, habréis tenido que lidiar con los archivos resx, a los que teníamos que ir añadiendo filas con un conjunto de clave y valor. Luego, poníamos esa clave en una sentencia de tipo: Resources.Pagina.Clave, para que se pintara el valor correspondiente en los distintos idiomas, según tuviéramos configurada la propiedad UICulture.

Así que los pasos para traducir una página web eran:

  • Abrir la página o vista que quisiéramos localizar, y abrir el fichero de recursos que hubiéramos creado para ella.
  • Por cada texto a localizar, ir al archivo de recursos y añadir una nueva fila, con una clave, y el texto como valor.
  • Poner esa clave en el archivo a ser traducido.
  • Crear archivos de recursos por cada idioma nuevo, y poner ahí las traducciones por cada clave que hubiéramos puesto en el fichero del idioma original.

No sé a vosotros, pero para mí esta era una de las tareas más tediosas de montar un sitio web cuando el cliente quería que estuviera en varios idiomas. Tener que ir añadiendo claves y valores por cada dichoso texto que apareciera me parecía un suplicio.

Además, viniendo del mundo Linux, donde desde hacía mucho teníamos el gettext y los archivos PO, aquello se me antojaba demasiado complejo.

Parece que los chicos de Microsoft también creyeron que el sistema de localización necesitaba un repaso, y en la plataforma Core han añadido una alternativa totalmente nueva para traducir nuestras aplicaciones.

Proyecto de ejemplo

He publicado en Github una solución con dos proyectos de ejemplo para mostraros cómo funciona la globalización y localización en Asp.Net Core: AspNetCoreLocalizationExample y AspNetCoreLocalizarionExample.Routing. Veremos más adelante sus diferencias y repasaremos el código.

Podéis bajar el proyecto de aquí: https://github.com/kastwey/AspNetCoreLocalizationExample/.

Necesitaréis el SDK para Net Core 2.1:

Para abrir la solución podéis usar Visual Studio 2017 (15.7.3 o posterior), Visual Studio For Mac (versión 7.5.1 o posterior), Visual Studio Code, o incluso un editor de texto plano y la cli de dotnet para Net Core que viene con el SDK. ¡Que no se diga que no tenéis alternativas! 😉

Paquetes necesarios

Como ya sabréis, Net Core se basa en paquetes nuget. A diferencia de .Net Framework (FX), en el que con el framework teníamos casi todo lo que necesitábamos en solo unos cuantos assemblies, en .Net Core solo cargamos lo que necesitamos, de una manera mucho más desacoplada y modular.

Por defecto, con la plantilla de creación de un nuevo proyecto, veréis que se instala el paquete Microsoft.AspNetCore.All, que es algo así como un metapaquete que engloba todos los paquetes habidos y por haber para el desarrollo con Asp.Net Core con el que nos olvidamos de tener que estar buscando qué paquete necesitamos para determinada funcionalidad. A mí personalmente no me gusta este enfoque, pues nos pone un montón de paquetes que no usamos y que se publicarán con nuestra aplicación. Existe una herramienta en preview llamada Microsoft.Packaging.Tools.Trimming, que en teoría nos permitiría quitar todas las referencias a los paquetes que no usemos… Pero la he estado probando y me da un bonito error… Así que os recomiendo que eliminéis ese metapaquete y añadáis solo lo que necesitéis.

La lista de paquetes de mi proyecto queda tal que así:

  • Microsoft.AspNetCore
  • Microsoft.AspNetCore.Hosting
  • Microsoft.AspNetCore.Localization
  • Microsoft.AspNetCore.Localization.Routing
  • Microsoft.AspNetCore.Mvc
  • Microsoft.AspNetCore.Mvc.Localization
  • Microsoft.AspNetCore.StaticFiles
  • Microsoft.VisualStudio.Web.BrowserLink
  • Microsoft.VisualStudio.Web.CodeGeneration.Design

De estos paquetes, como ya imaginaréis por el nombre, los que tienen que ver con la localización son:

  • Microsoft.AspNetCore.Localization (aquí se encuentra el middleware que determinará la referencia cultural de una petición)
  • Microsoft.AspNetCore.Localization.Routing (este veréis que está instalado solo en el proyecto de ejemplo de AspNetCoreLocalizationExample.Routing, pues contiene el proveedor que permite determinar la referencia cultural según una determinada ruta)
  • Microsoft.AspNetCore.Mvc.Localization (añade todo lo necesario para aplicar la localización a nuestra aplicación MVC)

Globalizando nuestra aplicación. Determinando Culture y UICulture

Antes de ponernos a la tarea de localizar nuestra aplicación, tenemos que aprender cómo globalizarla, es decir, cómo poder cambiar la referencia cultural de la misma para una determinada petición HTTP. Esto afectará tanto a la entrada como a la salida de bastantes métodos. Por poner un ejemplo, DateTime.Now.ToString() no devolverá lo mismo en en-US que en es-ES. Lo mismo ocurre con funciones de entrada, como DateTime.Parse, Decimal.Parse, ETC, que, de no forzar una cultura específica en alguna de sus sobrecargas, cogerá la cultura predeterminada de nuestra aplicación, y por tanto esperará que el formato de las cadenas de entrada coincidan con esa referencia cultural.

El ciclo de vida de una petición en Asp.Net Core está construido mediante middlewares, es decir, clases que se van apilando en una cadena por la que va pasando nuestra petición, y que modelarán el comportamiento de la aplicación durante esa petición y proporcionarán una respuesta a la misma. Así ocurre con MVC, que no deja de ser un middleware que se añade a la aplicación, recibe la petición, intenta asignarla a una ruta, carga el controlador adecuado, ejecuta la acción, renderiza la vista y la devuelve como respuesta a esa petición.

El sistema de globalización es también un middleware que se añade al pipeline. Ese middleware tiene distintos proveedores que intentarán determinar la cultura de nuestra aplicación según los datos de la petición y siguiendo el orden en el que hayan sido añadidos al middleware. Una vez el middleware de localización haya ajustado la cultura, los demás middlewares como MVC ya podrán hacer uso de ella.

En ASP.Net Core hay tres proveedores que se añaden por defecto cuando configuramos el middleware de localización:

  1. QueryStringRequestCultureProvider (hace uso de un parámetro QueryString para obtener la referencia cultural)
  2. CookieRequestCultureProvider (buscará una cookie concreta para intentar determinar la referencia cultural de su valor)
  3. AcceptLanguageHeaderRequestCultureProvider (buscará la cabecera Accept-language de la petición HTTP e intentará ajustar la referencia cultural con su valor).

Cuando alguno de los proveedores consiga determinar la referencia cultural, los restantes no se ejecutarán. Así, si el proveedor QueryStringRequestCultureProvider es capaz de obtener la referencia cultural, CookieRequestCultureProvider y AcceptLanguageHeaderRequestCultureProvider no serán ejecutados.

Para entender todo esto un poco mejor, nada mejor que verlo con un ejemplo de código. Veamos el fichero startup.cs del proyecto AspNetCoreLocalizationExample (el Routing lo veremos más adelante):

startup.cs

        public void ConfigureServices(IServiceCollection services)
        {
            /* Primero, creamos un array con las culturas soportadas. Si se intenta ajustar una cultura que no esté en este array, la aplicación utilizará la cultura por defecto. En nuestro caso hemos definido solo dos culturas, español de España e inglés de Estados Unidos. Fijaos en la forma de definir las culturas en el constructor de CultureInfo, que será la misma forma en la que más adelante pasaremos la cultura a la aplicación: es-ES y en-US. El formato es {ISO 639-1 código de idioma (dos caracteres)}-{ISO 3166-1 código de país (dos caracteres)}. Así, en-GB es inglés de Gran Bretaña, es-AR español de Argentina...
*/

            var supportedCultures = new[]
            {
                new CultureInfo("es-ES"),
                new CultureInfo("en-US")
            };

            /* Cumplimentamos el objeto RequestLocalizationOptions. Le decimos que la cultura por defecto tanto para la interfaz como para los métodos dependientes de cultura será es-ES (español de España). Así mismo, le decimos que las culturas soportadas, tanto para UI como para métodos son las definidas en el array anterior.
   */           

            var options = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture(culture: "es-ES", uiCulture: "es-ES"),
                SupportedCultures = supportedCultures,
                SupportedUICultures = supportedCultures
            };

            // Guardamos estas opciones en el contenedor de dependencias, pues luego las necesitaremos para satisfacer un parámetro del siguiente método:
            services.AddSingleton(options);

            // Añadimos todos los servicios necesarios para localizar nuestra aplicación al contenedor de inyección de dependencias, y le decimos que nuestros archivos de recursos estarán en la carpeta Resources
            services.AddLocalization(opt => opt.ResourcesPath = "Resources");
            // Añadimos los servicios de MVC, los servicios para localizar las vistas y las Data Anotations.
            services.AddMvc()
                /* con LanguageViewLocationExpanderFormat.Suffix le decimos al motor de localización que el identificador de idioma en un recurso de una vista, estará tras el nombre del mismo , de la manera: home.en.US.resx.
                */
                .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
                .AddDataAnnotationsLocalization();
        }


        /* Este método se llamará de manera automática en la inicialización de nuestra aplicación. Se utiliza para añadir todos los middlewares que gestionarán una petición HTTP.
            Aquí podemos añadir los parámetros que queramos, siempre y cuando sus tipos hayan sido añadidos al contenedor de inyección de dependencias.
            Por ejemplo, el parámetro de tipo RequestLocalizationOptions será satisfecho con el objeto que añadimos en la línea 35 (services.AddSingleton(options);)
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, RequestLocalizationOptions options)
        {
            if (env.IsDevelopment())
            {
                app.UseBrowserLink();
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();

            // ¡Aqí está la madre del cordero! Añadimos el middleware de localización al pipeline, con las opciones que definimos en el método anterior sobre las culturas soportadas y la cultura por defecto:
            app.UseRequestLocalization(options);
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}"
                    );
            });
        }

Y con este código en nuestro startup, ya tenemos preparada nuestra aplicación para que sea capaz de determinar la referencia cultural de una petición, de cualquiera de las tres maneras que Asp.Net Core nos trae por defecto. ### Determinando la referencia cultural mediante QueryString

El primer provider que por defecto intentará determinar la referencia cultural de la petición es el QueryStringRequestCultureProvider. Por defecto busca el parámetro culture para la cultura de los métodos dependientes, y el parámetro ui-culture para la cultura de la interfaz de la aplicación. Así, si arrancamos la aplicación con la siguiente ruta: /?culture=en-US&ui-culture=en-US, le estamos diciendo a este provider que tanto Culture como UICulture debe definirse como inglés de Estados Unidos.

Los valores de estas claves se pueden modificar. Así, si tras cumplimentar el objeto RequestLocalizationOptions añadiéramos lo siguiente, cambiaríamos el parámetro de cultura a lan, y el de UICulture a lan-ui:

Determinando la referencia cultural mediante cookies

El segundo provider que por defecto intentará extraer la referencia cultural para la petición es el CookieRequestCultureProvider. Este provider, buscará una cookie con el nombre CookieRequestCultureProvider.DefaultCookieName.DefaultCookieName, que está definido como .AspNetCore.Culture, y utilizará su valor para configurar Culture y UICulture. Hasta donde yo sé, con este provider no se puede configurar una cultura diferente para UI o para Culture, sino que ambas se definirán con el mismo valor.

Para crear y ajustar una cookie con una determinada cultura, podríamos utilizar un código como el siguiente:

HomeController.cs

Determinando la referencia cultural mediante la cabecera Accept-language de la petición

El tercer y último provider que por defecto intentará determinar la referencia cultural de la petición, es el AcceptLanguageHeaderRequestCultureProvider. Este provider inspeccionará las cabeceras HTTP de la petición, en busca de la cabecera Accept-Language, y si la encuentra, utilizará su valor para ajustar las referencias culturales tanto de Culture como de UICulture.

Para probar este funcionamiento, lo más sencillo es que os vayáis a las preferencias de vuestro navegador, y cambiéis el idioma por defecto que se enviará a las páginas webs. Si queréis hacer pruebas con varias culturas de forma sencilla, lo más fácil es utilizar Postman o Curl. Como Postman suspende en accesibilidad y no puedo usarlo, mi amigo Curl viene a sacarme las castañas del fuego:

Como podréis ver, en función del valor de la cabecera Accept-Language, el HTML que os devolverá curl cambiará de idioma. ¿mola, a que sí? 🙂 ### Determinando la referencia cultural mediante la URL de nuestra petición

Existe un cuarto proveedor llamado RouteDataRequestCultureProvider, que es capaz de determinar la referencia cultural de una petición según su URL. De este modo, podemos tener una web del tipo: www.jmontiel.es/es-ES/, y otra www.jmontiel.es/en-US/. Este tipo de urls son las más adecuadas para el posicionamiento en buscadores, ya que a efectos prácticos, es como si por cada idioma tuviéramos una carpeta, y dentro de la misma, nuestra web completa traducida a ese idioma.

Pero así como los tres primeros providers han sido muy fáciles de configurar, este cuarto provider tiene un poco más de complicación (no mucha, pero es un poco más tedioso).

Como comenté más arriba, el pipeline de una petición en AspNet Core está formado por distintos middlewares que se van invocando en el orden en que fueron añadidos. En el momento en que el middleware de localización invoca a los tres proveedores que vienen preconfigurados en Asp.Net Core, ellos ya tienen la información necesaria para determinar la referencia cultural, pues pueden sacarla directamente desde la QueryString, las cookies o las cabeceras de la petición. Sin embargo, en el caso del RouteDataRequestCultureProvider, él necesita saber qué segmentos se pueden extraer de la URL de la petición, para ser capaz de determinar la referencia cultural… Y para esto, necesita que antes de que se invoque el Middleware de localización, antes ya se haya ejecutado el middleware de Routing. Si no, los datos de la ruta aún no estarán disponibles, y el proveedor no funcionará. Pero el middleware de routing se configura junto a MVC, y si ponemos el middleware de localización después del de MVC, nuestra localización no funcionará en nuestra aplicación MVC, porque cuando ésta se ejecuta, el middleware de localización aún no se ha ejecutado. Ya sé que es un poco complicado de entender. Os pondría un diagrama, en serio, pero no se me dan bien 😉

¿Cómo solventamos esto? Pues tenemos dos opciones. A mí me gusta más la B, así que si te quieres ahorrar la lectura de la A, no te culparé 😉

Opción A: Configurar el pipeline para que se bifurque según la ruta:

Veamos el siguiente código:

            /// Guardamos las culturas soportadas
            var supportedCultures = new List<CultureInfo>
            {
                new CultureInfo("es-ES"),
                new CultureInfo("en-US")
            };
            // Definimos las opciones para el middleware de localización
            var localizationOptions = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture("es-ES"),
                SupportedCultures = supportedCultures,
                SupportedUICultures = supportedCultures
            };
            /// Instanciamos el provider RouteDataRequestCulture
            var requestProvider = new RouteDataRequestCultureProvider();
            /* Y lo añadimos como primer provider del middleware de localización, para que sea el primero que intente obtener la referencia cultural.
            */
            localizationOptions.RequestCultureProviders.Insert(0, requestProvider);

            //Aquí empieza lo bueno. Le decimos a nuestra aplicación que utilice el middleware de Route:
            app.UseRouter(routes =>
            {
                /* Señor middleware Router. Si el path de la petición cumple con el patrón de la siguiente ruta, ejecute  el middleware de Localization, y luego el middleware de MVC.
                */
                routes.MapMiddlewareRoute("{culture=es-ES}/{*mvcRoute}", subApp =>
                {
                    subApp.UseRequestLocalization(localizationOptions);
                    subApp.UseMvc(mvcRoutes =>
                    {
                        mvcRoutes.MapRoute(
                            name: "default",
                            template: "{culture=es-ES}/{controller=Home}/{action=Index}/{id?}");
                    });
                });
            });
            /* Ahora, sigamos con la línea principal de middlewares, añadiendo el middleware de localización, y luego, el de MVC, solo para rutas que no tengan la cultura en la URL:
            */
            app.UseRequestLocalization(localizationOptions);
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "defaultNoCulture",
                    template: "{controller=Home}/{action=Index}/{id?}"
                    );
            });

¿Qué hemos hecho? Básicamente, crear una bifurcación en el pipeline. Si la ruta contiene una cultura al inicio del path de nuestra petición, dicha petición pasará primero por el middleware de localización, y luego se irá a MVC. Como para hacer esta bifurcación hemos usado el middleware Router, cuando se ejecute el middleware de localización, este ya tendrá la información de los componentes de la ruta, y podrá determinar la referencia cultural.

Y por último, añadimos al pipeline principal el middleware de localización, y después el de MVC, solo para las rutas que no tengan la cultura al principio del path de nuestra petición, para que también funcionen las rutas sin cultura. En este caso, el middleware de localización utilizará los tres providers restantes para intentar sacar la referencia cultural, de modo que en rutas sin cultura podremos seguir utilizando QueryString, cookies o Accept-language para obtenerla. Si los tres fallan, se utilizará la cultura por defecto.

¡Dios! ¿alguien se ha enterado? 😉

Opción B: El maravilloso invento de MiddlewareFilterAttribute

En Asp.Net Core 1.1 se añadió un nuevo atributo a la familia MVC, el MiddlewareFilterAttribute. Este atributo nos permite añadir un middleware como un filtro de MVC, por lo que ahora podemos añadir el middleware de localización cuando el middleware de MVC ya se está ejecutando, pero justo antes de que se ejecute la acción.

Para ello, tendremos que definir una pequeña clase que será la equivalente a Startup, que solo se encargará de añadir este middleware al pipeline de MVC:

Y ahora, en el startup, añadimos el filtro cuando configuramos el middleware de MVC:

¿Elegante, a que sí? De esta forma, lo tenemos todo solucionado. Cuando el middleware de localización se ejecute, el middleware de MVC (y por tanto el de Router) ya se habrán ejecutado, por lo que el proveedor RouteDataRequestCultureProvider ya podrá obtener los segmentos de ruta y extraer la referencia cultural.

En el proyecto de ejemplo AspNetCoreLocalizationExample.Routing, tenéis una web totalmente funcional utilizando esta aproximación.

Localizando nuestra aplicación

¡Bien! Ya tenemos la referencia cultural de nuestra aplicación. ¡Ahora ha llegado el momento de localizarla!

Localizando nuestras vistas

Para localizar una vista, haremos uso de la inyección de dependencias, y de una clase llamada IViewLocalizer. Como mejor se ve es con un trocito de código, así que vamos a ello:

index.cshtml

¿Qué estamos haciendo aquí?

Primero utilizamos la sentencia @inject, que sirve para pedirle al contenedor de inyección de dependencias que queremos inyectar algo a nuestra vista, en este caso, la clase IViewLocalizer.

Cuando en el Startup.cs añadimos el servicio MVC, también añadimos los servicios para localizar vistas: .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix). De este modo, el contenedor de dependencias sabe que IViewLocalicer deberá resolver hacia una clase que se encargará de traducir nuestra vista, buscando las traducciones dentro de un fichero resx en la ruta que corresponde a la vista actual, eligiendo el fichero que tenga el sufijo de la cultura actual de nuestra aplicación.

Cuando utilizamos la sentencia: Localizer["texto"], el motor de localización buscará en el archivo resx de la cultura seleccionada una clave que coincida exactamente con el texto entre comillas, y renderizará el valor de esa clave, es decir, su traducción. Si no existe archivo de recursos para la cultura seleccionada, se devolverá ese texto directamente.

Es por eso que en el nuevo modelo de traducción de Asp.Net Core es mucho más sencillo preparar nuestra aplicación para ser localizada, ya que no tenemos que crear un fichero de recursos predeterminado, sino que utilizamos directamente los textos en el idioma original, tan solo colocándolos como índice del objeto Localizer que hemos inyectado a la clase.

Aunque podemos inyectar IViewLocalizer en cada vista, quizá os interese añadir esas líneas en /Views/Shared/_ViewImports.cshtml:

@using AspNetCoreLocalizationExample.Routing
@using AspNetCoreLocalizationExample.Routing.Models
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject IViewLocalizer Localizer

Así, ya tendremos disponibles para todas las vistas el objeto _Localizer sin tener que añadir ni una línea de código a ninguna de ellas.

Si la vista que estamos traduciendo es la vista Index del controlador Home, y siendo que en la configuración del middleware de localización hemos especificado /resources como ruta de almacén de nuestros recursos, el fichero resx de recursos para traducir nuestra vista a inglés de Estados Unidos deberá estar ubicado en una de las dos rutas siguientes:

  • Resources/Views/Home/Index.en-US.resx
  • Resources/Views.Home.Index.en-US.resx

Como veis, hay dos patrones posibles: patrón de ruta, y patrón de punto.

  • En el patrón de ruta, la ruta del fichero de recursos coincide con la ruta de la vista, salvo por la subcarpeta Resources.
  • Por otro lado, en el patrón de punto, todos los ficheros estarán bajo el directorio Resources, y será el nombre de cada fichero, el que determinará la ruta completa de la vista a la que corresponde, utilizándose el punto como separador de directorios. Así, la vista /Views/Users/Register.cshtml, tendrá su fichero resx para inglés de Estados Unidos en /Resources/View.Users.Register.en-US.resx.

Como mencioné más arriba, los archivos de recursos tendrán como clave el texto en el idioma original, y como valor, el texto traducido al idioma para el que se esté definiendo el recurso.

Por ejemplo, el fichero Index.en-US.resx tendrá la siguiente estructura:

Clave Valor
Hoy es {0}. Today is {0}.
La cultura seleccionada es: {0}. The selected culture is: {0}.
Página de inicio Home page

Como podréis observar, este modo de crear archivos de recursos es mucho más sencillo, ya que en todo momento tendremos el texto en el idioma original en la columna de claves, y podremos añadir las traducciones de forma sencilla.

Nota: Cualquier fichero de recursos puede definirse para una cultura completa ([idioma]-[país]), o solo para el idioma. Si por ejemplo tenemos un fichero para nuestra vista llamado Index.en-US.resx, otro llamado Index.en.resx, y nuestra cultura actual es en-US, se cargará el fichero Index.en-US.resx. Pero si nuestra cultura es en-GB, se cargará el fichero Index.en.resx, ya que si no encuentra el fichero para la cultura completa, intentará cargar el fichero para la cultura más genérica, en este caso, para el idioma inglés, independientemente del país.

Localizando las Data Anotations

Imaginad que tenemos un formulario con un montón de campos, y el modelo que servirá para recibir los datos del formulario tiene un montón de Data Anotations* para definir el nombre a mostrar, si es o no requerido, las restricciones que tiene cada campo…

En Asp.Net Core MVC, traducir estas Data Anotations es la mar de sencillo. Tan solo tenemos que colocar los textos tal cuál en el idioma original, y a continuación, añadir un fichero de recursos para los otros idiomas, colocando como clave cada uno de los textos originales, y como valor, el valor de la traducción. Como veis, el resx es exactamente igual que con las vistas.

SampleFormModel.cs

SampleFormModel.en.resx

Clave Valor
Nombre: Name:
El nombre es obligatorio. The name is required.
El nombre debe contener más de tres caracteres. The name should contains more than three characters.
El nombre debe contener, como máximo, cincuenta caracteres. The name must contain a maximum of fifty characters.
Apellidos: Surname:
Los apellidos son obligatorios. The surname is required.
Los apellidos deben contener más de tres caracteres. Surname must contain more than three characters.
Los apellidos deben contener, como máximo, cincuenta caracteres. Surname must contain a maximum of fifty characters.

Al igual que con las vistas, los ficheros de recursos de los modelos pueden estar en las siguientes dos rutas (suponiendo que hayamos puesto la carpeta Resources como carpeta contenedore de nuestros recursos en la configuración del middleware:

  • /Resources/Models/Home/SampleFormModel.en.resx
  • /Resources/Models.Home.SampleFormModel.en.resx

Localizando controladores con IStringLocalizer y IHtmlLocalizer

Localizar controladores es igual de fácil que hacerlo con vistas o DataAnotations.

Al igual que con las vistas, los controladores deberán recibir mediante inyección de dependencia un objeto de tipo IStringLocalizer o IHtmlLocalizer (más adelante veremos las diferencias).

Veamos un fragmento de código:

HomeController.cs

Como podéis ver, creamos una variable privada de tipo IStringLocalizer, y en el constructor de nuestro controlador, solicitamos un parámetro de ese tipo. El contenedor de inyección de dependencias será capaz de satisfacer este parámetro, cargando el archivo de recursos que corresponda a nuestro controlador y al idioma de la cultura actual de la interfaz de la aplicación, y devolviendo una clase StringLocalizer configurada para localizar nuestro controlador.

En nuestro ejemplo, estamos traduciendo el texto “Texto de ejemplo pasado desde el controlador.”. Para traducirlo, crearemos un archivo de recursos en cualquiera de las siguientes dos rutas:

  • Resources/Controllers/HomeController.en.resx
  • Resources/Controllers.HomeController.en.resx

Como habréis advertido, los patrones de punto o de subdirectorios funcionarán para cualquier clase que vayamos a traducir.

El archivo de recursos podría ser algo así:

Clave Valor
Texto de ejemplo pasado desde el controlador. Sample text passed from the controller

También podríamos utilizar la clase IHtmlLocalizer en lugar de StringLocalizer. La diferencia es que StringLocalizer codificará cualquier HTML que intentes introducir en un texto, por lo que aparecerá en pantalla como parte del texto. Sin embargo, IHtmlLocalizer nos permite escribir código html en los propios textos a ser traducidos, aunque esto no se recomienda ya que traducir html puede ser complejo y muy propenso a errores.

Veamos un ejemplo:

HomeController.cs

Como podéis ver, es casi igual que el ejemplo anterior, salvo que IStringLocalizer ahora es IHtmlLocalizer, y esto nos permite colocar html en los textos a ser traducidos.

Así mismo, también hemos visto cómo el texto generado tenía la apariencia de una cadena con parámetros de formato, que se han proveído diréctamente desde la llamada a _Localizer. Esto nos permite crear textos localizados en los que el parámetro que le estamos inyectando pueda ir en una posición diferente en alguna de las traducciones. Por ejemplo, la sentencia _Localizer["¿Está aquí {0}?", "kastwey"], puede tener, en la traducción al inglés el aspecto: Is {0} here?, por lo que en español el nombre aparecerá al final, y en inglés, en mitad de la frase.

Recursos compartidos

Imaginad que tenemos una serie de textos comunes que podrían ser utilizados en varias partes diferentes de nuestra aplicación. Para esto surgen los recursos compartidos. En nuestro caso, por ejemplo, podríamos crear una clase llamada SharedResources, y utilizarla para compartir un fichero de localización desde varias vistas o controladores.

Veamos un ejemplo.

Primero, creamos una clase vacía. Esta clase solo se creará para poder pasarla como tipo a las clases IStringLocalizer o IHtmlLocalizer:

Y ahora, vamos a inyectar al layout de nuestras vistas un objeto de tipo IStringLocalizer<SharedResources>:

@inject IStringLocalizer<SharedResources> SharedLocalizer
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - Localization demo</title>
    ...
</head>
<body>
    <div class="page-header">
        <h1>@SharedLocalizer["Demostración de localización en Asp.NetCore MVC"]</h1>

Como podéis observar, el uso es exactamente igual que en las vistas anteriores con IViewLocalizer, solo que en este caso, estamos usando IStringLocalizer<ClaseATraducir>.

Si en nuestro HomeController.cs en lugar de usar IStringLocalizer<HomeController> usáramos IStringLocalizer<SharedResources>, podríamos compartir los recursos de SharedResources también desde este controlador. Incluso, podríamos tener dos parámetros en el constructor: uno para los recursos del controlador, y otro para los recursos compartidos:

Siguiendo la misma lógica que en los puntos anteriores, podremos determinar que nuestro archivo de recursos para esa clase será: /Resources/SharedResources.en.resx, ya que la clase SharedResources está en la raiz de nuestro proyecto.

Hay desarrolladores que prefieren utilizar la clase Startup como tipo para utilizar recursos compartidos: IStringLocalizer<Startup>.... Como la clase que se le pasa a IStringLocalizer no es más que un tipo, no importa qué tipo compartido utilicemos, solo que la clase exista junto a su correspondiente fichero resx.

Conclusiones

Aunque a mí personalmente me gusta mucho más esta manera de localizar y globalizar nuestras aplicaciones AspNet Core, lo cierto es que este sistema también tiene sus dos caras:

Cosas buenas:

  • Preparar nuestra aplicación para globalizarla es realmente muy sencillo y elegante gracias al middleware de localización y a los proveedores por defecto, que admiten diversos modos de obtener la referencia cultural.
  • Preparar la aplicación para localizarla en distintos idiomas requiere de muy poco esfuerzo, ya que no existen archivos de recursos predeterminados, sino que son los propios textos originales los que se traducirán más adelante en los ficheros de traducción.
  • Trabajar con archivos de recursos es más sencillo, ya que en la columna de la clave tendremos el texto original, y no tendremos que estar navegando hasta el fichero predeterminado para leer el contenido de dicha clave.

Cosas no tan buenas

  • Estamos utilizando cadenas de texto para vincular texto original y traducciones, por lo que si se modifica una cadena en el idioma original, tendremos que asegurarnos de cambiar esa cadena en todos los ficheros de recursos. Con la forma clásica, lo más que podía pasar era que la traducción no fuera fiel a los últimos cambios, pero en este caso, si la cadena original no coincide, el texto que aparecerá en los ficheros que no se hayan modificado será el texto del idioma predeterminado.
  • Trabajar con cadenas largas puede ser complicado y propenso a errores, sobre todo si dichas cadenas contienen HTML.

Aún así, por la rapidez y el poco esfuerzo que me lleva ahora preparar una aplicación para ser traducida, yo me quedo sin duda con este nuevo modo de globalizar y localizar aplicaciones que nos brinda AspNet Core.

¡Espero que este post os haya parecido interesante!

¡Un saludo!

_comentarios

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Web

Globalización y localización en ASpNet Core

16-Jun-2018

por

Juan José Montiel

Cuando empecé en el mundo de .Net allá por 2007, me acuerdo de que ya me chirrió la manera de traducir las aplicaciones a otros idiomas.

Los que hayáis trabajado con globalización y localización en el ecosistema .Net, habréis tenido que lidiar con los archivos resx, a los que teníamos que ir añadiendo filas con un conjunto de clave y valor. Luego, poníamos esa clave en una sentencia de tipo: Resources.Pagina.Clave, para que se pintara el valor correspondiente en los distintos idiomas, según tuviéramos configurada la propiedad UICulture.

Así que los pasos para traducir una página web eran:

  • Abrir la página o vista que quisiéramos localizar, y abrir el fichero de recursos que hubiéramos creado para ella.
  • Por cada texto a localizar, ir al archivo de recursos y añadir una nueva fila, con una clave, y el texto como valor.
  • Poner esa clave en el archivo a ser traducido.
  • Crear archivos de recursos por cada idioma nuevo, y poner ahí las traducciones por cada clave que hubiéramos puesto en el fichero del idioma original.

No sé a vosotros, pero para mí esta era una de las tareas más tediosas de montar un sitio web cuando el cliente quería que estuviera en varios idiomas. Tener que ir añadiendo claves y valores por cada dichoso texto que apareciera me parecía un suplicio.

Además, viniendo del mundo Linux, donde desde hacía mucho teníamos el gettext y los archivos PO, aquello se me antojaba demasiado complejo.

Parece que los chicos de Microsoft también creyeron que el sistema de localización necesitaba un repaso, y en la plataforma Core han añadido una alternativa totalmente nueva para traducir nuestras aplicaciones.

Proyecto de ejemplo

He publicado en Github una solución con dos proyectos de ejemplo para mostraros cómo funciona la globalización y localización en Asp.Net Core: AspNetCoreLocalizationExample y AspNetCoreLocalizarionExample.Routing. Veremos más adelante sus diferencias y repasaremos el código.

Podéis bajar el proyecto de aquí: https://github.com/kastwey/AspNetCoreLocalizationExample/.

Necesitaréis el SDK para Net Core 2.1:

Para abrir la solución podéis usar Visual Studio 2017 (15.7.3 o posterior), Visual Studio For Mac (versión 7.5.1 o posterior), Visual Studio Code, o incluso un editor de texto plano y la cli de dotnet para Net Core que viene con el SDK. ¡Que no se diga que no tenéis alternativas! 😉

Paquetes necesarios

Como ya sabréis, Net Core se basa en paquetes nuget. A diferencia de .Net Framework (FX), en el que con el framework teníamos casi todo lo que necesitábamos en solo unos cuantos assemblies, en .Net Core solo cargamos lo que necesitamos, de una manera mucho más desacoplada y modular.

Por defecto, con la plantilla de creación de un nuevo proyecto, veréis que se instala el paquete Microsoft.AspNetCore.All, que es algo así como un metapaquete que engloba todos los paquetes habidos y por haber para el desarrollo con Asp.Net Core con el que nos olvidamos de tener que estar buscando qué paquete necesitamos para determinada funcionalidad. A mí personalmente no me gusta este enfoque, pues nos pone un montón de paquetes que no usamos y que se publicarán con nuestra aplicación. Existe una herramienta en preview llamada Microsoft.Packaging.Tools.Trimming, que en teoría nos permitiría quitar todas las referencias a los paquetes que no usemos… Pero la he estado probando y me da un bonito error… Así que os recomiendo que eliminéis ese metapaquete y añadáis solo lo que necesitéis.

La lista de paquetes de mi proyecto queda tal que así:

  • Microsoft.AspNetCore
  • Microsoft.AspNetCore.Hosting
  • Microsoft.AspNetCore.Localization
  • Microsoft.AspNetCore.Localization.Routing
  • Microsoft.AspNetCore.Mvc
  • Microsoft.AspNetCore.Mvc.Localization
  • Microsoft.AspNetCore.StaticFiles
  • Microsoft.VisualStudio.Web.BrowserLink
  • Microsoft.VisualStudio.Web.CodeGeneration.Design

De estos paquetes, como ya imaginaréis por el nombre, los que tienen que ver con la localización son:

  • Microsoft.AspNetCore.Localization (aquí se encuentra el middleware que determinará la referencia cultural de una petición)
  • Microsoft.AspNetCore.Localization.Routing (este veréis que está instalado solo en el proyecto de ejemplo de AspNetCoreLocalizationExample.Routing, pues contiene el proveedor que permite determinar la referencia cultural según una determinada ruta)
  • Microsoft.AspNetCore.Mvc.Localization (añade todo lo necesario para aplicar la localización a nuestra aplicación MVC)

Globalizando nuestra aplicación. Determinando Culture y UICulture

Antes de ponernos a la tarea de localizar nuestra aplicación, tenemos que aprender cómo globalizarla, es decir, cómo poder cambiar la referencia cultural de la misma para una determinada petición HTTP. Esto afectará tanto a la entrada como a la salida de bastantes métodos. Por poner un ejemplo, DateTime.Now.ToString() no devolverá lo mismo en en-US que en es-ES. Lo mismo ocurre con funciones de entrada, como DateTime.Parse, Decimal.Parse, ETC, que, de no forzar una cultura específica en alguna de sus sobrecargas, cogerá la cultura predeterminada de nuestra aplicación, y por tanto esperará que el formato de las cadenas de entrada coincidan con esa referencia cultural.

El ciclo de vida de una petición en Asp.Net Core está construido mediante middlewares, es decir, clases que se van apilando en una cadena por la que va pasando nuestra petición, y que modelarán el comportamiento de la aplicación durante esa petición y proporcionarán una respuesta a la misma. Así ocurre con MVC, que no deja de ser un middleware que se añade a la aplicación, recibe la petición, intenta asignarla a una ruta, carga el controlador adecuado, ejecuta la acción, renderiza la vista y la devuelve como respuesta a esa petición.

El sistema de globalización es también un middleware que se añade al pipeline. Ese middleware tiene distintos proveedores que intentarán determinar la cultura de nuestra aplicación según los datos de la petición y siguiendo el orden en el que hayan sido añadidos al middleware. Una vez el middleware de localización haya ajustado la cultura, los demás middlewares como MVC ya podrán hacer uso de ella.

En ASP.Net Core hay tres proveedores que se añaden por defecto cuando configuramos el middleware de localización:

  1. QueryStringRequestCultureProvider (hace uso de un parámetro QueryString para obtener la referencia cultural)
  2. CookieRequestCultureProvider (buscará una cookie concreta para intentar determinar la referencia cultural de su valor)
  3. AcceptLanguageHeaderRequestCultureProvider (buscará la cabecera Accept-language de la petición HTTP e intentará ajustar la referencia cultural con su valor).

Cuando alguno de los proveedores consiga determinar la referencia cultural, los restantes no se ejecutarán. Así, si el proveedor QueryStringRequestCultureProvider es capaz de obtener la referencia cultural, CookieRequestCultureProvider y AcceptLanguageHeaderRequestCultureProvider no serán ejecutados.

Para entender todo esto un poco mejor, nada mejor que verlo con un ejemplo de código. Veamos el fichero startup.cs del proyecto AspNetCoreLocalizationExample (el Routing lo veremos más adelante):

startup.cs

        public void ConfigureServices(IServiceCollection services)
        {
            /* Primero, creamos un array con las culturas soportadas. Si se intenta ajustar una cultura que no esté en este array, la aplicación utilizará la cultura por defecto. En nuestro caso hemos definido solo dos culturas, español de España e inglés de Estados Unidos. Fijaos en la forma de definir las culturas en el constructor de CultureInfo, que será la misma forma en la que más adelante pasaremos la cultura a la aplicación: es-ES y en-US. El formato es {ISO 639-1 código de idioma (dos caracteres)}-{ISO 3166-1 código de país (dos caracteres)}. Así, en-GB es inglés de Gran Bretaña, es-AR español de Argentina...
*/

            var supportedCultures = new[]
            {
                new CultureInfo("es-ES"),
                new CultureInfo("en-US")
            };

            /* Cumplimentamos el objeto RequestLocalizationOptions. Le decimos que la cultura por defecto tanto para la interfaz como para los métodos dependientes de cultura será es-ES (español de España). Así mismo, le decimos que las culturas soportadas, tanto para UI como para métodos son las definidas en el array anterior.
   */           

            var options = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture(culture: "es-ES", uiCulture: "es-ES"),
                SupportedCultures = supportedCultures,
                SupportedUICultures = supportedCultures
            };

            // Guardamos estas opciones en el contenedor de dependencias, pues luego las necesitaremos para satisfacer un parámetro del siguiente método:
            services.AddSingleton(options);

            // Añadimos todos los servicios necesarios para localizar nuestra aplicación al contenedor de inyección de dependencias, y le decimos que nuestros archivos de recursos estarán en la carpeta Resources
            services.AddLocalization(opt => opt.ResourcesPath = "Resources");
            // Añadimos los servicios de MVC, los servicios para localizar las vistas y las Data Anotations.
            services.AddMvc()
                /* con LanguageViewLocationExpanderFormat.Suffix le decimos al motor de localización que el identificador de idioma en un recurso de una vista, estará tras el nombre del mismo , de la manera: home.en.US.resx.
                */
                .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
                .AddDataAnnotationsLocalization();
        }


        /* Este método se llamará de manera automática en la inicialización de nuestra aplicación. Se utiliza para añadir todos los middlewares que gestionarán una petición HTTP.
            Aquí podemos añadir los parámetros que queramos, siempre y cuando sus tipos hayan sido añadidos al contenedor de inyección de dependencias.
            Por ejemplo, el parámetro de tipo RequestLocalizationOptions será satisfecho con el objeto que añadimos en la línea 35 (services.AddSingleton(options);)
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, RequestLocalizationOptions options)
        {
            if (env.IsDevelopment())
            {
                app.UseBrowserLink();
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();

            // ¡Aqí está la madre del cordero! Añadimos el middleware de localización al pipeline, con las opciones que definimos en el método anterior sobre las culturas soportadas y la cultura por defecto:
            app.UseRequestLocalization(options);
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}"
                    );
            });
        }

Y con este código en nuestro startup, ya tenemos preparada nuestra aplicación para que sea capaz de determinar la referencia cultural de una petición, de cualquiera de las tres maneras que Asp.Net Core nos trae por defecto. ### Determinando la referencia cultural mediante QueryString

El primer provider que por defecto intentará determinar la referencia cultural de la petición es el QueryStringRequestCultureProvider. Por defecto busca el parámetro culture para la cultura de los métodos dependientes, y el parámetro ui-culture para la cultura de la interfaz de la aplicación. Así, si arrancamos la aplicación con la siguiente ruta: /?culture=en-US&ui-culture=en-US, le estamos diciendo a este provider que tanto Culture como UICulture debe definirse como inglés de Estados Unidos.

Los valores de estas claves se pueden modificar. Así, si tras cumplimentar el objeto RequestLocalizationOptions añadiéramos lo siguiente, cambiaríamos el parámetro de cultura a lan, y el de UICulture a lan-ui:

Determinando la referencia cultural mediante cookies

El segundo provider que por defecto intentará extraer la referencia cultural para la petición es el CookieRequestCultureProvider. Este provider, buscará una cookie con el nombre CookieRequestCultureProvider.DefaultCookieName.DefaultCookieName, que está definido como .AspNetCore.Culture, y utilizará su valor para configurar Culture y UICulture. Hasta donde yo sé, con este provider no se puede configurar una cultura diferente para UI o para Culture, sino que ambas se definirán con el mismo valor.

Para crear y ajustar una cookie con una determinada cultura, podríamos utilizar un código como el siguiente:

HomeController.cs

Determinando la referencia cultural mediante la cabecera Accept-language de la petición

El tercer y último provider que por defecto intentará determinar la referencia cultural de la petición, es el AcceptLanguageHeaderRequestCultureProvider. Este provider inspeccionará las cabeceras HTTP de la petición, en busca de la cabecera Accept-Language, y si la encuentra, utilizará su valor para ajustar las referencias culturales tanto de Culture como de UICulture.

Para probar este funcionamiento, lo más sencillo es que os vayáis a las preferencias de vuestro navegador, y cambiéis el idioma por defecto que se enviará a las páginas webs. Si queréis hacer pruebas con varias culturas de forma sencilla, lo más fácil es utilizar Postman o Curl. Como Postman suspende en accesibilidad y no puedo usarlo, mi amigo Curl viene a sacarme las castañas del fuego:

Como podréis ver, en función del valor de la cabecera Accept-Language, el HTML que os devolverá curl cambiará de idioma. ¿mola, a que sí? 🙂 ### Determinando la referencia cultural mediante la URL de nuestra petición

Existe un cuarto proveedor llamado RouteDataRequestCultureProvider, que es capaz de determinar la referencia cultural de una petición según su URL. De este modo, podemos tener una web del tipo: www.jmontiel.es/es-ES/, y otra www.jmontiel.es/en-US/. Este tipo de urls son las más adecuadas para el posicionamiento en buscadores, ya que a efectos prácticos, es como si por cada idioma tuviéramos una carpeta, y dentro de la misma, nuestra web completa traducida a ese idioma.

Pero así como los tres primeros providers han sido muy fáciles de configurar, este cuarto provider tiene un poco más de complicación (no mucha, pero es un poco más tedioso).

Como comenté más arriba, el pipeline de una petición en AspNet Core está formado por distintos middlewares que se van invocando en el orden en que fueron añadidos. En el momento en que el middleware de localización invoca a los tres proveedores que vienen preconfigurados en Asp.Net Core, ellos ya tienen la información necesaria para determinar la referencia cultural, pues pueden sacarla directamente desde la QueryString, las cookies o las cabeceras de la petición. Sin embargo, en el caso del RouteDataRequestCultureProvider, él necesita saber qué segmentos se pueden extraer de la URL de la petición, para ser capaz de determinar la referencia cultural… Y para esto, necesita que antes de que se invoque el Middleware de localización, antes ya se haya ejecutado el middleware de Routing. Si no, los datos de la ruta aún no estarán disponibles, y el proveedor no funcionará. Pero el middleware de routing se configura junto a MVC, y si ponemos el middleware de localización después del de MVC, nuestra localización no funcionará en nuestra aplicación MVC, porque cuando ésta se ejecuta, el middleware de localización aún no se ha ejecutado. Ya sé que es un poco complicado de entender. Os pondría un diagrama, en serio, pero no se me dan bien 😉

¿Cómo solventamos esto? Pues tenemos dos opciones. A mí me gusta más la B, así que si te quieres ahorrar la lectura de la A, no te culparé 😉

Opción A: Configurar el pipeline para que se bifurque según la ruta:

Veamos el siguiente código:

            /// Guardamos las culturas soportadas
            var supportedCultures = new List<CultureInfo>
            {
                new CultureInfo("es-ES"),
                new CultureInfo("en-US")
            };
            // Definimos las opciones para el middleware de localización
            var localizationOptions = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture("es-ES"),
                SupportedCultures = supportedCultures,
                SupportedUICultures = supportedCultures
            };
            /// Instanciamos el provider RouteDataRequestCulture
            var requestProvider = new RouteDataRequestCultureProvider();
            /* Y lo añadimos como primer provider del middleware de localización, para que sea el primero que intente obtener la referencia cultural.
            */
            localizationOptions.RequestCultureProviders.Insert(0, requestProvider);

            //Aquí empieza lo bueno. Le decimos a nuestra aplicación que utilice el middleware de Route:
            app.UseRouter(routes =>
            {
                /* Señor middleware Router. Si el path de la petición cumple con el patrón de la siguiente ruta, ejecute  el middleware de Localization, y luego el middleware de MVC.
                */
                routes.MapMiddlewareRoute("{culture=es-ES}/{*mvcRoute}", subApp =>
                {
                    subApp.UseRequestLocalization(localizationOptions);
                    subApp.UseMvc(mvcRoutes =>
                    {
                        mvcRoutes.MapRoute(
                            name: "default",
                            template: "{culture=es-ES}/{controller=Home}/{action=Index}/{id?}");
                    });
                });
            });
            /* Ahora, sigamos con la línea principal de middlewares, añadiendo el middleware de localización, y luego, el de MVC, solo para rutas que no tengan la cultura en la URL:
            */
            app.UseRequestLocalization(localizationOptions);
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "defaultNoCulture",
                    template: "{controller=Home}/{action=Index}/{id?}"
                    );
            });

¿Qué hemos hecho? Básicamente, crear una bifurcación en el pipeline. Si la ruta contiene una cultura al inicio del path de nuestra petición, dicha petición pasará primero por el middleware de localización, y luego se irá a MVC. Como para hacer esta bifurcación hemos usado el middleware Router, cuando se ejecute el middleware de localización, este ya tendrá la información de los componentes de la ruta, y podrá determinar la referencia cultural.

Y por último, añadimos al pipeline principal el middleware de localización, y después el de MVC, solo para las rutas que no tengan la cultura al principio del path de nuestra petición, para que también funcionen las rutas sin cultura. En este caso, el middleware de localización utilizará los tres providers restantes para intentar sacar la referencia cultural, de modo que en rutas sin cultura podremos seguir utilizando QueryString, cookies o Accept-language para obtenerla. Si los tres fallan, se utilizará la cultura por defecto.

¡Dios! ¿alguien se ha enterado? 😉

Opción B: El maravilloso invento de MiddlewareFilterAttribute

En Asp.Net Core 1.1 se añadió un nuevo atributo a la familia MVC, el MiddlewareFilterAttribute. Este atributo nos permite añadir un middleware como un filtro de MVC, por lo que ahora podemos añadir el middleware de localización cuando el middleware de MVC ya se está ejecutando, pero justo antes de que se ejecute la acción.

Para ello, tendremos que definir una pequeña clase que será la equivalente a Startup, que solo se encargará de añadir este middleware al pipeline de MVC:

Y ahora, en el startup, añadimos el filtro cuando configuramos el middleware de MVC:

¿Elegante, a que sí? De esta forma, lo tenemos todo solucionado. Cuando el middleware de localización se ejecute, el middleware de MVC (y por tanto el de Router) ya se habrán ejecutado, por lo que el proveedor RouteDataRequestCultureProvider ya podrá obtener los segmentos de ruta y extraer la referencia cultural.

En el proyecto de ejemplo AspNetCoreLocalizationExample.Routing, tenéis una web totalmente funcional utilizando esta aproximación.

Localizando nuestra aplicación

¡Bien! Ya tenemos la referencia cultural de nuestra aplicación. ¡Ahora ha llegado el momento de localizarla!

Localizando nuestras vistas

Para localizar una vista, haremos uso de la inyección de dependencias, y de una clase llamada IViewLocalizer. Como mejor se ve es con un trocito de código, así que vamos a ello:

index.cshtml

¿Qué estamos haciendo aquí?

Primero utilizamos la sentencia @inject, que sirve para pedirle al contenedor de inyección de dependencias que queremos inyectar algo a nuestra vista, en este caso, la clase IViewLocalizer.

Cuando en el Startup.cs añadimos el servicio MVC, también añadimos los servicios para localizar vistas: .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix). De este modo, el contenedor de dependencias sabe que IViewLocalicer deberá resolver hacia una clase que se encargará de traducir nuestra vista, buscando las traducciones dentro de un fichero resx en la ruta que corresponde a la vista actual, eligiendo el fichero que tenga el sufijo de la cultura actual de nuestra aplicación.

Cuando utilizamos la sentencia: Localizer["texto"], el motor de localización buscará en el archivo resx de la cultura seleccionada una clave que coincida exactamente con el texto entre comillas, y renderizará el valor de esa clave, es decir, su traducción. Si no existe archivo de recursos para la cultura seleccionada, se devolverá ese texto directamente.

Es por eso que en el nuevo modelo de traducción de Asp.Net Core es mucho más sencillo preparar nuestra aplicación para ser localizada, ya que no tenemos que crear un fichero de recursos predeterminado, sino que utilizamos directamente los textos en el idioma original, tan solo colocándolos como índice del objeto Localizer que hemos inyectado a la clase.

Aunque podemos inyectar IViewLocalizer en cada vista, quizá os interese añadir esas líneas en /Views/Shared/_ViewImports.cshtml:

@using AspNetCoreLocalizationExample.Routing
@using AspNetCoreLocalizationExample.Routing.Models
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject IViewLocalizer Localizer

Así, ya tendremos disponibles para todas las vistas el objeto _Localizer sin tener que añadir ni una línea de código a ninguna de ellas.

Si la vista que estamos traduciendo es la vista Index del controlador Home, y siendo que en la configuración del middleware de localización hemos especificado /resources como ruta de almacén de nuestros recursos, el fichero resx de recursos para traducir nuestra vista a inglés de Estados Unidos deberá estar ubicado en una de las dos rutas siguientes:

  • Resources/Views/Home/Index.en-US.resx
  • Resources/Views.Home.Index.en-US.resx

Como veis, hay dos patrones posibles: patrón de ruta, y patrón de punto.

  • En el patrón de ruta, la ruta del fichero de recursos coincide con la ruta de la vista, salvo por la subcarpeta Resources.
  • Por otro lado, en el patrón de punto, todos los ficheros estarán bajo el directorio Resources, y será el nombre de cada fichero, el que determinará la ruta completa de la vista a la que corresponde, utilizándose el punto como separador de directorios. Así, la vista /Views/Users/Register.cshtml, tendrá su fichero resx para inglés de Estados Unidos en /Resources/View.Users.Register.en-US.resx.

Como mencioné más arriba, los archivos de recursos tendrán como clave el texto en el idioma original, y como valor, el texto traducido al idioma para el que se esté definiendo el recurso.

Por ejemplo, el fichero Index.en-US.resx tendrá la siguiente estructura:

Clave Valor
Hoy es {0}. Today is {0}.
La cultura seleccionada es: {0}. The selected culture is: {0}.
Página de inicio Home page

Como podréis observar, este modo de crear archivos de recursos es mucho más sencillo, ya que en todo momento tendremos el texto en el idioma original en la columna de claves, y podremos añadir las traducciones de forma sencilla.

Nota: Cualquier fichero de recursos puede definirse para una cultura completa ([idioma]-[país]), o solo para el idioma. Si por ejemplo tenemos un fichero para nuestra vista llamado Index.en-US.resx, otro llamado Index.en.resx, y nuestra cultura actual es en-US, se cargará el fichero Index.en-US.resx. Pero si nuestra cultura es en-GB, se cargará el fichero Index.en.resx, ya que si no encuentra el fichero para la cultura completa, intentará cargar el fichero para la cultura más genérica, en este caso, para el idioma inglés, independientemente del país.

Localizando las Data Anotations

Imaginad que tenemos un formulario con un montón de campos, y el modelo que servirá para recibir los datos del formulario tiene un montón de Data Anotations* para definir el nombre a mostrar, si es o no requerido, las restricciones que tiene cada campo…

En Asp.Net Core MVC, traducir estas Data Anotations es la mar de sencillo. Tan solo tenemos que colocar los textos tal cuál en el idioma original, y a continuación, añadir un fichero de recursos para los otros idiomas, colocando como clave cada uno de los textos originales, y como valor, el valor de la traducción. Como veis, el resx es exactamente igual que con las vistas.

SampleFormModel.cs

SampleFormModel.en.resx

Clave Valor
Nombre: Name:
El nombre es obligatorio. The name is required.
El nombre debe contener más de tres caracteres. The name should contains more than three characters.
El nombre debe contener, como máximo, cincuenta caracteres. The name must contain a maximum of fifty characters.
Apellidos: Surname:
Los apellidos son obligatorios. The surname is required.
Los apellidos deben contener más de tres caracteres. Surname must contain more than three characters.
Los apellidos deben contener, como máximo, cincuenta caracteres. Surname must contain a maximum of fifty characters.

Al igual que con las vistas, los ficheros de recursos de los modelos pueden estar en las siguientes dos rutas (suponiendo que hayamos puesto la carpeta Resources como carpeta contenedore de nuestros recursos en la configuración del middleware:

  • /Resources/Models/Home/SampleFormModel.en.resx
  • /Resources/Models.Home.SampleFormModel.en.resx

Localizando controladores con IStringLocalizer y IHtmlLocalizer

Localizar controladores es igual de fácil que hacerlo con vistas o DataAnotations.

Al igual que con las vistas, los controladores deberán recibir mediante inyección de dependencia un objeto de tipo IStringLocalizer o IHtmlLocalizer (más adelante veremos las diferencias).

Veamos un fragmento de código:

HomeController.cs

Como podéis ver, creamos una variable privada de tipo IStringLocalizer, y en el constructor de nuestro controlador, solicitamos un parámetro de ese tipo. El contenedor de inyección de dependencias será capaz de satisfacer este parámetro, cargando el archivo de recursos que corresponda a nuestro controlador y al idioma de la cultura actual de la interfaz de la aplicación, y devolviendo una clase StringLocalizer configurada para localizar nuestro controlador.

En nuestro ejemplo, estamos traduciendo el texto “Texto de ejemplo pasado desde el controlador.”. Para traducirlo, crearemos un archivo de recursos en cualquiera de las siguientes dos rutas:

  • Resources/Controllers/HomeController.en.resx
  • Resources/Controllers.HomeController.en.resx

Como habréis advertido, los patrones de punto o de subdirectorios funcionarán para cualquier clase que vayamos a traducir.

El archivo de recursos podría ser algo así:

Clave Valor
Texto de ejemplo pasado desde el controlador. Sample text passed from the controller

También podríamos utilizar la clase IHtmlLocalizer en lugar de StringLocalizer. La diferencia es que StringLocalizer codificará cualquier HTML que intentes introducir en un texto, por lo que aparecerá en pantalla como parte del texto. Sin embargo, IHtmlLocalizer nos permite escribir código html en los propios textos a ser traducidos, aunque esto no se recomienda ya que traducir html puede ser complejo y muy propenso a errores.

Veamos un ejemplo:

HomeController.cs

Como podéis ver, es casi igual que el ejemplo anterior, salvo que IStringLocalizer ahora es IHtmlLocalizer, y esto nos permite colocar html en los textos a ser traducidos.

Así mismo, también hemos visto cómo el texto generado tenía la apariencia de una cadena con parámetros de formato, que se han proveído diréctamente desde la llamada a _Localizer. Esto nos permite crear textos localizados en los que el parámetro que le estamos inyectando pueda ir en una posición diferente en alguna de las traducciones. Por ejemplo, la sentencia _Localizer["¿Está aquí {0}?", "kastwey"], puede tener, en la traducción al inglés el aspecto: Is {0} here?, por lo que en español el nombre aparecerá al final, y en inglés, en mitad de la frase.

Recursos compartidos

Imaginad que tenemos una serie de textos comunes que podrían ser utilizados en varias partes diferentes de nuestra aplicación. Para esto surgen los recursos compartidos. En nuestro caso, por ejemplo, podríamos crear una clase llamada SharedResources, y utilizarla para compartir un fichero de localización desde varias vistas o controladores.

Veamos un ejemplo.

Primero, creamos una clase vacía. Esta clase solo se creará para poder pasarla como tipo a las clases IStringLocalizer o IHtmlLocalizer:

Y ahora, vamos a inyectar al layout de nuestras vistas un objeto de tipo IStringLocalizer<SharedResources>:

@inject IStringLocalizer<SharedResources> SharedLocalizer
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - Localization demo</title>
    ...
</head>
<body>
    <div class="page-header">
        <h1>@SharedLocalizer["Demostración de localización en Asp.NetCore MVC"]</h1>

Como podéis observar, el uso es exactamente igual que en las vistas anteriores con IViewLocalizer, solo que en este caso, estamos usando IStringLocalizer<ClaseATraducir>.

Si en nuestro HomeController.cs en lugar de usar IStringLocalizer<HomeController> usáramos IStringLocalizer<SharedResources>, podríamos compartir los recursos de SharedResources también desde este controlador. Incluso, podríamos tener dos parámetros en el constructor: uno para los recursos del controlador, y otro para los recursos compartidos:

Siguiendo la misma lógica que en los puntos anteriores, podremos determinar que nuestro archivo de recursos para esa clase será: /Resources/SharedResources.en.resx, ya que la clase SharedResources está en la raiz de nuestro proyecto.

Hay desarrolladores que prefieren utilizar la clase Startup como tipo para utilizar recursos compartidos: IStringLocalizer<Startup>.... Como la clase que se le pasa a IStringLocalizer no es más que un tipo, no importa qué tipo compartido utilicemos, solo que la clase exista junto a su correspondiente fichero resx.

Conclusiones

Aunque a mí personalmente me gusta mucho más esta manera de localizar y globalizar nuestras aplicaciones AspNet Core, lo cierto es que este sistema también tiene sus dos caras:

Cosas buenas:

  • Preparar nuestra aplicación para globalizarla es realmente muy sencillo y elegante gracias al middleware de localización y a los proveedores por defecto, que admiten diversos modos de obtener la referencia cultural.
  • Preparar la aplicación para localizarla en distintos idiomas requiere de muy poco esfuerzo, ya que no existen archivos de recursos predeterminados, sino que son los propios textos originales los que se traducirán más adelante en los ficheros de traducción.
  • Trabajar con archivos de recursos es más sencillo, ya que en la columna de la clave tendremos el texto original, y no tendremos que estar navegando hasta el fichero predeterminado para leer el contenido de dicha clave.

Cosas no tan buenas

  • Estamos utilizando cadenas de texto para vincular texto original y traducciones, por lo que si se modifica una cadena en el idioma original, tendremos que asegurarnos de cambiar esa cadena en todos los ficheros de recursos. Con la forma clásica, lo más que podía pasar era que la traducción no fuera fiel a los últimos cambios, pero en este caso, si la cadena original no coincide, el texto que aparecerá en los ficheros que no se hayan modificado será el texto del idioma predeterminado.
  • Trabajar con cadenas largas puede ser complicado y propenso a errores, sobre todo si dichas cadenas contienen HTML.

Aún así, por la rapidez y el poco esfuerzo que me lleva ahora preparar una aplicación para ser traducida, yo me quedo sin duda con este nuevo modo de globalizar y localizar aplicaciones que nos brinda AspNet Core.

¡Espero que este post os haya parecido interesante!

¡Un saludo!

_comentarios

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *