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: AspNetCore.LocalizationExample y AspNetCoreLocalizationExample.Routing. Veremos más adelante sus diferencias y repasaremos el código.
Podéis bajar el proyecto de aquí: https://github.com/kastwey/AspNetCore.LocalizationExample/.
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.
En la versión 2.1 de Asp.Net Core, el propio SDK referencia un paquete llamado AspNetCore.App, que incluye todos los paquetes habidos y por haber que necesitaremos para trabajar con AspNet Core MVC. Anteriormente se llamaba Microsoft.AspNetCore.All, y se podía desinstalar para instalar solo los paquetes que quisiéramos… Ahora parece que no es así, así que como los paquetes de localización ya se incluyen en este nuevo metapaquete, no tendremos que instalar nada adicional.
A título informativo, comentaros que los paquetes de localización que vamos a utilizar son los siguientes:
- Microsoft.AspNetCore.Localization (aquí se encuentra el middleware que determinará la referencia cultural de una petición mediante cookies, QueryString o header accept-language)
- 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:
- QueryStringRequestCultureProvider (hace uso de un parámetro QueryString para obtener la referencia cultural)
- CookieRequestCultureProvider (buscará una cookie concreta para intentar determinar la referencia cultural de su valor)
- 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 AspNetCore.LocalizationExample (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, 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[]
{
"es-ES",
"en-US"
};
/* Cumplimentamos el objeto RequestLocalizationOptions. Le decimos que las culturas soportadas, tanto para UI como para métodos son las definidas en el array anterior. Así mismo, 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).
*/
var localizationOptions = new RequestLocalizationOptions();
localizationOptions.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures)
.SetDefaultCulture(supportedCultures[0]);
// Guardamos estas opciones en el contenedor de dependencias, pues luego las necesitaremos para satisfacer un parámetro del siguiente método y para pedirlo en uno de nuestros controladores, y así extraer la lista de idiomas soportados:
services.AddSingleton(localizationOptions);
// 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 39 (services.AddSingleton(localizationOptions);)
public void Configure(IApplicationBuilder app, IHostingEnvironment env, RequestLocalizationOptions options)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
// ¡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:
var localizationOptions = new RequestLocalizationOptions();
localizationOptions.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures)
.SetDefaultCulture(supportedCultures[0]);
var qsCultureProvider = options.RequestCultureProviders[0] as QueryStringRequestCultureProvider;
qsCultureProvider.QueryStringKey = "lan";
qsCultureProvider.UIQueryStringKey = "ui-lan";
chrome### 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
public IActionResult SetLanguage(string culture, string returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
// Caduca después de un año a contar a partir de ahora:
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
);
return LocalRedirect(returnUrl);
}
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[] { "es-ES", "en-US" };
// Definimos las opciones para el middleware de localización
var localizationOptions = new RequestLocalizationOptions();
localizationOptions.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures)
.SetDefaultCulture(supportedCultures[0]);
/// 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:
public class LocalizationPipeline
{
public void Configure(IApplicationBuilder app, RequestLocalizationOptions options)
{
app.UseRequestLocalization(options);
}
}
Y ahora, en el startup, añadimos el filtro cuando configuramos el middleware de MVC:
services.AddMvc(mvcOptions =>
{
mvcOptions.Filters.Add(new MiddlewareFilterAttribute(typeof(LocalizationPipeline)));
});
¿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 AspNetCore.LocalizationExample.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
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = Localizer["Página de inicio"];
}
<h2>@Localizer["Página de inicio"]</h2>
¿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 AspNetCore.LocalizationExample.Routing
@using AspNetCore.LocalizationExample.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 añadir en nuestro Startup (como ya vimos anteriormente), la llamada a AddDataAnnotationsLocalization()
al añadir los servicios de MVC con services.AddMvc()...
, 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
[Display(Name = "Nombre:")]
[MinLength(3, ErrorMessage = "El nombre debe contener más de tres caracteres.")]
[MaxLength(50, ErrorMessage = "El nombre debe contener, como máximo, cincuenta caracteres.")]
[Required(ErrorMessage = "El nombre es obligatorio.")]
public string Name { get; set; }
[Display(Name = "Apellidos:")]
[Required(ErrorMessage = "Los apellidos son obligatorios.")]
[MinLength(3, ErrorMessage = "Los apellidos deben contener más de tres caracteres.")]
[MaxLength(100, ErrorMessage = "Los apellidos deben contener, como máximo, cincuenta caracteres.")]
public string Surname { get; set; }
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
public class HomeController : Controller
{
private readonly IStringLocalizer<HomeController> _localizer;
public HomeController(IStringLocalizer<HomeController> localizer)
{
_localizer = localizer;
}
public IActionResult Index()
{
ViewData["controllerText"] = _localizer["Texto de ejemplo pasado desde el controlador."];
return View();
}
Como podéis ver, creamos una variable privada de tipo IStringLocalizer
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
public class HomeController : Controller
{
private readonly IHtmlLocalizer<HomeController> _localizer;
public HomeController(IHtmlLocalizer<HomeController> localizer)
{
_localizer = localizer;
}
public IActionResult Index()
{
var email = "kastwey@techdencias.net";
ViewData["controllerText"] = _localizer["Mi dirección de e-mail es: <a href=\"mailto:{0}\">{0}</a>", email];
return View();
}
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:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace AspNetCore.LocalizationExample.Routing
{
public class SharedResources
{
}
}
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:
public class HomeController : Controller
{
private readonly IStringLocalizer<HomeController> _localizer;
private readonly IStringLocalizer<SharedResources> _sharedLocalizer;
public HomeController(IStringLocalizer<HomeController> localizer, IStringLocalizer<SharedResources> sharedLocalizer)
{
_localizer = localizer;
_sharedLocalizer = sharedLocalizer;
}
public IActionResult Index()
{
ViewData["sharedControllerText"] = _sharedLocalizer["Texto proveniente de Shared resources."];
ViewData["controllerText"] = _localizer["Texto proveniente de HomeController resources."];
return View();
}
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.
Cambiando de cultura dentro de nuestra aplicación
En los proyectos de ejemplo podéis ver dos maneras de implementar cambio de idioma: mediante cookies (AspNetCore.LocalizationExample), y mediante la adición de prefijos de ruta (AspNetCore.LocalizationExample.Routing).
Cambiando la cultura mediante cookies
Lo primero será mostrar la lista de idiomas disponibles a los que cambiar. Para ello, he creado un ViewComponent con todo lo necesario, y lo he incrustado en mi plantilla:
public class LanguageListViewComponent : ViewComponent
{
private readonly RequestLocalizationOptions _locOptions;
public LanguageListViewComponent(RequestLocalizationOptions locOptions)
{
_locOptions = locOptions;
}
public async Task<IViewComponentResult> InvokeAsync()
{
var cultureItems = _locOptions.SupportedUICultures.Select(c => new LanguageModel { Culture = c.Name, Name = c.NativeName, Active = CultureInfo.CurrentCulture.Name == c.Name });
return await Task.FromResult(View(cultureItems));
}
}
Y aquí la vista asociada a este ViewComponent
@using System.Globalization
@using AspNetCore.LocalizationExample.Models
@model IEnumerable<LanguageModel>
@{
var returnUrl = string.IsNullOrEmpty(Context.Request.Path) ? "~/" : $"~{Context.Request.Path.Value}";
}
<ul>
@foreach (var item in Model)
{
if (item.Active)
{
<li>@item.Name</li>
}
else
{
<li>
<a asp-action="SetLanguage" asp-controller="Home" asp-route-culture="@item.Culture" asp-route-returnUrl="@returnUrl">@item.Name</a>
</li>
}
}
</ul>
Lo siguiente es incrustar el ViewComponent en mi layout:
<h2>@SharedLocalizer["Idiomas"]</h2>
@await Component.InvokeAsync("LanguageList")
Y por último, creamos la acción SetLanguage
, que es a la que dirigen todos los enlaces de los diferentes idiomas pintados por el componente anterior. Esta acción inyectará la cookie de idioma, y redirige de nuevo a la página actual.
public IActionResult SetLanguage(string culture, string returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
);
return LocalRedirect(returnUrl);
}
¿Sencillo, verdad?
Cambiando la cultura mediante ruta
Este procedimiento podéis encontrarlo en el proyecto de ejemplo AspNetCore.LocalizationExample.Routing.
Lo primero, al igual que con el ejemplo anterior, será mostrar la lista de idiomas disponibles a los que cambiar. Para ello, he creado un ViewComponent con todo lo necesario, y lo he incrustado en mi plantilla:
public class LanguageListViewComponent : ViewComponent
{
private readonly RequestLocalizationOptions _locOptions;
public LanguageListViewComponent(RequestLocalizationOptions locOptions)
{
_locOptions = locOptions;
}
public async Task<IViewComponentResult> InvokeAsync()
{
var requestCulture = HttpContext.Features.Get<IRequestCultureFeature>();
var cultureItems = _locOptions.SupportedUICultures.Select(c => new LanguageModel { Culture = c.Name, Name = c.NativeName, Active = CultureInfo.CurrentCulture.Name == c.Name });
return View(cultureItems);
}
}
Y aquí la vista asociada a este ViewComponent:
@using System.Globalization
@using AspNetCore.LocalizationExample.Routing.Models
@model IEnumerable<LanguageModel>
<ul>
@foreach (var item in Model)
{
if (item.Active)
{
<li>@item.Name</li>
}
else
{
<li><a href = "@Url.Action((string)ViewContext.RouteData.Values["action"], (string)ViewContext.RouteData.Values["controller"], new { culture = item.Culture})" > @item.Name</a></li>
}
}
</ul>
Como veis, para los idiomas que no sean el actual, el destino de cada enlace es el de la acción y controlador actual, pero con la cultura a la que queremos cambiar. Como la ruta de cultura está registrada en nuestro archivo Startup.cs, MVC es capaz de crear nuestro enlace con la ruta correcta hacia la nueva cultura.
En este caso, nos ahorramos la acción SetLanguage
del ejemplo de ajuste por cookies, ya que al cambiar el segmento de la cultura en la ruta, la página cambiará de idioma automáticamente.
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.
¿Y a vosotros, qué os parece?
¡Espero que este post os haya parecido interesante!
¡Un saludo!