viernes, 31 de julio de 2009

Ajax: C# .Net 3.5 + jQuery (IV) – Importando un proxy javascript.

Ayer ya teníamos armada una infraestructura que nos permite invocar los web methods de una página .aspx desde el código javascript de esa misma página fácilmente y sin necesidad de código adicional o mantenimiento.

Esto es útil en el caso –más común- de los web methods que sirven a las funcionalidades de una página en particular. Hay, sin embargo, otros web methods que implementan funcionalidades compartidas por varias páginas y que por ello no se ubican en una u otra sino en forma separada, en web services (.asmx).

Una pequeña digresión: se habrán dado cuenta de que una página web y un servicio son muy parecidos. Esto es porque básicamente son la misma cosa: HttpHandlers, clases que responden a una petición HTTP. Sólo tienen sutiles diferencias en la implementación. Sigamos.

El código que ubicamos en la página base (en mi ejemplo la clase PageBase) incluye en el html el proxy javascript (a través de nuestra clase JSProxy) para las llamadas a los web methods de cada página:

using System;
using System.Text;
using System.Web.UI;
using System.Web.UI.HtmlControls;

namespace AjaxConjQueryJSProxy
{
    public abstract class PageBase : Page
    {
        protected override void OnInit(EventArgs e)
        {
            base.OnInit(e);

            //crea el proxy para ESTA página.
            this.AddHeaderScriptInclude("jquery-1.3.2.js");
            this.AddHeaderScriptInclude("json2.js");
            this.AddHeaderScriptInclude("JSProxy.js");

            StringBuilder javascriptProxyCode = JSProxy.Create(this);
            this.AddHeaderScript(javascriptProxyCode.ToString());
        }
(etcétera)

Por ello la función JSProxy.Create tomaba como argumento una página en particular representada por una instancia de la clase Page.

Pensemos un poco. ¿Cual sería la diferencia entre crear el proxy para una página y un servicio?

  • Para empezar, en una página los web methods son métodos estáticos, mientras que en un servicio web no.
  • Del lado de javascript, la función en no debería estar ubicada en el espacio de nombres “Page” sino en otro. Es decir que en vez de codificar “Page.Sum(3,4);” deberíamos hacer (en realidad es cuestión de gustos, pero debería ser en uno distinto de Page) “Page.[nombre del servicio].Sum(3,4);”. Esto tiene dos razones: la primera y mas obvia es que podría darse el caso de que el método del servicio a importar coincidiera con el nombre de alguno de la página, y la segunda es un leve TOC que sufrimos casi todos los programadores.

¿Y cuál es la diferencia entre el crear el proxy para métodos ubicados de la misma página y métodos ubicados en otra?

  • Sólo el espacio de nombres en el que ubicamos las funciones proxy de javascript. Si tenemos que llamar a un método de la misma página hacemos Page.Sum(3,4), si está ubicado en otra deberíamos hacer Page.MiOtraPagina.Sum(3,4).

Así que necesitamos dos variaciones adicionales que modifican ligeramente el método JSProxy.Create. Así que vamos a hacer lo siguiente. Renombremos al método “JSProxy.Create” como “JSProxy.CreateInternal” y convirtámoslo en privado de JSProxy. Luego crearemos los métodos públicos necesarios para que “desde afuera” todo siga igual. JSProxy.Create quedará así:

private static StringBuilder CreateInternal(string targetNamespace, Type type, string url, string ajaxNamespace, SearchMembers searchMembers)

El método toma ahora cinco parámetros:

targetNamespace: es el espacio de nombres donde se ubicarán las funciones creadas en javascript. Si le pasamos “Page” las funciones estarán definidas como “Page.Sum”, “Page.HelloWorld”, etc. Si le pasamos “Page.Servicio1” las funciones estarán definidas “Page.Servicio1.Sum”, “Page.Servicio1.HelloWorld”, etc.

type: es un objeto Type que representa la clase para la cual queremos crear un proxy.

url: es la url (la dirección de la página o del servicio) en donde se encuentran los métodos. ¿Recuerdan que la url de un método es “/Default1.aspx/Sum” o “/WebService1.asmx/HelloWorld”? La primera parte “/Default1.aspx” o “/WebService1.asmx” no puede deducirse del Type. Esto es porque el Type describe qué es el objeto, y no dónde o a través de qué dirección responde a las llamadas.

ajaxNamespace: es el espacio de nombres en donde se encuentra la función compartida de javascript “Call”. En el ejemplo que estamos siguiendo es siempre “Page.Ajax” (porque hacemos siempre “Page.Ajax.Call(…);” ), pero decidí que ya que tenía que modificar la función iba a introducir este cambio principalmente porque no me gustó mucho cómo quedaron armados los espacios de nombres y pienso modificarlos en algún momento.

searchMembers: indicará si hay que crear proxies para los web methods estáticos (en una página), de instancia (los de un servicio) o de todos (un handler o cualquier otra cosa), y si bien no es estrictamente necesario (podríamos simplemente generar proxies para todos los métodos marcados con WebMethodAttribute) sirve para acelerar un poco la recorrida.

La funcionalidad queda codificada así:

private static StringBuilder CreateInternal(string targetNamespace, Type type, string url, string ajaxNamespace, SearchMembers searchMembers)
{
    BindingFlags bindingFlags = BindingFlags.FlattenHierarchy | BindingFlags.Public;
    switch (searchMembers)
    {
        case SearchMembers.Instance:
            bindingFlags = bindingFlags | BindingFlags.Instance;
            break;

        case SearchMembers.Static:
            bindingFlags = bindingFlags | BindingFlags.Static;
            break;
    }
    MethodInfo[] methods = type.GetMethods(bindingFlags);

    //50 caracteres para cada método es una estimación, claro.
    StringBuilder builder = new StringBuilder(50 * methods.Length);
    builder.Append("$().ready( function() {");

    builder.AppendFormat("{0}.Namespace(\"{1}\");", ajaxNamespace, targetNamespace);

    foreach (MethodInfo publicStaticMethod in methods)
    {
        if (publicStaticMethod.GetCustomAttributes(typeof(WebMethodAttribute), true).Length == 0)
            continue;

        builder.AppendFormat("{0}.{1} = function(", targetNamespace, publicStaticMethod.Name);

        ParameterInfo[] parameters = publicStaticMethod.GetParameters();

        if (parameters.Length > 0)
        {
            foreach (ParameterInfo parameter in parameters)
                builder.AppendFormat("{0},", parameter.Name);

            builder.Remove(builder.Length - 1, 1);
        }

        builder.Append("){");
        builder.AppendFormat("return {0}.Call(\"{1}/{2}\"", ajaxNamespace, url, publicStaticMethod.Name);

        if (parameters.Length > 0)
        {
            builder.Append(",{");
            foreach (ParameterInfo parameter in parameters)
                builder.AppendFormat("{0}:{0},", parameter.Name);

            builder.Remove(builder.Length - 1, 1);
            builder.Append("});");
        }
        else
            builder.AppendFormat(");");

        builder.Append("};");
    }

    builder.Append("});");
    return builder;
}

Y ahora creamos el método público Create para que todo siga igual “hacia afuera”… y lo renombramos a CreateForPage (me gusta más,  sólo eso):

public static StringBuilder CreateForPage(Page page)
{
    return JSProxy.CreateInternal("Page", page.GetType(), page.ResolveUrl(page.AppRelativeVirtualPath), "Page.Ajax", SearchMembers.Static);
}

Nota: no estoy muy seguro de ese “page.ResolveUrl(page.AppRelativeVirtualPath)”, están avisados.

Una función para importar métodos web de un servicio:

public static StringBuilder ImportService(Type type, string url)
{
    if (!type.IsSubclassOf(typeof(WebService)))
        throw new ArgumentException("El tipo a importar debe representar un servicio web (debe heredar de WebService).", "type");

    string targetNamespace = string.Concat("Page.", type.Name);
    return JSProxy.CreateInternal(targetNamespace, type, url, "Page.Ajax", SearchMembers.Instance);
}

Y otra para importar métodos web de otra página:

public static StringBuilder ImportPage(Type type, string url)
{
    if (!type.IsSubclassOf(typeof(Page)))
        throw new ArgumentException("El tipo a importar debe representar una página (debe heredar de Page).", "type");

    string targetNamespace = string.Concat("Page.", type.Name);
    return JSProxy.CreateInternal(targetNamespace, type, url, "Page.Ajax", SearchMembers.Static);
}

Resta un pequeño detalle… esos “Namespaces” en javascript (“Page.Webservice1”, “Page.Ajax”) no se van a crear solos mágicamente. Si revisan un poco el código de arriba verán que en la línea 20 de CreateInternal estoy generando código javascript que invoca a una función llamada “Namespace” que debe estar ubicada junto a “Call”. Este lugar para nosotros es Page.Ajax, así que falta el código de Page.Ajax.Namespace( fullns ), que podemos ubicar en el ejemplo en el archivo JSProxy.js.

Lo que hace esta función es justamente crear el espacio de nombres que se le pasa. Si queremos que esté disponible el espacio “Page.Webservice1” la llamada será

Page.Ajax.Namespace(“Page.Webservice1”);

En javascript no existen los espacios de nombres como en c#. Lo que sea hace es… una metida de dedos, un truquito. Por ejemplo, para crear “Page.Webservice1” hacemos

var Page = new Object();
Page.Webservice1 = new Object();

así que la función Namespace queda codificada así:

Page.Ajax.Namespace = function(fullns) {
    var ns = fullns.split(".");
    eval("var x=" + ns[0]+";");
    if (typeof (x) == "undefined")
        eval(ns[0] + " = new Object(); ");

    if (ns.length < 2)
        return;

    var nsAcum = ns[0];
    for (var i = 1; i < ns.length; i++) {
        eval("var x=" + nsAcum + "." + ns[i] + ";");
        if (typeof (x) == "undefined")
            eval(nsAcum + "." + ns[i] + " = new Object(); ");

        nsAcum += "." + ns[i]
    }
}

Nota: estoy seguro de que hay formas más elegantes de hacer eso. No soy un codificador muy “ingenioso”, ésta funciona y punto.

Bueno, ya podemos probar. Agreguemos un servicio web a nuestro proyecto y agreguemos un método “HelloWorld” que devuelva “hola mundo” (¡no olviden descomentar el atributo ScriptService en la clase y de agregar el atributo ScriptMethod a HelloWorld! Acabo de perder media hora dando vueltas con eso).

using System.ComponentModel;
using System.Web.Script.Services;
using System.Web.Services;

namespace AjaxConjQueryJSProxy
{
    /// 
    /// Summary description for WebService1
    /// 
    [WebService(Namespace = "http://tempuri.org/")]
    [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
    [ToolboxItem(false)]
    // To allow this Web Service to be called from script, using ASP.NET AJAX, uncomment the following line. 
    [System.Web.Script.Services.ScriptService]
    public class WebService1 : System.Web.Services.WebService
    {
        [WebMethod]
        [ScriptMethod(ResponseFormat= ResponseFormat.Json)]
        public string HelloWorld()
        {
            return "Hello World";
        }
    }
}

Ahora vamos a hacer que se pueda llamar a ese servicio desde el javascript de una página. Pongamos por caso “Default.aspx”. Como esto es específico de cada página, es razonable que el código para incluir el proxy esté en cada página que lo requiera. En este caso debemos agregar:

protected override void OnPreLoad(EventArgs e)
{
    string proxyWebService1 = JSProxy.ImportService(typeof(WebService1), "/WebService1.asmx").ToString();

    HtmlGenericControl script = new HtmlGenericControl("script");
    script.Attributes.Add("type", "text/javascript");
    script.InnerHtml = proxyWebService1;
    this.Header.Controls.Add(script);

    base.OnPreLoad(e);
}

Y sin hacer nada más tenemos disponible la llamada a HelloWorld de WebService1 en el javascript de Default.aspx haciendo:

$().ready(function() {
    alert(Page.WebService1.HelloWorld());
});

La solución completa está en Google Code.

Actualización: en la próxima entrada llevamos las cosas al extremo prototipando proxies en javascript de objetos c#.


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.: