IoC, DI et cerise

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 !