Logo EPK

Les bases de l'injection de dépendances

Quasiment tous les frameworks modernes utilisent un système de d'injection de dépendances (Dependency Injection en anglais). Ces systèmes sont extrêmement utiles pour limiter le couplage

En apparté : le couplage

Dans du joli code orienté objet, le couplage représente le fait de dépendre d'une classe précise pour effectuer une tâche. En cas de modification de cette classe, il faudrait modifier l'ensemble des utilisations de cette classe.

Pourquoi l'injection de dépendances réduirait-elle le couplage? Grâce à l'utilisation d'interfaces (et de la programmation par contrat) et au principe de Liskov.

Le principe de Liskov

Utilisé dans le pattern SOLID (ou il représente le L), le principe de substitution de Liskov dit que si on a un type/une interface T, alors on peut forcément remplacer T par S si S implémente T. En pratique, si on a besoin d'un Iterable, on pourra prendre n'importe quelle classe qui l'implémente. Plus d'infos sur la page Wikipédia qui décrit l'ensemble des propriétés qui découlent de ce principe.

Dans nos systèmes, on se contente d'avoir dans nos classes utilisatrices des attributs "injectés" qui peuvent demander des classes ou des interfaces.

Une classes est sa propre interface

Dans la suite de cet article, je ne mentionnerai que des interfaces en terme de dépendances à injecter, il est tout à fait possible de demander juste une classe, car une classe est sa propre interface.

Le système fournira automatiquement une instance valide de la classe, sans que l'utilisateur n'ait à se préoccuper de l'instancier (ce qui est la source du couplage).

Comment mettre en place l'injection de dépendances?

1. La table des injectables

On va avoir un contexte d'injection : un ensemble de classes qui peuvent être injectées ou qui ont besoin d'injections.

interface A {}
interface B {}

@Injectable()
class A1 implements A {
	@Inject()
	private static final B monAttributQuiValideB;
}

@Injectable()
class B1 implements B {
	/// il se passe bien évidemment des choses ici, comme partout ailleurs
}

@Injectable()
class B2 implements B {}

On définit que les classes injectables seront annotées d'une certaine façon, et que les propriétés à injecter sont aussi annotées (on pourrait le faire dans des fichiers de configuration sinon). Il est intéressant de voir qu'il n'y a pas besoin d'annoter la classe qui doit recevoir des injections, car on peut le déduire s'il y a une annotation sur une propriété.

On commence ainsi par faire la table des injectables : pour toute classe injectable, on note les interfaces qu'elle implémente. On inverse ensuite cette table pour avoir une relation

Interface -> Classes qui peuvent l'implémenter 

[!Details] La table des injectables de notre exemple précédent

|Interface | Classes | | --- | --- | | A |A1 | | B | B1, B2| | A1 | A1 | |B1 | B1 | |B2 | B2 | Notons ici que A1, B1 et B2 sont aussi leurs propres interfaces, et qu'il est directement possible de demander une instance de B1 si besoin.

2. Le graphe d'injections

Bouclez vos ceintures, les choses sérieuses commencent ici. C'ets à cette étape que l'on va déterminer si l'on peut effectivement injecter les dépendances dans l'ensemble du contexte. Le problème que nous cherchons à éviter? La dépendance cyclique : A a besoin de B, et B a besoin de A. Dans cette situtation, le serpent se mord la queue et on ne peut pas les instancier. Cette dépendance cyclique peut aussi avoir lieu de manière plus discrète : A -> B, B -> C, C -> D, D -> E et E -> A pour tout casser. Ainsi, on fait un graphe pour vérifier ces dépendances : les sommets sont les classes que l'on veut instancier, et les arrêtes sont orientées entre a et b, tel qu'on a a -> b si a à besoin d'injecter b.

On applique ensuite une recherche de cycles (ou de circuits, vu que le graphe est orienté) sur ce graphe. Si un circuit existe, alors nous ne pourrons pas injecter de dépendances.

Un algorithme simple de recherche de circuits

Pour effectuer une recherche de circuits, on peut se baser sur un parcours en profondeur (comme sur un arbre par exemple). Ces parcours ont une très bonne complexité, en $O(V + E)$ avec $V$ le nombre de sommets et $E$ le nombre d'arrêtes. L'algorithme à cette forme :

procedure detectCycles(G):
   état[s] ∈ {NON_VISITÉ, EN_COURS, TERMINÉ} pour chaque sommet s
   initialiser tous les état à NON_VISITÉ

  pour chaque sommet s dans G:
       si état[s] = NON_VISITÉ:
           si dfs(s) = vrai:
               retourner "Cycle trouvé"
   retourner "Pas de cycle"

procedure dfs(u):
   état[u] ← EN_COURS
   pour chaque voisin v de u:
       si état[v] = EN_COURS:
           retourner vrai   // cycle trouvé
       si état[v] = NON_VISITÉ et dfs(v):
           retourner vrai
   état[u] ← TERMINÉ
   retourner faux

Le dernier point à gérer dans ce graphe est de regarder notre ultime recours si l'on venait à avoir un circuit dans notre graphe : substituer une classe dans le circuit par une autre qui implémente la même interface.

interface A {}
class A1, A2 implements A {}

interface B {}
class B1, B2 implements B {}

interface C {}
class C1 implements C {}

La première version de notre graphe de dépendances pourrait ressembler à ça.

A1

B1

C1

On va remplacer au hasard une des classes dans le circuit pour essayer de le casser.

A1

B2

C1

Victoire

3. Portée et cycle de vie des classes

Imaginons maintenant que l'on soit dans une grande application. Nos services ont pour dépendance un objet qui fait le lien avec la base de données (un DAO), est-ce que les services ont besoin de se partager le même DAO (pour des problèmes de concurrence par exemple, on aurait donc un singleton), ou devraient-ils avoir chacun le leur? C'est au développeur du DAO de trancher cette question, et à l'injecteur de dépendances d'appeler un outil qui instancierait correctement la classe selon la stratégie choisie. Cet outil peut vous rappeler le pattern Factory notamment.

4. La réflexion / méta-programmation d'un injecteur

Côté code, le principe est simple, réécrire le constructeur des classes afin qu'il récupère auprès du gestionnaire d'injectables dont nous parlions au paragraphe précédent les instances qui lui seront nécessaires.

Pour cela, vous pouvez regarder ma version en Java d'un injecteur de dépendances (à paraître sur ce blog).

5. Aller plus loin : optimisations

Pour limiter la place utilisée en RAM par tout ce code, une heuristique intéressante à regarder est de privilégier des classes qui sont injectées en Singleton dans l'application. On peut ainsi jouer sur les différentes permutations du graphe d'injections pour trouver celle qui maximise le nombre de classes qui sont des singletons.

Conclusion

Une injection de dépendances évite des problèmes de couplage et permet d'augmenter la maintenabilité d'une application. Pour la mettre en oeuvre facilement, il faut :

  1. Annoter ses classes injectables
  2. Annoter les propriétés à injecter (ne pas oublier de mettre des interfaces comme type des propriétés)
  3. Construire le graphe d'injections
  4. Vérifier qu'il n'y ait pas de cycles ou permuter pour essayer de les faire disparaître
  5. Créer ses stratégies d'injection (singleton ou non...)
  6. Réécrire les constructeurs des classes ayant des propriétés à injecter pour injecter des "vraies" classes en lieu et place d'interfaces