Notes inintéressantes sur l’injection de dépendances.


Bienvenue dans le bonus de mon article sur l'injection de dépendance. Il s’agit d'un moyen de parler de ce patron de conception plus en détail sans avoir peur de faire fuir les lecteurs impatients.

J'écris ça complètement à l'arrache, sans aucun plan et sans me relire (merci Antidote), donc ne vous attendez pas à de la grande littérature. Et pour prouver que je n'en ai vraiment plus rien à foutre, je retire un bouton de ma chemise.

J'ai organisé l'article en différents points, comme ça, vous pouvez sauter directement aux parties qui vous intéressent.


Formes d'injection de dépendances

Je n'en ai pas parlé, mais il y a en réalité 3 grandes catégories d'injection de dépendance.


Injection par constructeur

C'est celle dont j'ai parlé dans l'article, et honnêtement, celle que vous utiliserez 95% du temps.

public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public NintendoEntertainmentSystem(INesVideoGame cartridge) => 
        VideoGame = cartridge;
}

Injection par mutateur

L'idée est de faire en sorte que le service puisse être remplacé même après l'instanciation du client.

public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public void SetVideoGame(INesVideoGame cartridge) =>
        VideoGame = cartridge;
}

Par exemple, si vous avez initialisé NintendoEntertainmentSystem avec un service de type SuperMarioBros mais que vous vous décidez le passer à un service de type Contra, vous avez juste à appeler la méthode SetVideoGame(INesVideoGame) qui se chargera du remplacement.

Avec une approche classique, vous devez rappeler le constructeur, et donc réinitialiser l'état de NintendoEntertainmentSystem.

Les raisons qui vous pousseront à faire ça sont assez spécifiques. Comme pour une NES, on ne retire généralement pas une cartouche alors qu'elle est en train de tourner dans la console.

Donc si vous ne ressentez pas le besoin de changer de service après l'initialisation du client, jouez la sécurité et optez plutôt pour une approche par constructeur.

Notez que cette approche n'est pas incompatible avec celle par constructeur. Rien ne vous empêche de faire une injection de dépendance par le constructeur du client tout en ayant un mutateur sur celui-ci.

Pourquoi ne pas juste mettre le service en public ?

Cela rendrait le service vulnérable, car le contexte aurait accès à ses propriétés et pourrait les modifier individuellement, avec le risque qu'il se retrouve dans un état invalide et fasse planter le client.

En ne faisant qu'un mutateur et en gardant le service privé, on peut le remplacer, mais pas le modifier. Voilà toute la nuance.


Injection par interface

L'idée est d'avoir une interface qui contient la définition d'un mutateur pour un service. Le client va ensuite implémenter cette interface et retrouver obligé d'avoir un mutateur pour son service.

public interface VideoGameSetter
{
    void SetVideoGame(INesVideoGame cartridge);
}

public class NintendoEntertainmentSystem : VideoGameSetter
{
    private INesVideoGame VideoGame;

    public SetVideoGame(INesVideoGame cartridge) =>
        VideoGame = cartridge;
}

L'intérêt d'une telle manigance est de faire en sorte que le contexte n'ait pas forcément besoin de connaitre le type du client pour lui injecter une dépendance.

Voici un cas d'utilisation :

public static void StartDefaultGame(VideoGameSetter videoGameSetter) =>
    videoGameSetter.SetVideoGame(new DuckHunt());

Dans ce code, on a une méthode statique qui prend un client en paramètre et remplace son service par un nouvel objet de type DuckHunt.

Et là où c'est puissant, c'est que cette méthode n'a pas besoin de connaitre le type du client, mais simplement qu'il possède une dépendance de type INesVideoGame. Ce qui fait qu'on peut utiliser cette méthode sur plusieurs types de clients.

var nes = new NintendoEntertainmentSystem(null);
StartDefaultGame(nes); 
nes.Start(); // Playing Duck Hunt

var chineseNes = new ChineseBootlegNes(null);
StartDefaultGame(chineseNes);
chineseNes.Start(); // Playing Duck Hunt
ChineseBootlegNes hérite aussi de VideoGameSetter.

Honnêtement, les cas d'utilisation de cette technique sont très TRÈS spécifiques. Il faut d'abord que vous ayez besoin de remplacer le service après l'initialisation de vos clients, ce qui est déjà rare en soi.

