Webs multilingüe con localización en MVC o Razor Pages

Diego Martin | 30 Apr 2020

Multilingual image

Hoy voy a hablar de webs y localización, pero antes me gustaría avisar de que mis motivaciones para escribir este artículo no son otras que las de compartir conocimiento y opiniones. No entraré a discutir servicios o proveedores concretos.

Si deseas tener visibilidad digital, para ti mismo o para tu empresa, seguramente hayas decidido tener una web pública. Seguramente también hayas tomado la decisión de mostrar la información en la forma y diseño deseado bien haciéndolo tú mismo (es divertido, pero requiere tiempo) o bien contratando los servicios de otra persona o utilizando algún motor de generación y personalización de webs.

Seguramente hayas comprado un dominio de tipo miempresa.com en algún lugar de registro de dominios como Ionos o GoDaddy. Hay muchísimos y muy buenas opciones disponibles. Personalmente yo me aseguraría de que cuenta con las siguientes características:

  • Barato y fiable
  • Permite configuración DNS
  • Permite reenviado de emails para que puedas contar con un email más profesional que tu email personal de Gmail o Outlook. De esta forma si alguien te escribe a pepe@miempresa.com ese email se redirigiría a tu email personal. Hay incluso formas para enviar emails como pepe@miempresa.com de manera gratuita si tienes por ejemplo una cuenta en Gmail.
  • Permite generación de certificado SSL gratuito. Nunca, jamás, pagues para tener un certificado SSL estándar para tu web. Si por alguna razón tu proveedor de dominio no tiene este servicio gratuíto, echa un vistazo a Let's encrypt. No hay excusa para que tu web no utilice protocolo https.

Es probable, también, que tengas un servicio para hospedar tu web (e.g: hosting) y que éste te permita ser utilizado con dominios personalizados como el que has comprado y con certificado SSL para atender el tráfico que vaya a miempresa.com.

Si ya tienes todo eso, enhorabuena, porque ya tienes visibilidad en la web. ¿Cuánta visibilidad? Eso dependerá de tu estrategia SEO y las buenas prácticas que lleves a cabo. Pero esto es un tema para otro día.

¿Es esto suficiente? Posiblemente no. ¿Deseas tener visibilidad solamente en un área local o por el contrario deseas abrazar la naturaleza global de internet y llegar a cualquier rincón del planeta? Si es así, ¿te has planteado la grave limitación que supone el hecho de que tu web esté en un solo idioma?

Localización

La localización es la técnica que se utiliza en una web para visualizar su contenido en diferentes culturas. Esto no solamente supone diferentes idiomas sino también diferentes formatos de fechas, símbolos, moneda, etc.

Por otro lado la globalización es un término bastante cercano que se refiere a adaptar la web para mostrar contenido diferente dependiendo del idioma y región que el usuario manifieste tener. Pero centrémonos en localización y, más concretamente, en permitir diferentes idiomas en tu web, por ahora.

Cuando uno empieza a meterse en temas de localización hay varias preguntas que surgen. Veamos algunas de las cuestiones que, ya os adelanto, no tienen una única respuesta válida porque todo son ventajas y desventajas que hay que poner en la balanza para que, al menos, la decisión final sea una decisión bien informada.

Cultura

Como ya he mencionado, la cultura es un identificativo ISO que determina región e idioma de un usuario (e.g: es, es-ES, es-MX, en, en-NZ, etc). La razón por la cual hay diferentes combinaciones entre región e idioma dentro de la cultura es porque normalmente una región especifíca cosas como el formato de fechas o moneda que puede ser diferente a otra región, aunque ambas compartan el mismo idioma. A su vez, una misma región puede tener varios idiomas diferentes.

La idea principal para la web es que cada petición http que le llege al servidor va a contener información sobre la cultura que el usuario desea aplicar al contenido. Dependiendo de esa cultura se puede determinar el idioma y, por tanto, se puede elegir si el contenido se muestra en un lenguaje o en otro. Luego veremos algún ejemplo práctico con .NET Razor Pages también aplicable para MVC.

Obtener cultura en una petición http

Hay muchas formas de obtener el idioma (y la cultura) en la que un usuario desea ver el contenido de una web.

Cabecera http Accept-Language

El idioma (la cultura) de un navegador se puede configurar. Ese código de cultura configurado pasa a formar parte de una cabecera http llamada Accept-Language que viaja en cada petición a un servidor web (e.g: Accept-Language: en-NZ). El servidor puede leer ese valor y automáticamente decidir que el usuario desea ver la información en inglés con las particularidades de la región de Nueva Zelanda, por ejemplo.

