Una delle caratteristiche interessanti e largamente utilizzate di JavaScript è il supporto alla programmazione asincrona, cioè alla possibilità di eseguire attività in background che non interferiscono con il flusso di elaborazione principale.
Il modello asincrono è ormai diventato di uso comune tra gli sviluppatori JavaScript, grazie anche alla diffusione di diversi framework e librerie che ne incentivano l'adozione. Tuttavia, in determinate situazioni e soprattutto al crescere della complessità dell'applicazione, il codice può risultare di difficile gestione e può pregiudicare la sua leggibilità e manutenzione.
Programmazione asincrona in JavaScript
Come abbiamo detto, la programmazione asincrona è una delle caratteristiche principali di JavaScript. In realtà, ad essere pignoli, il supporto di questo modello di programmazione è per JavaScript più una necessità che un'opzione aggiuntiva, dal momento che il linguaggio è single-threaded. Senza la possibilità di creare elaborazioni asincrone, tutte le attività lente o interattive rischierebbero di bloccare il flusso di elaborazione principale di un'applicazione JavaScript.
I due principali elementi che consentono di sfruttare il modello di programmazione asincrono in JavaScript sono gli eventi e le callback. I primi offrono un modello ormai consolidato nella maggior parte dei linguaggi di programmazione e risultano abbastanza intuitivi soprattutto nella gestione dell'interazione con l'utente. Le callback rappresentano un approccio comodo e molto utilizzato soprattutto nelle interazioni con il server. Ci concentreremo su queste ultime perché il loro utilizzo in situazioni complesse può presentare alcuni problemi.
Il classico esempio di utilizzo delle callback è quello delle chiamate Ajax, come quello mostrato dal seguente codice:
$.get("/users",
{ id: "12345" },
function(user) {
$("#resultMessage").html("Nome utente: " + user.Name);
}
);
Nell'esempio abbiamo utilizzato il metodo get()
di jQuery per richiedere al server i dati di un utente e visualizzarne il nome. Dovrebbe essere chiaro che la chiamata di questo metodo avvia la richiesta al server e prosegue con le eventuali istruzioni successive.
Il terzo argomento è la funzione di callback che sarà invocata quando il server avrà restituito la rappresentazione JSON dell'utente richiesto. Questo evita al flusso di elaborazione principale di rimanere in attesa della risposta del server che potrebbe impiegare molto tempo.
Supponiamo però di non essere interessati al nome dell'utente, ma all'elenco dei post pubblicati sul suo blog, il cui id
è incluso tra le proprietà dell'oggetto che rappresenta l'utente. Saremo costretti a fare due richieste al server: la prima per recuperare l'utente e la seconda per recuperare l'elenco dei post del suo blog. Il codice precedente verrebbe modificato nel seguente modo:
$.get("/users", {id: "12345"},
function(user) {
$.get("/blogs", {id: user.blogId},
function(blog) {
displayPostList(blog.posts);
}
);
}
);
Avremo quindi due chiamate del metodo get()
ciascuna con la relativa funzione di callback. Le chiamate devono necessariamente essere annidate l'una dentro l'altra dal momento che la seconda chiamata è dipendente dall'esito della prima.
E se fossimo interessati anche alle foto caricate dall'utente? Questo codice potrebbe fare al caso nostro:
$.get("/users", {id: "12345"},
function(user) {
$.get("/blogs", {id: user.blogId},
function(blog) {
displayPostList(blog.posts);
});
$.get("/photos", {id: user.albumId},
function(album) {
displayPhotoList(album.photos);
}
);
}
);
La visualizzazione dei post e delle foto è asincrona e non necessariamente i primi verranno visualizzati dopo le altre. Se volessimo vincolare l'ordine di visualizzazione o fare delle elaborazioni su entrambi gli insiemi di risultati prima di visualizzarli (ad esempio mostrare soltanto le foto relative ai post) dovremmo trovare un modo per sincronizzare le callback, compito la cui complessità si può facilmente intuire.
I problemi delle callback
Utilizzare le callback, quindi, può essere abbastanza intuitivo in situazioni relativamente semplici, ma può rapidamente trasformarsi in un incubo all'aumentare della complessità del codice.
Tra i principali difetti dell'uso intensivo delle callback
segnaliamo:
- una scarsa leggibilità del codice che in breve può diventare affetto dalla cosiddetta Pyramid of Doom, l'espansione verso destra dovuta agli annidamenti indiscriminati delle callback ed alle relative indentazioni
- difficoltà di composizione delle callback e di sincronizzazione del flusso di elaborazione, per ottenere i quali spesso occorre inventarsi artifici che rendono ancora più illeggibile il codice e talvolta poco efficiente
- difficoltà di gestione degli errori e di debug, soprattutto in presenza di callback anonime. Che succede infatti quando si verifica un errore all'interno di una callback? Non è possibile gestire l'eccezione all'interno della funzione chiamante e questo può porre dei problemi non indifferenti
Il punto è che, a differenza delle chiamate a funzioni sincrone dalla cui esecuzione attendiamo un valore di ritorno o un'eccezione, nel caso di chiamate asincrone non abbiamo nessuna delle due cose. Di conseguenza viene meno la possibilità di composizione tra funzioni e la gestione delle eventuali eccezioni. Insomma, con un approccio asincrono si perdono alcune delle caratteristiche tipiche del modello di programmazione funzionale a cui si ispira JavaScript.
Teoria delle promesse
In questo contesto possono darci una mano le promise, letteralmente promesse. Si tratta di oggetti che rappresentano il risultato di una chiamata di funzione asincrona e sono talvolta chiamati con nomi diversi: future
, delay
o deferred
, magari con qualche sottile differenza. In ogni caso, essi rappresentano una promessa che un risultato verrà fornito non appena disponibile.
Una promise può trovarsi in uno dei seguenti stati:
Stato | Descrizione |
---|---|
pending | è lo stato in cui non è stato ancora ottenuto il risultato della chiamata asincrona |
resolved | in questo stato la chiamata asincrona ha prodotto un risultato; talvolta questo stato è detto anche fullfilled |
rejected | rappresenta la situazione in cui non è possibile ottenere un risultato dalla chiamata asincrona, eventualmente per il verificarsi di una condizione d'errore |
Il vantaggio dell'utilizzo delle promise rispetto alle callback consiste nel rendere il codice più leggibile, più simile al flusso di esecuzione sincrona e in alcune situazioni anche più efficiente.
Diversi linguaggi di programmazione supportano il meccanismo delle promise per la gestione della programmazione asincrona. Alcuni in maniera nativa altri ricorrendo a librerie esterne. Allo stato attuale JavaScript non supporta nativamente le promise, anche se c'è una proposta di inclusione tra le specifiche di ECMAScript 6, pertanto dobbiamo ricorrere a librerie esterne come ad esempio Q.
La libreria Q
Q è una libreria JavaScript che implementa il pattern delle promise utilizzabile sia in un contesto server con node.js, sia lato client all'interno di una pagina HTML.
L'utilizzo su node.js richiede l'installazione del modulo tramite il seguente comando:
npm install q
e l'inclusione nella nostra applicazione tramite require()
:
var Q = require("q");
In una pagina HTML, invece, includeremo la libreria nel classico modo:
<script type="text/javascript" src="q.min.js"></script>
Con il supporto di Q possiamo sfruttare le promise per cambiare lo stile di programmazione asincrona generalmente utilizzato con le funzioni di callback. Per chiarire le idee facciamo un semplice esempio facendo riferimento all'esempio di chiamata con callback che abbiamo presentato all'inizio di questo articolo:
$.get("/users",
{ id: "12345" },
function(user) {
$("#resultMessage").html("Nome utente: " + user.Name);
}
);
Sfruttando il modello di programmazione basato su promise, invece di invocare la funzione passandole una callback da eseguire al termine dell'operazione asincrona, ridefiniamo il tutto nel seguente modo:
var getUser = function() {
var deferred = Q.defer();
$.get("/users", [id: "12345"], deferred.resolve);
return deferred.promise;
}
getUser().then(function(user) {
$("#resultMessage").html("Nome utente: " + user.Name);
});
La prima istruzione definisce la variabile getUser
come una funzione che restituisce una promise, la promessa del risultato della chiamata asincrona Ajax per ottenere un utente. In particolare, il metodo defer()
dell'oggetto globale Q restituisce un oggetto che rappresenta l'infrastruttura per gestire le promesse.
Il valore deferred.promise
restituito dalla funzione è una promessa in stato pending
, quindi non ancora valorizzata. Tramite deferred.resolve
la promessa passa in statoresolved
ed il valore ottenuto viene messo a disposizione.
La seconda istruzione mostra l'uso della promise: essa viene invocata e concatenata tramitethen()
alla funzione da eseguire dopo aver acquisito l'utente desiderato. Il risultato dell'operazione asincrona viene automaticamente passato alla funzione invocata tramitethen()
.
Già inquesto semplice esempio si intravede la flessibilità dell'approccio basato sulle promise rispetto a quello basato sulle callback. Ad esempio, possiamo riscrivere il codice precedente come mostrato di seguito con un significativo guadagno in leggibilità:
function getUser() {
var deferred = Q.defer();
$.get("/users", [id: "12345"], deferred.resolve);
return deferred.promise;
}
function displayUser(user) {
$("#resultMessage").html("Nome utente: " + user.Name);
}
getUser().then(displayUser);
Dopo aver definito opportunamente le funzioni che servono al nostro scopo, l'istruzione per la visualizzazione di un utente diventa molto simile ad una richiesta in linguaggio naturale.
Prima di proseguire nell'esplorazione delle promise è bene però sottolineare che il meccanismo è semanticamente equivalente a quello delle callback. Il vantaggio principale è nel rendere più chiaro e flessibile l'approccio alla programmazione asincrona.
Combinazione di promise
Alla luce di quanto appreso sull'uso delle promise grazie alla libreria Q, riprendiamo l'esempio di codice JavaScript che visualizza l'elenco dei post e le foto associate ad un utente
$.get("/users", {id: "12345"},
function(user) {
$.get("/blogs", {id: user.blogId},
function(blog) {
displayPostList(blog.posts);
});
$.get("/photos", {id: user.albumId},
function(album) {
displayPhotoList(album.photos);
}
);
}
);
e proviamo a riscriverlo, ad esempio, nel seguente modo:
function getResource(URL, resourceId) {
var deferred = Q.defer();
$.get(URL, [id: resourceId], deferred.resolve);
return deferred.promise;
}
function getUser(userID) {
return getResource("/users", userId);
}
function getBlogAndPhotos(user) {
return Q.all([
getResource("/blogs", user.blogId),
getResource("/photos", user.albumId)
]);
return getResource("/users", userId);
}
function displayBlogAndPhotos(blog, album) {
displayPostList(blog.posts);
displayPhotoList(album.photos);
}
getUser("12345")
.then(getBlogAndPhotos)
.spread(displayBlogAndPhotos);
In questa riscrittura abbiamo introdotto alcuni nuovi concetti rispetto a quanto visto finora. Per prima cosa la funzione getBlogAndPhotos()
utilizza il metodo all()
per la restituzione di un array di promise.
Questo metodo di Q consente di realizzare una sorta di sincronizzazione tra le richieste asincrone creando una promise cumulativa solo quando tutte le promise contenute nell'array non si trovano più nello stato pending
.
In altre parole, la promessa risultante dalla chiamata al metodo all()
verrà creata quando tutte le promise contenute nell'array saranno in stato resolved
o rejected
.
La promise generata da all()
contiene al suo interno il risultato delle singole chiamate asincrone. Per poter elaborare con una funzione questi risultati faremo uso del metodo spread() anzichè di then()
. Infatti, mentre then()
è in grado di passare un singolo risultato, spread()
passa un elenco di risultati alla funzione, riuscendo a sincronizzare e combinare insieme più chiamate asincrone.
Gestione degli errori
Un altro aspetto interessante nella gestione delle chiamate asincrone tramite le promise di Q è la gestione degli errori. In genere la gestione degli errori in una catena di chiamate asincrone annidate tramite callback non è semplice. La principale difficoltà riguarda la propagazione dell'errore nella catena di chiamate.
Tipicamente l'errore viene gestito localmente, all'interno della funzione di callback, e la catena di chiamate asincrone viene interrotta senza poter fornire informazioni al livello superiore. Oppure, nel caso di più chiamate asincrone parallele, un errore in un ramo delle chiamate non influisce sugli altri rami.
Con le promise di Q gli errori vengono automaticamente propagati nella catena di chiamate consentendo di gestirli opportunamente. In pratica, quando si verifica un errore in un punto della catena di chiamate, l'errore viene passato al primo gestore di errori successivo alla chiamata. Occorre però prestare attenzione che se non è presente nessun gestore, l'errore viene semplicemente ignorato.
Possiamo gestire gli errori in una catena di chiamate in diversi modi. Ad esempio fornendo un gestore di errori come secondo parametro del metodo then()
, come mostrato nel seguente esempio:
function gestisciErrore(error) {
console.log("Si è verificato un errore: " + error);
}
getUser().then(displayUser, gestisciErrore);
Con questo approccio, se la promessa generata da getUser()
passa in stato resolved
allora viene eseguita la funzione displayUser()
, se invece il suo stato diventa rejected
viene eseguita la funzione gestisciErrore()
.
Leggermente diverso è invece l'uso del metodo fail(). Consideriamo il seguente esempio:
getUser().then(displayUser).fail(gestisciErrore);
In questo caso, se si verifica un errore in qualsiasi punto della catena di chiamate, questo sarà gestito dalla funzione gestisciErrore()
.
Abbiamo quindi due approcci, che possono anche essere combinati tra loro:
- gestire l'errore in maniera mirata subito al suo verificarsi;
- gestire qualsiasi errore con in modo generico.
Come segnalato, se non introduciamo un gestore di errori nella catena di chiamate, l'eventuale errore sarà ignorato generando potenziali problemi nel successivo flusso del codice.
Perché l'errore sia propagato fuori dalla catena di chiamate occorre terminare la catena stessa con il metodo done(), come nel'esempio seguente:
getUser().then(displayUser).done();
Un'ultima osservazione riguarda la generazione dell'errore all'interno della funzione che genera la promessa. Il seguente codice mostra quale sarebbe l'approccio generale:
function getResource(URL, resourceId) {
var deferred = Q.defer();
try {
$.get(URL, [id: resourceId], deferred.resolve);
} catch (error) {
deferred.reject(error);
}
return deferred.promise;
}
Come possiamo vedere abbiamo semplicemente inserito la chiamata asincrona all'interno di un blocco try/catch
ed abbiamo previsto l'impostazione dello stato reject
in caso di errore.
Altre funzionalità
Quelle che abbiamo visto sono soltanto le nozioni fondamentali della gestione di chiamate asincrone tramite promise
. La libreria Q supporta numerose altre funzionalità che consentono di gestire situazioni complesse e forniscono alternative sintattiche alle operazioni più comuni.
Ad esempio, in alternativa alla seguente catena di chiamate:
getUser().then(displayUser).fail(gestisciErrore);
è possibile utilizzare il metodo when()
nel seguente modo:
Q.when(getUser, displayUser, gestisciErrore);
Diverse altre opzioni sono disponibili per gestire al meglio le promise. Per un elenco completo delle API supportate fare riferimento alla documentazione ufficiale.