Maintenez le cadre - Exploration des modèles d'injection de dépendance
Publié: 2022-03-11Les vues traditionnelles sur l'inversion de contrôle (IoC) semblent tracer une ligne dure entre deux approches différentes : le localisateur de service et les modèles d'injection de dépendance (DI).
Pratiquement tous les projets que je connais incluent un framework DI. Les gens sont attirés par eux parce qu'ils favorisent un couplage lâche entre les clients et leurs dépendances (généralement par injection de constructeur) avec un code passe-partout minimal ou nul. Bien que cela soit idéal pour un développement rapide, certaines personnes trouvent que cela peut rendre le code difficile à tracer et à déboguer. La « magie dans les coulisses » est généralement réalisée par la réflexion, ce qui peut apporter toute une série de nouveaux problèmes.
Dans cet article, nous explorerons un modèle alternatif bien adapté aux bases de code Java 8+ et Kotlin. Il conserve la plupart des avantages d'un cadre DI tout en étant aussi simple qu'un localisateur de service, sans nécessiter d'outils externes.
Motivation
- Éviter les dépendances externes
- Éviter la réflexion
- Promouvoir l'injection de constructeur
- Minimiser le comportement d'exécution
Un exemple
Dans l'exemple suivant, nous allons modéliser une implémentation TV, où différentes sources peuvent être utilisées pour obtenir du contenu. Nous devons construire un appareil capable de recevoir des signaux de diverses sources (par exemple, terrestre, câble, satellite, etc.). Nous allons construire la hiérarchie de classes suivante :
Commençons maintenant par une implémentation DI traditionnelle, où un framework tel que Spring câble tout pour nous :
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
On remarque certaines choses :
- La classe TV exprime une dépendance à une TvSource. Un framework externe verra cela et injectera une instance d'une implémentation concrète (Terrestre ou Câble).
- Le modèle d'injection de constructeur permet des tests faciles, car vous pouvez facilement créer des instances TV avec des implémentations alternatives.
Nous avons pris un bon départ, mais nous réalisons que l'introduction d'un cadre DI pour cela pourrait être un peu exagéré. Certains développeurs ont signalé des problèmes de débogage, des problèmes de construction (longue trace de pile, dépendances introuvables). Notre client a également indiqué que les délais de fabrication sont un peu plus longs que prévu, et notre profileur montre des ralentissements dans les appels réflexifs.
Une alternative serait d'appliquer le modèle Service Locator. C'est simple, n'utilise pas de réflexion et pourrait être suffisant pour notre petite base de code. Une autre alternative consiste à laisser les classes seules et à écrire le code d'emplacement de dépendance autour d'elles.
Après avoir évalué de nombreuses alternatives, nous avons choisi de l'implémenter sous la forme d'une hiérarchie d'interfaces de fournisseur. Chaque dépendance aura un fournisseur associé qui aura la seule responsabilité de localiser les dépendances d'une classe et de construire une instance injectée. Nous ferons également du fournisseur une interface interne pour en faciliter l'utilisation. Nous l'appellerons Mixin Injection car chaque fournisseur est mélangé avec d'autres fournisseurs pour localiser ses dépendances.
Les détails de la raison pour laquelle j'ai opté pour cette structure sont élaborés dans Détails et justification, mais voici la version courte :
- Il sépare le comportement d'emplacement de dépendance.
- L'extension des interfaces ne tombe pas dans le problème du diamant.
- Les interfaces ont des implémentations par défaut.
- Les dépendances manquantes empêchent la compilation (points bonus !).
Le diagramme suivant montre comment les dépendances et les fournisseurs interagissent, et l'implémentation est illustrée ci-dessous. Nous ajoutons également une méthode principale pour montrer comment nous pouvons composer nos dépendances et construire un objet TV. Une version plus longue de cet exemple peut également être trouvée sur ce GitHub.
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
Quelques notes sur cet exemple :
- La classe TV dépend d'une TvSource, mais elle ne connaît aucune implémentation.
- Le TV.Provider étend le TvSource.Provider car il a besoin de la méthode tvSource() pour construire un TvSource, et il peut l'utiliser même s'il n'y est pas implémenté.
- Les sources Terrestre et Câble peuvent être utilisées indifféremment par le téléviseur.
- Les interfaces Terrestrial.Provider et Cable.Provider fournissent des implémentations concrètes de TvSource.
- La méthode principale a une implémentation concrète MainContext de TV.Provider qui est utilisée pour obtenir une instance TV.
- Le programme nécessite une implémentation TvSource.Provider au moment de la compilation pour instancier un téléviseur, nous incluons donc Cable.Provider comme exemple.
Détails et justification
Nous avons vu le modèle en action et certains des raisonnements qui le sous-tendent. Vous pourriez ne pas être convaincu que vous devriez l'utiliser maintenant, et vous auriez raison ; ce n'est pas exactement une solution miracle. Personnellement, je pense qu'il est supérieur au modèle de localisateur de service dans la plupart des aspects. Cependant, par rapport aux frameworks DI, il faut évaluer si les avantages l'emportent sur les frais généraux liés à l'ajout de code passe-partout.
Les fournisseurs étendent d'autres fournisseurs pour localiser leurs dépendances
Lorsqu'un fournisseur en étend un autre, les dépendances sont liées entre elles. Cela fournit la base de base pour la validation statique qui empêche la création de contextes invalides.
L'un des principaux problèmes du modèle de localisateur de service est que vous devez appeler une méthode générique GetService<T>()
qui résoudra d'une manière ou d'une autre votre dépendance. Au moment de la compilation, vous n'avez aucune garantie que la dépendance sera jamais enregistrée dans le localisateur, et votre programme pourrait échouer lors de l'exécution.
Le modèle DI n'aborde pas cela non plus. La résolution des dépendances est généralement effectuée par réflexion par un outil externe qui est principalement caché à l'utilisateur, qui échoue également au moment de l'exécution si les dépendances ne sont pas satisfaites. Des outils tels que le CDI d'IntelliJ (uniquement disponible dans la version payante) fournissent un certain niveau de vérification statique, mais seul Dagger avec son préprocesseur d'annotation semble résoudre ce problème par conception.
Les classes maintiennent l'injection de constructeur typique du modèle DI
Ce n'est pas obligatoire mais certainement souhaité par la communauté des développeurs. D'une part, vous pouvez simplement regarder le constructeur et voir immédiatement les dépendances de la classe. D'un autre côté, cela permet le type de test unitaire auquel beaucoup de gens adhèrent, c'est-à-dire en construisant le sujet testé avec des simulations de ses dépendances.

