不要责怪依赖注入框架

Don’t Blame the Dependency Injection Framework

在过去的几个月中,我已经听到并阅读了很多声明,它们说依赖注入框架是一件坏事,您应该避免像瘟疫一样。 在我看来,这仅仅是因为某些东西被滥用太久而拒绝它的结果。 不要误会我的意思,我使用它们已有多年了,并且充分认识到滥用这些框架的后果以及对您的代码库的影响。 但是,我还了解到每种工具都有其优点和缺点。 了解其优势和劣势是成为专业软件开发人员的工作的一部分。 对我而言,由于后者而解雇工具似乎有点无知。 因此,让我们谈谈这是从哪里来的。

人们为什么使用它们?

考虑下面的(人为)图。

在这样的设计中,我可以想到开发人员决定使用依赖注入框架的几个原因。 一方面,他们可能希望促进开发人员以仅依赖抽象的方式设计类(1)。 在我看来,这是一件好事,因为依靠更稳定的物品可以防止涟漪效应。 而且由于抽象比实现更稳定,所以我们很好。 所有这些的一个很好的副作用是,它允许您将一个实现与另一个实现(2)切换出去,也称为策略模式。 如果您需要让单元测试针对该抽象的易于测试的实现(4)进行工作,则效果特别好。 并且不要忘了喜欢从外部模块(6)动态加载实现(也称为插件)的系统。

在大多数现实世界的设计中,不同抽象的不同实现由不同的生命周期控制(3)。 这是依赖注入框架可以发挥作用的地方。 它们使您可以控制何时实例化抽象的实现,在什么情况下可以重用抽象的实现以及何时处置实例时经常被忽略的事情。AutofacIs确实是其中一个很好的例子。 它将一次性用品视为头等大事,并假定(嵌套的)容器超出范围时,应丢弃实现IDIsposable的任何对象。

由于容器已经负责将实现连接到抽象,因此它还可以注入处理跨领域关注的对象(5)。 这样的一个示例是adecorator,它可以秘密监视对象之间的调用,并在超出某些阈值时记录警告消息。 但是我也看到了一些示例,例如遥测收集,异常屏蔽以及基于托管环境的动态策略选择。

那为什么有人这么鄙视他们呢?

我刚刚给出的示例是针对实际问题的有效且有用的解决方案。 那么为什么某些开发人员认为依赖注入框架是如此糟糕呢? 好吧,就像测试驱动开发一样,如果您做得不正确,它可能真的会伤害您,滥用DI框架可能会带来很多痛苦。 多年来,我经常听到的是这种框架引入的魔力,尤其是在框架提供的实例的生命周期内。 而且,如果您需要了解配置的生命周期是什么,那么查找原始注册可能很繁琐。 如果您只有几个注册,那么您可能很幸运。 但是我见过的大多数程序块都包含数百个这样的注册,导致笨拙的自举代码。 特别是如果类型是动态注册的,或者通过所有公共接口公开类型时,几乎不可能找到正确的注册。

我听说过(并亲身观察过)的另一个问题是当无法解决某种依赖关系时,其中包含了深度嵌套的调用堆栈。 Autofac可以做到这一点,但是我已经看到了其他框架中一些相当混乱的堆栈转储。 您可能会注意到,我一直使用术语框架和库。 这是因为大多数这些库的行为都不像库。 换句话说,如果您不小心,它们众所周知的触角往往会感染您的整个代码库。 我是否已经提到过这种趋势,那就是为用作依赖项的任何内容引入Iwhatever接口。 考虑到我们尝试遵循基于角色的接口和接口隔离原理之类的行之有效的做法,我在代码库中看不到任何东西。 更不用说单元测试中过度的伪造,通常是由此造成的。

正确定义术语

在我们讨论如何正确地进行依赖注入之前,我认为我首先需要澄清一些术语,首先是ControlInversIon(IoC)。 这是将负责创建依赖关系的责任移到类之外的过程。 换句话说,不是让Order ProcessIng模块(从本文顶部的图片开始)new-由IOrderReposItory的特定实现实现,我们将其委托给创建和/或使用模块。 您不需要任何DI框架。 但是,由于这是DI框架的主要工作,因此很多人称它们为 IoC容器是很合逻辑的。 从某种意义上说,容器负责将正确的依赖项注入到"订单处理"模块(例如构造函数)中,从而使这些术语混合使用。

但是,许多人仍然认为SOLID中的" D"也意味着依赖注入。 但是," D"代表依赖反转原理(简称DIP),它与我刚刚解释的概念正交。 DIP讨论了以高层次的抽象不依赖于低层次的抽象的方式来反转对象之间的依赖性。 其背后的思想是较低级别的抽象倾向于更通用和可重用,因此更容易发生更改。 这也是成功进行软件包管理的原理之一(它也受SOLID的启发)。 例如,考虑图片左侧和右侧之间的差异:

尽管它们看起来很相似,但还是有细微的差别。 左侧定义了一个漂亮的通用IStoreOrders< T >接口,该接口由可能在数据层中的某些接口拥有。 基于该名称,我认为它将定义用于创建,删除和查询订单的通用方法。 请注意,依赖性从"订单处理"模块(一个较高级别的抽象)流向IStoreOrders接口(一个较低级别的抽象)。 这也意味着该接口的实现不能真正对调用者要完成的工作做出任何假设。 当然,您可以向该接口添加更多的功能方法(如右侧所示),但这可能意味着抽象最终将拥有大量方法。

DIP通过让"订单处理"模块定义其需要运行的功能并允许较低层的层来实现这些功能,从而还原了这种依赖性。 这样可以将模块与经常更改的任何低级细节分离。 这也确实与"清洁架构"思想集非常吻合。 这样做的一个很好的副作用是,它有助于避免那些可怕的接口,这些接口只不过是前缀为I的实现类名称。

正确进行依赖注入

现在我们已经弄清了术语,并且在得出任何结论之前,让我首先分享一些有关如何以不会伤害您的方式进行依赖项注入的准则。 例如,静态可变状态(static或类似行为)总是不好的。 它在多线程环境中引起奇怪的问题,尤其是现在我们都在拥抱asyncawait。 知道这一点,让我震惊的是,看到MIcrosoft一直在大力推广ServIce Locator模式。 它应该允许您使用以下语句来解决依赖关系:

1
ServIceLocator.Current.GetInstance<IStoreOrders>();

但这是一个静态对象。 在所有线程都将重用相同依赖项的生产环境中使用此方法通常很好。 但是,只需尝试将您的测试框架从MSTest切换到XunIt,即可并行运行单元测试,并且每个单元测试都应具有自己的(伪造的)依赖实例。 因此不要使用服务定位符

杀死该模式后,下一步是确保仅在依赖关系图的根部使用容器。 换句话说,从容器中解析根对象,并且从不从不直接依赖于该容器 。 让容器处理图形中较深的对象具有的所有依赖关系。 这可以帮助您将容器视为一个库,而不是一个在代码库中无处不在的框架。

如果您需要根据类型,键或名称动态解析依赖项,该怎么办? 例如,系统域中的某个类可能希望将用于计算订单折扣的逻辑委托给策略(策略模式的特定于域的实现)。 考虑以下示例:

1
2
3
4
5
Interface IDiscountPolicy
{
    float GetDIscount(float orIgInalPrIce);
    bool AllowsCombInIngWIthOtherDIscounts { get; }
}

那么,如何根据订单类型获得正确的政策? Just 定义一个抽象来捕获解决这种依赖关系的概念。 例如,为此定义另一个接口。 但是,接口要求您在单元测试中创建伪造品。 因此,相反,假设您使用C#编写代码,我可能会定义一个这样的委托:

public delegate IDiscountPolicy GetDiscountPolicy(OrderType orderType);

键入该委托的参数将采用与签名匹配的任何方法或lambda表达式。 这样做的一个很好的副作用是,它允许在生产代码中避免使用特定于容器的扩展名

在Autofac(我的首选容器)中,您可以使用以下注册代码来允许某人依赖该委托:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Internal class OrderManagementModule : Module
{
    protected overrIde voId Load(ContaInerBuIlder buIlder)
    {
        buIlder.RegIsterType<SmallOrderDIscountPolIcy>().Keyed<IDiscountPolicy>(OrderType.Small);
        buIlder.RegIsterType<LargeOrderDIscountPolIcy>().Keyed<IDiscountPolicy>(OrderType.Large);

        buIlder.RegIster<GetDIscountPolIcy>(ctx =>
        {
            var contaIner = ctx.Resolve<IComponentContext>();

            return orderType => contaIner.ResolveKeyed<IDiscountPolicy>(orderType);
        });
    }
}

注册代码或在线 是您的生产代码的一部分,因此请确保避免任何类型的部署 -时间基于文本的配置,并在您的单元测试中包括注册。 没有什么比拥有一套绿色的单元测试更令人讨厌的了,然后观察应用程序在启动时就崩溃了,因为注册没有被测试覆盖。 要保持 连接代码接近功能,请从示例中查看您的容器是否支持封装注册 。 例如,上面的模块可以像这样在Autofac中注册:

