Votre projet commence à prendre de l’importance, vous avez tendance à ballader votre configuration ou vos objets d’un bout à l’autre de votre application et vous aimeriez ranger tout ça ? J’ai peut-être la solution à votre problème.
Donne-moi un sandwich !
Prenons l’exemple d’une personne qui déciderait d’aller chercher un sandwich au comptoir d’un restaurant que nous allons appeler McRonald. Elle demande un sandwich, celui-ci est préparé et on lui donne un sac contenant sa commande.
const prepareSandwich = sandwichName => {
return {
type: 'SANDWICH',
name: sandwichName,
};
};
const putInBag = (...orderItems) => {
return {
type: 'BAG',
contents: orderItems,
};
};
const takeout = sandwichName => {
const sandwich = prepareSandwich(sandwichName);
const bag = putInBag(sandwich);
return bag;
};
Remarquez-vous un problème dans la fonction takeout
? C’est une fonction dite
impure, car elle fait appel à deux variables qui ne font pas partie de ses
arguments : prepareSandwich
et putInBag
.
Pour être pure, une fonction doit remplir deux conditions :
- retourner toujours le même résultat lorsqu’on lui passe les mêmes arguments – ce qui implique de ne pas dépendre de variables externes ;
- ne pas avoir d’effet de bord – c’est-à-dire qu’elle ne doit pas modifier l’environnement.
Ce n’est finalement pas si grave dans mon cas – mais cela jette un voile sur
mes tests unitaires car je ne peux plus tester takeout
en isolation. Peu à
peu, mes tests unitaires se transforment en tests d’intégration.
Pour contourner le problème, il est possible de faire appel à des bibliothèques
comme proxyquire ou rewire qui vont manipuler la fonction require
de
Node.js afin de pouvoir injecter des mocks.
Je n’aime pas cette solution car non seulement elle va trifouiller dans les entrailles de la plateforme, mais aussi car elle introduit à mon sens une dissociation entre l’intention du développeur et l’exécution des tests. Je trouve cela inélégant.
Le problème devient plus apparent au fur et à mesure qu’on ajoute de la complexité, par exemple si l’on considère qu’une commande ne comporte pas qu’un seul produit, si l’on intègre la gestion des stocks ou le paiement par carte bancaire.
const takeout = (sandwichName, drinkName, paymentMethod) => {
const total = getTotal(sandwichName, drinkName);
// Bonus : ajout d'un effet de bord
debit(total, paymentMethod);
const sandwich = prepareSandwich(sandwichName);
const drink = prepareDrink(drinkName);
const bag = putInBag(sandwich, drink);
return bag;
};
Il devient par ailleurs de plus en plus compliqué de se retrouver dans le code. Un point positif cependant : vos compétences récemment acquises vous ont permis de transcender la préparation des spaghettis.
Purification
Un premier pas dans notre approche sera de purifier nos fonctions en faisant de
nos dépendances – prepareSandwich
et putInBag
– des arguments à takeout
pour éliminer la dépendance à des variables externes.
const takeout = (prepareSandwich, putInBag, sandwichName) => {
const sandwich = prepareSandwich(sandwichName);
const bag = putInBag(sandwich);
return bag;
};
On peut pousser plus loin en passant les dépendances et les arguments en deux
temps. Le gros avantage sera de pouvoir laisser le restaurateur choisir ce qu’il
souhaite exposer à ses clients. On fait appel à des factories. La
takeoutFactory
est une fonction qui prend les dépendances en entrée et
retourne une fonction takeout
à laquelle il ne reste plus qu’à donner le nom
du sandwich.
const takeoutFactory = (prepareSandwich, putInBag) => sandwichName => {
const sandwich = prepareSandwich(sandwichName);
const bag = putInBag(sandwich);
return bag;
};
Avec des fonctions pures, je peux me passer de solutions bancales lorsque j’écris mes tests unitaires.
describe('takeout', () => {
it('gives me a sandwich in a bag', () => {
// GIVEN
const prepareSandwich = sandwichName => sandwichName;
const putInBag = sandwich => `${sandwich} in a bag`;
const takeout = takeoutFactory(prepareSandwich, putInBag);
// WHEN
const bag = takeout('my favourite sandwich');
// THEN
expect(bag).to.equal('my favourite sandwich in a bag');
});
});
Inversion du contrôle
Un argument – sans jeu de mot – de taille contre cette approche est qu’il est
irréaliste de penser que l’on va passer chaque dépendance de cette façon. Si
l’on reprend l’exemple plus touffu de tout à l’heure, on finit rapidement avec
un bon gros tas de dépendances à passer pour initialiser takeout
.
const takeoutFactory = (
getTotal,
debit,
prepareSandwich,
prepareDrink,
putInBag,
) => (sandwichName, drinkName, paymentMethod) => {
const total = getTotal(sandwichName, drinkName);
debit(total, paymentMethod);
const sandwich = prepareSandwich(sandwichName);
const drink = prepareDrink(drinkName);
const bag = putInBag(sandwich, drink);
return bag;
};
const takeout = takeoutFactory(
getTotal,
debit,
prepareSandwich,
prepareDrink,
putInBag,
);
L’idée de l’injection de dépendance ne s’arrête pas là. On veut effectivement
pouvoir donner le contrôle à la fonction takeoutFactory
et que ce soit à elle
de demander ses dépendances. Pour cela, on va utiliser ce qu’on appelle un
container ; un objet chargé de fournir à la fonction les dépendances dont elle
a besoin.
const takeoutFactory = container => sandwichName => {
const prepareSandwich = container('prepareSandwich');
const putInBag = container('putInBag');
const sandwich = prepareSandwich(sandwichName);
const bag = putInBag(sandwich);
return bag;
};
Je tiens à bien vous faire comprendre qu’un container n’a rien de magique. On peut en définir un facilement et en quelques lignes. C’est une fonction toute bête qui garde une liste de dépendances sous le coude et peut les ressortir à la demande.
const createContainer = () => {
const dependencies = {};
const container = dependencyName => dependencies[dependencyName];
container.register = (name, dependency) => {
dependencies[name] = dependency;
};
return container;
};
Cette petite modification a un impact conséquent sur la façon de concevoir notre code. Plutôt que de devoir gérer moi-même les dépendances de chacune de mes fonctions, je vais pouvoir leur déléguer cette tâche et ainsi me concentrer sur l’implémentation du code et non sur son utilisation.
La seule responsabilité qui m’incombe en tant qu’orchestrateur de cette symphonie gustative est celle d’enregistrer mes dépendances afin qu’elles soient connues du container – et de passer ce dernier à mes factories.
describe('takeout', () => {
it('gives me a sandwich in a bag', () => {
// GIVEN
const prepareSandwich = sandwichName => sandwichName;
const putInBag = sandwich => `${sandwich} in a bag`;
const container = createContainer();
container.register('prepareSandwich', prepareSandwich);
container.register('putInBag', putInBag);
const takeout = takeoutFactory(container);
// WHEN
const bag = takeout('my favourite sandwich');
// THEN
expect(bag).to.equal('my favourite sandwich in a bag');
});
});
Injection et proxies
Les Proxies sont une fonctionnalité d’ES6 disponible dans Node.js depuis sa version 6 et dans tous les navigateurs maintenus. Ils permettent entre autres de définir des objets dont les propriétés sont dynamiques. Dans cet exemple bateau on définit un objet qui à chaque clé affecte comme valeur le nom de cette clé, en capitales.
const proxy = new Proxy(
{},
{
get(target, prop) {
return prop.toUpperCase();
},
},
);
console.log(proxy.rednet); // => "REDNET"
console.log(proxy.cerise); // => "CERISE"
On peut exploiter cette fonctionnalité pour donner un proxy à notre container
et rendre container.proxy[dependencyName]
équivalent à
container(dependencyName)
.
const createContainer = () => {
const dependencies = {};
const container = dependencyName => dependencies[dependencyName];
container.proxy = new Proxy(container, {
get(_, dependencyName) {
return container(dependencyName);
},
});
container.register = (name, dependency) => {
dependencies[name] = dependency;
};
return container;
};
Enfin, on peut passer ce proxy à la place du container et utiliser les affectations par décomposition – destructuring assignments – pour arriver à un résultat nettement plus déclaratif.
const takeoutFactory = ({
getTotal,
debit,
prepareSandwich,
prepareDrink,
putInBag,
}) => (sandwichName, drinkName, paymentMethod) => {
const total = getTotal(sandwichName, drinkName);
debit(total, paymentMethod);
const sandwich = prepareSandwich(sandwichName);
const drink = prepareDrink(drinkName);
const bag = putInBag(sandwich, drink);
return bag;
};
Notez bien que bien que si la syntaxe se rapproche de celle citée plus haut, on passe bien le proxy du container à
takeoutFactory
et non plus une liste de fonctions.
Bien entendu, cela me permet également d’améliorer la lisibilité de mes tests.
describe('takeout', () => {
it('gives me a sandwich in a bag', () => {
// GIVEN
const prepareSandwich = sandwichName => sandwichName;
const putInBag = sandwich => `${sandwich} in a bag`;
const takeout = takeoutFactory({
prepareSandwich,
putInBag,
});
// WHEN
const bag = takeout('my favourite sandwich');
// THEN
expect(bag).to.equal('my favourite sandwich in a bag');
});
});
Allons plus loin à présent. Dans mon exemple simplifié la fonction
prepareSandwich
est pure mais dans un exemple plus complet elle pourrait faire
appel aux stocks pour savoir si les ingrédients sont disponibles. En fait j’ai
besoin de l’injecter aussi.
const prepareSandwich = ({
getIngredients,
hasStocks,
removeOneFromStocks,
}) => sandwichName => {
for (const ingredient of getIngredients(sandwichName)) {
if (!hasStocks(ingredient)) {
throw new Error(`No stocks left for ${ingredient}`);
}
removeOneFromStocks(ingredient);
}
return {
type: 'SANDWICH',
name: sandwichName,
};
};
En pratique, la plupart de nos dépendances auront elle-mêmes des dépendances. On peut faire en sorte que notre container initialise celles-ci en leur passant son propre proxy.
const createContainer = () => {
// Je ne garde plus les dépendances mais leur factories
const factories = {};
const container = dependencyName => {
// Je ne passe plus le container mais bien le proxy
return factories[dependencyName](container.proxy);
};
container.proxy = new Proxy(container, {
get(_, dependencyName) {
return container(dependencyName);
},
});
// J'enregistre maintenant des factories et plus des dépendances
container.register = (name, factory) => {
factories[name] = factory;
};
return container;
};
Si on reprend notre exemple de base, notre code est à la fois plus homogène et plus simple à tester unitairement.
// Définition des services
const prepareSandwichFactory = () => sandwichName => {
return {
type: 'SANDWICH',
name: sandwichName,
};
};
const putInBagFactory = () => (...orderItems) => {
return {
type: 'BAG',
contents: orderItems,
};
};
const takeoutFactory = ({ prepareSandwich, putInBag }) => sandwichName => {
const sandwich = prepareSandwich(sandwichName);
const bag = putInBag(sandwich);
return bag;
};
// Création du container et enregistrement des services
const container = createContainer();
container.register('prepareSandwich', prepareSandwichFactory);
container.register('putInBag', putInBagFactory);
container.register('takeout', takeoutFactory);
Cas concret
Une fois mon système bien en place, la seule difficulté restante est de passer
mon container à droite, à gauche, pour toutes les fonctions qui en ont besoin.
Imaginons un instant que Donald McRonald, dans son infinie sagesse, décide de
prendre le virage du numérique et d’exposer son API à ses bornes, via
Express. Il est habituel avec Express de fournir des informations de
l’application aux routes via l’objet req
, passé en argument de cette dernière.
J’en profite pour y insérer mon proxy.
Attention pour les deux du fond : ici rien à voir avec un proxy HTTP, on parle bien de
container.proxy
, le proxy ES6 qui me permet d’accéder à mes dépendances
const express = require('express');
const container = createContainer();
// ...
const app = express();
// Ajout d'un middleware pour passer le proxy aux routes
app.use((req, res, next) => {
req.proxy = container.proxy;
});
// ...
app.post('/takeout', (req, res) => {
// takeout est un service dont nous avons enregistré la factory.
const { takeout } = req.proxy;
const { sandwichName } = req.query;
res.json(takeout(sandwichName));
});
// >>> HTTP/1.1 POST /takeout?sandwichName=burger
//
// <<< HTTP/1.1 200 OK
// <<< Content-Type: application/json
// <<<
// <<< {
// <<< "type": "BAG",
// <<< "contents": [
// <<< {
// <<< "type": "SANDWICH",
// <<< "name": "burger"
// <<< }
// <<< ]
// <<< }
Cerise
Cerise est une bibliothèque discrète codée par votre serviteur afin de
fournir une base à vos besoins en injection de dépendance. Elle reprend dans les
grosses lignes toute la partie createContainer
vue ci-dessus – avec des
améliorations toutefois. Elle propose également des middlewares Express et Koa
ainsi que des fonctions utilitaires.
Grâce à cerise, l’exemple ci-dessus peut être simplifié comme suit :
const express = require('express');
const { createContainer, controller, middleware: cerise } = require('cerise');
const container = createContainer();
// ...
const app = express();
app.use(cerise(container));
// ...
app.post('/takeout', controller(({
takeout,
'req.query': { sandwichName }
}) => {
return takeout(sandwichName);
}));
Je vous invite à aller lire le guide d’utilisation ainsi que la documentation de cerise, puis d’aller jeter un œil aux exemples si le cœur vous en dit. Les pull requests sont bienvenues !