Contacter DARVA

Les Generators : Simplifier le traitement des flux de données

26 novembre 2024
Code
JS
Node.js
Tech

Avec l’explosion des volumes de données dans nos applications modernes, gérer efficacement les flux de données devient un enjeu clé. JavaScript propose une solution puissante et élégante à cette problématique : les generators.

Cet article se propose d’explorer comment utiliser les generators pour rendre la gestion des flux de données plus simple et plus efficace.

Pourquoi gérer les flux de données en JavaScript 🤔?

Aujourd’hui, les applications doivent souvent traiter de grands volumes de données.

Par exemple, des millions de documents JSON à transformer en temps réel 😉.

Bien que des outils comme les ETL puissent gérer ce travail, il arrive que des solutions spécifiques ou moins dépendantes de solutions soient nécessaires.

C’est là que JavaScript, avec sa gestion native du JSON et ses outils d’async, devient un choix naturel.

Cependant, la gestion des flux de données dans JavaScript peut rapidement devenir complexe, en particulier avec des APIs vieillissantes comme Stream 🥵.

C’est ici que les generators entrent en jeu.

Les Generators

Les generators, introduits avec ES6 en 2015, permettent de créer des fonctions dont l’exécution peut être interrompue et reprendre ultérieurement.

Contrairement aux fonctions classiques qui s’exécutent d’une traite, un generator permet de “pauser” son exécution, puis de la reprendre à un moment ultérieur, tout en consommant des ressources de manière optimale.

function* feelingGenerator() {
yield "I";
yield "love";
yield "coding";
}

const g = feelingGenerator();
console.log(g.next().value); // "I"
console.log(g.next().value); // "love"
console.log(g.next().value); // "coding"
console.log(g.next().value); // undefined

Dans cet exemple, la fonction feelingGenerator utilise le mot clé yield pour retourner successivement les valeurs “I”, “love”, et “coding”.
À chaque appel de next(), le generator reprend là où il s’était arrêté.

Protocoles Iterator et Iterable

Les generators sont basés sur les protocoles Iterator et Iterable.

Comprendre ces concepts est fondamental pour bien maîtriser leur usage.

Protocole Iterator

Un objet Iterator a une méthode next() qui retourne un objet contenant deux propriétés : value et done.

const iterator = iterable[Symbol.iterator]();
console.log(iterator.next()); // { value: 'Hello', done: false }
console.log(iterator.next()); // { value: 'World', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

Protocole Iterable

Un objet est Iterable s’il implémente une méthode [Symbol.iterator] qui renvoie un objet conforme au protocole Iterator.

Cela signifie qu’on peut itérer sur cet objet avec une boucle for...of.

const iterable = {
    [Symbol.iterator]: function() {
        let step = 0;
        return {
            next: function() {
                step++;
                if (step === 1) {
                    return { value: 'Hello', done: false };
                } else if (step === 2) {
                    return { value: 'World', done: false };
                }
                    return { value: undefined, done: true };
                }
            };
        }
    }
};

for (const value of iterable) {
    console.log(value); // Hello, World
}

Les generators sont à la fois des Iterator et des Iterable.

On peut ainsi les utiliser directement dans des boucles for...of.

Exemples pratiques

Pagination de données asynchrones

async function* paginateResults(url) {
    let page = 1;
    while (true) {
        const response = await fetch(`${url}?page=${page}`);
        const data = await response.json();
        if (data.length === 0) break;
        yield data;
        page++;
    }
}

const url = 'https://api.example.com/data';
const generator = paginateResults(url);

async function handleGenerator(gen) {
    for await (let data of gen) {
        handlePageData(data);
    }
}

handleGenerator(generator);

Ce generator itère sur des pages d’une API jusqu’à ce qu’il n’y ait plus de données à traiter, assurant ainsi une consommation optimale des ressources.

Pagination de données asynchrones

async function* generateRandomNumbers() {
    while (true) {
        await new Promise(resolve => setTimeout(resolve, 1000)); // Attente d'une seconde
        yield Math.random();
    }
}

const generator = generateRandomNumbers();

async function handleGenerator(gen) {
    for await (let number of gen) {
        handleRandom(number);
        if (someCondition) {
            gen.return();
        }
    }
}

handleGenerator(generator);

Ce generator génère une suite de nombre aléatoire de manière infini.

Ce type de flux est particulièrement utile lorsqu’on veut simuler ou surveiller un flux de données en continu.

Simplifier le traitement des flux de données

Après avoir vu les generators dans la théorie, une des premières questions qui peut venir à l’esprit serait : « En quoi peuvent ils être utiles pour simplifier des traitements sur des flux de données » ?

Pour en démontrer leur intérêt, utilisons l’exemple suivant:

import { readFile, open } from "node:fs/promises";
import path from "node:path";

async function bootstrap() {
  const file = await open(path.join(import.meta.dirname, "./assets/athletes.txt"));

  const countries = JSON.parse(await readFile(path.join(import.meta.dirname, './assets/countries.json')))

  for await (const line of file.readLines()) {
    const { country: countryId, ...data } = JSON.parse(line);
    const enrichedData = { ...data, country: countries.find(i => i.id === countryId) };

    const stringified = `[${enrichedData.country.name}] [${enrichedData.sport}] ${enrichedData.firstName} ${enrichedData.lastName.toUpperCase()}`;

    console.log(stringified);
  }
}

await bootstrap();

Dans cet exemple, on lit un fichier d’athlètes et un fichier de pays au format JSON. Puis pour chaque athlète du premier fichier, on récupère les données du pays associé et on log une version stringifiée des données.

Rien de complexe.

Cependant le moindre changement de fonctionnement nous impose de toucher à notre unique fonction et les tests doivent garantir le fonctionnement complet de la fonction.

Sur ce cas, cela reste largement gérable, mais quid lorsqu’il y a 20/30 opérations de transformations plus ou moins complexes?

Pour simplifier tout cela, l’idée est de revenir aux bases de la programmation fonctionnelle et d’isoler chaque traitement logique.

Une brique d’ajout de données, une brique de parsing des données, une brique de formatage des données, etc…

Chaque brique serait appelée sur le résultat du bloc précédent le premier étant appelé sur les itérations de la consommation du fichier d’entrée.

index.js

async function bootstrap() {
  const file = await fs.open(path.join(import.meta.dirname, "./assets/athletes.txt"));
  const stream = file.readLines();

  for await (const chunk of stringifier(addCountryDetails(parser(stream)))) {
    console.log(chunk)
  }
}

generators.js

export const parser = async function* (input) {
  for await (const chunk of input) {
    yield JSON.parse(chunk);
  }
}

export async function* stringifier(input) {
  for await (const chunk of input) {
    const { country, sport, lastName, firstName } = chunk;
    yield `[${country.name}] [${sport}] ${firstName} ${lastName.toUpperCase()}`
  }
}

export const addCountryDetails = async function* (input) {
  for await (const chunk of input) {
    const { country: countryId, ...rest } = chunk ;
    const country = await getCountryById(countryId);
    yield { ...rest, country }
  }
}

Bon, jusque là, on ne gagne pas vraiment en simplification…

On splite les logiques mais c’est sans intérêt.

C’est seulement car il manque 2 outils permettant d’aboutir à un chaînage propre et clair, un chaînage plug and play !

toConsumerGenerator

Le premier problème dans le refacto avec les generators, c’est la lourdeur d’écriture et de testing des briques logiques qui se voulaient « simples ».

On remarque que cette lourdeur provient de la consommation du generator en lui-même.

Avec la petite méthode toConsumerGenerator ci-jointe, on peut écrire notre brique logique comme de simples fonctions classiques (les tests se font aussi de manière aussi simple😉).

Ce tool transformera notre brique en tant que generator consommateur et nous abstiendra de toute lourdeur.

/**
 * Build a generator that apply fn passed on each data from input that will received
 * 
 * @param {Function} fn 
 * @returns {AsyncGenerator} generator
 */
export function toConsumerGenerator(fn) {
  return async function* (input) {
    for await (const chunk of input) {
      yield fn(chunk);
    }
  }
}
/**
 * Stringify with defined format data passed
 */
function stringifier(data) {
  const { country, sport, lastName, firstName } = data;
  return `[${country.name}] [${sport}] ${firstName} ${lastName.toUpperCase()}`
}

export const stringifierGenerator = toConsumerGenerator(stringifier);

chain

Le second problème réside dans la consommation du generator principal (boucle for await principale).
En effet, plus on aura de briques logiques plus on devra faire une consommation sur un generator ayant une définition longue étant donné que les generators sont appelés avec le résultat du generator précédent.
stringifier(addCountryDetails(parser(stream)))

Ici, parser reçoit stream, puis addCountryDetails reçoit ce résultat de parser et enfin stringifier reçoit le résultat de addCountryDetails.

La méthode chain s’occupe de faire ces appels récursifs pour nous et d’enlever le code spécifique de consommation des generators.

/**
 * Chain list of generators and return result
 * 
 * @param  {...(GeneratorFunction|AsyncGeneratorFunction)} fns - ordered array of operations
 * @returns result data (array or unique data)
 */
export async function chain(...fns) {
  const dataPipe = fns.filter(Boolean).reduce(
    (accumulatedData, fn) => fn(accumulatedData),
    undefined
  );

  let results = [];
  for await (const result of dataPipe) {
    results.push(result);
  }

  return results.length === 1 ? results[0] : results;
}
async function bootstrap() {
  const file = await fs.open(path.join(import.meta.dirname, "./assets/athletes.txt"));
  const stream = file.readLines();

  await chain(
    stream,
    parser,
    addCountryDetails,
    stringifier,
    logger // méthode console.log wrappé dans toConsumerGenerator
  );
}

On obtient une solution facilement testable, propre, avec des responsabilités claires et performantes.

L’ajout de nouvelles brique est simple et facile à mettre en œuvre.

Avec la méthode chain, on peut changer l’ordre des opérations rapidement en changeant l’ordre des paramètres qui lui sont passées ou encore conditionner la résolution de tel ou tel generator grâce à l’usage de condition.

C’est devenu Plug’N’Play!

Conclusion

Les generators en JavaScript offrent une manière élégante de gérer des flux de données complexes tout en maintenant un code lisible et performant (avec l’aide de quelques abstractions 😉).

En les combinant avec des concepts comme async/await, ils deviennent un outil incontournable pour la gestion des données asynchrones et séquentielles.

N’attendez plus, explorez les generators pour simplifier vos prochains projets de gestion de données !

L’intégralité des exemples se trouve sur mon repo github: nesimer/talks/Generators Lab

Nicolas REMISEÀ propos de l'auteur.
Nicolas REMISE est TechLead JS/TS
au sein de l'Usine Digitale Données de DARVA.
Passionné par les technos web,
il aime partager les nouveautés
qu'il met en œuvre au quotidien.