Cela ne veut pas dire que les autres modèles ne sont pas pris en charge. En fait, on pourrait même trouver que Mixin Injection simplifie la construction de graphes de dépendance complexes pour les tests, car il vous suffit d'implémenter une classe de contexte qui étend le fournisseur de votre sujet. Le MainContext
ci-dessus est un exemple parfait où toutes les interfaces ont des implémentations par défaut, il peut donc avoir une implémentation vide. Remplacer une dépendance nécessite uniquement de remplacer sa méthode de fournisseur.
Regardons le test suivant pour la classe TV. Il doit instancier un téléviseur, mais au lieu d'appeler le constructeur de classe, il utilise l'interface TV.Provider. Le TvSource.Provider n'a pas d'implémentation par défaut, nous devons donc l'écrire nous-mêmes.
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
Ajoutons maintenant une autre dépendance à la classe TV. La dépendance CathodeRayTube opère la magie pour faire apparaître une image sur l'écran du téléviseur. Il est découplé de la mise en œuvre du téléviseur car nous pourrions vouloir passer à l'écran LCD ou à la LED à l'avenir.
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Si vous faites cela, vous remarquerez que le test que nous venons d'écrire se compile et réussit toujours comme prévu. Nous avons ajouté une nouvelle dépendance au téléviseur, mais nous avons également fourni une implémentation par défaut. Cela signifie que nous n'avons pas à nous en moquer si nous voulons simplement utiliser l'implémentation réelle, et nos tests peuvent créer des objets complexes avec n'importe quel niveau de granularité fictive que nous voulons.
Ceci est pratique lorsque vous souhaitez vous moquer de quelque chose de spécifique dans une hiérarchie de classes complexe (par exemple, uniquement la couche d'accès à la base de données). Le modèle permet de mettre en place facilement le type de tests sociables qui sont parfois préférés aux tests solitaires.
Quelle que soit votre préférence, vous pouvez être sûr de pouvoir vous tourner vers n'importe quelle forme de test qui répond le mieux à vos besoins dans chaque situation.
Éviter les dépendances externes
Comme vous pouvez le voir, il n'y a aucune référence ou mention à des composants externes. Ceci est essentiel pour de nombreux projets qui ont des contraintes de taille ou même de sécurité. Cela contribue également à l'interopérabilité, car les frameworks n'ont pas à s'engager dans un framework DI spécifique. En Java, il y a eu des efforts tels que JSR-330 Dependency Injection for Java Standard qui atténuent les problèmes de compatibilité.
Éviter la réflexion
Les implémentations de localisateur de service ne reposent généralement pas sur la réflexion, contrairement aux implémentations DI (à l'exception notable de Dagger 2). Cela a pour principaux inconvénients de ralentir le démarrage de l'application car le framework doit scanner vos modules, résoudre le graphe de dépendances, construire de manière réflexive vos objets, etc.
Mixin Injection nécessite que vous écriviez le code pour instancier vos services, similaire à l'étape d'enregistrement dans le modèle de localisateur de service. Ce petit travail supplémentaire supprime complètement les appels réfléchis, ce qui rend votre code plus rapide et simple.
Deux projets qui ont récemment attiré mon attention et qui bénéficient d'éviter la réflexion sont Graal's Substrate VM et Kotlin/Native. Les deux se compilent en bytecode natif, ce qui nécessite que le compilateur connaisse à l'avance tous les appels réflexifs que vous ferez. Dans le cas de Graal, il est spécifié dans un fichier JSON difficile à écrire, ne peut pas être vérifié statiquement, ne peut pas être facilement refactorisé à l'aide de vos outils préférés. Utiliser Mixin Injection pour éviter la réflexion en premier lieu est un excellent moyen de bénéficier des avantages de la compilation native.
Minimiser le comportement d'exécution
En implémentant et en étendant les interfaces requises, vous construisez le graphe de dépendance un élément à la fois. Chaque fournisseur est assis à côté de la mise en œuvre concrète, ce qui apporte de l'ordre et de la logique à votre programme. Ce type de superposition vous sera familier si vous avez déjà utilisé le motif Mixin ou le motif Cake.
À ce stade, il peut être utile de parler de la classe MainContext. C'est la racine du graphe de dépendance et connaît la situation dans son ensemble. Cette classe comprend toutes les interfaces de fournisseur et est essentielle pour activer les vérifications statiques. Si nous revenons à l'exemple et supprimons Cable.Provider de sa liste d'implémentations, nous verrons cela clairement :
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
Ce qui s'est passé ici, c'est que l'application n'a pas spécifié le TvSource concret à utiliser, et le compilateur a détecté l'erreur. Avec le localisateur de service et l'ID basée sur la réflexion, cette erreur aurait pu passer inaperçue jusqu'à ce que le programme se bloque lors de l'exécution, même si tous les tests unitaires ont réussi ! Je crois que ces avantages et les autres que nous avons montrés l'emportent sur l'inconvénient d'écrire le passe-partout nécessaire pour que le modèle fonctionne.
Attraper les dépendances circulaires
Revenons à l'exemple CathodeRayTube et ajoutons une dépendance circulaire. Disons que nous voulons lui injecter une instance TV, nous étendons donc TV.Provider :
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Le compilateur n'autorise pas l'héritage cyclique et nous ne sommes pas en mesure de définir ce type de relation. La plupart des frameworks échouent au moment de l'exécution lorsque cela se produit, et les développeurs ont tendance à contourner ce problème uniquement pour que le programme s'exécute. Même si cet anti-modèle peut être trouvé dans le monde réel, c'est généralement un signe de mauvaise conception. Lorsque le code ne parvient pas à se compiler, nous devrions être encouragés à rechercher de meilleures solutions avant qu'il ne soit trop tard pour changer.
Maintenir la simplicité dans la construction d'objets
L'un des arguments en faveur de SL par rapport à DI est qu'il est simple et plus facile à déboguer. Il ressort clairement des exemples que l'instanciation d'une dépendance ne sera qu'une chaîne d'appels de méthode de fournisseur. Retracer la source d'une dépendance est aussi simple que d'entrer dans l'appel de méthode et de voir où vous vous retrouvez. Le débogage est plus simple que les deux alternatives car vous pouvez naviguer exactement là où les dépendances sont instanciées, directement depuis le fournisseur.
Durée de vie
Un lecteur attentif aura peut-être remarqué que cette implémentation ne résout pas le problème de la durée de vie du service. Tous les appels aux méthodes du fournisseur instancieront de nouveaux objets, ce qui s'apparente à la portée du prototype de Spring.
Ceci et d'autres considérations sortent légèrement du cadre de cet article, car je voulais simplement présenter l'essence du modèle sans détail gênant. L'utilisation et la mise en œuvre complètes dans un produit devraient cependant prendre en compte la solution complète avec un support à vie.
Conclusion
Que vous soyez habitué aux frameworks d'injection de dépendances ou que vous écriviez vos propres localisateurs de services, vous voudrez peut-être explorer cette alternative. Envisagez d'utiliser le modèle mixin que nous venons de voir et voyez si vous pouvez rendre votre code plus sûr et plus facile à raisonner.