Mais en plus, il faut que vous ayez plusieurs clients qui utilisent la même dépendance, et que ceux-ci soient utilisés dans le même contexte.

Autant vous dire que ce n'est pas une méthode que vous utiliserez tous les quatre matins, mais c'est toujours bon à avoir dans un coin de sa tête, car on ne sait jamais quels genres de programmes tordus l'avenir nous réserve :D


Vérifier qu'un service n'est pas null

Très souvent, on va vouloir s'assurer que le service soit bien dans un état valide avant de faire quoi que ce soit. Typiquement, que celui-ci ne soit pas null.

Voici quelques pistes de réflexion :

  • Injection par constructeur : jamais null
public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public NintendoEntertainmentSystem(INesVideoGame cartridge)
    {
        if(cartridge == null)
            throw new ArgumentNullException();
        
        VideoGame = cartridge;
    }
}
  • Injection par constructeur : null par défaut
public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public NintendoEntertainmentSystem(INesVideoGame cartridge = null) =>
        VideoGame = cartridge;
}
  • Injection par constructeur : null quand il ne reçoit pas de paramètres, mais refuser la valeur null lorsqu'elle est passée manuellement.
public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public NintendoEntertainmentSystem(INesVideoGame cartridge)
    {
        if(cartridge == null)
            throw new ArgumentNullException();
        
        VideoGame = cartridge;
    }

    public NintendoEntertainmentSystem() =>
        VideoGame = null;
}
  • Injection par mutateur : jamais null
public class NintendoEntertainmentSystem
{
    private INesVideoGame VideoGame;

    public NintendoEntertainmentSystem(INesVideoGame cartridge) =>
        SetVideoGame(cartridge);

    public SetVideoGame(INesVideoGame cartridge)
    {
        if(cartridge == null)
            throw new ArgumentNullException();
        
        VideoGame = cartridge;
    }
}

Injection de dépendances dans des fonctions

Même si j'ai principalement parlé de classes dans mon article, vous pouvez faire de l'injection de dépendance dans à peu près n'importe quel type de module qui prend des paramètres.

Typiquement, voici ce que ça donne dans une fonction.

public static void RunVideoGame(int menuIndex)
{
    // Choix du type du jeu
    INesVideoGame game = menuIndex switch
    {
        1 => new SuperMarioBros(),
        2 => new Contra(),
        3 => new DeadlyTowers(),
        // ...
        _ => new DuckHunt()
    };

    cartridge.Run();
}
Sans injection de dépendances.
public static void RunVideoGame(INesVideoGame cartridge)
{
    if(cartridge == null)
        cartridge = new DuckHunt();

    cartridge.Run();
}
Avec injection de dépendances.

Injection de dépendance et stratégie

J'ai mentionné dans mon article le fait qu'on pouvait utiliser l'injection de dépendance pour changer le comportement d'un client de l'extérieur. Il suffit d'avoir différentes implémentations d'une interface, et en fonction de celle qu'on choisit, le client n'exécute plus le même code.

Je ne l'ai pas évoqué pour éviter de complexifier l'article, mais cette technique est en soi un patron de conception appelé stratégie (Strategy Pattern).

Je ne vais pas m'y attarder, car le sujet mérite son article à part entière, mais si vous voulez en apprendre plus, rendez-vous sur les liens suivants.

https://refactoring.guru/design-patterns/strategy

https://fr.wikipedia.org/wiki/Strat%C3%A9gie_(patron_de_conception)

https://en.wikipedia.org/wiki/Strategy_pattern


Injection de dépendance et fabrique

Dans un article précédent, j'avais parlé des fabriques comme étant un moyen redoutable d'initialiser les produits d'une interface.

Et pendant mes recherches, j'ai vu certaines personnes hésiter entre utiliser une fabrique ou l'injection de dépendances.

Tout d'abord, je rappelle que ces deux patrons de conception ne ciblent pas vraiment le même problème :

  • Les fabriques sont des utilitaires permettant d'initialiser les produits d'une interface en un même point, notamment pour encapsuler la complexité d'une telle action et éviter la duplication de code.
  • L'injection de dépendance sert à initialiser les dépendances d'une classe de l'extérieur au lieu de lui laisser faire ça elle-même.

Ça signifie que les deux ne sont absolument pas incompatibles. Au contraire, ils sont généralement plutôt complémentaires.