Si bien puede ser interesante utilizar esta cabecera para establecer el idioma por defecto en el que visualizar la información la primera vez, es extremadamente molesto esconder la opción de cambiar de idioma de otra forma. Piensa en un usuario que está viajando y que accede a internet con el PC de un hotel de un país cuyo idioma puede ser totalmente desconocido. Ahora imagina que este usuario accede a una web que se muestra en ese idioma que no comprende, y no es capaz de modificar el idioma ni en el navegador (por no entender el idioma de la configuración o directamente por no saber que existe esa posibilidad) ni en la web.

Cookies

Cuando un usuario accede a una web, se le puede preguntar por su idioma y región. Si además da su consentimiento para aceptar cookies en ese sitio web, esta preferencia puede quedar registrada en su navegador de modo que cada vez que vuelva a acceder a la misma web, el servidor web leerá la cookie que viaja en la petición web y podrá determinar en qué idioma mostrar el contenido y para qué región.

De nuevo, piensa en las implicaciones de este mecanismo si el cambio de idioma no es una opción explícita en subsiguientes visitas a la web.

Local Storage o Session Storage

Los navegadores tienen su propia pequeña base de datos. En lugar de guardar la preferencia con una cookie, tu aplicación web puede tener código javascript que, una vez elegida una cultura (recuerda cultura = idioima + región opcional) sea capaz de guardar esta preferencia en el local o session storage del navegador y colocar este valor en alguna cabecera. La idea es similar a las cookies y, por lo tanto, las consecuencias también.

Query parameter

Otra forma de especificar la cultura del usuario es de forma explícita en la URL como un string parameter (e.g: https: miempresa.com/contact?culture=en-NZ). Un botón o un enlace podría ser el encargado de formar esa URL automáticamente al pulsarlo. Pero si necesitamos que el usuario especifique a mano ese parámetro, es algo engorroso.

Personalmente me gusta la idea de que la cultura vaya especificada de alguna forma en la URL pero me inclino más por la idea de que no sea como query parameter sino que forme parte del host o del path. Veámoslo en la siguiente sección.

Cultura especificada en la URL

Como he comentado anteriormente, soy de la opinión de especificar la cultura de forma explícita en la URL para dar la posibilidad a un usuario de cambiarla y para facilitar la vida a los motores de búsqueda.

Una url debe identificar un recurso único y los motores de búsqueda escanearan cada una de las diferentes url considerándolas contenido único de alguna forma. Esto nos lleva a entender que cada página de contenido diferente debería tener su propia url.

¿Hay que traducir URL?

Pero es posible que nos surjan dilemas sobre la necesidad de traducir URLs. Si una web soporta español (e.g: es) e inglés (e.g: en), las URL ¿deben traducirse? Supongamos que tenemos una sección en nuestra web con datos de contacto. ¿Cuál de las siguientes opciones crees más adecuada?

Opción A

https://miempresa.com/contacto
https://miempresa.com/contact

Opción B

https://miempresa.com/es/contact
https://miempresa.com/en/contact

En la opción A las URLs son más significativas y legibles para cada uno de los idiomas ya que el recurso en sí está traducido. Con esta opción la cultura realmente no importa porque va implícita en el nombre del recurso. Se pueden tener tantos recursos (e.g: URLs) como idiomas o regiones se quieran soportar. Un motor de búsqueda que se encuentre las primeras URL no tendrá problema en asociar cada una de esas URLs con un idioma en concreto, aunque lo cierto es que una web bien articulada ya debería incluir de por si un atributo en la etiqueta html que especifica el idioma (e.g: <html lang="es">).

En la opción B ambas páginas de contacto tienen el mismo identificador de recurso (e.g: /contact), que encaja mejor en inglés que en español. Sin embargo la cultura está explícitamente indicado en la URL y esto aporta una ventaja adicional: las URL se pueden compartir y resulta obvio qué parte de la URL habría que cambiar para acceder al mismo recurso pero en diferente idioma.

Mi preferencia, sin duda alguna es la opción B, es además mucho más escalable porque se puede añadir soporte para más idiomas o regiones más fácilmente

Esta no es, ni mucho menos, la única opción para identificar recursos únicos en una URL haciendo explícito su idioma.

¿En qué parte de la URL debe ir especificada la cultura?

Si estáis de acuerdo conmigo, la cultura debe ir especificada de alguna forma en la URL pero quizás no como query parameter.

Antes que nada conviene repasar algunos conceptos para no perdernos. El siguiente gráfico muestra el nombre de cada una de las partes de una URL.

