Zenject:IoC容器如何杀死项目的依赖注入

危险从哪里开始? 假设您坚定地确定要按照特定的概念或方法来开发项目。 在我们的情况下,这就是DI,尽管例如也可以使用响应式编程。 合乎逻辑的是,要实现您的目标,您将转向现成的解决方案(在我们的示例中为DI Zenject容器)。 您将熟悉文档并开始使用主要功能来构建应用程序框架。 如果在使用该解决方案的最初阶段没有任何不愉快的感觉,那么很可能它会在您的整个项目中持续存在。 在使用解决方案(容器)的基本功能时,您可能有疑问或希望使某些功能更美观或更有效。 当然,首先,您将为此使用解决方案(容器)的更高级“功能”。 在此阶段,可能会出现以下情况:您已经很了解并信任所选的解决方案,因此许多人可能没有考虑到在意识形态上如何正确使用解决方案中的一个或另一个功能,或者过渡到另一个解决方案已经非常昂贵且不合适(例如截止日期临近)。 在此阶段可能会出现最危险的情况-解决方案功能的使用很少,或者很少(仅在机器上)使用(很少)。

谁可能对此感兴趣?


本文对熟悉DI和DI初学者的人都将很有用。 要了解有关DI使用哪些模式,DI的目的以及IoC容器执行的功能的足够的基础知识。 这与Zenject实现的复杂性无关,而在于其部分功能的应用。 本文仅依靠官方的Zenject文档和其中的代码示例,以及Mark Siman的书“ .NET中的依赖注入”,这是关于DI理论的经典详尽著作。 本文中所有引用均摘自Mark Siman的书。 尽管事实上我们将讨论特定的容器,但本文对于使用其他容器的用户可能还是有用的。

本文的目的是展示旨在帮助您在项目上实现DI的工具如何引导您朝着完全不同的方向前进,使您犯下绑定代码的错误,降低代码的可测试性,并通常剥夺您可以提供的所有优势你DI。

免责声明 :本文的目的不是批评Zenject或其作者。 Zenject可以用于其预期目的,并且可以作为实现DI的出色工具,但前提是您不会使用其全部功能,并且已为自己定义了一些限制。

引言


Zenject是一个开放源代码依赖项注入容器,旨在与Unity3D游戏引擎一起使用,该引擎可在Unity3D支持的大多数平台上使用。 值得注意的是,Zenject也可以用于没有Unity3D的C#应用​​程序。 这个容器在Unity开发人员中非常流行,得到了积极的支持和开发。 此外,Zenject具有所有必需的DI容器功能。

我在3个大型Unity项目中使用了Zenject,并且还与使用它的大量开发人员进行了交流。 撰写本文的原因是常见问题:

  • 使用Zenject是一个好的解决方案吗?
  • Zenject怎么了?
  • 使用Zenject会遇到什么困难?

在某些项目中,使用Zenject并没有导致解决强大的代码连接和不成功的体系结构的问题,反而加剧了这种情况。

让我们看看为什么开发人员会有这样的问题。 您可以回答如下:
具有讽刺意味的是,DI容器本身往往是稳定的依赖项。 ...当您决定基于特定的DI容器开发应用程序时,您可能会在整个应用程序生命周期中受限于此选择。
值得注意的是,通过适当和有限地使用容器,在应用程序中切换到使用其他容器(或拒绝使用该容器以支持“ 为穷人实施 ”)是很可能的,并且不会花费很多时间。 的确,在这种情况下,您不太可能需要它。

在开始分解Zenject的潜在危险功能之前,有必要从表面上刷新DI的几个基本方面。

第一个方面是DI容器目的。 马克·西曼(Mark Siman)在这本书中写了以下内容:
DI容器是一个软件库,可以自动执行组装对象和管理对象生命周期时执行的许多任务。
不要期望DI容器神奇地将大量耦合的代码转换为松散耦合的代码。 容器可以提高使用DI的效率,但是在应用程序中的重点应该主要放在使用模式和使用DI上。
第二个方面是DI模式 。 马克·西曼(Mark Siman)确定了四种主要模式,按频率和使用需求进行了分类:

  1. 构造函数的实现-我们如何保证所需的依赖关系始终对正在开发的类可用?
  2. 属性实现-如果存在合适的本地默认值,如何在类中启用DI作为选项?
  3. 方法的实现-如果每个操作的依赖项都不相同,如何将依赖项注入到类中?
  4. 环境上下文-如何在每个模块中提供依赖项而不在每个API组件中包括应用程序的横切方面?

模式名称旁边指示的问题充分描述了它们的范围。 同时,本文将不会讨论构造函数的实现(因为实际上在Zenject中没有抱怨它的实现)和环境上下文(它的实现不在容器中,但是您可以根据现有功能轻松地实现)。
现在,您可以直接使用Zenject的潜在危险功能。

危险功能。


实施属性


这是在构造函数实现之后的第二种最常见的DI模式,但是使用起来却少得多。 在Zenject中实现如下:

public class Foo { [Inject] public IBar Bar { get; private set; } } 

此外,Zenject还具有“场注入”之类的概念。 让我们看看为什么在所有Zenject中此功能最危险。

  • 属性用于显示容器要嵌入哪个字段。 从容器本身的简单性和实现逻辑来看,这是一个完全可以理解的解决方案。 但是,我们在类代码中看到了一个属性(以及名称空间)。 也就是说,至少是间接地,但是类开始知道从何处获得依赖。 另外,我们开始将类代码加紧到容器上。 换句话说,我们不能在不处理类代码的情况下拒绝使用Zenject。
  • 模式本身用于依赖项具有本地默认值的情况。 也就是说,这是一个可选的依赖项,如果容器不能提供它,那么项目中就不会有错误,一切都会正常进行。 但是,使用Zenject,您始终会获得此依赖关系-依赖关系变得不是可选的。
  • 由于这种情况下的依赖关系不是可选的,因此它开始破坏构造函数实现的整个逻辑,因为仅在此处引入了所需的依赖关系。 通过属性实现非可选的依赖关系,您就有机会在代码中创建循环依赖关系。 它们不会那么明显,因为在Zenject中,首先实现了构造函数的实现,然后是属性的实现,并且您将不会从容器收到警告。
  • 使用DI容器意味着实现了成分根模式,但是,使用属性配置属性的实现会导致这样一个事实,即您不仅在成分根中配置了代码,还根据每个类的需要配置了代码。

工厂(和MemoryPool)


Zenject文档中有关于工厂的整个章节 。 此功能是在容器本身的级别上实现的,也可以创建自己的自定义工厂。 让我们看一下文档中的第一个示例:

 public class Enemy { DiContainer Container; public Enemy(DiContainer container) { Container = container; } public void Update() { ... var player = Container.Resolve<Player>(); WalkTowards(player.Position); ... etc. } } 

