Débutant

Dépendances, DIP et IOC

« Injection de dépendances », « inversion des dépendances », « inversion de contrôle » : autant d’expressions dont on a entendu parler et dont on ne distingue pas toujours les différences, leur consonance n’aidant pas. Voyons tout cela ensemble, en commençant par clarifier une notion centrale : celle de dépendance.

Dépendance

Lorsqu’une classe (A) a besoin d’une autre classe (B) pour fonctionner, on dit que A a une dépendance vers B et que B est une dépendance pour A. Le cas le plus basique se produit lorsque la classe A instancie elle-même la classe B : elle crée in situ la dépendance dont elle a besoin.

Partons de l’exemple suivant qui va nous servir tout au long de cet article :

Class diagram #1

public class CalculatorDataService
{
   public DataResult CalculateForUser(Guid userId)
   {
      var userRepository = new UserSQLRepository();
      var user = userRepository.GetById(userId);
      return PerformCalculation(user);
   }
}

☝️ Note : Data est un terme générique, utilisé pour simplifier cet exemple. Dans une vraie codebase, il conviendrait de le remplacer par un terme métier comme Invoice, Tax

On voit en ligne 5 que CalculatorDataService instancie UserSQLRepository en appelant son constructeur : new UserSQLRepository(). La classe CalculatorDataService dépend donc de la classe UserSQLRepository.

On voit que si l’on modifie le constructeur de la classe UserSQLRepository, on devra modifier son instanciation dans CalculatorDataService, ce qui pose problème. Le changement d’une classe impose la modification d’une autre. On a un couplage fort entre les 2 classes. Cela rigidifie le code et est un frein à son évolution.

Cela impacte aussi la testabilité. Pour l’heure, l’écriture de tests unitaires doit prendre en compte la dépendance vers une base de données dans UserSQLRepository. C’est plus compliqué à mettre en place. Ces tests ne répondent pas aux critères FIRST. On peut même considérer que ce ne sont plus des tests unitaires mais des tests d’intégration.

La première chose à faire pour améliorer le design est alors d’utiliser l’injection de dépendances.

Injection de dépendances

L’injection de dépendances, Dependency Injection (DI), a pour but de séparer la création d’un objet de son usage. Cette création est faite à l’extérieur de la classe, par le code appelant. L’instance de la dépendance ainsi créée est injectée dans la classe qui en fait usage.

☝️ Note : tout comme la création, la destruction / “disposition” des dépendances est du ressort du code appelant.

Vis-à-vis de l’extérieur, on signale dans la signature de la classe qu’elle a besoin que l’on lui injecte ses dépendances car elle ne va pas les instancier elle-même. Les dépendances sont donc visibles dans la signature de la classe, dans son contrat. Cela présente l’avantage de rendre les dépendances explicites. Cela facilite également les tests en permettant de fournir des doublures de test (aka mocks) à la place des dépendances.

Concrètement, cela se manifeste de 3 manières, correspondant aux 3 types d’injection de dépendances :

  • Par constructeur : la dépendance est passée en paramètre au constructeur.
  • Par méthode : la dépendance est passée en paramètre à la méthode qui l’utilise.
  • Par propriété : la dépendance est injectée dans la classe au moyen d’un setter, d’une propriété en écriture (et éventuellement en lecture).

Si la classe a plusieurs dépendances, chacune peut faire l’objet de son propre type d’injection. Tout dépend du contexte. Chaque type d’injection présente des avantages et des inconvénients :

Injection par Caractéristiques Contraintes Usage
Constructeur Objet créé directement dans un état stable, toutes ses dépendances initialisées – Impossibilité de changer la dépendance
– Augmente le nombre de paramètres du constructeur (1)
Cas par défaut
Paramètre Possibilité de faire varier l’instance de la dépendance à chaque appel à la méthode – Dépendance à fournir à chaque appel à la méthode Quand la dépendance n’est nécessaire que dans la méthode
Setter Dépendance modifiable à tout moment – Couplage temporel dans le code appelant (2)
– Programmation défensive dans la classe (3)
Lorsque l’on est contraint, par exemple par un framework
Notes (à déplier)
  1. Remèdes possibles au trop grand nombre de paramètres d’entrée du constructeur :
    • Si la classe manque de cohésion, la décomposer en plus petites classes, chacune étant plus cohésive et avec moins de dépendances.
    • Sinon, simplifier la construction de la classe côté client, par exemple en passant par un Builder.
    • Alternative : regrouper les arguments cohésifs entre eux dans leur propre objet, ce qui correspond au refactoring Introduce Parameter Object.
  2. Les méthodes qui utilisent la dépendance ne pourront fonctionner que si la dépendance a été “settée”. Cela nécessite un protocole, un ordonnancement des appels pour fonctionner correctement, protocole qu’il est difficile de rendre explicite.
  3. En effet, il est nécessaire de vérifier que la dépendance n’est pas null avant de pouvoir l’utiliser.

👉 Recommandation : l’injection par constructeur est à envisager en premier car elle présente le meilleur rapport avantages/inconvénients.

Appliquons l’injection par constructeur à notre classe CalculatorDataService :

public class CalculatorDataService
{
   private readonly UserSQLRepository userRepository;

   public CalculatorDataService(UserSQLRepository userRepository)
   {
      this.userRepository = userRepository;
   }

   public DataResult CalculateForUser(Guid userId)
   {
      var user = userRepository.GetById(user id);
      return PerformCalculation(user);
   }
}

☝️ Note : comme on peut le constater, on peut stocker les dépendances dans des champs readonly (en C#) / final (en Java) afin de chercher à rendre la classe immuable. Cela permet de réduire les effets de bord, source de bugs “à distance” difficiles à détecter. Plus d’informations sur l’immuabilité dans cet article et sur l’architecture immuable dans celui-ci.

On a ainsi amélioré le design de la classe : on sait désormais que CalculatorDataService dépend de UserSQLRepository juste en regardant sa signature, sans avoir à regarder dans son code. On a aussi amélioré sa testabilité, en facilitant la substitution de UserSQLRepository par une doublure de tests.

Il reste cependant un couplage fort entre CalculatorDataService et UserSQLRepository du fait que cette dernière est une classe concrète : CalculatorDataService a besoin d’une instance de UserSQLRepository (ou d’une éventuelle classe dérivée), au lieu que cela ne soit qu’un détail d’implémentation. Dès que l’on va modifier UserSQLRepository, il va falloir recompiler CalculatorDataService. Même couplage pour les déploiements. Ces couplages se retrouvent dans le code quand on constate l’usage des opérateurs using (en C#), import (en Java)… pointant vers un autre namespace/package. On notera également que le diagramme de classes n’a pas changé.

Pire, ces problèmes se propagent par transitivité des dépendances les plus profondes au code appelant le plus haut. On aimerait pouvoir modifier un détail dans les couches basses sans que cela impacte les couches plus hautes. À défaut, notre design reste rigide et fragile, compliquant la maintenance et augmentant le risque de bugs par impacts non maîtrisés, le risque de rallongement de délai de livraison et donc le risque de perte de confiance des utilisateurs.

C’est à ce moment qu’opère la “magie” de l’inversion des dépendances.

Inversion des dépendances

Le principe d’inversion des dépendances, Dependency Inversion Principle (DIP), est l’un des 5 principes SOLID : c’en est le “D”. Oncle Bob définit le DIP comme suit :

  1. Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Application à notre exemple :

  • Des deux classes, CalculatorDataService est celle appelée par l’extérieur. Elle est de plus haut niveau que UserSQLRepository. Cette dernière peut être vue comme un détail d’implémentation c’est-à-dire une information encapsulée dans la classe parent, en particulier lors de l’appel à CalculateForUser() où rien n’indique explicitement l’appel à GetById() de UserSQLRepository. Il n’y a qu’en regardant dans le code que l’on peut connaître ces détails. CalculatorDataService dépend donc d’un “détail”.
  • Au lieu de cela, chaque classe devrait dépendre d’une abstraction, “tampon” entre les deux. CalculatorDataService devrait dépendre d’une abstraction de UserSQLRepository, par exemple d’une interface UserRepository.

Class diagram #2

public class CalculatorDataService
{
   private readonly UserRepository userRepository;

   public CalculatorDataService(UserRepository userRepository)
   {
      this.userRepository = userRepository;
   }

   public DataResult CalculateForUser(Guid userId)
   {
      var user = userRepository.GetById(user id);
      return PerformCalculation(user);
   }
}

public interface UserRepository
{
   User GetById(Guid userId);
}

Dans le diagramme de classes ci-dessus, les flèches indiquent les dépendances dans le code source (Source Code Dependency). À l’exécution, le code appelant va créer une instance de CalculatorDataService en lui fournissant une instance UserSQLRepository qui satisfait le contrat attendu : UserRepository. Il s’agit d’une dépendance à l’exécution (Runtime Dependency), représentée avec une flèche en pointillé dans la diagramme ci-dessous :

Class diagram #3

On constate donc une inversion entre source code dependencies et runtime dependencies. C’est l’inversion dont il est question dans l’expression “Inversion des dépendances”. C’est le côté “magique” du DIP, rendu possible grâce au polymorphisme, véritable pilier de la programmation orientée-objet.

Inversion de contrôle

L’inversion de contrôle, Inversion of control (IoC), consiste à déléguer une partie du “contrôle” à l’extérieur de l’élément courant dans un système. Au départ, CalculatorDataService contrôlait l’instanciation de ses dépendances. Après le premier refactoring (mettant en place l’injection de dépendances), CalculatorDataService a perdu ce contrôle. Il y a eu une inversion du flux de contrôle. On constate donc que l’injection de dépendances est une forme d’IoC.

☝️ Note : il existe d’autres formes d’IoC, dont le pattern Service Locator, controversé car pouvant induire du couplage fort et diminuer la testabilité. C’est pour cela que l’on préfère l’injection de dépendances.

Après le deuxième refactoring (application du DIP), le type de sa dépendance est devenu abstrait : UserRepository. En regardant cette portion de code en isolation, on n’est plus en mesure de savoir quel sera le type concret de l’objet utilisé à l’exécution. La création de cet objet est désormais aux mains de celui créant également notre CalculatorDataService, le premier étant injecté dans le constructeur de ce dernier.

Pour créer notre CalculatorDataService, le moyen le plus simple est de tout coder à la main : new CalculatorDataService(new UserSQLRepository). Cette façon de faire est envisageable à petite échelle, soit pour une petite application, soit dans un test unitaire ou d’intégration. À une échelle plus large, avec plus de niveaux d’imbrication, cela devient pénible à écrire et difficile à lire. On comprend pourquoi certains parlent de “poor-man dependency injection”.

Un contournement possible est d’avoir recours à des fabriques (Factory) dont la seule responsabilité est de créer des objets. Les fabriques peuvent être concrètes ou abstraites, selon le type renvoyé par leur méthode Create() : UserSQLRepositoryFactory pour créer des UserSQLRepository, IUserRepositoryFactory pour les IUserRepository. Ces différentes types de fabriques diminuent progressivement le couplage. Cette solution apporte quelques bénéfices mais demande encore une quantité non négligeable de code à écrire. C’est pour cela que nous ne la détaillons pas plus.

Un moyen plus industriel est de recourir à un conteneur d’IoC (IoC container), également appelé conteneur d’injection de dépendances. Le conteneur est responsable de la création d’objets, y compris leurs dépendances définies dans les constructeurs. Quand on demandera au conteneur une instance de CalculatorDataService, il va alors détecter les dépendances dont a besoin CalculatorDataService, déterminer les types concrets compatibles, créer les instances associées et les injecter.

Autres problématiques : la durée de vie des objets ainsi créés. Si un objet est utilisé à différents endroits mais est long ou coûteux à créer, différentes options sont envisageables :

  • En faire une classe statique ? S’il est dépourvu d’état (stateless), cela peut marcher mais cela va réduire la testabilité. Sinon, c’est exclu car non thread safe.
  • Utiliser un Singleton ? Cela diffère peu de la classe statique. De plus, il y a un risque de fuite mémoire. Il existe quand même quelques cas intéressants d’utilisation de Singletons, bien expliqués dans cet article.

La solution industrielle passe encore par un conteneur d’IoC, en le configurant pour définir la durée de vie des objets qu’il crée.

Durées de vie (à déplier)
  • Transient (temporaire) : une nouvelle instance par appel ; convient parfaitement aux objets légers et sans état tels que les “Services”.
  • Scoped (délimitée) : une instance est créée lors d’une requête (par exemple une requête HTTP à l’intérieur d’un serveur Web).
  • Singleton : une seule instance, créée lors du premier appel. On a les avantages d’un singleton sans les inconvénients.
Frameworks d’IoC (à déplier)

Il existe différents frameworks fournissant leur conteneur d’IoC, ceci dans les principaux langages de programmation. En voici quelques exemples :

  • Java : Spring IoC
  • .NET : ASP .Net Core, Castle Windsor, StructureMap, Unity, NInject
  • JavaScript : Angular

💡 Astuce : lorsqu’une classe a besoin de personnaliser une de ses dépendances selon des paramètres contextuels, ce qui affecte l’état voire le type concret / au runtime de la dépendance, au lieu d’injecter directement la dépendance, il suffit d’injecter une Factory ou un Builder. La classe pourra alors lui transmettre les paramètres et obtenir en retour sa dépendance personnalisée.

En résumé

L’injection de dépendances consiste pour une classe à déléguer la création de ses dépendances au code appelant qui va les lui injecter avant de s’en servir. Cela constitue un type d’inversion de contrôle. L’injection de dépendances peut se mettre en place grâce à un conteneur d’inversion de contrôle s’occupant de la création de ces dépendances.

Dans un cas d’inversion des dépendances, une classe ne connaît ses dépendances qu’au travers de contrats, interfaces polymorphiques. La classe ne connaît pas leurs implémentations, situées dans d’autres composants de plus bas niveau. La classe est ainsi découplée de ses dépendances. L’inversion de dépendances n’est possible qu’au moyen d’une inversion de contrôle, de préférence l’injection de dépendances.

On constate la synergie opérant entre ces principes, que l’on peut schématiser ainsi :

Schéma d'ensemble

Pour aller plus loin

👉 Avertissement : nous voyons ici quelques notions et subtilités complémentaires. Cette section est recommandée aux lecteurs pour qui tout ce que l’on vient de voir est clair. Pour les autres, cela risque d’être une source de confusion.

Interface et ISP

L’interface UserRepository ne contient que la méthode GetById(), seul membre utilisé par CalculatorDataService. Il en serait ainsi même si d’autres membres étaient ajoutés à UserSQLRepository car l’interface UserRepository appartient à CalculatorDataService et lui définit le contrat attendu pour la dépendance.

Dans le cas inverse, si l’on mettait dans UserRepository d’autres méthodes (existantes dans UserSQLRepository mais non utilisées par CalculatorDataService) dans UserRepository, cela forcerait la classe CalculatorDataService à être recompilée / redéployée à chaque modification dans UserRepository, même celles concernant des méthodes qu’elle n’utilise pas.

De la sorte, on respecte un autre principe SOLID, l’ISP. C’est un des exemples permettant de constater la synergie entre plusieurs principes SOLID.

Composants

Pour renforcer ce lien entre les classes CalculatorDataService et UserRepository, les deux peuvent être placées dans le même composant (assembly .NET, JAR Java) tandis que la classe UserSQLRepository peut être placée dans un autre composant, “plugin” du premier.

Cela consolide l’inversion des dépendances, au point où l’on peut supprimer le deuxième composant et garder le premier composant qui compile !

Abstraction

On n’a évoqué que la facette technique d’une abstraction, c’est-à-dire une implémentation absente ou partielle d’une classe via les mots clés interface ou abstract class. L’inversion des dépendances et l’inversion de contrôle peuvent être mises en place avec juste cette facette technique.

L’abstraction a aussi un coté sémantique. On parle alors de niveaux d’abstraction. Dans notre cas UserRepository n’a qu’une seule implémentation, UserSQLRepository. Les deux ont actuellement la même signature. Ils ont donc le même niveau d’abstraction.

L’ajout d’une autre implémentation, telle que UserFileRepository, oblige à se questionner sur ses points en commun avec UserSQLRepository. Si l’on constate que les deux partagent / répliquent le même concept, la création d’une abstraction fait sens. Celle-ci aura bien abstrait des détails d’implémentation technique tout en augmentant le niveau d’abstraction sémantique. C’est le vrai sens du principe DRY.

Plus d’explications dans l’excellent article de Mark Seemann : Interfaces are not abstractions.

IoC

L’inversion de contrôle est en fait un concept très abstrait, plus vaste que les quelques exemples que nous avons vus. L’IoC peut d’ailleurs être vue comme ce qui distingue un framework d’une librairie, ce premier suivant le principe d’Hollywood : « Ne nous appelez pas. Nous vous appellerons. ».

Le billet InversionOfControl du bliki de Martin Fowler explique tout cela en détail.


Article écrit à quatre mains par Stéphane et Romain, membres de la communauté Craftsmanship de SOAT, suite aux masterclasses Clean Code sur les principes SOLID.


© SOAT
Toute reproduction interdite sans autorisation de l’auteur.

Nombre de vue : 885

AJOUTER UN COMMENTAIRE