URL parts

  • Protocolo: también llamado esquema. En la web es http o https. Como ya he mencionado antes utiliza siempre https
  • Dominio raíz: o dominio a secas. Es lo que se compra, incluye el nombre del dominio y el nivel superior o top level (e.g: miempresa.com)
  • Nombre de dominio: normalmente corresponde al nombre de la empresa o la persona (e.g: miempresa). Sin espacios ni caracteres extraños y en minúscula, por supuesto.
  • Nivel superior: también conocido con las siglas TLD del inglés top level domain es la extensión (e.g: .es, .com, .io, co.uk, co.nz, etc). No siempre representa un país, a veces representa un concepto y a veces son simples modas.
  • Subdominio: es la parte que va antes del dominio raíz. El prefijo. Suelen ser gratis una vez que posees un dominio raíz, y el más popular es el www. porque tradicionalmente ha representado el world wide web, aunque en realidad puedes utilizar lo que quieras (e.g: info., education., loquesea.) o directamente ninguno.

Una vez visto esto, vamos a ver algunas opciones ¿Cuál te gusta más?

Opción A

https://miempresa.com/es/contact
https://miempresa.com/en/contact

Opción B

https://miempresa.es/contact
https://miempresa.co.uk/contact

Opción C

https://es.miempresa.com/contact
https://en.miempresa.com/contact

En la opción A, como se ha visto anteriormente, el idioma (o cultura) está de forma explícita en la URL y esto es muy interesante pero es parte del path. Esto de por sí no suele suponer ningún problema pero personalmente yo lo considero algo intrusivo porque da la sensación de que entra en el terreno de la aplicación web, de lo relativo. Imagina que cierta funcionalidad de tu web depende del path especificado en la URL para acceder a un recurso físico como un PDF guardado en disco. Ahora imagina que decides hacer tu aplicación web multilingüe y añades ese código de cultura al path de la URL. Estarías obligado a modificar la funcionalidad existente para acomodar los cambios que no tienen nada que ver con una región. No es mala opción pero prefiero las siguientes.

En la opción B el TLD o nivel superior del dominio raíz especifica inequívocamente una región. Puede ser muy interesante para la globalización y para la estrategia de algunas empresas, pero esta solución obliga a comprar más dominios y eso es bastante caro. Y eso suponiendo que están disponibles, porque podrían no estarlo. Además, se queda un poco corto si por ejemplo queremos comunicar al servidor en la URL el idioma y no solamente la región. La terminación .es identifica a España, sí. Pero en España hay varias lenguas como el euskera, el catalán o el gallego. Simplemente especificando en la URL la región, no se identifica inequívocamente el idioma dentro de esa región.

Esto nos lleva a mi preferida, la opción C. No solamente nos ahorramos dinero en dominios porque solo necesitaríamos uno (e.g: miempresa.com), el hecho de utilizar subdominios (normalmente gratuitos) nos permite identificar el idioma (e.g: es., en.) e incluso la región (e.g: en-nz.). Al ser parte del nombre del host, no afecta en absoluto a la aplicación web porque ésta debería funcionar tanto en miempresa.com como en es.miempresa.com o en localhost:8080. En principio, y sin menospreciar otras opciones, solamente le veo ventajas.

Ejemplo con .NET Razor Pages

Ha llegado el momento de ver esto desde un punto de vista más práctico. En .NET Core existen varios frameworks como MVC con o sin vistas, y el nuevo Razor Pages que, personalmente, me encanta por su simplicidad al no requerir controladores y al funcionar con ciertas convenciones dependiendo de la localización física de cada página Razor.

Hemos visto que la localización de una aplicación web se basa en dos conceptos principales dentro del programa en sí:

  1. Identificar la cultura del usuario
  2. Utilizar esa cultura para renderizar contenido en un idioma (y región) u otro.

Poco más se requiere aparte de disponer de texto en varios idiomas, claro.

Dentro de la clase Startup.cs en el método ConfigureServices donde se hacen los registros del contenedor de dependencias podemos indicar que queremos registrar lo necesario para utilizar los servicios de localización que AspNetCore nos provee.

var supportedCultures = 
    new[]
    {
        new CultureInfo("en"),
        new CultureInfo("es")
    };
    services.Configure<RequestLocalizationOptions>(
        options =>
        {
            options.DefaultRequestCulture = new RequestCulture("en");
            options.SupportedCultures = supportedCultures;
            options.SupportedUICultures = supportedCultures;
            options.AddInitialRequestCultureProvider(
                new CustomRequestCultureProvider(
                    context =>
                    {
                        //custom request culture logic here
                        var languageCode = context.Request.Host.Host.Split(".").FirstOrDefault();
                        if (languageCode != null
                            && supportedCultures.Any(x => x.Name == languageCode))
                        {
                            return Task.FromResult(new ProviderCultureResult(languageCode));
                        }

                        return Task.FromResult(new ProviderCultureResult("en"));
                    }));
        });