1
2
3
4
var buIlder = new ContaInerBuIlder();
buIlder.RegIsterModule<OrderManagementModule>();

IContaIner contaIner = buIlder.BuIld();

并且由于我在这里谈论注册,请确保您保持注册明晰。 例如,Autofac允许您在其接口下注册一个具体类,如下所示:

builder.RegisterType<SmallOrderDiscountPolicy>().AsImplementedInterfaces();

但这隐藏了此类公开的接口的名称。 它可以在运行时运行,但是如果您不知道要查找的位置,尝试查找IDiscountPolicy的注册将变得非常困难。 相反,我也非常明确地说明了这一点:

builder.RegisterType<SmallOrderDiscountPolicy>().As<IDiscountPolicy>();

即使您的IDE告诉您它可以从注册码推断出已注册的接口,也请保留在此。 待会儿我会谢谢你的。

我经常听到的一个问题是是否要在类的构造函数,setter属性或其他地方获取依赖关系。 我的个人建议是使依赖范围尽可能地局部。 因此,如果仅一个类的单个方法需要该依赖关系,则将依赖关系直接传递给该方法。 一个常见的例子是当这种方法需要获取当前日期和时间时。 在C#中,您可以通过取消引用DateTime.Now轻松地做到这一点。 但这是static属性,可能会导致与使用服务定位器相同的问题。 在大多数情况下,我通过为此定义一个委托来解决这个问题:

public delegate DateTime GetNow()

与相应的注册看起来像这样:

builder.Register<GetNow>(_ => () => DateTime.Now);

如果整个类都需要该依赖项,请考虑构造函数注入。 但是无论您做什么,都不要使用属性注入。 它掩盖了太多的依赖关系,并模糊了何时需要该依赖关系的逻辑。 作为一般经验法则,应该皱眉具有三个以上依赖项的类。

确定依赖关系的范围不仅处理构造函数或方法。 我认为重要的是要强调依赖应该被限制使用。 一种明确的方法是遵循这样的约定:代码只能使用委托或接口之类的抽象,而位于同一文件夹中或其任何子文件夹。 它不能防止您违反该规则,但是可以帮助其他开发人员了解您如何设想这种依赖性。

另一种方法是使用嵌套容器。 Autofac允许您通过在容器接口上调用BeginLifetimeScope来创建estested范围。 这将返回一个一次性的ILifetimeScope,只要该作用域存在,就可以用来限制依赖项的可用性。 根据您注册依赖项的方式,您甚至可以针对每个ILifetimeScope允许单独的实例,例如 每个HTTP请求。 即使您不需要嵌套容器,也强烈建议委派 将依赖项放到容器中。 成熟的容器是非常先进且经过优化的代码段,它在跟踪对象引用以及何时调用它的方法方面要好得多。 甚至不要尝试自己编写(除非您的名字叫杰里米,尼古拉·史蒂文)。

所以? 您是否应该使用容器?

在介绍了依赖项注入的好,坏和丑陋之后,剩下的就是回答本文的原始问题。 IoC容器或依赖项注入框架是一件好事吗?好吧,我认为这是一个愚蠢的问题。任何工具都有其优点。只要负责任地使用它。但是你需要吗?好的,您可以通过将系统从重点突出的小型自治组件组成有用的组件来避免使用容器。但这通常说起来容易做起来难。如果您需要连接依赖项并且需要考虑对象的生命周期,并且想要构建一个松散耦合的系统,那么您将一无所获。为何不使用经过考验的东西,而不是自己动手包装可怜的容器呢?当然,如果不需要任何特殊功能,可以尝试使用后者。但是要诚实一些,当您需要更复杂的东西时,请改用真正的容器。我本人更喜欢将Autofacat用作组成系统的根,因为它非常快速,非常灵活,并允许我遵循自己的准则。在组件和库中,我更喜欢TInyIocsI,因为它是仅源代码的NuGet程序包,不会给我带来任何依赖痛苦。但是,无论您做什么,都不要忘记准则。他们源于多年做错事。不要让历史重演。

你呢?

所以你怎么看? 我确定这是一个非常热门的话题,因此,很高兴听到您对这个话题的想法。 还有其他准则可以帮助我们更负责任地使用我们的容器吗? 请在下面评论让我知道。 哦,欢迎来信至我@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@请,由于我/她一直在寻求更好的解决方案,所以我会定期更新。