JavaScript Async/Await: costruire pipeline leggibili con gli Async Iterator

by Anastasia P.
SHARE
JavaScript Async/Await: costruire pipeline leggibili con gli Async Iterator
© Guida-HTML5.it

Introduzione

Quando si lavora con dati asincroni, spesso non basta eseguire una singola Promise. Un’applicazione può dover leggere pagine successive di un’API, elaborare risultati man mano che arrivano oppure consumare eventi prodotti nel tempo. In questi casi, accumulare tutto in un array prima di iniziare l’elaborazione può essere poco efficiente e rendere il codice difficile da seguire.

Un approccio elegante consiste nell’utilizzare gli async iterator e gli async generator. Questi strumenti permettono di produrre valori asincroni uno alla volta e di consumarli con il ciclo for await...of. Il codice mantiene una struttura lineare, simile a quella sincrona, ma continua a rispettare i tempi delle operazioni di rete o di I/O.

In questo tutorial costruiremo una pipeline che legge più pagine da un’API, filtra gli elementi interessanti e li elabora progressivamente. L’esempio è utile quando si gestiscono grandi quantità di dati o risposte paginated, perché non è necessario caricare tutto in memoria prima di iniziare.

Codice completo

// Simula una richiesta HTTP a una pagina dell´API.
async function fetchPage(page) {
  const response = await fetch(
    `https://api.example.com/products?page=${page}`
  );

  if (!response.ok) {
    throw new Error(`Errore HTTP ${response.status}`);
  }

  // Ci aspettiamo un oggetto con:
  // { items: [...], hasNextPage: true/false }
  return response.json();
}

// Async generator: produce una pagina alla volta.
async function* readAllProducts() {
  let page = 1;

  while (true) {
    const result = await fetchPage(page);

    // Produci ogni prodotto singolarmente.
    for (const product of result.items) {
      yield product;
    }

    // Interrompiamo la lettura quando non ci sono altre pagine.
    if (!result.hasNextPage) {
      break;
    }

    page++;
  }
}

// Elabora ogni prodotto senza accumularli tutti in memoria.
async function processAvailableProducts() {
  try {
    for await (const product of readAllProducts()) {
      if (product.stock > 0 && product.price < 100) {
        await saveProduct(product);
        console.log(`Prodotto salvato: ${product.name}`);
      }
    }

    console.log("Elaborazione completata");
  } catch (error) {
    console.error("Pipeline interrotta:", error.message);
  }
}

// Simula il salvataggio su un database o su un altro servizio.
async function saveProduct(product) {
  await new Promise((resolve) => setTimeout(resolve, 100));

  // In un´applicazione reale potremmo usare:
  // await database.products.insert(product);
}

processAvailableProducts();

Spiegazione

Che cos’è un async generator?

Una funzione dichiarata con async function* è un generatore asincrono. A differenza di una normale funzione asincrona, non restituisce subito un singolo risultato finale. Produce invece una sequenza di valori nel tempo tramite la parola chiave yield.

Nel nostro esempio, readAllProducts() richiede una pagina, percorre i prodotti ricevuti e usa yield product per consegnarli uno alla volta. Quando il consumatore richiede il valore successivo, il generatore riprende l’esecuzione dal punto in cui era stato sospeso.

Questo meccanismo è particolarmente utile per:

  • leggere pagine successive di un’API;
  • processare file o stream di grandi dimensioni;
  • consumare messaggi provenienti da una coda;
  • separare la produzione dei dati dalla loro elaborazione;
  • evitare array molto grandi e inutili accumuli in memoria.

Il ciclo for await...of

Il ciclo for await...of consuma valori provenienti da un async iterator. A ogni iterazione, JavaScript attende automaticamente il prossimo elemento. Per questo è possibile usare await saveProduct(product) direttamente nel corpo del ciclo, mantenendo un flusso leggibile.

Il ciclo non scarica necessariamente tutte le pagine in anticipo. La pagina successiva viene richiesta quando il generatore ne ha bisogno. Questo comportamento introduce una forma naturale di backpressure: il produttore non continua a generare dati più velocemente di quanto il consumatore riesca a elaborarli.

Separare responsabilità diverse

La funzione fetchPage si occupa soltanto della richiesta HTTP. Il generatore gestisce la paginazione e la produzione progressiva degli elementi. Infine, processAvailableProducts decide quali prodotti filtrare e come salvarli.

Questa separazione rende il codice più semplice da testare e modificare. Se cambia il formato dell’API, interveniamo principalmente in fetchPage. Se cambia il criterio di selezione, aggiorniamo il consumatore senza riscrivere la logica di paginazione.

Best practice

  • Controlla sempre response.ok: una risposta HTTP come 404 o 500 non provoca automaticamente un’eccezione con fetch. È quindi necessario verificare esplicitamente lo stato.
  • Produci dati granulari: usare yield per ogni elemento consente al consumatore di iniziare subito il lavoro, senza attendere il caricamento completo.
  • Non confondere asincronia e parallelismo: il ciclo mostrato elabora un prodotto alla volta. È una scelta sicura quando il servizio di destinazione ha limiti o quando l’ordine è importante.
  • Gestisci la terminazione: il generatore deve avere una condizione chiara, come hasNextPage, per evitare richieste infinite.
  • Propaga gli errori: lasciare che l’errore di fetchPage risalga al chiamante permette di gestirlo in un unico punto con try...catch.
  • Evita effetti collaterali nel generatore: il generatore dovrebbe concentrarsi sulla lettura e sulla produzione dei dati. Il salvataggio o l’invio a un altro servizio appartengono meglio al consumatore.
  • Considera l’annullamento: in scenari reali puoi aggiungere un AbortSignal a fetch, così l’utente può interrompere una pipeline non più necessaria.

Un ulteriore vantaggio è la possibilità di creare trasformazioni componibili. Per esempio, un altro async generator potrebbe ricevere i prodotti e produrre soltanto quelli appartenenti a una determinata categoria. In questo modo ogni fase della pipeline rimane piccola, riutilizzabile e facilmente verificabile.

Riepilogo

Gli async iterator permettono di gestire sequenze di dati asincroni con una sintassi naturale. Gli async generator, grazie a async function* e yield, producono valori progressivamente, mentre for await...of li consuma rispettando i tempi delle Promise.

Questo pattern è una soluzione pratica per API paginated, stream, code di messaggi e grandi dataset. Rispetto all’accumulo completo dei risultati, consente di ridurre l’uso della memoria, iniziare prima l’elaborazione e mantenere separate le responsabilità del programma.

Approfondisci con risorse ufficiali

SHARE