Où commence le danger? Supposons que vous soyez fermement déterminé à développer un projet, en adhérant à un concept ou une approche spécifique. Dans notre situation, c'est DI, bien que la programmation réactive, par exemple, puisse également être à sa place. Il est logique que pour atteindre votre objectif, vous vous tournerez vers des solutions toutes faites (dans notre exemple, le conteneur DI Zenject). Vous vous familiariserez avec la documentation et commencerez à construire le cadre d'application en utilisant la fonctionnalité principale. Si, aux premières étapes de l'utilisation de la solution, vous n'avez pas de sensations désagréables, il est probable qu'elle persistera sur votre projet pendant toute sa vie. Lorsque vous travaillez avec les fonctions de base de la solution (conteneur), vous pouvez avoir des questions ou des désirs pour rendre certaines fonctionnalités plus belles ou plus efficaces. Assurément, vous allez d'abord vous tourner vers les «fonctionnalités» plus avancées de la solution (conteneur) pour cela. Et à ce stade, la situation suivante peut se produire: vous connaissez déjà bien et faites confiance à la solution choisie, en raison de laquelle beaucoup peuvent ne pas penser à la façon idéologiquement correcte d'utiliser l'une ou l'autre fonction dans la solution, ou la transition vers une autre solution est déjà assez coûteuse et inappropriée ( par exemple, la date limite approche). C'est à ce stade que la situation la plus dangereuse peut survenir - la fonctionnalité de la solution est utilisée avec peu de soin, ou dans de rares cas, simplement sur la machine (sans réfléchir).
Qui pourrait être intéressé par cela?
Cet article sera utile à la fois à ceux qui connaissent bien les adeptes de DI et aux DI débutants. Pour comprendre suffisamment de connaissances de base sur les modèles utilisés par DI, le but de DI et les fonctions qu'un conteneur IoC remplit. Il ne s'agit pas des subtilités de la mise en œuvre de Zenject, mais de l'application d'une partie de ses fonctionnalités. L'article s'appuie uniquement sur la documentation Zenject officielle et les exemples de code qui en découlent, ainsi que sur le livre de Mark Siman «Dependency Injection in .NET», qui est un ouvrage exhaustif classique sur le thème de la théorie de l'ID. Toutes les citations de cet article sont des extraits du livre de Mark Siman. Malgré le fait que nous parlerons d'un conteneur spécifique, l'article peut être utile à ceux qui utilisent d'autres conteneurs.
Le but de cet article est de montrer comment un outil dont le but est de vous aider à implémenter DI sur votre projet peut vous conduire dans une direction complètement différente, vous poussant à faire des erreurs qui lient votre code, réduisent la testabilité du code et vous privent généralement de tous les avantages qui peuvent vous donner vous DI.
Avertissement : Le but de cet article n'est pas de critiquer Zenject ou ses auteurs. Zenject peut être utilisé conformément à sa destination et constitue un excellent outil pour implémenter DI, à condition que vous n'utilisiez pas un ensemble complet de ses fonctions, après avoir défini vous-même certaines limitations.
Présentation
Zenject est un conteneur d'injection de dépendance open source destiné à utiliser le moteur de jeu Unity3D, qui fonctionne sur la plupart des plates-formes prises en charge par Unity3D. Il convient de noter que Zenject peut également être utilisé pour les applications C # développées sans Unity3D. Ce conteneur est très populaire parmi les développeurs Unity, est activement pris en charge et développé. De plus, Zenject possède toutes les fonctionnalités nécessaires du conteneur DI.
J'ai utilisé Zenject dans 3 grands projets Unity et j'ai également communiqué avec un grand nombre de développeurs qui l'ont utilisé. La raison de la rédaction de cet article est la foire aux questions:
- L'utilisation de Zenject est-elle une bonne solution?
- Quel est le problème avec Zenject?
- Quelles difficultés surviennent lors de l'utilisation de Zenject?
Et aussi certains projets dans lesquels l'utilisation de Zenject n'a pas permis de résoudre des problèmes de forte connectivité de code et d'architecture infructueuse, mais au contraire a exacerbé la situation.
Voyons pourquoi les développeurs ont de telles questions et problèmes. Vous pouvez répondre comme suit:
Ironiquement, les conteneurs DI eux-mêmes ont tendance à être des dépendances stables. ... Lorsque vous décidez de développer votre application à partir d'un conteneur DI particulier, vous courez le risque d'être limité à ce choix pour l'ensemble du cycle de vie de l'application.
Il convient de noter qu'avec une utilisation correcte et limitée du conteneur, le passage à l'utilisation d'un autre conteneur dans l'application (ou le refus d'utiliser le conteneur en faveur d'une «
mise en œuvre pour les pauvres ») est tout à fait possible et ne prendra pas beaucoup de temps. Certes, dans une telle situation, il est peu probable que vous en ayez besoin.
Avant de commencer à désassembler les fonctionnalités potentiellement dangereuses de Zenject, il est logique d'actualiser superficiellement plusieurs aspects de base de DI.
Le premier aspect est le
but des conteneurs DI. Mark Siman écrit ce qui suit dans son livre sur ce sujet:
Un conteneur DI est une bibliothèque de logiciels qui peut automatiser la plupart des tâches effectuées lors de l'assemblage d'objets et de la gestion de leur cycle de vie.
Ne vous attendez pas à ce que le conteneur DI transforme comme par magie le code fortement couplé en code faiblement couplé. Un conteneur peut améliorer l'efficacité de l'utilisation de DI, mais l'accent dans l'application doit être mis principalement sur l'utilisation de modèles et sur l'utilisation de DI.
Le deuxième aspect est les
modèles DI . Mark Siman identifie quatre modèles principaux, triés par fréquence et la nécessité de leur utilisation:
- Implémentation du constructeur - Comment garantir que la dépendance requise sera toujours disponible pour la classe en cours de développement?
- Implémentation de la propriété - Comment puis-je activer DI en option dans la classe s'il existe une valeur par défaut locale appropriée?
- Implémentation de la méthode - Comment puis-je injecter des dépendances dans une classe si elles sont différentes pour chaque opération?
- Contexte ambiant - Comment rendre une dépendance disponible dans chaque module sans inclure les aspects transversaux de l'application dans chaque composant API?
Les questions indiquées à côté du nom des modèles décrivent pleinement leur portée. Dans le même temps, l'article ne discutera pas de l'implémentation du constructeur (car il n'y a pratiquement aucune plainte concernant son implémentation dans Zenject) et du contexte ambiant (son implémentation n'est pas dans le conteneur, mais vous pouvez facilement l'implémenter en fonction des fonctionnalités existantes).
Vous pouvez maintenant accéder directement aux fonctionnalités potentiellement dangereuses de Zenject.
Fonctionnalité dangereuse.
Implémenter les propriétés
Il s'agit du deuxième modèle DI le plus courant, après l'implémentation du constructeur, mais il est utilisé beaucoup moins souvent. Mis en œuvre dans Zenject comme suit:
public class Foo { [Inject] public IBar Bar { get; private set; } }
De plus, Zenject a également un concept tel que «Field Injection». Voyons pourquoi dans tous les Zenject cette fonctionnalité est la plus dangereuse.
- Un attribut est utilisé pour montrer au conteneur quel champ intégrer. Il s'agit d'une solution tout à fait compréhensible, du point de vue de la simplicité et de la logique de mise en œuvre du conteneur lui-même. Cependant, nous voyons un attribut (ainsi qu'un espace de noms) dans le code de classe. C'est, au moins indirectement, mais la classe commence à savoir d'où elle tire la dépendance. De plus, nous commençons à resserrer le code de classe sur le conteneur. En d'autres termes, nous ne pouvons plus refuser d'utiliser Zenject sans manipuler le code de classe.
- Le modèle lui-même est utilisé dans les situations où la dépendance a une valeur par défaut locale. Autrement dit, il s'agit d'une dépendance facultative, et si le conteneur ne peut pas le fournir, il n'y aura aucune erreur dans le projet et tout fonctionnera. Cependant, en utilisant Zenject, vous obtenez toujours cette dépendance - la dépendance ne devient pas facultative.
- Étant donné que la dépendance dans ce cas n'est pas facultative, elle commence à gâcher toute la logique de l'implémentation du constructeur, car seules les dépendances requises doivent y être introduites. En implémentant des dépendances non facultatives via des propriétés, vous avez la possibilité de créer des dépendances circulaires dans le code. Ils ne seront pas aussi évidents, car dans Zenject, l'implémentation du constructeur est d'abord réalisée, puis l'implémentation de la propriété, et vous ne recevrez pas d'avertissement du conteneur.
- L'utilisation du conteneur DI implique l'implémentation du modèle Racine de composition, cependant, l'utilisation de l'attribut pour configurer l'implémentation de la propriété conduit au fait que vous configurez le code non seulement dans la racine de composition, mais aussi selon les besoins dans chaque classe.
Usines (et MemoryPool)
La documentation Zenject contient une
section entière sur les usines. Cette fonctionnalité est implémentée au niveau du conteneur lui-même, et il est également possible de créer vos propres usines personnalisées. Jetons un coup d'œil au premier exemple de la documentation:
public class Enemy { DiContainer Container; public Enemy(DiContainer container) { Container = container; } public void Update() { ... var player = Container.Resolve<Player>(); WalkTowards(player.Position); ... etc. } }
Déjà dans cet exemple, il y a une violation flagrante de DI. Mais c'est plutôt un exemple de la façon de fabriquer une usine entièrement personnalisée. Quel est le principal problème ici?
Un conteneur DI peut être considéré à tort comme un localisateur de services, mais il ne doit être utilisé que comme mécanisme de liaison de graphiques d'objets. Si nous considérons le conteneur de ce point de vue, il est logique de limiter son utilisation uniquement à la racine de la mise en page. Cette approche présente l'avantage important d'éliminer toute liaison entre le conteneur et le reste du code d'application.
Voyons comment fonctionnent les usines «intégrées» de Zenject. Il existe une interface IFactory pour cela, dont l'implémentation nous conduit à la classe PlaceholderFactory:
public abstract class PlaceholderFactory<TValue> : IPlaceholderFactory { [Inject] void Construct(IProvider provider, InjectContext injectContext)
On y voit le paramètre InjectContext qui a de nombreux constructeurs, de la forme:
public InjectContext(DiContainer container, Type memberType) : this() { Container = container; MemberType = memberType; }
Et encore une fois, nous obtenons le transfert du conteneur lui-même comme une dépendance à la classe. Cette approche est une violation flagrante de DI et une transformation partielle du conteneur en un localisateur de services.
De plus, l'inconvénient de cette solution est que le conteneur est utilisé pour créer des dépendances à court terme et ne doit créer que des dépendances à long terme.
Pour éviter de telles violations, les auteurs du conteneur pourraient exclure complètement la possibilité de passer le conteneur en tant que dépendance à toutes les classes enregistrées. Il ne serait pas difficile de mettre en œuvre cela, étant donné que l'ensemble du conteneur fonctionne au moyen de la réflexion et de l'analyse des paramètres des méthodes et des constructeurs pour créer et mettre en page le graphique des objets d'application.
Implémentation de la méthode
La logique de l'implémentation de la méthode dans Zenject est la suivante: d'abord, dans toutes les classes, le constructeur est implémenté, puis les propriétés sont implémentées, et enfin la méthode est implémentée. Considérez l'exemple d'implémentation fourni dans la documentation:
public class Foo { [Inject] public Init(IBar bar, Qux qux) { _bar = bar; _qux = qux; } }
Quels sont les inconvénients ici:
- Vous pouvez écrire n'importe quel nombre de méthodes qui seront implémentées dans le cadre d'une classe. Ainsi, comme dans le cas de l'implémentation de la propriété, nous avons l'opportunité de faire autant de dépendances cycliques que possible.
- Comme l'implémentation d'une propriété, l'implémentation d'une méthode est implémentée au moyen d'un attribut, qui associe votre code au code du conteneur lui-même.
- L'implémentation de la méthode dans Zenject n'est utilisée que comme une alternative aux constructeurs, ce qui est pratique dans le cas des classes MonoBehavior, mais elle contredit absolument la théorie décrite par Mark Siman. L'exemple classique de la mise en œuvre canonique de la méthode peut être considéré comme l'utilisation d'usines (méthodes d'usine).
- S'il y a plusieurs méthodes introduites dans la classe, ou en plus de la méthode il y a aussi un constructeur, il s'avère que les dépendances nécessaires pour la classe seront dispersées à différents endroits, ce qui interfèrera avec l'image dans son ensemble. Autrement dit, si la classe 1 a un constructeur, le nombre de ses paramètres peut clairement montrer s'il y a des erreurs de conception dans la classe et si le principe de la responsabilité exclusive est violé, et si les dépendances sont dispersées par plusieurs méthodes, par le constructeur, ou peut-être par quelques propriétés, alors l'image ne sera pas aussi évidente qu'elle pourrait l'être.
Il s'ensuit que la présence d'une telle implémentation de l'implémentation de méthode dans le conteneur, qui contredit la théorie DI, n'a pas un seul plus. Avec une grosse mise en garde, un plus ne peut être considéré que la possibilité d'utiliser la méthode implémentée, en tant que constructeur de MonoBehaviour. Mais c'est un point plutôt controversé, car du point de vue de la logique du conteneur, des modèles DI et du périphérique de mémoire interne Unity3D, tous les objets MonoBehaviour de votre application peuvent être considérés comme gérés par les ressources, et dans ce cas, il sera beaucoup plus efficace de déléguer la gestion du cycle de vie de ces objets pas un conteneur DI, mais une classe d'assistance (que ce soit Wrapper, ViewModel, Fasade ou autre).
Liaisons globales
Il s'agit d'une fonctionnalité auxiliaire plutôt pratique qui vous permet de définir des classeurs globaux pouvant vivre indépendamment de la transition entre les scènes. Vous pouvez en lire plus
dans la documentation . Cette fonctionnalité est extrêmement pratique et très utile. Il convient de noter qu'il ne viole pas les modèles et les principes de DI, cependant, il a une mise en œuvre peu évidente et laide. L'essentiel est que vous créez un type spécial de préfabriqué, y attachez un script avec la configuration du conteneur (Installer) et l'enregistrez dans un dossier de projet strictement défini, sans possibilité de se déplacer quelque part et sans aucun lien vers celui-ci. L'inconvénient de cet outil réside uniquement dans son implicitation. En ce qui concerne les installateurs ordinaires, tout est assez simple: vous avez un objet sur la scène, le script d'installation se bloque dessus. Si un nouveau développeur vient au projet, l'installateur devient un excellent point d'immersion dans le projet. Sur la base d'un seul programme d'installation, un développeur peut se faire une idée des modules d'un projet et de la façon dont un graphique d'objets est construit. Mais avec l'utilisation de classeurs globaux, le programme d'installation sur scène cesse d'être une source suffisante de ces informations. Il n'y a pas un seul lien vers la liaison globale dans le code des autres installateurs (présents sur les scènes) et par conséquent, vous ne voyez pas le graphique complet des objets. Et seulement pendant l'analyse des classes, vous comprenez que certains des classeurs ne sont pas suffisants dans le programme d'installation sur scène. Encore une fois, je ferai une réserve que cet inconvénient est purement cosmétique.
Identifiants
La possibilité de définir une liaison spécifique pour un identifiant afin d'obtenir une certaine dépendance à partir d'un ensemble de dépendances similaires dans une classe. Un exemple:
Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle(); Container.Bind<IFoo>().To<Foo2>().AsSingle(); public class Bar1 { [Inject(Id = "foo")] IFoo _foo; } public class Bar2 { [Inject] IFoo _foo; }
Cette fonctionnalité peut vraiment être utile dans certaines situations et constitue une option supplémentaire à la mise en œuvre des propriétés. Cependant, avec la commodité, il hérite de tous les problèmes identifiés dans la section «Implémentation des propriétés», ajoutant encore plus de cohérence au code en introduisant une certaine constante dont vous devez vous souvenir lors de la configuration de votre code. Si vous supprimez accidentellement cet identifiant, vous pouvez facilement en obtenir un non fonctionnel à partir de l'application qui fonctionne.
Signaux et ITickable
Les signaux sont un analogue du mécanisme d'agrégateur d'événements intégré au conteneur. L'idée de mettre en œuvre cette fonctionnalité est sans aucun doute noble, car elle vise à réduire le nombre de connexions entre les objets qui communiquent via le mécanisme des abonnements aux événements. Un exemple assez volumineux peut être trouvé dans la
documentation , cependant, il ne sera pas dans l'article, car l'implémentation spécifique n'a pas d'importance.
Prise en charge de l'interface ITickable - remplacement des méthodes standard Update, LateUpdate et FixedUpdate dans Unity en déléguant des appels aux méthodes de mise à jour des objets avec l'interface ITickable au conteneur. Un exemple est également dans la
documentation , et sa mise en œuvre dans le contexte de l'article n'a pas d'importance non plus.
Le problème des signaux et ITickable ne concerne pas les aspects de leur mise en œuvre, sa racine réside dans l'utilisation des effets secondaires du conteneur. À la base, le conteneur connaît presque toutes les classes et leurs instances au sein du projet, mais sa responsabilité est de créer un graphique des objets et de gérer leur cycle de vie. En ajoutant des mécanismes tels que des signaux, ITickable, etc., nous ajoutons plus de responsabilités au conteneur, et de plus en plus nous y attachons le code d'application, ce qui en fait la partie exclusive et irremplaçable du code, pratiquement un «objet divin».
Au lieu de la sortie
La chose la plus importante à propos des conteneurs est de comprendre que l'utilisation de DI est indépendante de l'utilisation d'un conteneur DI. Une application peut être construite à partir de nombreuses classes et modules faiblement couplés, et aucun de ces modules ne doit rien savoir du conteneur.
Soyez prudent lorsque vous utilisez des solutions prêtes à l'emploi (en boîte) ou de petits plugins. Utilisez-les judicieusement. En effet, des choses encore plus grandioses sur lesquelles vous comptez (par exemple, des moteurs de jeu à l'échelle de Unity3D lui-même) peuvent pécher avec de telles erreurs théoriques et des taches. Et cela, en fin de compte, n'affectera pas le travail de la solution que vous utilisez, mais la durabilité, le travail et la qualité de votre produit final. J'espère que tous ceux qui auront lu jusqu'à la fin, l'article seront utiles ou, au moins, ne regretteront pas le temps passé à le lire.