Injection de dépendances : “from Zero to Hero”

Alin Dorin Dicu
Esker-Labs
Published in
8 min readSep 12, 2019

L’ “injection de dépendances” n’est pas un nouveau concept. Et même s’il a fait couler beaucoup d’encre, c’est un concept qui mérite d’être vulgarisé, afin de faciliter sa compréhension et qu’il soit utilisé pour ses valeurs intrinsèques et non juste parce que c’est une tendance.

Pour simplifier, dorénavant, l’injection de dépendances sera notée comme “DI”, i.e. “Dependency Injection” en anglais.

La DI consiste à décrire formellement par programmation les dépendances d’un programme ou d’un module (au sens large). L’avantage majeur est de formaliser les dépendances. De cette façon, le code est plus structuré, plus lisible, plus maintenable, plus testable, et enfin, plus découplé.

Une dépendance de code est définie comme un autre fragment de code dont le code principal dépend pour prendre des décisions.

L’article présentera étape par étape, comment faire de la DI de façon théorique et sera aussi illustré par des exemples de code. L’article se restreint à la programmation objet. Les exemples seront fournis en C#.

Passage de paramètres

La première étape pour faire de la DI est de passer des variables en paramètre de la fonction appelée. Simple… et pourtant… pas tant que ça. En fait, c’est même le début du “drame”. Le “drame” est d’avoir un code avec des dépendances implicites, des dépendances statiques, autrement dit spécifiques à un contexte d’exécution.

Il y a des avantages notables au passage de paramètres :

  • Le passage de paramètre est le moyen technique d’exprimer explicitement une dépendance logique. Le paramètre créé une dépendance “explicite” au contraire d’une dépendance “implicite”. La dépendance “explicite” est déclarée. La dépendance “implicite”… il faut la “chercher”… Un exemple très simple de couplage avec une dépendance implicite est celui de la Console.
Console.WriteLine();

Console est une variable statique du contexte d’exécution d’un certain type d’application. Etant donné que Console est statique, elle ne peut pas être surchargée. Le code qui l’utilise est couplé à un contexte où cette variable existe. Dans un contexte d’utilisation web, Console peut ne pas exister. De même dans un contexte de test unitaire, le code “cherche” la variable et l’utilise. Au contraire, le code aurait pu recevoir cette variable en paramètre l’utilisant en tant que dépendance explicite. Des extraits de code présentés ci-dessous montrent comment découpler ce type de dépendance.

  • Un point fort de cette approche est que la fonction dont le comportement dépend uniquement de ses paramètres devient une “fonction pure”. Une fonction pure n’a pas d’effet de bord, en théorie. Mais, c’est un autre sujet…
  • Un autre avantage est que cette fonction est complètement réutilisable. Le changement de contexte de cette fonction n’aura aucun impact imprévu sur son comportement, en théorie. En pratique, les impacts en dehors de sa portée sont limités.

Jusqu’à maintenant, je n’ai illustré la DI que dans un contexte de passage de paramètres à des fonctions. Avec cette approche, le comportement d’une fonction devient plus prédictible dans un contexte donné.

Donner un contexte

Nous sommes prêts pour la deuxième étape vers la DI. Ce n’est que la suite logique de notre premier pas : donner un contexte à des fonctions. Ce contexte est… la classe.

Le concept de classe donne un moyen d’encapsuler du code. La classe est un moyen de créer une dépendance. Ce type de dépendance peut être utilisé pour son fonctionnement propre ou peut être surchargé. Le code devient plus découplé, dans le sens qu’il reste couplé à des dépendances explicites, auxquelles le code appelant a accès. Le code est plus structuré, en modules qui ont un nom et qui représentent une unité fonctionnelle.

Donc, il suffit de concevoir des classes dans lesquelles sont injectées des dépendances. L’injection se fait en passant par le constructeur. Ainsi, la dépendance est stockée et sera utilisée au besoin par les méthodes de la classe.

Il est conseillé d’utiliser des types abstraits (classe abstraite ou interface) ou au moins des types utilisateur en tant que dépendance. On parle alors de dépendances abstraites. Utiliser des types abstraits en tant que dépendance injectée par le constructeur aide à :

  • injecter une nouvelle ou une autre implémentation de la dépendance si aucune implémentation ne convient
  • injecter toutes les implémentations et laisser le choix au code qui consomme les dépendances.

Ces dépendances sont enfichables. Selon son besoin, le développeur peut débrancher et rebrancher des composants à la volée, ou le code lui même peut les utiliser à la volée. De cette façon le code se transforme d’un code statique, qui est lié à un contexte d’exécution donné, à un code dynamique qui s’adapte au contexte d’exécution.

Cet avantage est confirmé lors de l’écriture des tests unitaires. En principe, pour tester un objet, celui-ci est sorti de son contexte de production, il est instancié dans un contexte basique, de test, il est exécuté et son comportement est “asserté”. Si toutes ses dépendances sont explicites, qui plus est si elles sont abstraites, il suffit de coder des dépendances de test (mocks, fakes, etc). C’est souvent dans les tests unitaires que les dépendances implicites ressortent, soit en empêchant le changement de contexte (l’instanciation), soit en contraignant le comportement voulu à l’exécution.