在此示例中,已经严重违反了DI。 但这只是一个如何创建完全自定义工厂的示例。 这里的主要问题是什么?
DI容器可能错误地视为服务定位器,但仅应用作链接对象图的机制。 如果从这种角度考虑容器,则将其使用仅限于布局的根是有意义的。 这种方法的重要优势在于,它消除了容器与其余应用程序代码之间的任何绑定。
让我们看一下Zenject中的“内置”工厂是如何工作的。 为此,有一个IFactory接口,该接口的实现将我们引到PlaceholderFactory类:

  public abstract class PlaceholderFactory<TValue> : IPlaceholderFactory { [Inject] void Construct(IProvider provider, InjectContext injectContext) 

在其中,我们可以看到InjectContext参数,它具有许多形式的构造函数:

  public InjectContext(DiContainer container, Type memberType) : this() { Container = container; MemberType = memberType; } 

再一次,我们将容器本身的转移作为对类的依赖。 这种方法严重违反了DI,将容器部分转换为服务定位器。
另外,该解决方案的缺点是容器用于创建短期依赖关系,而只能创建长期依赖关系。

为避免此类违规,容器的作者可以完全排除将容器作为对所有注册类的依赖项传递的可能性。 鉴于整个容器都是通过对方法和构造函数的参数进行反射和分析以创建和布局应用程序对象图的方式来实现的,这将不难实现。

方法实施


在Zenject中实现Method的逻辑如下:首先,在所有类中,实现构造函数,然后实现属性,最后实现该方法。 请考虑文档中提供的实现示例:

 public class Foo { [Inject] public Init(IBar bar, Qux qux) { _bar = bar; _qux = qux; } } 

这里有什么缺点:

  • 您可以编写将在一个类的框架内实现的许多方法。 因此,与实现该属性的情况一样,我们有机会建立尽可能多的循环依赖项。
  • 像属性的实现一样,方法的实现也通过属性来实现,该属性将您的代码与容器本身的代码相关联。
  • Zenject中方法的实现仅用作构造函数的替代方法,这在MonoBehavior类的情况下很方便,但它与Mark Siman所描述的理论完全矛盾。 该方法的规范实现的经典示例可以考虑使用工厂(工厂方法)。
  • 如果类中有几种引入的方法,或者除了该方法之外还有构造函数,那么事实证明,该类所需的依赖项将分散在不同的位置,这将对整个图片产生干扰。 也就是说,如果类1有一个构造函数,则其参数数量可以清楚地显示出该类中是否存在设计错误以及是否违反了唯一责任原则,并且如果依赖项是由几种方法,构造函数或可能由几个属性分散的,那么图片就不会那么明显了。

随之而来的是,这种方法实现的实现在容器中与DI理论相矛盾,没有一个加号。 需要特别注意的是,仅可以将加号视为使用实现的方法作为MonoBehaviour的构造函数的可能性。 但这是一个颇有争议的观点,因为从容器逻辑,DI模式和Unity3D内部存储设备的角度来看,您的应用程序中的所有MonoBehaviour对象都可以视为资源管理的,在这种情况下,委派此类对象的生命周期管理将更加有效不是DI容器,而是助手类(可以是Wrapper,ViewModel,Fasade或其他东西)。

全局绑定


这是一个相当方便的辅助功能,使您可以设置无论场景之间的过渡如何都可以存在的全局活页夹。 您可以在文档中阅读更多内容 。 此功能非常方便且非常有用。 值得注意的是,它没有违反DI的模式和原理,但是它的实现方式不明显且难看。 最重要的是,您要创建一种特殊的预制件,将带有容器配置(安装程序)的脚本附加到该脚本上,然后将其保存在严格定义的项目文件夹中,而无法移动到任何地方且没有任何链接。 该工具的缺点仅在于其隐式性。 对于普通的安装程序,一切都非常简单:舞台上有一个对象,安装程序脚本将挂在该对象上。 如果有新的开发人员加入该项目,则安装程序将成为您沉浸于该项目的绝佳地点。 基于单个安装程序,开发人员可以了解项目包含哪些模块以及如何构建对象图。 但是,使用全局绑定程序后,舞台上的安装程序不再是该信息的充分来源。 其他安装程序(在场景中显示)的代码中没有指向全局绑定的单个链接,因此,您看不到对象的完整图。 仅在对类进行分析时,您才能了解到某些绑定器在舞台上的安装程序中不够用。 我将再次保留这个缺点,纯粹是出于美观。

标识符


为标识符设置特定绑定以便从类中的一组相似依赖关系中获取某个依赖关系的能力。 一个例子:

 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; } 

此功能在实际情况下确实有用,并且是属性实现的附加选项。 但是,除了方便之外,它还继承了“实现属性”部分中确定的所有问题,通过引入在配置代码时需要记住的某个常数,为代码增加了更多的一致性。 如果您不小心删除了该标识符,则可以从正常运行的应用程序中轻松获取一个无效的标识符。

信号与可挑剔


信号类似于内置在容器中的事件聚合器机制。 无疑,实现此功能的想法是崇高的,因为它旨在减少通过事件订阅机制进行通信的对象之间的连接数。 可以在文档中找到相当多的示例,但是在本文中找不到,因为特定的实现无关紧要。

对ITickable接口的支持-通过将对具有ITickable接口的更新对象的方法的调用委派给容器,从而替换Unity中的标准方法Update,LateUpdate和FixedUpdate。 文档中也有一个示例,在本文中的实现也无关紧要。

信号和ITickable问题与它们的实现方面无关,它的根本在于容器副作用的使用。 容器的核心是知道项目中几乎所有的类及其实例,但是其职责是创建对象图并管理其生命周期。 添加诸如Signals,ITickable之类的机制,我们给容器增加了更多的责任,并且越来越多的我们将应用程序代码附加到该容器上,使其成为代码中排他性和不可替代的部分,实际上是一个“神圣的对象”。

代替输出


关于容器,最重要的是要了解DI的使用独立于DI容器的使用。 可以从许多松散耦合的类和模块构建应用程序,而这些模块都不应该对容器一无所知。
使用开箱即用的解决方案或小插件时,请当心。 深思熟虑地使用它们。 确实,您依赖的更多宏伟事物(例如Unity3D本身规模的游戏引擎)可能会因此类理论错误和污点而犯罪。 最终,这将不影响您使用的解决方案的工作,而是最终产品的可持续性,工作和质量。 希望所有读完这篇文章的人对您有所帮助,或者至少不会因为阅读本文而感到抱歉。

Source: https://habr.com/ru/post/zh-CN420235/


All Articles