INesVideoGame cartridge = new RandomNesVideoGameFactory().Create();
var nes = new NintendoEntertainmentSystem(cartridge);
nes.Start(); // Impossible à prédire car le jeu est choisi au hasard

En revanche, si vous décidez de construire votre dépendance à l'intérieur du client, alors l'utilisation d'une fabrique dans le constructeur est vivement recommandée.


Inversion des contrôles en pratique

Un des meilleurs moyens d'expliquer l'inversion des contrôles est d'analyser la différence entre une bibliothèque et un framework. Petit rappel :

La relation entre votre code et une bibliothèque est un simple flux de contrôles direct. Vous appelez une fonction, lui passez des paramètres, elle fait ses bails et retourne éventuellement un résultat.

Mais pour un framework, le flux de contrôles est inversé, car c'est lui qui va utiliser votre code et non l'inverse.

Pour preuve, alors qu'on peut utiliser n'importe quelle bibliothèque sans souci, pour un framework, c'est plus compliqué, car il faut s'y adapter, et l’on en utilise généralement qu'un seul à la fois.

La raison pour laquelle l'inversion des contrôles est primordiale pour un framework, c'est qu'elle vous permet de vous donner la main sur du code que vous ne pouvez pas modifier vous-même.

Si un framework n'utilise pas cette technique, alors ne peut être utilisé nulle part.

Mais comment ça se passe concrètement ?

Il y a plein de manières dont les frameworks utilisent l'inversion de contrôle, l'une d'entre elles étant bien sur l'injection de dépendances.

Je vais prendre l'exemple d'ASP.NET, le framework de développement web de référence pour l'environnement .NET.

Si l’on jette un coup d'œil aux vues, on va généralement retrouver un mot clef qui nous est familier.

@page "/customer-list"
@inject IDataAccess DataRepository

//...

Le mot clef @inject permet de dire à ASP.NET qu'un service du type qui le suit (ici IDataAccess) est requis pour compiler la page. Et au moment d'exécuter le programme, ASP.NET va injecter un singleton du type précisé.

Ce n'est bien sûr pas le seul exemple, mais un des plus évidents.


Autres formes d'inversion des contrôles

J'ai dit dans mon article que l'injection de dépendance est une forme d'inversion des contrôles, mais c'est loin d'être la seule.

Parmi les formes les plus connues, on retrouve celles basées sur l'héritage. Dans la même veine que le point précédent, prenons cet exemple du framework ASP.NET.

public class ProductsController : ApiController
{
    public IEnumerable<Product> GetAllProducts()
    {
        //...
    }

    public IHttpActionResult GetProduct(int id)
    {
        //...
    }
}

Il s'agit d'un contrôleur permettant de regrouper des API REST en un même point. Celui-ci hérite d'une autre classe : ApiController.

Cette classe, issue d'ASP.NET, prend en charge toute la partie chiante du développement web : lecture d'une requête HTTP, écriture d'une réponse…

Mais tout ceci est caché, tout ce qu'il nous reste à faire, c'est de nous concentrer sur l'essentiel, à savoir nos règles métiers (GetAllProducts() et GetProduct(int)).

Notre programme se trouve une fois de plus en bas du flux de contrôle (puisqu'il va être utilisé par ASP.NET), mais comme pour le point précédent, le framework nous laisse la main sur ce qu'il se passe dans notre contrôleur.


Sources

Si vous voulez en apprendre encore plus sur l'injection de dépendances (décidément !), je vous invite à aller visionner les sources suivantes qui m'ont aidée à écrire cet article.

https://betterprogramming.pub/what-is-dependency-injection-b2671b1ea90a

https://stackoverflow.com/questions/130794/what-is-dependency-injection

https://en.wikipedia.org/wiki/Dependency_injection#Dependency_injection_frameworks

https://en.wikipedia.org/wiki/Inversion_of_control

https://martinfowler.com/articles/injection.html

https://softwareengineering.stackexchange.com/questions/135971/when-is-it-not-appropriate-to-use-the-dependency-injection-pattern

https://betterprogramming.pub/the-3-types-of-dependency-injection-141b40d2cebc

https://betterprogramming.pub/five-principles-of-dependency-injection-5bd0cca9cb04

https://www.reddit.com/r/learnprogramming/comments/g734rb/how_does_dependency_injection_make_unit_testing/