Il reste un inconvénient qui peut freiner l’utilisation de la DI. Il est complexe de maintenir et d’utiliser un système à base de DI. La maintenance peut être laborieuse quand les dépendances changent, car il faut impacter une chaîne de dépendances plus ou moins verbeuse. Le code d’instanciation d’un objet est verbeux, car il faut d’abord instancier ses dépendances. Un tel code d’instanciation dévient également illisible au premier abord.

Maintenant, l’exemple de la variable Console peut être retravaillé. Le code se servira encore de Console, mais d’une façon découplée.

    public class CopyTableNamesToCopTranslations
{
private readonly string _packBPath;
private readonly string _copLanguageFolder;
private readonly IConsole _console;
private readonly Dictionary<string, string> _processNamesToTranslationKeyMappings;
public CopyTableNamesToCopTranslations(
string packBPath,
string copLanguageFolder,
IConsole console,
IProcessNamesToTranslationKeyMappingsFactory mappingsFactory)
{
_packBPath = packBPath;
_copLanguageFolder = copLanguageFolder;
_console = console;
_processNamesToTranslationKeyMappings = mappingsFactory.Get();
}
public void Execute()
{
// business logic code, skipped
Report();
Report($"tableProcessNames count: {tableProcessNames.Count}");
Report();
}
private void Report(string reportLine = null)
{
_console.WriteLine(reportLine);
}
}

public class StandardConsole : IConsole
{
public ConsoleKeyInfo ReadKey()
{
return Console.ReadKey();
}
public void WriteLine(string line)
{
Console.WriteLine(line);
}
}
public interface IConsole
{
void WriteLine(string line);
ConsoleKeyInfo ReadKey();
}

Dans le code ci-dessus, la classe CopyTableNamesToCopTranslations gère le métier. Elle fait des calculs et copie des nœuds XML entre fichiers. Elle écrit également un petit rapport dans la Console. La Console est encapsulée dans la classe StandardConsole qui implémente l’interface IConsole.

CopyTableNamesToCopTranslations s’instancie de cette façon, en injectant ses dépendances. Dans ce contexte, ce sont des dépendances de production.

    public static void Main(string[] args)
{
new CopyTableNamesToCopTranslations(
PackBPath,
CopLanguagegDir,
new StandardConsole(),
new ProcessNamesToTranslationKeyMappingsFactory())
.Execute();
}

En principe, juste en lisant le prototype du constructeur, le lecteur est guidé et se rend compte que le code en dessous a besoin de deux répertoires, une espèce de console et des mappings pour prendre ses décisions.

Une autre implémentation et utilisation est possible en combinant les deux formes d’injection.

    public static void Main(string[] args)
{
new CopyTableNamesToCopTranslations(
new StandardConsole(),
new ProcessNamesToTranslationKeyMappingsFactory())
.Execute(@"D:\temp\PackB", @"D:\temp\COP\lg");
}

Dans un contexte de test, d’autres implémentations des dépendances peuvent être utilisées. Ces dépendances doivent bien évidemment respecter leurs abstractions. Voici une possibilité d’implémentation de test pour IConsole.

    public class ConsoleTestImpl : IConsole
{
private readonly List<string> _lines = new List<string>();
public void WriteLine(string line)
{
_lines.Add(line);
}
public IEnumerable<string> Lines => _lines; public ConsoleKeyInfo ReadKey()
{
return new ConsoleKeyInfo();
}
}

ConsoleTestImpl implémente l’abstraction IConsole et rajoute le membre Lines pour accéder aux lignes écrites pour les “asserter”.

DI automatique

La troisième étape vers la DI consiste à rendre la DI automatique. Ce n’est pas obligatoire, mais ça rajoute du confort dans la programmation. Un tel système créé tout seul des instances d’un type donné en lui instanciant récursivement ses dépendances. Il peut se substituer au “new” pour des objets lourds à instancier, mais ce n’est pas obligatoire.

L’exemple ci-dessus peut être repris pour lui appliquer de la DI automatique. La bibliothèque .Net NInject est utilisée.

  • Le constructeur de la classe CopyTableNamesToCopTranslations est modifié pour qu’il accepte des dépendances surchargeables. C’est une façon de faire parmi d’autres.
    public class CopyTableNamesToCopTranslations
{
private readonly string _packBPath;
private readonly string _copLanguageFolder;
private readonly IConsole _console;
private readonly Dictionary<string, string> _processNamesToTranslationKeyMappings;
public CopyTableNamesToCopTranslations(
IPackBPathProvider packPathProvider,
ICopLanguageFolderProvider copLanguageFolderProvider,
IConsole console,
IProcessNamesToTranslationKeyMappingsFactory mappingsFactory)
{
_packBPath = packPathProvider.Provide();
_copLanguageFolder = copLanguageFolderProvider.Provide();
_console = console;
_processNamesToTranslationKeyMappings = mappingsFactory.Get();
}
public void Execute()
{
// business logic code, skipped
Report();
Report($"tableProcessNames count: {tableProcessNames.Count}");
Report();
}
private void Report(string reportLine)
{
_console.WriteLine(reportLine);
}
}
  • Les dépendances IPackBPathProvider et ICopLanguageFolderProvider sont implémentées.
    public class PackBPathDefaultProvider : IPackBPathProvider
{
public const string PackBPath = @"D:\temp\Pack B";
public string Provide()
{
return PackBPath;
}
}

