JavaScript SOAP Client

Se ritieni utile questo articolo, considera la possibilità di effettuare una donazione (il cui importo è a tua completa discrezione) tramite PayPal. Grazie.

Da un po' di tempo a questa parte si sente molto parlare di AJAX, acronimo di "Asynchronous JavaScript and XML", una tecnologia basata su XMLHttpRequest (ormai supportato da tutti i principali browser). L'idea di fondo è piuttosto semplice (e nemmeno troppo innovativa) ma permette di aggiornare una pagina a seguito di chiamate al server senza dover ricaricare interamente la pagina stessa (utilizzando il client Web di GMail o Google Suggest possiamo vedere AJAX all'opera e comprendere meglio di cosa si tratta). Per altre informazioni su AJAX è possibile consultare Wikipedia.

In questo articolo viene proposta una soluzione basata su AJAX ma che, rispetto alle svariate implementazioni disponibili in rete, ha un grosso vantaggio: le chiamate vengono fatte a Web Service, così:

  1. lato server non dovremo far altro che esporre un Web Service con i metodi necessari (anziché generare pagine dinamiche che forniscano risposte personalizzate, basate su un meta-linguaggio proprietario o su un XML generico)

  2. lato client sfrutteremo la descrizione del servizio (WSDL, Web Service Description Language) per processare in modo trasparente la risposta ad una chiamata, utilizzando direttamente i tipi di ritorno (indipendentemente dalla loro complessità) riproducendo così quella che è la generazione della classe proxy in .NET (cioè quello che fa Visual Studio all'aggiunta di un Web Reference o il tool dell'SDK "wsdl.exe")

Lo schema seguente mostra il flusso seguito dal client SOAP per le chiamate asincrone:

Client SOAP: esecuzione di una chiamata asincrona

Il client (mediante una funzione JavaScript) richiama il metodo SOAPClient.invoke specificando:

  • l'URL del Web Service (si noti che molti browser, per ragioni di sicurezza, limitano la chiamata via XMLHttpRequest al dominio della pagina corrente)

  • il nome del metodo del Web Service da eseguire

  • i parametri da passare al metodo del Web Service

  • la modalità di esecuzione della chiamata (asincrona = true, sincrona = false)

  • il metodo di callBack (opzionale per chiamate sincrone) da eseguire al termine dell'operazione (utilizzo del risultato in chiamate asincrone)

Il metodo SOAPClient.invoke esegue le seguenti operazioni (la numerazione è riferita al diagramma di esecuzione di chiamate asincrone):

  1. recupera la descrizione del servizio (WSDL). Se è la prima volta nel contesto corrente che viene invocato il Web Service la descrizione viene richiesta al server e poi archiviata per eventuali richieste future

  2. costruisce una richiesta SOAP (la versione di SOAP utilizzata è la 1.1) per l'invocazione del metodo (con i relativi parametri) e la invia al server

  3. quando il server risponde con il risultato dell'operazione richiesta, il client elabora la risposta e, utilizzando la descrizione del servizio, costruisce gli oggetti JavaScript corrispondenti (classe proxy)

  4. se la chiamata è stata effettuata in modo asincrono viene invocato il metodo di callBack specificato; se invece il client era in attesa della risposta in modalità sincrona, viene restituito l'oggetto corrispondente

Implementazione

Dopo aver chiarito il principio di funzionamento del client SOAP per chiamare via JavaScript un Web Service non ci resta che analizzarne l'implementazione.

Iniziamo dalla classe per la definizione dei parametri da passare al metodo del webservice: SOAPClientParameters

function SOAPClientParameters()
{
    var _pl = new Array();
    this.add = function(name, value)
    {
        _pl[name] = value;
        return this;
    }
    this.toXml = function()
    {
        var xml = "";
        for(var p in _pl)
            xml += "<" + p + ">" + SOAPClientParameters._serialize(_pl[p]) + "</" + p + ">";
        return xml;    
    }
}

Il codice è molto semplice: un array associativo (dictionary) interno contiene l'elenco dei parametri (in chiave il nome del parametro e nel valore il relativo valore impostato); il metodo add consente di aggiungere altri parametri e il metodo toXml richiama la funzione ricorsiva SOAPClientParameters._serialize per la serializzazione in XML da accodare alla richiesta SOAP (si veda il metodo SOAPClient._sendSoapRequest):

SOAPClientParameters._serialize = function(o)
{
    var s = "";
    switch(typeof(o))
    {
        case "string":
            s += o.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); break;
        case "number":
        case "boolean":
            s += o.toString(); break;
        case "object":
            // Date
            if(o.constructor.toString().indexOf("function Date()") > -1)
            {
                var year = o.getFullYear().toString();
                var month = (o.getMonth() + 1).toString(); month = (month.length == 1) ? "0" + month : month;
                var date = o.getDate().toString(); date = (date.length == 1) ? "0" + date : date;
                var hours = o.getHours().toString(); hours = (hours.length == 1) ? "0" + hours : hours;
                var minutes = o.getMinutes().toString(); minutes = (minutes.length == 1) ? "0" + minutes : minutes;
                var seconds = o.getSeconds().toString(); seconds = (seconds.length == 1) ? "0" + seconds : seconds;
                var milliseconds = o.getMilliseconds().toString();
                var tzminutes = Math.abs(o.getTimezoneOffset());
                var tzhours = 0;
                while(tzminutes >= 60)
                {
                    tzhours++;
                    tzminutes -= 60;
                }
                tzminutes = (tzminutes.toString().length == 1) ? "0" + tzminutes.toString() : tzminutes.toString();
                tzhours = (tzhours.toString().length == 1) ? "0" + tzhours.toString() : tzhours.toString();
                var timezone = ((o.getTimezoneOffset() < 0) ? "+" : "-") + tzhours + ":" + tzminutes;
                s += year + "-" + month + "-" + date + "T" + hours + ":" + minutes + ":" + seconds + "." + milliseconds + timezone;
            }
            // Array
            else if(o.constructor.toString().indexOf("function Array()") > -1)
            {
                for(var p in o)
                {
                    if(!isNaN(p)) // linear array
                    {
                        (/function\s+(\w*)\s*\(/ig).exec(o[p].constructor.toString());
                        var type = RegExp.$1;
                        switch(type)
                        {
                            case "":
                                type = typeof(o[p]);
                            case "String":
                                type = "string"; break;
                            case "Number":
                                type = "int"; break;
                            case "Boolean":
                                type = "bool"; break;
                            case "Date":
                                type = "DateTime"; break;
                        }
                        s += "<" + type + ">" + SOAPClientParameters._serialize(o[p]) + "</" + type + ">"
                    }
                    else // associative array
                        s += "<" + p + ">" + SOAPClientParameters._serialize(o[p]) + "</" + p + ">"
                }
            }
            // Object or custom function
            else
                for(var p in o)
                    s += "<" + p + ">" + SOAPClientParameters._serialize(o[p]) + "</" + p + ">";
            break;
        default:
            throw new Error(500, "SOAPClientParameters: type '" + typeof(o) + "' is not supported");
    }
    return s;
}

Definiamo la classe SOAPClient (che avrà solo metodi statici per consentire l'utilizzo in modalità asincrona) e l'unico metodo pubblico che espone: SOAPClient.invoke

NOTA: poiché JavaScript non contempla l'utilizzo di modificatori di visibilità - quali "public", "private", "protected", ecc. - sarà adottata la convenzione di apporre il prefisso "_" per indicare i metodi ad uso interno (privati).

function SOAPClient() {}

SOAPClient.invoke = function(url, method, parameters, async, callback)
{
    if(async)
        SOAPClient._loadWsdl(url, method, parameters, async, callback);
    else
        return SOAPClient._loadWsdl(url, method, parameters, async, callback);
}

L'interfaccia del metodo SOAPClient.invoke è già stata descritta in precedenza; l'implementazione distingue semplicemente tra chiamate asincrone (per cui il risultato della chiamata verrà passato al metodo di callback) e sincrone (per cui il risultato della chiamata verrà restituito direttamente dal metodo SOAPClient.invoke, mantenendo il client in attesa del completamento dell'operazione). La chiamata al webservice inizia invocando il metodo privato SOAPClient._loadWsdl:

SOAPClient._loadWsdl = function(url, method, parameters, async, callback)
{
    // load from cache?
    var wsdl = SOAPClient_cacheWsdl[url];
    if(wsdl + "" != "" && wsdl + "" != "undefined")
        return SOAPClient._sendSoapRequest(url, method, parameters, async, callback, wsdl);
    // get wsdl
    var xmlHttp = SOAPClient._getXmlHttp();
    xmlHttp.open("GET", url + "?wsdl", async);
    if(async)
    {
        xmlHttp.onreadystatechange = function()
        {
            if(xmlHttp.readyState == 4)
                SOAPClient._onLoadWsdl(url, method, parameters, async, callback, xmlHttp);
        }
    }
    xmlHttp.send(null);
    if (!async)
        return SOAPClient._onLoadWsdl(url, method, parameters, async, callback, xmlHttp);
}

Il parser DOM con la descrizione del servizio viene cercato nella cache al fine di evitare chiamate remote ripetitive:

SOAPClient_cacheWsdl = new Array();

Se il WSDL non è disponibile nella cache (prima chiamata al webservice nel contesto corrente) viene richiesto al server tramite XMLHttpRequest secondo la modalità richiesta, sincrona o asincrona. Ottenuta la risposta dal server verrà invocato il metodo SOAPClient._onLoadWsdl:

SOAPClient._onLoadWsdl = function(url, method, parameters, async, callback, req)
{
    var wsdl = req.responseXML;
    SOAPClient_cacheWsdl[url] = wsdl;    // save a copy in cache
    return SOAPClient._sendSoapRequest(url, method, parameters, async, callback, wsdl);
}

La copia del WSDL viene archiviata in cache per le eventuali richieste successive allo stesso webservice, quindi viene richiamato il metodo SOAPClient._sendSoapRequest:

SOAPClient._sendSoapRequest = function(url, method, parameters, async, callback, wsdl)
{
    // get namespace
    var ns = (wsdl.documentElement.attributes["targetNamespace"] + "" == "undefined") ? wsdl.documentElement.attributes.getNamedItem("targetNamespace").nodeValue : wsdl.documentElement.attributes["targetNamespace"].value;
    // build SOAP request
    var sr =
                "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
                "<soap:Envelope " +
                "xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" " +
                "xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" " +
                "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
                "<soap:Body>" +
                "<" + method + " xmlns=\"" + ns + "\">" +
                parameters.toXml() +
                "</" + method + "></soap:Body></soap:Envelope>";
    // send request
    var xmlHttp = SOAPClient._getXmlHttp();
    xmlHttp.open("POST", url, async);
    var soapaction = ((ns.lastIndexOf("/") != ns.length - 1) ? ns + "/" : ns) + method;
    xmlHttp.setRequestHeader("SOAPAction", soapaction);
    xmlHttp.setRequestHeader("Content-Type", "text/xml; charset=utf-8");
    if(async)
    {
        xmlHttp.onreadystatechange = function()
        {
            if(xmlHttp.readyState == 4)
                SOAPClient._onSendSoapRequest(method, async, callback, wsdl, xmlHttp);
        }
    }
    xmlHttp.send(sr);
    if (!async)
        return SOAPClient._onSendSoapRequest(method, async, callback, wsdl, xmlHttp);
}

Dal WSDL viene recuperato il namespace del servizio (con un'interrogazione del DOM XML diversificata per Internet Explorer e Mozilla / FireFox), quindi viene creata ed inviata la richiesta SOAP 1.1 di interrogazione. Il metodo SOAPClient._onSendSoapRequest verrà invocato alla ricezione della risposta del server:

SOAPClient._onSendSoapRequest = function(method, async, callback, wsdl, req)
{
    var o = null;
    var nd = SOAPClient._getElementsByTagName(req.responseXML, method + "Result");
    if(nd.length == 0)
    {
        if(req.responseXML.getElementsByTagName("faultcode").length > 0)
        {
            if(async || callback)
                o = new Error(500, req.responseXML.getElementsByTagName("faultstring")[0].childNodes[0].nodeValue);
            else
                throw new Error(500, req.responseXML.getElementsByTagName("faultstring")[0].childNodes[0].nodeValue);            
        }
    }
    else
        o = SOAPClient._soapresult2object(nd[0], wsdl);
    if(callback)
        callback(o, req.responseXML);
    if(!async)
        return o;        
}

La risposta del server viene processata, cercando eventuali errori (nel qual caso viene alzata l'eccezione anche dal codice client). Se la richiesta ha invece ottenuto esito positivo verrà utilizzata la descrizione del servizio per ricostruire la gerarchia degli oggetti ricevuti in risposta (ricorsione):

SOAPClient._soapresult2object = function(node, wsdl)
{
    var wsdlTypes = SOAPClient._getTypesFromWsdl(wsdl);
    return SOAPClient._node2object(node, wsdlTypes);
}

SOAPClient._node2object = function(node, wsdlTypes)
{
    // null node
    if(node == null)
        return null;
    // text node
    if(node.nodeType == 3 || node.nodeType == 4)
        return SOAPClient._extractValue(node, wsdlTypes);
    // leaf node
    if (node.childNodes.length == 1 && (node.childNodes[0].nodeType == 3 || node.childNodes[0].nodeType == 4))
        return SOAPClient._node2object(node.childNodes[0], wsdlTypes);
    var isarray = SOAPClient._getTypeFromWsdl(node.nodeName, wsdlTypes).toLowerCase().indexOf("arrayof") != -1;
    // object node
    if(!isarray)
    {
        var obj = null;
        if(node.hasChildNodes())
            obj = new Object();
        for(var i = 0; i < node.childNodes.length; i++)
        {
            var p = SOAPClient._node2object(node.childNodes[i], wsdlTypes);
            obj[node.childNodes[i].nodeName] = p;
        }
        return obj;
    }
    // list node
    else
    {
        // create node ref
        var l = new Array();
        for(var i = 0; i < node.childNodes.length; i++)
            l[l.length] = SOAPClient._node2object(node.childNodes[i], wsdlTypes);
        return l;
    }
    return null;
}

SOAPClient._extractValue = function(node, wsdlTypes)
{
    var value = node.nodeValue;
    switch(SOAPClient._getTypeFromWsdl(node.parentNode.nodeName, wsdlTypes).toLowerCase())
    {
        default:
        case "s:string":            
            return (value != null) ? value + "" : "";
        case "s:boolean":
            return value + "" == "true";
        case "s:int":
        case "s:long":
            return (value != null) ? parseInt(value + "", 10) : 0;
        case "s:double":
            return (value != null) ? parseFloat(value + "") : 0;
        case "s:datetime":
            if(value == null)
                return null;
            else
            {
                value = value + "";
                value = value.substring(0, (value.lastIndexOf(".") == -1 ? value.length : value.lastIndexOf(".")));
                value = value.replace(/T/gi," ");
                value = value.replace(/-/gi,"/");
                var d = new Date();
                d.setTime(Date.parse(value));                                        
                return d;                
            }
    }
}

SOAPClient._getTypesFromWsdl = function(wsdl)
{
    var wsdlTypes = new Array();
    // IE
    var ell = wsdl.getElementsByTagName("s:element");    
    var useNamedItem = true;
    // MOZ
    if(ell.length == 0)
    {
        ell = wsdl.getElementsByTagName("element");    
        useNamedItem = false;
    }
    for(var i = 0; i < ell.length; i++)
    {
        if(useNamedItem)
        {
            if(ell[i].attributes.getNamedItem("name") != null && ell[i].attributes.getNamedItem("type") != null)
                wsdlTypes[ell[i].attributes.getNamedItem("name").nodeValue] = ell[i].attributes.getNamedItem("type").nodeValue;
        }    
        else
        {
            if(ell[i].attributes["name"] != null && ell[i].attributes["type"] != null)
                wsdlTypes[ell[i].attributes["name"].value] = ell[i].attributes["type"].value;
        }
    }
    return wsdlTypes;
}

SOAPClient._getTypeFromWsdl = function(elementname, wsdlTypes)
{
    var type = wsdlTypes[elementname] + "";
    return (type == "undefined") ? "" : type;
}

Il metodo SOAPClient._getElementsByTagName ottimizza le query XPath in funzione del parser XML disponibile:

SOAPClient._getElementsByTagName = function(document, tagName)
{
    try
    {
        // trying to get node omitting any namespaces (latest versions of MSXML.XMLDocument)
        return document.selectNodes(".//*[local-name()=\""+ tagName +"\"]");
    }
    catch (ex) {}
    // old XML parser support
    return document.getElementsByTagName(tagName);
}

A completare la libreria è presente una factory per inizializzare l'oggetto XMLHttpRequest in funzione del browser:

SOAPClient._getXmlHttp = function()
{
    try
    {
        if(window.XMLHttpRequest)
        {
            var req = new XMLHttpRequest();
            // some versions of Moz do not support the readyState property and the onreadystate event so we patch it!
            if(req.readyState == null)
            {
                req.readyState = 1;
                req.addEventListener("load",
                                    function()
                                    {
                                        req.readyState = 4;
                                        if(typeof req.onreadystatechange == "function")
                                            req.onreadystatechange();
                                    },
                                    false);
            }
            return req;
        }
        if(window.ActiveXObject)
            return new ActiveXObject(SOAPClient._getXmlHttpProgID());
    }
    catch (ex) {}
    throw new Error("Your browser does not support XmlHttp objects");
}

SOAPClient._getXmlHttpProgID = function()
{
    if(SOAPClient._getXmlHttpProgID.progid)
        return SOAPClient._getXmlHttpProgID.progid;
    var progids = ["Msxml2.XMLHTTP.5.0", "Msxml2.XMLHTTP.4.0", "MSXML2.XMLHTTP.3.0", "MSXML2.XMLHTTP", "Microsoft.XMLHTTP"];
    var o;
    for(var i = 0; i < progids.length; i++)
    {
        try
        {
            o = new ActiveXObject(progids[i]);
            return SOAPClient._getXmlHttpProgID.progid = progids[i];
        }
        catch (ex) {};
    }
    throw new Error("Could not find an installed XML parser");
}

Il codice per la ricerca del componente è tratto da WebFX

Esempi di utilizzo

Diversi esempi di utilizzo sono disponibili nella demo on-line che cerca di illustare gli aspetti principali per l'utilizzo di AJAX con il SOAP Client, come ad esempio:

  • "da dove devo iniziare?" - La risposta sarà il classico esempio "Hello world!" (rif. DEMO 1)

  • come passare i parametri (semplici) al webservice (rif. DEMO 2)

  • utilizzo di classi native del .NET framework (rif. DEMO 3)

  • chiamata a metodi che non hanno tipo di ritorno (rif. DEMO 4)

  • gestione delle eccezioni (rif. DEMO 5)

  • differenza tra chiamate sincrone e asincrone; chiamate a metodi con tempi di risposta elevati (rif. DEMO 4 e DEMO 6)

  • utilizzo delle classi complesse restituite dal webservice (rif. DEMO 7)

  • utilizzo di array come tipi di ritorno (rif. DEMO 8)

  • utilizzo di classi che implementano l'interfaccia ICollection restituite dal webservice (rif. DEMO 9)

  • esempi pratici di utilizzo: popolare le option di un controllo select con chiamate remote (rif. DEMO 10)

  • utilizzare la risposta SOAP (XmlDocument) nei metodi di callback (rif. DEMO 11)

  • come passare i parametri (oggetti complessi) al webservice (rif. DEMO 12)

Conclusioni

Mediante un'unica libreria javascript dal peso ridotto (meno di 13 KB) sarà possibile utilizzare AJAX nelle nostre applicazioni Web per la gestione di contenuti dinamici senza richiedere il reload della pagina e senza alcuna modifica al codice server side ma, semplicemente, esponendo un webservice per l'accesso ai metodi remoti.

Argomenti correlati

Per informazioni aggiuntive sui webservices è possibile consultare l'articolo Introduzione ai Web Service con .NET.

Per una versione server del client SOAP per classic ASP si veda ASP SOAP Client.