miércoles, 29 de julio de 2009

Ajax: C# .Net 3.5 + jQuery (II) – Resolviendo el problema de la serialización de fechas en JSON.

En la entrada anterior experimentaba la comunicación entre cliente (jQuery) y servidor (ASP .Net 3.5) mediante JSON. Más específicamente, entre una página web (html plano) y una clase que hereda de WebService, utilizando dos serializadores: System.Runtime.Serialization.Json.DataContractJsonSerializer y System.Web.Script.Serialization.JavaScriptSerializer.

Los web services son buenos para implementar funcionalidad consumida desde diferentes páginas (.aspx). Pero en una aplicación de gestión la gran mayoría de los métodos son específicos de una sola página, ya que están fuertemente vinculados con la interacción que esa página propone.

Estaba a punto de crear una enorme infraestructura para vincular servicios web (elementos .asmx) con páginas  (.aspx) en una relación “uno a uno” (repasen el post anterior) cuando fui amablemente desasnado por Martín (gracias), más avezado en .Net 3.5.

Cuestión que me entero de que podemos incluir un método web en cualquier página. Vamos a hacer una prueba (todo el código de este ejemplo está subido a Google Code).

  • Vamos al VS 2008, creamos un nuevo proyecto web y en el código c# de default.aspx incluimos:
using System;
using System.Web.Script.Services;
using System.Web.Services;
using System.Web.UI;

namespace AjaxConjQueryJSProxy
{
    public partial class _Default : Page
    {
        [WebMethod]
        [ScriptMethod(ResponseFormat = ResponseFormat.Json)]
        public static string HelloWorld()
        {
            return "HelloWorld";
        }

        [WebMethod]
        [ScriptMethod(ResponseFormat = ResponseFormat.Json)]
        public static int Sum(int a, int b)
        {
            return a + b;
        }

        [WebMethod]
        [ScriptMethod(ResponseFormat = ResponseFormat.Json)]
        public static DateTime AddMonths(DateTime date, int months)
        {
            return date.AddMonths(months);
        }

    }
}

¡Tan simple como hacer los métodos estáticos y marcarlos con el atributo System.Web.Services.WebMethodAttribute!

  • Ahora agreguemos el javascript de jQuery al proyecto y la respectiva referencia en el <head> de default.aspx:
<head runat="server">
    <title>Untitled Page</title>
    <script type="text/javascript" src="jquery-1.3.2.js"></script>
</head>
  • Una llamada al método “Sum” se vería, desde javascript, así:
function TestSum()
{
    $.ajax({
        type: "POST",
        data: '{"a":"3","b":"5"}',
        url: "/Default.aspx/Sum",
        contentType: "application/json; charset=utf-8",
        dataType: "json",
        async: false,
        success: function (response){ alert(response.d); },
        error: function(){alert("error");}
    });          
}

Si la invocamos veremos un alert con un “8” que es, casualmente, la suma de 3+5. Hay dos novedades, si es que vienen del primer post de esta serie: la primera es la url, que tiene la forma [Nombre de la página]/[Nombre del método] (“/Default.aspx/Sum”) y la segunda es que en este caso pasamos dos parámetros. Si ven bien la opción “data” verán que se los pasamos como una cadena con la representación en notación JSON de un objeto (NO es lo mismo que un objeto, es sólo su representación en JSON) con una propiedad correspondiente a cada parámetro del método.

Como pasamos los parámetros como cadenas en JSON necesitamos una función que tome los objetos reales y cree esa cadena. Como en todo algoritmo que maneje datos ingresados por el usuario, lo mejor (creo yo) es confiar en código ampliamente distribuido antes de hacerlo uno mismo. Créanme que se van a ahorrar más de un dolor de cabeza. Estas funciones tienen que lidiar con caracteres de escape y código malicioso ingresado por algún vivillio. Recuerden quién está al tope de los 25 errores de programación más peligrosos.

  • Así que vamos a agregar el código javascript mantenido por JSON.org de dos funciones: JSON.parse (converte una cadena JSON en un objeto) y JSON.stringify (convierte un objeto en una cadena JSON). Lo bajamos, lo agregamos al proyecto, y ahora el <head> de default.aspx debe verse así:
<head runat="server">
    <title>Untitled Page</title>
    <script type="text/javascript" src="jquery-1.3.2.js"></script>
    <script type="text/javascript" src="json2.js"></script>
</head>
  • Bien, vamos a probar los otros métodos, pero antes… soy un tipo vago, muy vago. Y eso es bueno, porque me da tanta fiaca escribir código que tiendo a la reutilización. Ya que todos los métodos de la página van a ser sincrónicos y todos apuntan a la misma url, podríamos…
var Page = new Object();                   
Page.Ajax = new Object();        
Page.Ajax.HandleError = function(xhr, textStatus, errorThrown)
{
    var error = JSON.parse(xhr.responseText);
    throw error;
}
Page.Ajax.Call = function(methodName, data)
{
    var returnValue = null;
    
    if(typeof(data) == "undefined" || data == null )
        data = "{}";
    
    else 
        data = JSON.stringify(data);
    
    $.ajax({
        type: "POST",
        data: data,
        url: "/Default.aspx/" + methodName,
        contentType: "application/json; charset=utf-8",
        dataType: "json",
        async: false,
        success: function (response){ returnValue = response.d; },
        error: Page.Ajax.HandleError
    });  
    
    return returnValue;
}

El código anterior corresponde a la función Page.Ajax.Call(methodName, data) (me gustan los namespaces en javascript) a la que le pasamos el nombre del método y los parámetros si es que los tiene. Podemos pasarlos como cadenas o como objetos. En este segundo caso la función se encarga de pasarlos a cadenas usando JSON.stringify.

Por otro lado esta función encapsula el manejo de errores que como verán es muy rudimentario, pero que podremos ir mejorando luego. La idea es que si hay una excepción del lado del servidor ésta se transforme en una excepción de javascript en forma transparente, creándose una cadena de excepciones continua desde el servidor hacia el cliente hasta llegar a la primera función que desencadena toda la pila de llamadas (veremos cómo queda al final).

  • Un paso más… podríamos hacer un “proxy” en javascript para cada método:
Page.HelloWorld = function()
{           
    return Page.Ajax.Call("HelloWorld");
}

Page.Suma = function(a, b)
{
    return Page.Ajax.Call( "Sum", {a:a,b:b} );
}

Page.AddMonths = function(date, months)
{
    return Page.Ajax.Call( "AddMonths", {date:date,months:months} );
}
  • Esto nos proporciona un acceso mucho más natural a los métodos del lado del servidor. Hagamos la prueba:
function TestHelloWorld()
{                
    try
    {
        alert(Page.HelloWorld());
    }
    catch(ex)
    {
        alert(ex);
        alert("Test error: " + ex.ExceptionType + "\n" + ex.Message);
    } 
}  

function TestSum()
{
    try
    {
        alert("2+5="+Page.Suma(2,5));
        alert("3-2="+Page.Suma(3,-2));
        alert("Next call should raise an exception: Sum(2.4,5)");
        alert("2.4+5="+Page.Suma(2.4,5));
    }
    catch(ex)
    {
        alert("Test error: " + ex.ExceptionType + "\n" + ex.Message);
    }             
}

function AddMonths()
{
    try
    {
        alert("3 Months from now="+Page.AddMonths( new Date(), 3).toString() );
        alert("2 Months ago="+Page.AddMonths( new Date(),-2).toString() );
    }
    catch(ex)
    {
        alert("Test error: " + ex.ExceptionType + "\n" + ex.Message);
    }                       
}

$().ready( function(){ 
    TestHelloWorld();
    TestSum(); 
    AddMonths();
});

Como verán, a la hora de programar la funcionalidad real, pongamos por ejemplo la suma, sólo tengo que hacer:

var s = Page.Suma(3,5);

…y del resto puedo olvidarme. Si hay errores en la infraestructura su corrección no afectará a la funcionalidad y viceversa. Por ejemplo… notarán un “pequeño” problema con las fechas. En realidad no es un “pequeño” sino un “enorme” problema con las fechas.

El problema es que JSON no define un formato estándar para fechas. Así que se suelen presentar diferencias de implementación. Sin ir más lejos, ASP.Net, cuando devuelve la respuesta del servidor, serializa las fechas en JSON utilizando la forma "/Date(1198908717056)/". Cuando jQuery intenta transformar de vuelta la cadena en objeto simplemente no entiende esa fecha y la interpreta como un string.

Problemas de delegar el control de una interfaz (entre cliente y servidor, ¿se acuerdan que lo había mencionado?) a una herramienta que no controlamos. Para no ser duros con ASP.Net y jQuery por el malentendido, digamos desde el vamos que es solucionable. Pero (por lo menos para mí) fue muy, MUY difícil.

Lo que me gustaría enfatizar, de todas maneras, es que es importante arreglarlo donde corresponde: adentro de Page.Ajax.Call, y no mirar para otro lado y dejar que cada programador se enfrente al problema de convertir las cadenas en fechas por sí mismo. Lo que sucedería si hiciésemos eso (y no por culpa de cada programador en particular) es que cada uno “haría la suya” y muchas de esas conversiones tendrían pequeños y sutiles problemas listos para explotar en el peor momento.

Mi “workaround” comenzó por interiorizarme un poco del problema, aquí. La solución, entonces, es interceptar la respuesta del servidor y transformar las cadenas "/Date(1198908717056)/" en algo que jQuery interprete como fecha.

Luego de pelearme un buen rato desistí de encontrar cómo es que jQuery necesita las fechas para entenderlas como tales. Por suerte las funciones JSON.parse y JSON.stringify permiten pasar una función para realizar conversiones especiales en uno y otro sentido que salva la vida en estos casos.

  • Así que primero encapsulamos la librería de JSON dentro de Page e implementamos nuestra propia deserialización de fechas:
Page.Utils = new Object();
Page.Utils.JSON = new Object();    
Page.Utils.JSON.stringify = function(obj)
{
    return JSON.stringify(obj);
}
Page.Utils.JSON.parse = function(text)
{
    return JSON.parse(text, function (key, value)
    {
        if (typeof value === 'string')
        {
            var found = /^\/Date\((\d+)\)\/$/.exec(value);
            if(found != null)            
                return new Date(parseInt(found[1],10));                
        }        

        return value;                
    });    
}
  • Y ahora le decimos a jQuery que queremos la respuesta del servidor como texto plano, y la parseamos utilizando Page.Utils.JSON.parse. Esto lo podemos hacer en Page.Ajax.Call, cambiando dataType de “json” a “text” y parseando al final. Queda:
Page.Ajax.Call = function(methodName, data)
{
    var returnValue = null;
    
    if(typeof(data) == "undefined" || data == null )
        data = "{}";
    
    else 
        data = Page.Utils.JSON.stringify(data);
    
    $.ajax({
        type: "POST",
        data: data,
        url: "/Default.aspx/" + methodName,
        contentType: "application/json; charset=utf-8",
        dataType: "text",
        async: false,
        success: function (response){ returnValue = response; },
        error: Page.Ajax.HandleError
    });  
    
    return Page.Utils.JSON.parse(returnValue).d;
}

There I fixed It! Un poco de alambre, pero todo queda tras bambalinas. Hay tres claves para el éxito en el desarrollo de software: encapsulamiento, encapsulamiento y encapsulamiento. No importa qué tan grave sea un problema o qué tan mal hayamos implementado una función, un procedimiento o una tecnología, el encapsulamiento hace que las explosiones queden contenidas y que podamos reemplazar o mejorar las partes por separado.

Sin ir más lejos es probable que éstos, mis primeros pasos en jQuery y en el .Net 3.5 estén viciados de errores y omisiones. Pero estoy seguro de que a medida que surjan los problemas se podrán ir arreglando sin que eso afecte a la funcionalidad codificada sobre esta estructura.

Les recuerdo que la solución de VS 2008 completa de este walkthrough está en google code.

Falta mucho camino todavía…

Un paso más en la batalla: Ajax: C# .Net 3.5 + jQuery (III) – Automatizando la creación de proxies de javascript.


Esta serie de posts son una especie de puesta al día de las ideas que se plasmaron en la arquitectura que utilizaba en mi trabajo en ElectroChance, la gran mayoría de ellas pergeñadas y/o recolectadas por Rick Hunter (por más que te cambies el nombre sabemos quién sos en realidad -sigan el link-), así que a él especialmente y a todos los que alguna vez conformaron ese dream-team, gracias.

No hay comentarios.: