Marmelab Blog

SG: Un moteur à effet pour JavaScript

Dans le post précédent, je vous ai parlé des générateurs et de leur fonctionnement. Aujourd'hui je vais vous parler de leur exécution et de ce qu'ils peuvent accomplir.

Exécution d'un générateur

Jusqu'ici nous avons exécuté le générateur à la main. Mais cela peut se révéler fastidieux d'appeler next à chaque étape, etc... Il existe des mécanismes qui permettent dans une certaine mesure d'exécuter un générateur automatiquement.

for of

Permet de récupérer toutes les valeurs retournées par un générateur. Il n'est cependant pas possible de passer de valeur au générateur, et l'on se retrouve avec une boucle infinie dans le cas d'un générateur infini.

function* oneTwoThree() {
    yield 1;
    yield 2;
    yield 3;
}

for (var i of oneTwoThree()) {
    console.log(i);
}
// 1
// 2
// 3

L'opérateur ...

On peut également utiliser le spread operator sur un itérateur.

const arr = ...oneTwoThree();
// [1,2,3]

On récupère alors un tableau de toutes les valeurs de celui-ci, avec les même limitations que pour le for of bien entendu.

Les moteurs

for of et ... ne permettent que de lire un générateur sans pouvoir intéragir avec lui. Pouvoir automatiser l'exécution d'un générateur demandant une intéraction nécessite un générateur qui retourne des valeurs d'un type que l'on sait gérer indépendamment de leur valeur. On appelle une fonction capable d'exécuter un de ces générateurs un moteur.

Par exemple, on peut imaginer un générateur retournant uniquement une simple fonction sans paramètres. Le moteur pour ce genre de générateurs aurait juste à exécuter la fonction et à redonner la valeur au générateur.

function funcMotor(it) {
    let next = it.next(); // on déclenche la première itération

    while (!next.done) { // tant que l'itérateur n'est pas fini
        try {
            const result = next.value(); // On exécute la fonction retournée par le générateur
            next = it.next(result); // On repasse le résultat au générateur
        } catch (error) {
            next = it.throw(error); // Ou l'erreur
        }
    }

    return next.value; // à la fin on retourne la dernière valeur
}

Ce moteur est alors capable d'exécuter n'importe quel générateur ne retournant que des fonctions sans paramètres.

function* gen() {
    const a = yield () => 5;
    const b = yield () => a * 2;
    return b;
}
funcMotor(gen()); // 10

Pas très utile c'est vrai, mais voyons d'autres exemples plus avancés. Et si on avait un générateur qui ne retournait que des promesses ? On pourrait assez facilement créer un moteur qui récupère ces promesses, les résout et repasse le résultat au générateur.

function promiseMotor(it) {
    let firstNext = it.next();

    return new Promise((resolve, reject) => {
        function loop(next) {
            if (next.done) {
                resolve(next.value);
                return;
            }
            next.value
                .catch(error => loop(it.throw(error)))
                .then(result loop(it.next(result)))
                .catch(reject);
        }

        loop(firstNext);
    });
}

Attendez, il existe déjà un moteur pour cela, il s'appelle co. Cela dit es7 avec async await, gère le même problème, et le fait nativement.

De plus les promesses ont un inconvénient: elles représentent une tâche en cours d'exécution. Du moment que l'on a une promesse, la tâche qu'elle représente a déjà commencée. Cela signifie également que pour récupérer la promesse, il faut lancer l'exécution de la tâche. En d'autres termes c'est le générateur qui lance la tâche, et non le moteur.

Il serait plus intéressant que ce soit l'inverse. Le générateur ne manipulerait que des objets, qu'il recevrait et passerait au moteur. Et seul le moteur s'occuperait de les exécuter, comme dans notre premier exemple ou l'on ne retournait que des fonctions, sauf qu'au lieu de fonctions, notre générateur retournerait des effets.

Les effets

Un effet est un objet qui décrit une tâche mais ne l'exécute pas.

{
    type: 'call',
    func: aFunction,
    args: ['arg', 'to pass', 'to the function'],
}

{
    type: 'sql',
    sql: 'SELECT * FROM user WHERE id=$0',
    params: ['5'],
}

...

Comme vous pouvez le voir ces effets sont parlants et on imagine comment un moteur pourrait exécuter ces effets et passer leur résultat au générateur.

Un générateur retournant des effets couplé à un moteur capable d'exécuter ces effets, c'est ce qu'utilise redux-saga. Cela permet en plus de découpler l'ordonnancement des tâches (ce qui doit être fait et dans quel ordre), de leur exécution proprement dite.

Cependant, redux-saga est fortement couplé à redux, et n'a guère d'intéret à être utilisé seul. De plus, il ne permet pas de créer ses propres effets.

Je vous présente SG

Pour ces raisons, j'ai créé SG, une librairie fortement inspirée de redux-saga et co, et qui est un moteur permettant d'exécuter des générateurs yieldant des effets. Elle fournit par défaut des helpers pour gérer les appels asynchrones que ce soit les continuations, les thunks ou les functions retournant des promesses ainsi que les effets les plus connus de redux-saga, le tout indépendamment d'un environnement.

Sg reçoit un générateur et retourne une promesse :

import sg from 'sg';
import { call } from 'sg/effects';

function* gen(name) {
    //...
    yield call(console.log, `hello ${name}`);

    return 'done';
}

const func = sg(gen);

func('world')
.then((result) => {
    console.log(result); // done
});

Sg permet également de créer ses propres effets spécialisés, grâce à createEffect.

createEffect prend un type, un handler et retourne une fonction créant ce type d'effet. Le type est le nom permettant d'identifier le genre d'effet. Le handler est la fonction qui sera exécutée pour résoudre l'effet, elle reçoit les arguments passés à la fonction de création, et doit retourner une promesse.

import sg, { createEffect } from 'sg.js';
import { createEffect } from 'sg.js/effects';

function handleSql([query, parameter]) {
    return executeQueryAndReturnAPromise(query, parameter);
}

const sqlEffect = createEffect('sql', handleSql);

On obtient une fonction qui crée un effet du type défini. sqlEffect('query', 'parameter'); donne

{
    type: 'sql',
    handle: handleSql,
    args: ['query', 'parameter'],
}

Dans un générateur l'effet s'utilise comme suit :

sg(function* (userData) {
    yield sqlEffect('INSERT ... INTO user', userData);
})({ name: 'john' });

Lors de l'exécution handleSql se verra appelé comme suit.

handleSql(['INSERT ... INTO user', userData]);

Cette librairie est disponible sur github Essayez la, et dites moi ce que vous en pensez.