public interface IPackBPathProvider
{
string Provide();
}
public class CopLanguageFolderDefaultProvider : ICopLanguageFolderProvider
{
private const string CopLanguagegDir = @"D:\temp\Customer Order Processing\lg";
public string Provide()
{
return CopLanguagegDir;
}
}

public interface ICopLanguageFolderProvider
{
string Provide();
}
  • Les “bindings” NInject sont paramétrés.
    public class DependencyInjectionInitializer : NinjectModule
{
public override void Load()
{
Bind<IConsole>().To<ConsoleImpl>();
Bind<IProcessNamesToTranslationKeyMappingsFactory>().To<ProcessNamesToTranslationKeyMappingsFactory>();
Bind<IPackBPathProvider>().To<PackBPathDefaultProvider>();
Bind<ICopLanguageFolderProvider>().To<CopLanguageFolderDefaultProvider>();
}
}
  • La classe métier CopyTableNamesToCopTranslations est instanciée et exécutée. Vous notez l’absence des mots-clés “new”.
    public class Program
{
public static void Main(string[] args)
{
var kernel = new StandardKernel();
kernel.Load(Assembly.GetExecutingAssembly());
kernel.Get<CopyTableNamesToCopTranslations>().Execute();
}
}

Voici la même instanciation, mais avec DI manuelle.

    public class Program
{
public static void Main(string[] args)
{
new CopyTableNamesToCopTranslations(
new PackBPathDefaultProvider(),
new CopLanguageFolderDefaultProvider(),
new ConsoleImpl(),
new ProcessNamesToTranslationKeyMappingsFactory())
.Execute();
}
}

Le code de la DI automatique est moins verbeux. La verbosité est encore plus réduite quand les dépendances ont elles mêmes des dépendances et ainsi de suite.

Et voici un exemple, d’utilisation du même code métier dans un contexte web. La classe CopyTableNamesToCopTranslations est surchargée pour récupérer le rapport. IConsole est également modifiée pour exposer Lines.

    public class WebCopyTableNamesToCopTranslations: CopyTableNamesToCopTranslations
{
private readonly IConsole _console;
public WebCopyTableNamesToCopTranslations(
IPackBPathProvider packPathProvider,
ICopLanguageFolderProvider copLanguageFolderProvider,
IConsole console,
IProcessNamesToTranslationKeyMappingsFactory mappingsFactory)
: base(packPathProvider, copLanguageFolderProvider, console, mappingsFactory)
{
_console = console;
}
public IEnumerable<string> GetReport()
{
return _console.Lines;
}
}

Puis, WebCopyTableNamesToCopTranslations est injectée dans le controleur web. La DI automatique est faite de façon transparente par le mécanisme qui instancie le controleur. Les “bindings” sont paramétrées de la même façon.

    [Route("api/[controller]")]
[ApiController]
public class WebCopyTableNamesToCopTranslationsController : ControllerBase
{
private readonly WebCopyTableNamesToCopTranslations _copyTableNamesToCopTranslations;
public WebCopyTableNamesToCopTranslationsController(
WebCopyTableNamesToCopTranslations copyTableNamesToCopTranslations)
: base()
{
_copyTableNamesToCopTranslations = copyTableNamesToCopTranslations;
}
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
_copyTableNamesToCopTranslations.Execute();
return _copyTableNamesToCopTranslations.GetReport().ToArray();
}
}

Voici une vue d’ensemble de l’outil qui a servi comme exemple. Le code est également disponible sur GitHub. Utilisez le projet CopyTableNamesToCopTranslations.Core.Tests et lancez des tests pour exécuter le code. Les autres projets nécessitent des dépendances qui doivent être créées sur votre environnement.

Le projet Core contient le métier. C’est une librairie non exécutable en elle même. Les autres projets sont des “clients” (exécutables) du projet Core. Le projet Core définit uniquement l’abstraction IConsole qui est implémentée par StandardConsole, ConsoleTestImpl, WebConsole selon le contexte d’utilisation.

Pour conclure, la DI ne reste qu’une méthode de développement de logiciels. En l’utilisant à bon escient, elle nous amène à développer des programmes plus justes et plus prédictibles grâce à des composants :

  • connus à l’avance
  • enfichables et permutables à la volée ou presque
  • avec une logique encapsulée
  • testables facilement unitairement
  • qui n’utilisent que des composants “présentés formellement” (injectés).

--

--