services.AddRazorPages()
    .AddViewLocalization(options => { options.ResourcesPath = "Resources"; });

En este código estamos definiendo dos idiomias soportados, inglés y español. También indicamos que por defecto, si la aplicación no logra determinar la cultura que la petición especifica, se utilice la cultura en correspondiente al inglés.

AspNetCore provee 3 proveedores middleware de cultura predefinidos capaces de leer el código de una petición web de cookies, de cabecera Accept-Language o de query parameters. Como a mi no me convence ninguna de esas formas he decidido implementar uno personalizado capaz de leer el subdominio de la parte del host de la URL. Si ese subdominio coincide con alguna de las culturas soportadas, se usa. En caso contrario se utiliza la cultura por defecto (i.e: en).

También se puede ver cómo añado Razor Pages (podría ser MVC) indicando que el lugar donde estarán las traducciones es en una carpeta que se llamará Resources.

Ahora necesitaríamos añadir este middleware personalizado a la pipeline. Para ello añadelo al principio del método Configure con lo siguiente

app.UseRequestLocalization();

Se asume que Razor Pages o MVC está también a continuación en la pipeline.

Ya está. Con esto la aplicación web será capaz de colocar dentro de CultureInfo.CurrentCulture la cultura extraída del subdominio del host de la URL en cada petición http que llegue al servidor.

Y ahora que podemos saber la cultura del usuario ¿qué hacemos con ello?

Ahora podemos utilizar esta cultura para renderizar texto en uno u otro idioma. En nuestro html, o mejor dicho en nuestas vistas o cshtml, en vez de colocar texto de forma estática hardcodeado (qué bonito y terrible anglicismo), podemos utilizar algun tag helper o servicio capaz de leer texto en uno u otro idioma de archivos de recursos resx.

Crea una carpeta que cuelgue del proyecto web, llamala Resources y crea dentro un Translations.resx que va a contener texto en inglés y un Translations.es.resx que va a contener el mismo texto pero en español. Por ejemplo, Translations.resx puede tener algo así:

<?xml version="1.0" encoding="utf-8"?>
<root>
    <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
        <xsd:element name="root" msdata:IsDataSet="true">
        </xsd:element>
    </xsd:schema>
    <data name="SAMPLE_ONE">
        <value>Welcome to Sunny Attic Software!</value>
    </data>
</root>

y Translations.es.resx lo mismo pero en español:

<?xml version="1.0" encoding="utf-8"?>
<root>
    <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
        <xsd:element name="root" msdata:IsDataSet="true">
        </xsd:element>
    </xsd:schema>
    <data name="SAMPLE_ONE">
        <value>¡Bienvenido a Sunny Attic Software!</value>
    </data>
</root>

Ahora necesitamos un servicio que sepa leer texto del recurso anterior que corresponda. Crea la siguiente clase en cualquier sitio dentro del proyecto:

public class CultureLocalizer
{
    private readonly IStringLocalizer _stringLocalizer;
    public CultureLocalizer(IStringLocalizerFactory stringLocalizerFactory)
    {
        var assemblyName = typeof(Startup).Assembly.GetName().Name;
        _stringLocalizer = stringLocalizerFactory.Create("Translations", assemblyName);
    }

    public LocalizedString Text(string key, params object[] arguments)
    {
        if (arguments == null)
        {
            return _stringLocalizer[key];
        }

        return _stringLocalizer[key, arguments];
    }
}

Con ayuda de una factoría podemos crear una instancia para IStringLocalizer indicando que busque en archivos de recursos resx llamados Translations situados dentro del mismo proyecto web en el que estamos.

Este servicio va a ser capaz, con ayuda de la cultura que el middleware ha establecido, de leer del Translations.XX.resx que corresponda con esa cultura. Para el en, como es por defecto, no hace falta especificar Translations.en.resx y basta con dejar uno por defecto.

Para que este servicio esté disponible, lo registramos en el contenedor de dependencias como singleton.

services.AddSingleton<CultureLocalizer>();

Y ya tenemos todo listo para probarlo. En cualquier vista o Razor Page, por ejemplo Contact.cshtml inyecta el servicio

@inject CultureLocalizer Localizer

y utilízalo para renderizar texto en función de la cultura actual.

<h1>@Localizer.Text("SAMPLE_ONE")</h1>

Una petición http GET a https://es.miempresa.com/contact renderizará

¡Bienvenido a Sunny Attic Software!

mientras que una petición a https://miempresa.com/contact o a cualquier /contact dentro de cualquier otro subdominio renderizará:

Welcome to Sunny Attic Software!

Buena suerte! (o good luck!)