Skip to content

Controllo del flusso asincrono

INFO

Il materiale in questo post è fortemente ispirato a Mixu's Node.js Book.

Nel suo nucleo, JavaScript è progettato per essere non bloccante sul thread "principale", ovvero dove vengono renderizzate le viste. Si può immaginare l'importanza di questo nel browser. Quando il thread principale viene bloccato, si verifica il famigerato "congelamento" che gli utenti finali temono, e nessun altro evento può essere inviato, con conseguente perdita di acquisizione di dati, ad esempio.

Questo crea alcuni vincoli unici che solo uno stile funzionale di programmazione può curare. È qui che entrano in gioco le callback.

Tuttavia, le callback possono diventare difficili da gestire in procedure più complicate. Questo spesso si traduce in un "inferno di callback" in cui più funzioni nidificate con callback rendono il codice più difficile da leggere, debuggare, organizzare, ecc.

js
async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // fai qualcosa con output
        });
      });
    });
  });
});

Naturalmente, nella vita reale ci sarebbero molto probabilmente ulteriori righe di codice per gestire result1, result2, ecc., quindi, la lunghezza e la complessità di questo problema di solito si traduce in un codice che appare molto più disordinato dell'esempio sopra.

È qui che le funzioni entrano in grande uso. Le operazioni più complesse sono composte da molte funzioni:

  1. stile iniziatore / input
  2. middleware
  3. terminatore

Lo "stile iniziatore / input" è la prima funzione nella sequenza. Questa funzione accetterà l'input originale, se presente, per l'operazione. L'operazione è una serie eseguibile di funzioni, e l'input originale sarà principalmente:

  1. variabili in un ambiente globale
  2. invocazione diretta con o senza argomenti
  3. valori ottenuti da richieste del file system o di rete

Le richieste di rete possono essere richieste in arrivo iniziate da una rete esterna, da un'altra applicazione sulla stessa rete o dall'app stessa sulla stessa rete o su una rete esterna.

Una funzione middleware restituirà un'altra funzione e una funzione terminatore invocherà la callback. Quanto segue illustra il flusso alle richieste di rete o del file system. Qui la latenza è 0 perché tutti questi valori sono disponibili in memoria.

js
function final(someInput, callback) {
  callback(`${someInput} e terminato eseguendo la callback `);
}
function middleware(someInput, callback) {
  return final(`${someInput} toccato dal middleware `, callback);
}
function initiate() {
  const someInput = 'ciao questa è una funzione ';
  middleware(someInput, function (result) {
    console.log(result);
    // richiede alla callback di `return` il risultato
  });
}
initiate();

Gestione dello stato

Le funzioni possono essere o meno dipendenti dallo stato. La dipendenza dallo stato si verifica quando l'input o un'altra variabile di una funzione si basa su una funzione esterna.

In questo modo esistono due strategie principali per la gestione dello stato:

  1. passare le variabili direttamente a una funzione, e
  2. acquisire il valore di una variabile da una cache, una sessione, un file, un database, una rete o un'altra fonte esterna.

Si noti che non ho menzionato le variabili globali. Gestire lo stato con variabili globali è spesso un anti-pattern approssimativo che rende difficile o impossibile garantire lo stato. Le variabili globali nei programmi complessi dovrebbero essere evitate quando possibile.

Controllo del flusso

Se un oggetto è disponibile in memoria, l'iterazione è possibile e non ci saranno modifiche al flusso di controllo:

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} beers on the wall, you take one down and pass it around, ${
      i - 1
    } bottles of beer on the wall\n`;
    if (i === 1) {
      _song += "Hey let's get some more beer";
    }
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}
const song = getSong();
// questo funzionerà
singSong(song);

Tuttavia, se i dati esistono al di fuori della memoria, l'iterazione non funzionerà più:

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    /* eslint-disable no-loop-func */
    setTimeout(function () {
      _song += `${i} beers on the wall, you take one down and pass it around, ${
        i - 1
      } bottles of beer on the wall\n`;
      if (i === 1) {
        _song += "Hey let's get some more beer";
      }
    }, 0);
    /* eslint-enable no-loop-func */
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}
const song = getSong('beer');
// questo non funzionerà
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!

Perché è successo questo? setTimeout istruisce la CPU a memorizzare le istruzioni altrove sul bus e istruisce che i dati sono programmati per essere prelevati in un momento successivo. Migliaia di cicli della CPU passano prima che la funzione venga nuovamente colpita al limite di 0 millisecondi, la CPU recupera le istruzioni dal bus e le esegue. L'unico problema è che la canzone ('') è stata restituita migliaia di cicli prima.

La stessa situazione si presenta nella gestione dei file system e delle richieste di rete. Il thread principale semplicemente non può essere bloccato per un periodo di tempo indeterminato, quindi utilizziamo le callback per pianificare l'esecuzione del codice nel tempo in modo controllato.

Sarai in grado di eseguire quasi tutte le tue operazioni con i seguenti 3 pattern:

  1. In serie: le funzioni verranno eseguite in un ordine sequenziale rigoroso, questo è più simile ai cicli for.
js
// operazioni definite altrove e pronte per l'esecuzione
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
  // esegue la funzione
  const { args, func } = operation;
  func(args, callback);
}
function serialProcedure(operation) {
  if (!operation) process.exit(0); // finito
  executeFunctionWithArgs(operation, function (result) {
    // continua DOPO la callback
    serialProcedure(operations.shift());
  });
}
serialProcedure(operations.shift());
  1. Completamente parallelo: quando l'ordinamento non è un problema, come inviare un'e-mail a un elenco di 1.000.000 di destinatari di e-mail.
js
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: 'Bart', email: 'bart@tld' },
  { name: 'Marge', email: 'marge@tld' },
  { name: 'Homer', email: 'homer@tld' },
  { name: 'Lisa', email: 'lisa@tld' },
  { name: 'Maggie', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
  // `sendMail` è un client SMTP ipotetico
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}
function final(result) {
  console.log(`Result: ${result.count} attempts \
      & ${result.success} succeeded emails`);
  if (result.failed.length)
    console.log(`Failed to send to: \
        \n${result.failed.join('\n')}\n`);
}
recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;
    if (count === recipients.length) {
      final({
        count,
        success,
        failed,
      });
    }
  });
});
  1. Parallelo limitato: parallelo con limite, come inviare correttamente e-mail a 1.000.000 di destinatari da un elenco di 10 milioni di utenti.
js
let successCount = 0;
function final() {
  console.log(`dispatched ${successCount} emails`);
  console.log('finished');
}
function dispatch(recipient, callback) {
  // `sendMail` è un client SMTP ipotetico
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}
function sendOneMillionEmailsOnly() {
  getListOfTenMillionGreatEmails(function (err, bigList) {
    if (err) throw err;
    function serial(recipient) {
      if (!recipient || successCount >= 1000000) return final();
      dispatch(recipient, function (_err) {
        if (!_err) successCount += 1;
        serial(bigList.pop());
      });
    }
    serial(bigList.pop());
  });
}
sendOneMillionEmailsOnly();

Ognuno ha i propri casi d'uso, vantaggi e problemi su cui puoi sperimentare e leggere più dettagliatamente. Cosa più importante, ricordati di modularizzare le tue operazioni e di usare le callback! Se hai qualche dubbio, tratta tutto come se fosse middleware!