Archivo

Archive for the ‘Web’ Category

Razor, vistas y jQuery–solucionando la carga de componentes

En la programación web moderna nos encontramos en algunas ocasiones con que necesitamos ejecutar determinados scripts cuando todo el contenido está cargado, y en casi todos los casos acabamos dependiendo de jQuery para ello. Es necesaria realmente esta dependencia?

En otro tiempo, cuando la programación web era mucho más directa y básica y no existían los frameworks de los que disponemos hoy en día, la solución era sencilla: cargas jQuery (posiblemente al principio de la página), luego todo el script que se necesita y, en algún lugar, posiblemente en el último script que cargas, se pone en ejecución el módulo principal de la aplicación. Sencillo, lineal, básico… hoy por hoy, completamente insuficiente.

Si vemos cualquier aplicación de hoy basada por ejemplo en MVC y Razor (o cualquier otro framework moderno similar), con infinidad de páginas parciales que se van ensamblando desde una página superior, se pierde totalmente el concepto de linealidad del que gozábamos antes. Ya no es “obvio” dónde hacer las cosas.

Vamos a fabricarnos un ejemplo para poner todo esto en contexto. Imaginemos una página que carga sus paneles por medio de vistas parciales (o incluso Ajax) dependiendo de las selecciones del usuario. Uno de esos paneles, que hemos definido en una vista parcial (vamos a emplear ASP.NET, MVC y Razor en nuestro ejemplo) contiene un par de selectores de fecha:

@{
    ViewData["Title"] = "Fechas de entrada y salida";
}

<div>
  <label>Fecha de entrada</label>
  <input class="datefield" id="DateIn" />
</div>

<div>
  <label>Fecha de salida</label>
  <input class="datefield" id="DateOut" />
</div>

<script>
  $(function ()
  {
    $(".datefield").datepicker();
  });
</script>

Típica vista parcial (muy simplificada), que añade ciertos campos y, por supuesto, usa jQuery para inicializarlos con una vista de calendario una vez se haya cargado el documento. El problema aquí es el pequeño fragmento de JavaScript del final. En otro tiempo, sabíamos en qué fase de carga de la página estamos, pero en nuestro ejemplo, con una vista parcial, no podemos ni siquiera saber si los scripts necesarios ya están cargados, o en qué orden se ha llamado a nuestra vista para incluir los contenidos en la página. De hecho, si seguimos los patrones recomendados hoy en día para cargar script, sólo se carga jQuery (o cualquier otra librería) después del cuerpo principal de la página, mucho después de invocar nuestra vista parcial. El resultado?

jQuery_not_found_1

Cuando se ejecuta nuestra parcial, jQuery ni siquiera está todavía cargado.

La solución habitual para este tipo de casos es, simplemente, cargar jQuery al principio de la página y todo resuelto: las parciales pueden usar $(function) (o “$.ready(function)). Pero y si ni siquiera usamos jQuery para los calendarios o usamos frameworks que no requieren jQuery? No es un poco absurdo cargar todo un framework solamente para una función?

Y si nos hacemos nuestro propio $.ready()?

Realmente no es nada difícil, ni tenemos que depender de un framework. Sólo necesitamos una función (que llamaremos ready(), para variar) que antes de terminar de cargar la página simplemente acumula llamadas a funciones y que, después de la carga, las ejecuta directamente. Una versión básica sería tan simple como esto:

(function ()
{
  var pending = [];
  window.ready = function (action)
  {
    pending.push(action);
  }

  window.addEventListener('load', function ()
  {
    pending.forEach(function (action)
    {
      action();
    });
    window.ready = function (action)
    {
      action();
    }
  });
})();

Sencillo, no? Vale, un poco largo para ponerlo al principio de la página, pero si minimificamos (se escribe así?) quedaría como una sola línea (y ni siquiera larga) que incluso nos ahorra una petición extra si no la cargamos como un script externo:

<script>(function(){var n=[];window.ready=function(t){n.push(t)};window.addEventListener("load",function(){n.forEach(function(n){n()});window.ready=function(n){n()}})})();</script>

Si colocamos ese fragmento de script al principio de nuestras páginas (en el elemento HEAD), eventualmente en nuestra página de layout principal (_Layout.cshtml en jerga Razor), sin ni siquiera cargarlo desde un archivo separado, todo se vuelve mucho más fácil: podemos cargar (en nuestro ejemplo) tanto jQuery como jQuery.UI desde cualquier otro lugar que consideremos oportuno, y todo simplemente funciona:

<script>
  ready(function ()
  {
    $(".datefield").datepicker();
  });
</script>

De hecho, y si utilizamos la nueva sintaxis para escribir funciones (soportado por los navegadores modernos), queda incluso más bonito:

<script>
  ready(() => $(".datefield").datepicker());
</script>

El precio? Aproximadamente 180 bytes al principio de nuestras páginas, sin cargar ningún elemento externo ni depender de un framework cargado previamente. Y nuestro código, más limpio y fácil de mantener.

Anuncios
Categorías:Desarrollo, Tutorial, Web Etiquetas: , , , , ,

Aplicaciones internacionales con ASP.NET

Me sigue chocando seguir viendo soluciones de lo más artesanal para internacionalizar páginas web hospedadas con IIS, cuando la plataforma nos las da de serie y con un mínimo esfuerzo por parte del programador.

He visto usar parámetros en las URLs, pasarlos de módulo a módulo, y construir complejas funcionalidades para arrastrar el parámetro de página en página… Realmente no hay que hacer nada de eso, simplemente dejar que el navegador nos ayude y .NET (e IIS) lo aprovechen.

Todo empieza en el navegador

Es posible que no lo sepáis, pero los navegadores incluyen el idioma deseado en cada petición que realizan al servidor. Las peticiones HTTP (y HTTPS, por supuesto) incluyen una cabecera donde se incluye información útil para que el servidor pueda realizar su trabajo: desde la página que se está solicitando, un método (GET, POST) si soporta compresión o no, y, lo que nos interesa, el idioma esperado para la respuesta. La cabecera en cuestión se llama “Accept-Language” y da la lista de idiomas configurados por el usuario.

Cread (o usad una página web ya existente) en vuestro servidor – en este ejemplo usaremos una página WebForms clásica “TestPage.aspx”, y abridla con vuestro navegador favorito. Usando las herramientas de desarrollo de Chrome, por ejemplo, en la parte de visualización de red, encontraréis algo como esto, y la entrada en la que estamos particularmente interesados es “Accept-Language”:

AcceptLanguageHeader

En este ejemplo, Chrome está solicitando la página en inglés, y ofrece dos opciones: la “específica” (“en-US”, inglés de Estados Unidos) y la genérica (“en”, Inglés).

Obviamente, esa información está al alcance de una línea de código en el servidor. En nuestra página TestPage.aspx podemos incluir lo siguiente para comprobarlo:

protected void Page_Load(object sender, EventArgs e)
{
  var requestedCulture = Request.Headers["Accept-Language"];
  System.Diagnostics.Debug.WriteLine("Culture:" + requestedCulture);
}

En la ventana de Depuración veréis lo siguiente:

AcceptLanguageHeader_Server

Pero qué ocurre cuando configuramos Chrome para que solicite páginas en francés como idioma preferido e inglés como secundario? Podemos configurarlo navegando la url “chrome://settings” y abriendo “Ajustes avanzados”. Configuraremos los idiomas así:

ChromeLangSettings

y refrescamos la página: en el servidor veremos lo siguiente, con la nueva opción (“fr”) como primera preferencia:

AcceptLanguageHeader_Server_2

Y ahora, antes de que os pongáis a leer la cabecera “a pelo” en todas vuestras páginas, seguid leyendo… porque no tenéis que hacerlo!!!

Una página para experimentar

Vamos a añadir una simple página (también WebForms, aunque es exactamente lo mismo en Razor, o WebAPI, o MVC) para experimentar un poco. El código de la página es el siguiente (por simplicidad he eliminado el código posterior (“code behind”):

<%@ Page Language="C#" Inherits="System.Web.UI.Page" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Welcome</title>
</head>
<body>
    <h1>Welcome to my web!!</h1>
    <p>Today is <% =DateTime.Today.ToLongDateString() %></p>
</body>
</html>

Y vamos a navegarla con Chrome configurado para pedir contenidos en francés. El resultado es, como podéis esperar, una página en… inglés?

Intl_Nav_1

El primer problema es obvio: el ordenador que hospeda IIS corre un sistema operativo en español, así que las fechas salen en el idioma de Cervantes. Vamos mal… pero aunque parezca increíble, todo el proceso empieza con una sola línea en el archivo web.config. Cambiad la sección <system.web> de esto:

  <system.web>
    <compilation debug="true" targetFramework="4.5.2"/>
    <httpRuntime targetFramework="4.5.2"/>
  </system.web>

a esto:

  <system.web>
    <compilation debug="true" targetFramework="4.5.2"/>
    <httpRuntime targetFramework="4.5.2"/>
    <globalization culture="auto" uiCulture="auto" enableClientBasedCulture="true"/>
  </system.web>

Con una simple directiva IIS aplicará la región solicitada por el navegador a todas (si, todas!) las peticiones que gestione. Simplemente arrancad de nuevo, navegad la página otra vez y veréis el cambio:

Intl_Nav_2

La fecha aparece perfectamente formateada en francés… sin escribir ni una sola línea de código.

Al hacer la cultura de interface de usuario (uiCulture) automática y permitir a IIS que aplique la cultura requerida por el usuario (enableClientBasedCulture) lo que ocurre es que IIS, en las primeras fases de la cola de procesos que implica responder a una solicitud web, lee la cabecera Accept-Language, la analiza y busca la mejor cultura (CultureInfo) disponible y la aplica de manera automática al resto del proceso. Cuando en la página escribimos DateTime.Today.ToLongDateString(), la cultura está correctamente configurada para generar la fecha en el idioma correcto. Y tenemos acceso a dicha cultura automática por medio de la propiedad CurrentUICulture de CultureInfo. Si cambiamos nuestra página de pruebas a lo siguiente:

protected void Page_Load(object sender, EventArgs e)
{
  var requestedCulture = CultureInfo.CurrentUICulture;
  System.Diagnostics.Debug.WriteLine("Culture:" + requestedCulture.NativeName);
}

al navegarla con Chrome el resultado es el siguiente:

AcceptLanguageHeader_Server_3

Ya, pero, y el resto de los textos?

Si, hasta ahora hemos traducido una parte extremadamente pequeña de nuestra página. Ahora viene lo realmente importante, nuestros textos.

Y .NET ha facilitado la operación de obtener textos traducidos de forma automática desde el primer día, empleando archivos de recursos. Los archivos de recursos son colecciones de elementos dependientes de idioma (habitualmente cadenas de texto, pero también imágenes) gestionados por .NET. Para empezar a traducir nuestra página web vamos a empezar trasladando lo textos actuales a un archivo de recursos.

Empezaremos añadiendo un archivo con los textos en inglés (ya que nuestra web está actualmente en inglés). En Visual Studio cread una carpeta “Resources” y dentro de ella cread un nuevo archivo de recursos. Llamadlo “WebStrings.resx”:

NewResource

(Nota: si usáis WebForms hay una forma más específica de añadir archivos .resx y de gestionarlos desde la página. El método que estoy documentando es genérico y válido tanto para páginas web como para aplicaciones nativas).

Una vez creado el archivo, abridlo en VisualStudio:

StringEditor_1

Importante: antes de empezar a añadir los textos, cambiad (en la parte superior) el modificador de acceso para los recursos de “Interno” a “Público”.

Ahora añadid los diversos textos necesarios en la página:

StringEditor_2

Las cadenas que estamos añadiendo se convierten en miembros de una clase que podemos emplear en cualquier parte de nuestro código. Y lo mejor, al ser un tipo generado automáticamente, disponemos de Intellisense a la hora de trabajar con él:

UsingStrings_Intellisense

En nuestro ejemplo, y dado que la aplicación se llama “IntlWeb”, el namespace para nuestras cadenas es IntlWeb.Resources.WebStrings. Una vez reemplazados todos los textos, la página queda así:

<%@ Page Language="C#" Inherits="System.Web.UI.Page" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title><% =IntlWeb.Resources.WebStrings.Page_Title %></title>
</head>
<body>
    <h1><% =IntlWeb.Resources.WebStrings.Body_WelcomeMessage %></h1>
    <p><% =IntlWeb.Resources.WebStrings.Body_TodayIs %> <% =DateTime.Today.ToLongDateString() %></p>
</body>
</html>

Obviamente, nuestra página sigue mostrando mensajes en inglés. Ahora tenemos que añadir los textos en francés. Para ello (y tendréis que disculparme, no hablo francés así que los textos pueden no estar del todo correctos) duplicad desde VisualStudio el archivo WebStrings.resx y ponedle como nombre WebStrings.fr.resx. El nombre del archivo es extremadamente importante: el código del idioma debe ir entre el nombre y la extensión, y puede ser un idioma general (“.es.”) o un código completo cuando necesitamos más “finura” a la hora de ofrecer textos (“.es-ES.”). Una vez copiado, abridlo y cambiad los textos:

StringEditor_3

Si navegamos la página desde Chrome, el resultado será el siguiente:

Intl_Nav_3

Como no podría ser de otro modo, añadamos también textos en español. Como podréis imaginar el nombre del archivo será WebStrings.es.resx:

StringEditor_4

Y desde Chrome volved a abrir la configuración y cambiad los idiomas colocando el Español el primero de la lista (no es necesario eliminar el idioma francés):

ChromeLangSettings_2

Aceptad los ajustes, volved a la página y simplemente refrescadla:

Intl_Nav_4

Cuando una aplicación pide una de las cadenas de recursos guardadas en un archivo .resx, .NET busca primero el archivo con la cultura que más se aproxime y luego los va evaluando por prioridad descendente. Si, por ejemplo, la cultura pedida por el usuario es “Alemán de Austria” (“de-AU”) buscará primero un archivo con la extensión “de-AU.resx”, Si no lo encuentra continuará con la versión “Alemán” (".de.resx") y finalmente leerá los contenidos de la versión genérica (“.resx”). Procurad por ello emplear en lo posible archivos de idiomas lo más generales posibles: si empleáis “.es-MX.resx”, un usuario desde España verá los textos en el idioma predeterminado al no encontrar los recursos ni específicos (“es-ES.resx”) ni generales para el idioma (“.es.resx”).

Ya, pero… y el JavaScript?

Pues es una estupenda pregunta. Si bien localizar textos en el servidor está perfectamente soportado por .NET (e IIS) de “fábrica”, las páginas emplean cada vez más librerías de cliente escritas en JavaScript. Pero aunque dicha funcionalidad no exista en el servidor, no significa que no podamos añadirla muy fácilmente.

Volviendo a nuestro ejemplo, imaginemos que nuestra página usa código JavaScript para realizar alguna operación en el cliente. Nuestro archivo se llama “App.js” y actualmente usa textos en inglés, junto con controles en la misma página. En este ejemplo vamos a traducir (también!) los controles de la página desde JavaScript en lugar de utilizar los recursos del servidor como hemos hecho hasta ahora – traducción totalmente en el lado del cliente.

El código de la página es el siguiente:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <script src="App.js"></script>
</head>
<body>
  <button id="cmd1">Command 1</button>
  <button id="cmd2">Command 2</button>
  <button id="cmd3">Command 3</button>
</body>
</html>

y el archivo JavaScript (“App.js”) contiene lo siguiente:

window.onload = function ()
{
  var cmd1 = document.getElementById("cmd1");
  var cmd2 = document.getElementById("cmd2");
  var cmd3 = document.getElementById("cmd3");

  cmd1.addEventListener("click", function ()
  {
    alert("You clicked on the first button");
  });
  cmd2.addEventListener("click", function ()
  {
    alert("You clicked on the second button");
  });
  cmd3.addEventListener("click", function ()
  {
    alert("You clicked on the third button");
  });
};

Como podréis imaginar, no es que haga mucho, pero la idea es simple: cuando el usuario pulsa sobre uno de los botones, se muestra un texto informando de la importantísima operación que se ha llevado a cabo:

JavaScript_1

El tema es: cómo traducimos las cadenas incluidas en el programa JavaScript? Si bien IIS no tiene soporte directo para traducir javascript, nosotros podemos añadirlo.

Lo primero que vamos a hacer es algo parecido a lo que hicimos con nuestra página: convertir las cadenas de texto en variables que guardaremos en sendos archivos javascript, empleando un nombre que nos permita identificarlos por idioma. El primero que escribiremos será el archivo con los textos genéricos, en nuestro caso, en inglés. Para identificar que el archivo contiene los textos de nuestra aplicación, lo llamaremos “App.strings.js”:

// App.strings.js
var strings =
  {
    cmd1Caption: "Button 1",
    cmd2Caption: "Button 2",
    cmd3Caption: "Button 3",
    cmd1Clicked: "You clicked on the first button",
    cmd2Clicked: "You clicked on the second button",
    cmd3Clicked: "You clicked on the third button",
  };

También modificaremos nuestro programa para usar las cadenas del archivo en lugar de las predeterminadas:

window.onload = function ()
{
  var cmd1 = document.getElementById("cmd1");
  var cmd2 = document.getElementById("cmd2");
  var cmd3 = document.getElementById("cmd3");

  cmd1.innerText = strings.cmd1Caption;
  cmd2.innerText = strings.cmd2Caption;
  cmd3.innerText = strings.cmd3Caption;

  cmd1.addEventListener("click", function ()
  {
    alert(strings.cmd1Clicked);
  });
  cmd2.addEventListener("click", function ()
  {
    alert(strings.cmd2Clicked);
  });
  cmd3.addEventListener("click", function ()
  {
    alert(strings.cmd3Clicked);
  });
};

Y finalmente, en la página añadiremos la referencia al archivo de cadenas (App.strings.js) además de nuestro código (App.js) en la cabecera de la página:

<head>
  <meta charset="utf-8" />
  <script src="App.strings.js"></script>
  <script src="App.js"></script>
</head>

Hasta ahora todo muy bien, pero nuestra página sigue funcionando únicamente en inglés y, de hecho, si la ejecutamos ahora veremos exactamente lo mismo que antes. Cómo conseguir ahora que los textos (o, mejor dicho, el archivo App.strings.js) se traduzca? Ahora es cuando IIS nuevamente vuelve a echarnos una mano. Vamos a añadir un “handler” al servidor que, aprovechando que IIS nos informará del idioma que desea el usuario, se encargue de devolvernos el archivo .strings.js correcto.

Cuando el handler reciba una petición para un archivo cuyo nombre termine en “.strings.js”, usará la propiedad CurrentUICulture para construir diversos nombres de archivo. Si por ejemplo el navegador solicita el archivo “app.strings.js” y el idioma es “de-AU”, primero buscará un archivo “app.de-AU.strings.js”, a continuación buscará “app.de.strings.js” y finalmente probará con “app.strings.js”.

using System.Globalization;
using System.IO;
using System.Web;

namespace IntlWeb
{
  public class JSLocalizer : IHttpHandler
  {
    private  const string Extension = ".strings.js";

    public bool IsReusable => true;

    public void ProcessRequest(HttpContext context)
    {
      var culture = CultureInfo.CurrentUICulture;
      var basePath = context.Request.AppRelativeCurrentExecutionFilePath;

      basePath = basePath.Remove(basePath.Length - Extension.Length);
      var physPath = context.Server.MapPath(basePath);

      // <filename>.ll-RR.strings.js
      var fileName = $"{physPath}.{culture.Name}{Extension}";
      if (File.Exists(fileName) == false)
      {
        // <filename>.ll.strings.js
        fileName = $"{physPath}.{culture.TwoLetterISOLanguageName}{Extension}";
        if (File.Exists(fileName) == false)
        {
          // <filename>.strings.js
          fileName = $"{physPath}{Extension}";
          if (File.Exists(fileName) == false)
          {
            context.Response.StatusCode = 404// File not found
            return;
          }
        }
      }

      var response = context.Response;
      response.ContentType = "text/javascript";
      response.Charset = System.Text.Encoding.UTF8.WebName;
      response.ContentEncoding = System.Text.Encoding.UTF8;
      response.Expires = 24 * 60;   // 1 day user caching

      response.WriteFile(fileName);
    }
  }
}

Solo queda registrarlo en el archivo web.config para que gestione las peticiones de cualquier archivo “*.strings.js”:

  <system.webServer>
    <handlers>
      <add name="Script String Localizer" verb="*" path="*.strings.js" type="IntlWeb.JSLocalizer" />
    </handlers>
  </system.webServer>

Finalmente, sólo nos queda probarlo. Vamos a añadir un archivo con los textos en español a nuestra aplicación JavaScript, copiando el archivo App.strings.js en uno que se llame App.es.strings.js. Cambiemos los textos y pidamos la página, a ver qué pasa:

// App.es.strings.js
var strings =
  {
    cmd1Caption: "Botón 1",
    cmd2Caption: "Botón 2",
    cmd3Caption: "Botón 3",
    cmd1Clicked: "Ha pulsado en el primer botón",
    cmd2Clicked: "Ha pulsado en el segundo botón",
    cmd3Clicked: "Ha pulsado en el tercer botón",
  };

Cuando el navegador solicita el archivo App.strings.js con los ajustes del navegador para solicitar contenido en castellano, la petición acabará en nuestro handler, que conoce el idioma esperado por el usuario (via la propiedad CultureInfo.CurrentUICulture). Primero busca un archivo App.es-ES.strings.js. Al no encontrarlo, busca la versión App.es.strings.js, que sí encuentra y devuelve al cliente. El resultado es:

Intl_Nav_5

Tanto el contenido de los botones como el mensaje mostrado por Javascript aparecen en castellano. Al igual que con los archivos .resx, ahora en el servidor tendremos archivos Javascript con versiones de los textos para cada idioma. El handler se encargará de devolver la mejor versión posible.

Este (largo!) artículo ha intentado describir de forma rápida las principales herramientas que permiten traducir de forma sencilla tanto aplicaciones como servicios web hospedados en IIS como aplicaciones de usuario escritas en JavaScript. Y recordad que todo esto empieza con una simple línea de configuración en web.config, que no está nada mal. Aprovechadla!