Crear aplicaciones que soporten múltiples lenguajes en .NET es muy simple, y es una funcionalidad muy útil cuando tenemos clientes de varios países, ya bien sean clientes web, o clientes de una API.
En el post o de hoy, vamos a ver tanto una aplicación multilenguaje, que dará una respuesta al cliente en diferentes idiomas, en este caso una API.
Índice
1 - Mejor manera de configurar múltiples idiomas en .NET
La mejor manera de crear aplicaciones o páginas webs en diferentes idiomas es utilizando los Resource files
que nos invita a utilizar microsoft, los cuales tienen extensión .resx
Como vemos indicamos un nombre, este fichero será nuestro fichero por defecto, esto qué quiere decir? Quiere decir que, si intentamos acceder a un idioma que no existe, nos elegirá este archivo.
El contenido del archivo es un XML
pero si lo abrimos con visual studio veremos que se nos abre como un fichero con tres columnas, donde son:
Name
: Donde indicamos el nombre o key de la traducción, el cual tiene que ser únicoValue
: Donde definimos el valor de dicha key.Comentario
: Este campo es opcional y sirve para ayudar a entender que es lo que hace dicho campo, es muy útil por ejemplo si utilizamos algún software tipo Saas para las traducciones en idiomas que no conocemos.
El cual traduce en el siguiente fichero .xml
(obviamente he ignorado todo el código autogenerado)
<data name="IdentityNotFound" xml:space="preserve">
<value>Usuario no encontrado</value>
</data>
<data name="PersonalProfileNotFound" xml:space="preserve">
<value>Perfil personal no encontrado</value>
</data>
Una vez tenemos nuestro fichero de idiomas, vamos a añadir un segundo idioma, por ejemplo, inglés.
Para ello debemos crear otro fichero con el mismo nombre, pero cuando indicamos la extensión debemos indicar el idioma, para ello, indicamos un punto y las dos letras del código del idioma tal que así: nombreFichero.{códigoIdioma}.resx
.
Para nuestro ejemplo del inglés es el código en
mientras que en español es es
, pero en este caso no necesitamos el español porque ya tenemos nuestro fichero por defecto.
Nota: es en español por defectosi tenemos nuestra máquina en español.
Por lo que creamos un fichero llamado TraduccionErrores.en.resx
y debemos incluir todos los elementos de traducción que hemos creado en el primero.
Si por ejemplo quieres filtrar, no solo el idioma, sino también la región o el país puedes hacerlo, por ejemplo español españa es `es-ES
` mientras que español de argentina es `es-AR
` por lo que podríamos filtrar para dichos países si hiciéramos ficheros llamados:
TraduccionErrores.es-ES.resx
y TraduccionErrores.es-AR.resx
1.1 - Acceder a los recursos de idiomas en .NET
Una vez tenemos nuestros ficheros de recursos, queremos acceder a ellos.
Si nos hemos dado cuenta, cuando hemos creado el fichero .resx
, visual studio nos ha creado otro fichero llamado TraduccionErrores.Designer.cs
.
Este fichero contiene un “enlace” a través de métodos estáticos a dichas traducciones.
Nota: el fichero se crea y se actualiza automáticamente conforme editamos el fichero de recursos.
Por lo tanto para imprimir el error únicamente debemos invocar dicho error a través de su método estático.
Ejemplo de imprimir el valor:
Console.WriteLine(TraduccionErrores.PersonalProfileNotFound);
Resultado en la consola:
Aún así, esta forma de obtener las traducciones no es la mejor.
Esto es debido a que tiene dos limitaciones:
- Solo puedes acceder a un idioma.
- No puedes acceder a las traducciones de forma dinámica. Aunque esta opción tiene otras limitaciones
2 - Cómo funcionan los idiomas en .NET
.NET nos provee de una forma un tanto particular de la elección de los idiomas, la cual es a través del host, y esta información la tenemos en disponible en Thread.CurrentThread.CurrentUICulture
y Thread.CurrentThread.CurrentCulture
y esta se puede modificar simplemente asignándole el valor utilizando el tipo CultureInfo
.
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("en-IE");
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("en-IE");
Si cambiamos la “cultura” y ejecutamos el mismo código de antes, veremos como cambia el resultado a inglés:
Pero esta no es la única forma de cambiar el idioma, más adelante veremos otra forma.
2.1 - Diferencia entre CurrentUICulture y CurrentCulture
He mencionado dos formas de definir la cultura de nuestra aplicación las cuales son CurrentCulture
y CurrentUICulture
, pero cuál es su diferencia.
Cuando nos referimos a CurrentCulture
estamos indicando el “user locale” del sistema, que es lo que controla la numeración o el formato de la fecha, por ejemplo en estados unidos el formato de fecha es mes-dia-año mientras que en españa es dia-mes-año.
Cuando indicamos CurrentUICulture
nos estamos refiriendo al idioma de la interfaz, o lo que viene siendo la traducción.
3 - Múltiples idiomas en .NET
Primero de todo debemos pensar en el caso de uso, por que podemos querer acceso a múltiples idiomas en nuestro código.
El ejemplo más sencillo que se me ocurre es, Imagínate que trabajas en una gran multinacional, y esa multinacional te manda unos meses a trabajar a un país del que no dominas el idioma, como puede ser noruega.
En tu nueva oficina, como trabajan con clientes del país tienen todos los documentos en noruego, con los formatos de fecha noruegos, etc.
Pero tú, como no tienes ni idea del idioma te cambias el idioma a inglés o español.
Y ahora supongamos que tenemos acceso a una API donde consultamos datos, Si tienes un error en la API tú quieres que el usuario que manda esa request sea capaz de entenderlo, mientras que por detrás guardas el mensaje de error de los logs en el idioma nativo del sistema (noruego).
Para realizar esta acción debemos cambiar el idioma justo antes de consultar dicha traducción y volverlo a poner original justo después.
Nota: es muy común definir por defecto en cada request la cultura al idioma seleccionado por la empresa cliente, así puedes tener empresas de múltiples países cada una con los formatos de sus países.
3.1- Creación del Scope de la cultura.
Para realizar dicha acción vamos a utilizar un scope
.
Para el que no lo sepa, un scope es un bloque de código, que puede ser una línea o la mitad del programa.
La lógica de este scope es simple, asignar el valor del idioma que envía el usuario y cuando el scope termina, poner el idioma original de vuelta.
Para ello implementaremos IDisposable
(enlace a explicación IDisposable).
La cual luce como la siguiente:
public class CultureScope : IDisposable
{
private readonly CultureInfo _originalCulture;
private readonly CultureInfo _originalUICulture;
public CultureScope(CultureInfo culture)
{
_originalCulture = Thread.CurrentThread.CurrentCulture;
_originalUICulture = Thread.CurrentThread.CurrentUICulture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
}
public void Dispose()
{
Thread.CurrentThread.CurrentCulture = _originalCulture;
Thread.CurrentThread.CurrentUICulture = _originalUICulture;
}
}
De esta forma podemos ejecutar en el mismo bloque de código múltiples idiomas, como podemos ver en el ejemplo:
using (new CultureScope(CultureInfo.GetCultureInfo("en-IE")))
{
Console.WriteLine($"Esta traducción esta en ingles: {TraduccionErrores.PersonalProfileNotFound}");
}
using (new CultureScope(CultureInfo.GetCultureInfo("es-ES")))
{
Console.WriteLine($"Esta traducción esta en Español: {TraduccionErrores.PersonalProfileNotFound}");
}
Y como podemos ver el resultado contiene ambos idiomas:
4 - Librería para administrar múltiples idiomas en .NET
A partir de aquí no es completamente necesario, pero a mi si me parece útil, pero es cierto que no en todos los casos necesitamos una lógica como la que voy a explicar:
Un caso de uso para esta librería es si necesitas el mismo texto en múltiples idiomas.
Como he comentado en el punto 1.1 esta no es la única forma de utilizar múltiples idiomas en .NET, ahora mismo estamos utilizando una clase autogenerada, lo cual personalmente odio.
Así que el primer paso, es eliminar esa clase y crear una nueva vacía, eso sí, con el mismo nombre
public class TraduccionErrores
{
}
Además de que con la versión previa no estábamos traduciendo únicamente el texto que queremos, sino todo lo que está dentro del scope, que en este caso es el mensaje, pero el mensaje podría contener una fecha, queremos que esa fecha utilice el idioma que hemos seleccionado o queremos que utilice el de por defecto?
Quiero que únicamente se traduzca el texto, y no, la solución no es actualizar nuestro CultureScope
para utilizar solo CurrentUICulture
, la solución es crear una clase intermedia que haga de “seleccionador” de la traducción.
Como vemos, nuestro código falla
Para ello crearemos una clase que reciba un tipo genérico junto con el tipo IStringLocalizer
el cual nos lo da microsoft en su paquete Microsoft.Extensions.Localization
.
Debemos asignar el valor de nuestro IStringLocalizer al fichero que acabamos de crear y ello lo hacemos en el constructor estático de nuestra clase.
public class LocalizationUtils<TEntity>
{
private static readonly IStringLocalizer _localizer
static LocalizationUtils()
{
var options = Options.Create(new LocalizationOptions());
var factory = new ResourceManagerStringLocalizerFactory(options, NullLoggerFactory.Instance);
var type = typeof(TEntity);
_localizer = factory.Create(type);
}
}
Y para leer del mismo únicamente debemos indicar el campo que queremos leer a través de un indexer -https://www.netmentor.es/Entrada/indexer-csharp -
public static string GetValue(string field)
{
return _localizer[field];
}
Pero aquí no estamos indicando el idioma en ningún lado, así que creamos un método que recibe dicho idioma y lo aplica únicamente a nuestra lectura de la traducción.
Este sería el código completo:
public class LocalizationUtils<TEntity>
{
private static readonly IStringLocalizer _localizer;
static LocalizationUtils()
{
var options = Options.Create(new LocalizationOptions());
var factory = new ResourceManagerStringLocalizerFactory(options, NullLoggerFactory.Instance);
var type = typeof(TEntity);
_localizer = factory.Create(type);
}
public static string GetValue(string field)
{
return _localizer[field];
}
public static string GetValue(string field, CultureInfo cultureinfo)
{
using (new CultureScope(cultureinfo))
{
return GetValue(field);
}
}
}
Esta librería tiene un “problema” y es que al ser genérica debemos saber cual es el código o key de la traducción para acceder a la misma.
Y la forma de acceder al valor es a través del extension method .GetValue
que acabamos de crear.
{
var traduccion = LocalizationUtils<TraduccionErrores>.GetValue("PersonalProfileNotFound", CultureInfo.GetCultureInfo("en-IE"));
Console.WriteLine($"Esta traducción esta en ingles: {traduccion}");
}
{
var traduccion = LocalizationUtils<TraduccionErrores>.GetValue("PersonalProfileNotFound", CultureInfo.GetCultureInfo("sp-ES"));
Console.WriteLine($"Esta traducción esta en Español: {traduccion}");
}
///Resultado:
Esta traducción esta en ingles: Personal profile not found
Esta traducción esta en Español: Perfil personal no encontrado
Utilizando esta librería podemos simular el uso de la librería por defecto, únicamente debemos modificar, nuestra clase TraduccionErrores
la cual esta vacía, para leer en ella los campos, y tenemos varias opciones, leer un solo idioma, mandando dicho idioma en el constructor y posteriormente creando propiedades en la clase
public class TraduccionErrores
{
private readonly CultureInfo _culture;
public TraduccionErrores(CultureInfo culture)
{
_culture = culture;
}
public string PersonalProfile => LocalizationUtils<TraduccionErrores>.GetValue("PersonalProfileNotFound", _culture);
public string IdentityNotFound => LocalizationUtils<TraduccionErrores>.GetValue("IdentityNotFound", _culture);
}
y esta es la forma de acceder:
var traducciones = new TraduccionErrores(CultureInfo.GetCultureInfo("en-IE"));
Console.WriteLine($"Esta traducción esta en ingles: {traducciones.PersonalProfile}");
O también podemos cargar todos los idiomas en el fichero y devolverlos todos, aunque para esta opción debemos saber los idiomas de antemano.
public class TraduccionErrores
{
public static string PersonalProfileEn => LocalizationUtils<TraduccionErrores>.GetValue("PersonalProfileNotFound", CultureInfo.GetCultureInfo("en-IE"));
public static string PersonalProfileEs => LocalizationUtils<TraduccionErrores>.GetValue("PersonalProfileNotFound", CultureInfo.GetCultureInfo("es-ES"));
public static string IdentityNotFoundEn => LocalizationUtils<TraduccionErrores>.GetValue("IdentityNotFoundEn", CultureInfo.GetCultureInfo("en-IE"));
public static string IdentityNotFoundEs => LocalizationUtils<TraduccionErrores>.GetValue("IdentityNotFoundEn", CultureInfo.GetCultureInfo("es-ES"));
}
Esta librería es muy útil cuando estamos creando una librería que va a ser utilizada por otras librerías, por ejemplo, recibimos un código de error, y esta lo traduce automáticamente o lo añade a la respuesta Json.
De hecho veremos cómo crear un JsonConverter
que muestre el uso de esta librería
5 - Cómo detectar el idioma del usuario
Finalmente cuando trabajamos con web api debemos saber cómo detectar el idioma del usuario, en circunstancias normales, este vendrá en la cabecera.
Para acceder a este recurso de una manera muy sencilla, haremos uso de la interfaz IHttpContextAccessor
que viene dentro de la librería Microsoft.AspNetCore.Http.Abstractions
.
No te olvides que estas inyectando un servicio, lo que implica que debes añadirlo a tu contenedor de dependencias.
Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>()
Y una vez tenemos acceso a la interfaz creamos un extenion method que nos permita recoger el valor del header Accept-Language
.
public static CultureInfo GetCultureInfo(this IHeaderDictionary header)
{
using (new CultureScope(new CultureInfo("en")))
{
var languages = new List<(string, decimal)>();
string acceptedLanguage = header["Accept-Language"];
if (acceptedLanguage == null || acceptedLanguage.Length == 0)
{
return new CultureInfo("es");
}
string[] acceptedLanguages = acceptedLanguage.Split(',');
foreach (string accLang in acceptedLanguages)
{
var languageDetails = accLang.Split(';');
if (languageDetails.Length == 1)
{
languages.Add((languageDetails[0], 1));
}
else
{
languages.Add((languageDetails[0], Convert.ToDecimal(languageDetails[1].Replace("q=", ""))));
}
}
string languageToSet = languages.OrderByDescending(a => a.Item2).First().Item1;
return new CultureInfo(languageToSet);
}
}
Como nota he dejado que si no existe la cabecera elegirá “es
” como lenguaje por defecto.
Ahora únicamente debes instanciarlo en el constructor de tu servicio
public class PersonalProfile
{
private readonly IGetPersonalProfileDependencies _dependencies;
private readonly IDataProtector _protector;
private readonly TraduccionErrores _traduccionErrores;
public PersonalProfile(IGetPersonalProfileDependencies dependencies, IDataProtectionProvider provider,
IHttpContextAccessor httpcontextAccessor)
{
_dependencies = dependencies;
_protector = provider.CreateProtector("PersonalProfile.Protector");
_traduccionErrores = new TraduccionErrores(httpcontextAccessor.HttpContext.Request.Headers.GetCultureInfo());
}
/*Mas código*/
}
Y podrás acceder a los valores a través de la variable _traduccionErrores.{propiedad}
Cabe señalar que también puedes inyectar directamente tu fichero de traducciones en el contenedor de dependencias.
Finalmente cuando realizamos la llamada en postman podemos ver la traducción, tanto cuando utilizamos inglés como español:
Podemos observar el mismo endpoint y el mismo resultado, con la diferencia de que es un idioma u otro.
Conclusión
En este post hemos visto cómo configurar una web o aplicación para dar soporte a múltiples idiomas utilizando .NET.
En el entorno empresarial, es muy posible que sea necesario, ya que muchas empresas operan en múltiples países y no todos los países hablan el mismo idioma.
En el entorno personal, tener una web en múltiples idiomas va bien para el SEO, aunque en caso de crear una web en múltiples idiomas tienes que traducir también el contenido.