Nota: tutto il codice scritto in questo articolo utilizza lo standard ES6.

Introduzione alle callback

Le callback sono un sistema di gestione di task asincroni enormemente utilizzato nel mondo Nodejs. Si tratta sostanzialmente di funzioni che vengono passate come parametri e che vengono invocate al completamento di determinate operazioni che non hanno un tempo di risposta ben determinato, come ad esempio la lettura/scrittura da file system, operazioni su un database oppure comunicazioni remote attraverso una rete (es. http). Risulta evidente come tali procedure richiedono un tempo che varia a seconda di diversi fattori, spesso non prevedibili a monte, e di conseguenza costituiscono una porzione di codice cosiddetto bloccante. Esistono diversi scenari in cui operazioni diverse non hanno dipendenze tra di loro, per cui possono essere eseguite "in parallelo" dal momento che il completamento di una non è requisito dell'inizio di un'altra. Le callback vengono in aiuto a questi scenari, permettendo allo sviluppatore di gestire in maniera ottimizzata il codice. Vediamo subito un esempio pratico:

'use strict';

const sendMessage = (message, cb) => {
  console.log(`Message received: "${message}"`);
  setTimeout(() => {
    return cb('I\'m fine thanks');
  }, 2000);
};

sendMessage('How are you?', (response) => {
  console.log(`Response received: "${response}"`);
});

console.log('Waiting for response...');

In questo caso viene dichiarata la funzione sendMessage, che accetta due parametri, un messaggio (stringa) e una callback (funzione), e non restituisce alcun valore. La risposta infatti viene "passata" in un secondo momento termine dell'operazione asincrona setTimeout, nota funzione Javascript che attende un certo numero di millisecondi per poi eseguire la funzione indicata come primo parametro. Di conseguenza la funzione sendMessage invocherà la callback passata come parametro dopo due secondi, e il messaggio di risposta sarà contenuto nel parametro della funzione.

Chi invoca la funzione sendMessage si occupa di creare una funzione anonima e di implementarne il corpo, per poi passarlo come secondo parametro. Tutto ciò che viene dichiarato dopo la chiamata alla funzione sendMessage è codice sincrono, che verrà dunque eseguito immediatamente, senza dover attendere il risultato della funzione sendMessage. L'output di questo programma sarà dunque il seguente:

Message received: "How are you?"
Waiting for response...
Response received: "I'm fine thanks"

In questo modo mentre si attende la risposta è posibile proseguire nell'esecuzione del programma, senza dover attendere inutimente. Questo meccanismo viene utilizzato in Node.js, così come in altri ambienti, per evitare di creare del codice bloccante, che altrimenti bloccherebe l'esecuzione fino a che le operazioni asincrone sono terminate. Con le callback è possibile quindi ottimizzare l'esecuzione del proprio programma, rendendolo molto scalabile dal momento che è possibile gestire un alto numero di richieste senza dover attendere il risultato delle funzioni. La scalabilità è infatti uno dei maggiori punti di forza dell'ambiente Node.js.

Il problema della "callback hell"

Come si può immaginare, se da un lato tutto ciò porta grossi vantaggi allo stesso tempo il tutto può risultare parecchio complesso, soprattutto per programmatori alle prime armi, e man mano che la complessità dei programmi aumenta diviene sempre più difficile gestire con efficienza il flusso di callback e allo stesso tempo scrivere codice pulito e leggibile. In questo caso un problema in cui si può facilmente incappare è la cosiddetta callback hell, soprattutto quando vi sono strette dipendenze tra una funzione asincrona ed un altra. Riporto in seguito l'esempio preso dal riferimento:

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

Come si può notare il codice è piuttosto confusionario, difficile da leggere e tende ad indentarsi sempre di più, in quanto vi sono chiamate a funzioni "asincrone" l'una dentro l'altra. Procedendo con lo sviluppo e portandosi dietro questa cattiva gestione si finirà ben presto in un vero e proprio "inferno di callback", in cui ciascuna indentazione sembra essere un vero e proprio girone infernale che porta i seguenti svantaggi:

  • Il codice è "sporco", difficile da leggere ed intuire, quindi difficile da manutenere
  • Senza modularità sarà impossibile riutilizzare il codice già scritto, con il rischio di dover riscrivere (e testare) funzionalità già definite
  • Qualora vi fossero dei bug sarà molto difficile scovarli e gestirli

Questi tre punti rappresentano un incubo per ciascun programmatore di buon livello ed è quindi necessario prestare attenzione fin dalla prima riga di codice che si scrive. Per fortuna esistono soluzioni eleganti che rispondono a questa necessità.

La soluzione

Il problema della "callback hell" insieme ad altre casistiche similari portano ad ampie discussioni che contengono un ampio numero di argomenti sulle best practice da seguire per scrivere codice pulito, riusabile e manutenibile. Ottenere una gestione efficiente del flusso di callback tuttavia può risultare molto complesso e spesso richiede una certa dose di "codice scritto in casa". Quest'ultimo caso è assolutamente da evitare, specialmente dal momento che altri sviluppatori prima di noi hanno già sviluppato e contribuito pubblicamente alla realizzazione di soluzioni eleganti e riusabili: è il caso per esempio di async, un modulo che mette a disposizione una serie di funzioni per gestire il codice asincrono in Javascript. Questa libreria conta oltre 20.000 stelle su github ed copre un'altissima quantitià di casi d'uso. È disponibile una distribuzione su npm, per cui in un qualsiasi programma Node.js è possibile installarlo digitando:

$ npm install async

La documentazione è scritta molto bene e come si può notare le funzionalità sono moltissime. In questo articolo vedremo un esempio pratico che lavora con gli array o collezioni.

Supponiamo di voler scrivere una funzione che:

  • Instaura una connessione con un database mongodb
  • Ottiene le informazioni pubbliche di un utente su github tramite le REST API pubbliche
  • Salva sul database le informazioni dell'utente ottenuto, inserendo un nuovo document

Questi task sono tutti asincroni ed hanno delle dipendenze tra loro, in quanto salvare un oggetto su un database richiede l'aver instaurato una connessione e di aver ottenuto l'oggetto, mentre il primo e secondo punto sono totalmente indipendenti. Vediamo come questo scenario possa essere risolto con async e in particolare con la funzione async.auto.

La funzione Async.auto prevede tre parametri:

  • Un oggetto le cui proprietà sono funzioni oppure array di requisiti (o dipendenze). La chiave di una proprietà in questo caso serve ad identificare univocamente il task e può essere utilizzato per specificare le dipendenze oppure ottenere i risultati.
    • Nel caso non ci siano dipendenze la proprietà prende una funzione che riceve come parametro una callback, che dev'essere invocata all'interno del corpo della funzione
    • Nel caso in cui vi siano delle dipendenze la proprietà prende come valore un'array, che conterrà tutte le chiavi delle proprietà da cui dipende ed infine una funzione che accetta questa volta due parametri, il primo un oggetto con i risultati delle dipendenze e il secondo una callback da invocare nel corpo della funzione
  • Un intero (opzionale) che specifica il numero massimo di operazioni concorrenti possibili. Di default questo valore è impostato ad infinito.
  • Una callback che viene invocata quando tutti i task sono stati completati. Questa callback riceve l'argomento err se ci sono stati errori in uno qualsiasi dei task ed inoltre come secondo parametro prende i risultati di tutti i task

L'esempio presuppone che si disponga di una connessione valida ad un database mongo:

'use strict';

const Async = require('async');
const MongoClient = require('mongodb').MongoClient;
const Request = require('request');

Async.auto({
  connectToDb: (cb) => {
    MongoClient.connect('mongodb://localhost:27017/test', (err, db) => {
      if (err) {
        return cb(err);
      }
      console.log('Successfully connected to db');
      return cb(null, db);
    });
  },
  getGithubUser: (cb) => {
    console.log('Retrieving user...');
    let options = {
      url: 'https://api.github.com/users/defra91',
      headers: {
        'User-Agent': 'request'
      }
    };

    Request.get(options, (err, resp, body) => {
      if (err) {
        return cb(err);
      } else {
        return cb(null, JSON.parse(body));
      }
    });
  },
  insertGithubUser: ['connectToDb', 'getGithubUser', (results, cb) => {
    let db = results.connectToDb;
    let dbo = db.db('test');
    let user = results.getGithubUser;
    return dbo.collection('github_users').insertOne(user, cb);
  }]
}, (err) => {
  if (err) {
    console.log('An error occurred');
    console.log(err);
  } else {
    console.log('Successfully added user');
  }
});

Nell'esempio appena visto vengono dichiarati 3 task:

  • connectToDb, non ha dipendenze, quindi il suo valore è una funzione che prevede al suo interno una callback. Nel corpo di questa funzione viene invocata una connessione al database. Se la connessione fallisce allore viene chiamata la callback con un errore, altrimenti viene chiamata la callback con errore nullo e il riferimento al database, utile in un secondo momento.
  • getGithubUser, non ha dipendenze, quindi, come connectToDb, il suo valore è una funzione che riceve in input una callback. Viene poi invocata una chiamata GET ad un certo url. Se la chiamata fallisce (ad esempio per un errore 4xx) viene invocata la callback con un errore, altrimenti viene invocata la callback con errore nullo e il corpo della risposta (json) contenente l'utente.
  • insertGithubUser ha due dipendenze, quindi il suo valore è un array con due stringhe, corrispondenti alle chiavi delle due proprietà precedenti ed una funzione che contiene due parametri: i risultati dei task da cui dipende ed una callback. Nel corpo della funzione vengono qundi recuperati i riferimenti al database e l'utente ed infine viene inserito l'utente all'interno della collection github_user

La callback finale se contiene l'errore stamperà l'errore a video, altrimenti stamperà un messaggio di completamento dell'operazione.

Conclusioni

La soluzione appena vista è molto complessa e richiede sicuramente uno sforzo iniziale per riuscire ad entrare nel meccanismo. Tuttavia il codice è molto pulito e chiaro, ciascuno task è nettamente separato e non viene innestato dentro altri task. Aggiungere ulteriori task o gestire diversamente il flusso è molto facile se si parte da una soluzione di questo tipo, quindi il codice è facilmente manutenibile. Async è un pacchetto che ciascun programmatore Node.js dovrebbe conoscere ed utilizzare in modo opportuno. Il codice può (e forse dovrebbe) essere ulteriormente ottimizzato, ad esempio con la modularizzazione dei task per favorirne il riuso. 

Per approfondire l'argomento relativo alla gestione di task asincroni un'altra soluzione elegante sono le Promise, visto in un articolo precedente.

Il codice sorgente di esempio è disponibile a questo indirizzo: https://github.com/defra91/nodejs-callback-examples

Il file seguente inoltre è contro-esempio di come il codice della callback-hell visto sopra possa essere ottimizzato con Async: https://github.com/defra91/nodejs-callback-examples/blob/master/callback-hell-optimized.js