Nelle due principali implementazioni di JavaScript, i browser e Node.js, le operazioni di input / output (I/O) sono offerte attraverso API asincrone. Per molti sviluppatori il modo di lavorare che ne consegue risulta poco intuitivo e/o poco pratico, ma in realtà ha i suoi vantaggi e può essere reso decisamente più agevole attraverso l'uso di appositi pattern e di sintassi dedicata.
In questa guida introdurrò brevemente le motivazioni dietro l'uso di API asincrone in JavaScript, il modo classico di utilizzarle ed alcune "tecniche avanzate".
Prima di poter lavorare efficacemente con API asincrone è ovviamente necessario capire cosa sono e a che servono.
Un'API asincrona è essenzialmente un meccanismo per richiedere operazioni e specificare come reagire al loro completamento, lasciando però il thread chiamante libero di proseguire mentre tali operazioni vengono svolte in parallelo. Le API asincrone possono essere quindi implementate mediante thread ausiliari. Nel caso di operazioni di I/O (che poi sono quelle per le quali le API asincrone vengono impiegate più spesso) esistono anche meccanismi più efficienti che richiedono però un supporto da parte del sistema operativo all'I/O non bloccante.
Ma a cosa serve avere API asincrone nel browser e in Node.js? Per capirlo occorre partire dal fatto che il browser e Node.js sono essenzialmente dei gestori di eventi, come ad esempio il click su un bottone (browser) o la ricezione di una richiesta HTTP (Node.js). La gestione di questi eventi avviene eseguendo il codice JavaScript specificato dalla pagina (o dal programma nel caso di Node.js). Un aspetto cruciale è che la gestione degli eventi viene sempre effettuata utilizzando solo un thread dunque gli eventi vengono gestiti uno per volta. In quest'ottica le API asincrone servono ad avviare operazioni anche molto lunghe senza impattare sulla durata della gestione di un evento corrente e, quindi, senza ritardare troppo la gestione del prossimo evento. Senza API asincrone il browser non potrebbe rimanere al passo con le azioni dell'utente e Node.js non potrebbe gestire richieste concorrenti.
Per certi versi le API asincrone sono dunque il prezzo da pagare per l'uso di un solo thread per la gestione di eventi. D'altro canto l'utilizzo di un solo thread comporta degli importanti benefici: gestire un solo evento per volta vuol dire che la gestione di un evento non interferirà mai con quella di un altro. Inoltre, nel caso di Node.js, non dover istanziare un thread per ogni richiesta permette di una maggiore scalabilità ed una minore latenza.
Come anticipato, browser e Node.js funzionano ad eventi. Esistono meccanismi di sottoscrizione che permettono di definire sotto forma di funzione, detta "callback", il codice da eseguire per effettuare la gestione di un dato evento. Ad esempio in una pagina web la definizione di come debba essere gestito il click su un certo bottone può essere effettuata come segue:
bottone.addEventListener('click', function () {
console.log('Pulsante premuto!');
});
Poiché il completamento di un operazione asincrona è considerato un evento (cioè il browser e Node.js lo trattano come tale, ovvero eseguendo codice JavaScript in risposta ad esso), anche le API asincrone sono basate sull'uso di callback:
fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function () {
console.log('Operazione completata!');
});
In questo esempio la funzione readFile
del modulo fs
di Node.js, che
permette di leggere un file, prende una callback come ultimo parametro (che per
altro è una convenzione piuttosto comune a fra le API asincrone JavaScript).
La callback passata ad un'API asincrona può avere a sua volta dei parametri che rappresentano l'esito dell'operazione:
fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function (errore, dati) {
if (errore) {
console.log('Ops: si è verificato un errore!');
} else {
console.log(dati);
}
});
Convenzionalmete il primo parametro è quello che rappresenta l'eventuale errore che ha fatto fallire l'operazione (se è fallita) mentre i parametri successivi rappresentano gli eventuali valori restituiti dall'operazione in caso di successo.
Nel lavorare con API asincrone bisogna sempre ricordare che JavaScript gestisce un solo evento per volta e che perciò le callback non saranno mai eseguite prima del termine della gestone dell'evento corrente. Ad esempio, supponiamo che durante la gestione di un evento venga eseguito il seguente codice:
var errore = null;
var dati = null;
fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function (e, d) {
errore = e;
dati = d;
});
if (errore) {
console.log('Ops: si è verificato un errore!');
} else {
console.log(dati);
}
Tale codice è errato e mostrerà sempre il messaggio di errore, anche se la
lettura del file avrà successo. Il motivo è semplice: la parte if-else
che
determina la selezione del messaggio corretto, nonostante appaia per ultima nel
codice, viene eseguita prima della callback passata a readFile
. Il codice
all'interno di tale callback, infatti, viene sì definito durante la gestione
dell'evento corrente, ma verrà eseguito durante la gestione di un altro evento,
ovvero il completamento della lettura del file.
Il codice che gestisce l'esito di un'operazione asincrona, quindi, va sempre scritto tutto all'interno della rispettiva callback. Nel caso esso stesso contenga chiamate asincrone si avrà quindi una callback nella callback e in generale una sequenza di operazioni asincrone può assumere un aspetto simile al seguente:
operazioneUno(function () {
operazioneDue(function () {
operazioneTre(function () {
operazioneQuattro(function () {
operazioneCinque(function () {
operazioneSei(function () {
operazioneSette(function () {
// ecc.
});
});
});
});
});
});
});
Nella comunità JavaScript questo genere di struttura è ironicamente chiamata "Pyramid of Doom" ed è evidente come possa diventare rapidamente difficile da gestire in termini di indentazione e di scope. Quando poi si passa da una semplice sequenza prefissata a flussi più complessi scrivere codice manutenibile in questo modo diventa un'impresa, perciò sono emersi pattern specifici per la semplificazione del codice asincrono e fra questi il pattern delle promesse è il più popolare.
L'idea alla base delle promesse è relativamente semplice. Al momento di invocare
un'API asincrona, invece di passare una callback, ci si aspetta semmai di
ricevere un oggetto, chiamato appunto "promessa", dotato di un metodo next
che
permette di specificare due distinte callback: una per gestire il successo
dell'operazione e l'altra per gestirne il fallimento.
Ad esempio un'operazione per la lettura di un file potrebbe avvenire in modo simile al seguente:
var promessa = leggiFile('/home/marco/Desktop/esempio.txt', 'utf8');
promessa.then(function (dati) {
console.log(dati);
}, function (errore) {
console.log('Si è verificato un errore!');
});
Rispetto all'approccio "tradizionale", sono già evidenti due miglioramenti:
Le promesse, poi, permettono di risolvere la "Pyramid of Doom" attraverso un meccanismo di concatenazione basato su singola callback e in cui ogni callback restituisce la promessa associata all'operazione asincrona successiva:
operazioneUno().then(function () {
return operazioneDue();
}).then(function () {
return operazioneTre();
}).then(function () {
return operazioneQuattro();
}).then(function () {
return operazioneCinque();
}).then(function () {
return operazioneSei();
}).then(function () {
return operazioneSette();
}); // ecc.
Se le callback si limitano a restituire una promessa, è anche possibile utilizzare la seguente forma, ancora più concisa:
operazioneUno().
then(operazioneDue).
then(operazioneTre).
then(operazioneQuattro).
then(operazioneCinque).
then(operazioneSei).
then(operazioneSette); // ecc.
Il risultato della concatenazione di promesse è a sua volta una promessa rappresentante l'intera sequenza e permette di gestire in maniera centralizzata il fallimento di una qualunque delle operazioni nella sequenza:
operazioneUno().
then(operazioneDue).
then(operazioneTre).
then(function () {
console.log('Le tre operazioni hanno avuto successo');
}, function () {
console.log('Una delle tre operazioni è fallita');
});
Una trattazione più esaustiva delle promesse richiederebbe una guida a parte, ma gli esempi mostrati sono già sufficienti a muovere i primi passi con le promesse. A questo indirizzo sono poi disponibili delle specifiche complete che possono essere usate per capire meglio tutti gli effettivi requisiti del pattern. Come si potrà facilmente verificare, si tratta di un pattern in realtà piuttosto complesso, perciò invece che implementarlo autonomamente gli sviluppatori JavaScript utilizzano librerie di supporto che permettono di creare promesse conformi alle specifiche. Nella versione ECMAScript 6 di JavaScript le funzioni offerte da queste librerie sono persino diventate parte delle API standard delle linguaggio e dunque non è necessaria alcuna libreria. Ecco quindi un esempio di come trasformare un'API asincrona tradizionale in una basata su promesse utilizzando ECMAScript 6:
function apiConPromessa(x) {
return new Promise(function (resolve, reject) {
apiAsincronaTradizionale(x, function (errore, y) {
if (errore) return reject(errore);
resolve(y);
});
};
}
Oltre al supporto alle promesse, ECMAScript 6 introduce i generator, ovvero funzioni che possono essere sospese e riprese (anche più volte) prima di terminare la propria esecuzione. Come vedremo nella prossima sezione, i generator permettono di gestire il codice asincrono in nuovi modi. In questa sezione, però, cerchiamo prima di capire il loro funzionamento generale.
Come detto, un generator può sospendere la propria esecuzione. In particolare
ECMAScript 6 introduce a tal proposito la parola chiave yield
:
function* esempioDiGenerator() {
yield;
}
Nota: Per dichiarare una generator occorre aggiungere un asterisco
alla parola chiave function
.
Uno dei modi in cui i generator si distinguono dalle normali funzioni è il fatto
che non è sufficiente invocarli per avviarne l'esecuzione. Piuttosto quando una
generator viene invocato, esso restituisce immediatamente un oggetto chiamato
"iterator" con un metodo next
che permette finalmente di avviare l'esecuzione
del generator:
function* esempioDiGenerator() {
console.log('Esecuzione iniziata');
yield;
}
var iteratore = esempioDiGenerator();
console.log('Esecuzione non ancora iniziata');
iteratore.next(); // avvia l'esecuzione
Una volta avviata, l'esecuzione prosegue fino a quando non viene incontrata la
parola chiave yield
:
function* esempioDiGenerator() {
console.log('Questo messaggio sarà mostrato');
yield;
console.log('Questo no');
}
var iteratore = esempioDiGenerator();
iteratore.next();
Chiamando nuovamente next
sarà possibile riprendere l'eseuzione del generator:
function* esempioDiGenerator() {
console.log('Questo messaggio sarà mostrato');
yield;
console.log('E anche questo');
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.next();
Il metodo next
e yield
permettono quindi di alternare l'esecuzione della
funzione chiamante e quella del generator:
function* esempioDiGenerator() {
console.log('b');
yield;
console.log('d');
yield;
console.log('f');
yield;
// ecc.
}
var iteratore = esempioDiGenerator();
console.log('a');
iteratore.next();
console.log('c');
iteratore.next();
console.log('e');
iteratore.next();
console.log('g');
// ecc.
Inoltre next
e yield
permettono di scambiare valori fra le due funzioni. In
particolare, next
restituisce ogggetti basati sui valori inviati da yield
:
function* esempioDiGenerator() {
yield 1;
yield 2;
yield 3;
}
var iteratore = esempioDiGenerator();
var n = iteratore.next();
console.log(n); // { value: 1, done: false }
n = iteratore.next();
console.log(n); // { value: 2, done: false }
n = iteratore.next();
console.log(n); // { value: 3, done: false }
n = iteratore.next();
console.log(n); // { value: undefined, done: true }
Nota: La proprietà done
permette di sapere se l'esecuzione del generator
è terminata.
A sua volta yield
fornisce accesso agli eventuali valori passati tramite
next
:
function* esempioDiGenerator() {
var x;
x = yield;
console.log(x);
x = yield;
console.log(x);
x = yield;
console.log(x);
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.next(1);
iteratore.next(2);
iteratore.next(3);
Nota: La prima invocazione di next
non passa alcun valore. In altre
parole, il primo yield
riceve il valore passato dal secondo next
, il secondo
yield
riceve dal terzo next
, e così via.
L'iterator permette anche di provocare un'eccezione all'interno del corrispondente generator:
function* esempioDiGenerator() {
try {
yield;
} catch (errore) {
console.log(errore);
}
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.throw('finto errore');
Ok: cosa c'entrano i generator con le API asincrone?
Per capirlo osserviamo anzitutto che una normale funzione JavaScript viene interamente eseguita nel corso della gestione di un singolo evento, mentre l'esecuzione di un generator può iniziare durante la gestione di un evento, essere sospesa e poi riprendere durante la gestione di un altro evento: è sufficiente che le callback che gestiscono i due eventi abbiano entrambe accesso all'iterator che controlla il generator. Ad esempio, dati due bottoni su una pagina web, il secondo bottone può far riprendere l'esecuzione di un generator avviato dal primo bottone:
var iteratore;
b1.addEventListener('click', function () {
iteratore = (function* () {
console.log('esecuzione avviata da b1...');
yield;
console.log('...e ripresa da b2');
})();
iteratore.next();
});
b2.addEventListener('click', function () {
iteratore.next();
});
I generator possono quindi essere utilizzati per scrivere codice in grado di
"attendere" il risultato di un'operazione asincrona: l'attesa inizia
all'interno del generator facendo lo yield
di una promessa e termina quando
da una delle callback della promessa viene chiamato next
o throw
. Esempio:
function funzioneMagica(unGenerator) {
var iteratore = unGenerator();
var promessa = iteratore.next();
promessa.then(function (risultato) {
iteratore.next(risultato);
}, function (errore) {
iteratore.throw(errore);
});
}
funzioneMagica(function* () {
try {
var risultato = yield fammiUnaPromessa();
console.log(risultato);
} catch (errore) {
console.log(errore);
}
});
Estendendo opportunamente funzioneMagica
possiamo arrivare poi a scrivere
generator che "attendono" più promesse:
function funzioneMagica(unGenerator) {
var iteratore = unGenerator();
var promessa = iteratore.next();
promessa.then(function (risultato) {
iteratore.next(risultato);
}, function (errore) {
iteratore.throw(errore);
});
}
funzioneMagica(function* () {
try {
var risultato = yield fammiUnaPromessa();
console.log(risultato);
} catch (errore) {
console.log(errore);
}
});
Estendendo opportunamente funzioneMagica
possiamo arrivare a scrivere
generator che "attendono" più promesse:
funzioneMagica(function* () {
try {
var x = yield promessaUno();
var y = yield promessaDue();
var z = yield promessaTre();
console.log(x + y + z);
} catch (errore) {
console.log(errore);
}
});
Ora, se focalizziamo l'attenzione sul corpo del generator ci rendiamo conto che
questo modo di scrivere il codice fa sembrare sincrono il codice asincrono. In
particolare dal punto di vista del generator i "valori asincroni" attesi con lo
yield
di una promessa sono del tutto equivalenti a quelli sincroni / ottenuti
immediatamente con una normale funzione. Di conseguenza non serve scrivere più
alcuna callback né sbizzarrirsi nella composizione di promesse: è sufficiente
scrivere codice lo stesso codice che si sarebbe scritto con API sincrone, avendo
cura di premettere yield
alle invocazioni che restituiscono promesse.
Naturalmente queste affermazioni presuppongono la disponibilità di codice in
grado di gestire correttamente le promesse su cui viene invocato yield
. Negli
esempi precedenti abbiamo visto un'implementazione (funzioneMagica
) che andava
bene per uno scenario estremamente semplificato, ma con un po' di fantasia e
bravura è possibile scriverne una versione in grado di coprire un numero non
prefissato di promesse e di supportare la parametrizzazione del generator. Si
veda ad esempio l'implementazione disponibile a
questo indirizzo.
Ora, il pattern descritto in questa sezione si chiama "async / await" e in
ECMAScript 2017 è implementato nativamente, per altro anche attraverso sintassi
dedicata. In particolare una funzione può essere marcata come async
e
attendere un numero generico di promesse tramite parola chiave await
:
async function esempio() {
try {
var x = await promessaUno();
var y = await promessaDue();
var z = await promessaTre();
console.log(x + y + z);
} catch (errore) {
console.log(errore);
}
}
Tale codice sarà internamente convertito in qualcosa di simile a:
async(function* () {
try {
var x = yield promessaUno();
var y = yield promessaDue();
var z = yield promessaTre();
console.log(x + y + z);
} catch (errore) {
console.log(errore);
}
});
... e con questo si chiude la nostra panoramica sulla programmazione asincrona in JavaScript! ;)