依赖注入的批评和弊端


119

依赖注入(DI)是一种众所周知的流行模式。大多数工程师都知道它的优点,例如:

  • 使单元测试中的隔离变得可能/容易
  • 明确定义一个类的依赖
  • 促进良好的设计(例如,单一职责原则(SRP))
  • 启用快速(开关实现DbLogger的,而不是ConsoleLogger例如)

我认为业界普遍认为DI是一种很好的有用模式。目前没有太多批评。社区中提到的缺点通常很小。他们中的几个:

  • 增加班数
  • 创建不必要的接口

目前,我们与同事讨论建筑设计。他相当保守,但思想开放。他喜欢质疑一些我认为很好的事情,因为IT领域中的许多人只是复制最新趋势,重复优势,而且通常不会考虑太多-不要分析得太深。

我想问的是:

  • 只有一个实现时,应该使用依赖注入吗?
  • 我们应该禁止创建除语言/框架对象之外的新对象吗?
  • 如果我们不打算对特定的类进行单元测试,那么注入单个实现是个坏主意(假设我们只有一个实现,因此我们不想创建“空”接口)吗?

33
您是否真的在问依赖注入作为一种模式,还是在问使用DI框架?这些确实是截然不同的事情,您应该弄清您感兴趣的问题的哪一部分,或者明确地询问这两者。
Frax '18

10
@Frax关于模式,而不是框架
Landeeyo,

10
您将依赖倒置与依赖注入混淆了。前者是设计原则。后者是一种用于构造对象层次结构的技术(通常使用现有工具来实现)。
jpmc26 '18

3
我经常使用真实的数据库编写测试,而根本没有模拟对象。在很多情况下效果很好。然后,您通常不需要接口。如果您拥有UserService该类,那么它只是逻辑的持有者。它被注入数据库连接,并且测试在回滚的事务内部运行。许多人会称这种做法不好,但我发现这样做非常有效。不需要仅仅为了测试而扭曲您的代码,您就可以发现集成测试中的错误。
usr

3
DI几乎总是好的。它的坏处是很多人认为他们了解DI,但他们所知道的就是如何使用某些奇怪的框架,甚至不确定自己在做什么。如今,DI受货物崇拜编程的影响很大。
T. Sar

Answers:


160

首先,我想将设计方法与框架的概念区分开。最简单,最基础的级别的依赖注入就是:

父对象提供了子对象所需的所有依赖关系。

而已。请注意,其中的任何内容都不需要接口,框架,任何注入样式等。为了公平起见,我20年前首次了解了这种模式。 这不是新的。

在依赖注入的情况下,由于有两个以上的人对“父母和孩子”一词感到困惑,因此:

  • 家长的是,实例化和配置它使用的子对象的对象
  • 孩子是被设计成被动实例化的组件。即,它旨在使用父级提供的任何依赖项,并且不会实例化其自身的依赖项。

依赖注入是对象合成的一种模式。

为什么要接口?

接口是合同。它们的存在是为了限制两个对象之间的紧密耦合。并非每个依赖项都需要一个接口,但是它们有助于编写模块化代码。

当添加单元测试的概念时,对于任何给定的接口,您可能都有两种概念上的实现:要在应用程序中使用的实际对象,以及用于测试依赖于该对象的代码的模拟对象或存根对象。仅此一点就足以为接口辩解。

为什么使用框架?

当存在大量子对象时,实质上初始化和提供对子对象的依赖关系可能会令人生畏。框架具有以下优点:

  • 自动装配对组件的依赖关系
  • 使用某种设置配置组件
  • 自动化样板代码,因此您不必看到它写在多个位置。

它们还具有以下缺点:

  • 父对象是一个“容器”,而不是您的代码中的任何内容
  • 如果您不能直接在测试代码中提供依赖项,则使测试变得更加复杂
  • 它可能会减慢初始化速度,因为它会使用反射和许多其他技巧来解决所有依赖项
  • 运行时调试可能会更加困难,尤其是如果容器在接口和实现该接口的实际组件之间注入代理(想到Spring内置的面向方面的程序)。容器是一个黑匣子,它们并不总是以促进调试过程的任何概念构建的。

综上所述,需要权衡取舍。对于没有很多活动部件的小型项目,没有什么理由使用DI框架。但是,对于已经为您准备了某些组件的更复杂的项目,可以证明该框架是合理的。

那[互联网上的随机文章]呢?

那呢 很多时候,如果您不是以“一种真实的方式”来做事情,人们会变得过分热心,增加了很多限制,并谴责您。没有一种真正的方法。看看您是否可以从本文中提取有用的内容,并忽略您不同意的内容。

简而言之,请自己考虑并尝试一下。

与“老头子”一起工作

尽可能多地学习。在70年代进入工作状态的许多开发人员中,您会发现他们已经学会了不要对很多事情保持教条。他们已经使用了数十年的方法来产生正确的结果。

我有幸与其中的一些人一起工作,他们可以提供一些有意义的残酷诚实的反馈。在他们看到价值的地方,他们将这些工具添加到他们的曲目中。


6
@CarlLeth,我使用过从Spring到.net变体的许多框架。Spring将允许您使用一些反射/类加载器黑魔法将实现注入到私有字段中。测试如此构建的组件的唯一方法是使用容器。Spring确实有JUnit运行器来配置测试环境,但是它比自己设置要复杂得多。是的,我只是举了一个实际的例子。
Berin Loritsch '18

17
当我戴着故障排除程序/维护人员的帽子时,我发现通过框架对DI构成一个障碍:它们所提供的怪异动作使离线调试更加困难。在最坏的情况下,我必须运行代码以查看依赖项的初始化和传递方式。您在“测试”的上下文中提到了这一点,但实际上,如果您只是开始查看源代码,那就更糟了,请尝试使其运行(可能涉及大量设置)。仅仅看一眼就阻碍了我分辨代码功能的能力。
Jeroen Mostert '18

1
接口不是合同,它们只是API的接口。契约暗示着语义。该答案使用特定于语言的术语和特定于Java / C#的约定。
弗兰克·希勒曼

2
@BerinLoritsch您自己答案的重点是DI原则!=任何给定的DI框架。Spring可以做可怕的,不可原谅的事情,这是Spring的缺点,而不是一般的DI框架。良好的DI框架可帮助您遵循DI原则,而不会遇到麻烦。
卡尔·莱斯

1
@CarlLeth:所有DI框架都旨在删除或自动化程序员不希望阐明的某些事情,它们只是在方式上有所不同。就我所知,它们都消除了仅通过查看A和B 来知道A类和B类如何(或是否)相互作用的能力-至少,您还需要查看DI的设置/配置/约定。对于程序员而言,这不是一个问题(实际上正是他们想要的),但是对于维护者/调试者而言,则可能是一个问题(稍后可能是同一位程序员)。即使您的DI框架“完美”,这也是您要做出的权衡。
杰罗恩·莫斯特

88

像大多数模式一样,依赖注入是解决问题的一种方法。因此,首先要问您是否有问题。如果不是,那么使用该模式最有可能使代码变得更糟

如果可以减少或消除依赖性,请首先考虑。在所有其他条件相同的情况下,我们希望系统中的每个组件都具有尽可能少的依赖关系。如果依赖关系消失了,注入与否的问题就变得毫无意义了!

考虑一个模块,该模块从外部服务下载一些数据,对其进行解析,然后执行一些复杂的分析,然后将结果写入文件。

现在,如果对外部服务的依赖关系进行了硬编码,那么将很难对这个模块的内部处理进行单元测试。因此,您可能决定将外部服务和文件系统作为接口依赖项进行注入,这将允许您注入模拟,从而使对内部逻辑进行单元测试成为可能。

但是更好的解决方案是将分析与输入/输出分开。如果将分析提取到没有副作用的模块,则测试起来会容易得多。请注意,模拟是一种代码气味-并非总是可以避免的,但是总的来说,如果您不依赖模拟就可以进行测试会更好。因此,通过消除依赖性,可以避免DI应该缓解的问题。请注意,这种设计也更好地遵守了SRP。

我要强调的是,DI不一定会促进SRP或其他好的设计原则,例如关注点分离,高内聚/低耦合等。它也可能产生相反的效果。考虑一个A类,该A类在内部使用另一个B类。B仅由A使用,因此被完全封装,可以视为实现细节。如果将其更改为将B注入到A的构造函数中,那么您已经暴露了该实现的细节,现在有关此依赖项以及如何初始化B的知识,B的生存期等必须分别存在于系统中的其他位置。因此,您的整体架构较差,而且存在泄漏问题。

另一方面,在某些情况下,DI确实很有用。例如,对于带有记录器等副作用的全局服务。

问题在于,模式和体系结构本身成为目标而不是工具。只是问“我们应该使用DI吗?” 有点像在把马车推到马前。您应该问:“我们有问题吗?” 和“针对此问题的最佳解决方案是什么?”

您的问题的一部分归结为:“我们是否应该创建多余的接口来满足模式的需求?” 您可能已经意识到了答案- 绝对不是!否则,任何人告诉您都在试图向您出售产品-可能是昂贵的咨询时间。仅当接口表示抽象时,它才有价值。仅模仿单个类的表面的接口称为“头接口”,这是已知的反模式。


15
我完全同意!还要注意,为此而进行的模拟操作意味着我们实际上并未测试实际的实现。如果A用于B生产,但仅经过测试MockB,我们的测试不会告诉我们它是否可以在生产中使用。当域模型的纯组件(无副作用)相互注入和嘲笑时,结果是浪费了每个人的时间,庞大而脆弱的代码库以及对结果系统的低置信度。模拟系统的边界,而不是在同一系统的任意部分之间。
华宝'18

17
@CarlLeth为什么您认为DI使代码“可测试和可维护”,而没有代码则更少?JacquesB认为副作用是会损害可测试性/可维护性的东西,这是正确的。如果代码没有副作用,那么我们不在乎它在什么地方/何时/如何调用其他代码。我们可以使其简单直接。如果代码有副作用,我们必须注意。DI可以从函数中消除副作用,并将其放入参数中,从而使这些函数更易于测试,但程序更加复杂。有时这是不可避免的(例如,数据库访问)。如果代码没有副作用,那么DI就是无用的复杂性。
华宝

13
@CarlLeth:DI是解决使代码具有可禁止性的独立问题的一种解决方案。但这并不会降低总体复杂性,也不会使代码更具可读性,这意味着它不一定会提高可维护性。但是,如果可以通过更好地分离关注点来消除所有这些依赖关系,则这将完全“抵消” DI的好处,因为它满足了对DI的需求。这通常是使代码同时更可测试可维护的更好解决方案。
布朗

5
@Warbo这是原始方法,可能仍然是唯一有效的嘲笑方法。即使在系统边界,也很少需要它。人们确实浪费大量时间来创建和更新几乎毫无价值的测试。
弗兰克·希勒曼

6
@CarlLeth:好的,现在我明白了误解是从哪里来的。您正在谈论依赖倒置。但是,这个问题,这个答案和我的评论都是关于DI =依赖注入
布朗

36

根据我的经验,依赖项注入有许多缺点。

首先,使用DI不能像宣传的那样简化自动化测试。使用接口的模拟实现对类进行单元测试,使您可以验证该类如何与接口交互。也就是说,它使您可以单元测试被测类如何使用接口提供的协定。但是,这提供了更大的保证,即可以保证被测类在接口中的输入是预期的。它几乎不能保证测试中的类会像预期的那样响应接口的输出,因为它几乎是通用的模拟输出,它本身会受到错误,过分简化等的影响。简而言之,它不允许您验证类在接口的实际实现中的行为是否符合预期。

其次,DI使浏览代码变得更加困难。当尝试导航到用作函数输入的类的定义时,接口可以是从小麻烦(例如,只有一个实现的地方)到主要的时间槽(例如,使用像IDisposable这样的过于通用的接口)的任何东西尝试查找正在使用的实际实现时。这可以使诸如“我需要在打印此日志记录语句之后立即发生的代码中修复空引用异常”这样的简单练习变成一天的工作。

第三,DI和框架的使用是一把双刃剑。它可以大大减少普通操作所需的样板代码量。但是,这是以需要特定DI框架的详细知识来理解这些通用操作实际上如何连接在一起为代价的。要了解如何将依赖项加载到框架中并在框架中添加新的依赖项以进行注入,可能需要阅读大量的背景资料并遵循有关该框架的一些基本教程。这会将一些简单的任务变成相当耗时的任务。


我还要补充一点,您注入的次数越多,启动时间就越长。大多数DI框架在启动时都会创建所有可注入的单例实例,无论它们在何处使用。
罗德尼·巴尔巴蒂

7
如果您想使用真实的实现(而不是模拟)来测试类,则可以编写功能测试-与单元测试类似的测试,但不使用模拟。
BЈовиat

2
我认为您的第二段需要更加细致入微:DI本身不会使浏览代码更加困难。简单来说,DI只是遵循SOLID的结果。增加复杂性的是使用不必要的间接和DI框架。除此之外,这个答案打在了头上。
康拉德·鲁道夫'18

4
在确实需要依赖注入的情况之外,依赖注入也是一个警告信号,表明可能会大量存在其他多余的代码。高管常常惊讶地发现,开发人员为了复杂而增加了复杂性。
弗兰克·希勒曼

1
+1。这是对所提问题的真正答案,应该被接受。
梅森惠勒

13

我遵循Mark Seemann在“ .NET中的依赖注入”中的建议进行推测。

如果您具有“易变的依赖关系”,则应使用DI,例如,它有可能会发生变化。

因此,如果您认为将来可能有多个实现,或者实现可能会发生变化,请使用DI。否则new很好。


5
请注意,他也给出了不同的建议对面向对象和函数式语言blog.ploeh.dk/2017/01/27/...
JK。

1
那是好点。如果默认情况下我们为每个依赖关系创建接口,那么它一定会对YAGNI不利。
Landeeyo

4
您可以提供“ .NET中的依赖项注入”参考吗?
彼得·莫滕森

1
如果您正在进行单元测试,那么您的依赖性很有可能是不稳定的。
Jaquez '18

11
好消息是,开发人员始终能够以完美的准确性预测未来。
弗兰克·希勒曼

5

我已经在一些答案中提到了我最大的关于DI的烦恼,但是在这里我将对其进行扩展。DI(因为今天大多数情况下都是使用容器等完成的),确实损害了代码的可读性。可以说,代码可读性是当今大多数编程创新背后的原因。就像有人说的那样-编写代码很容易。读取代码很难。但这也非常重要,除非您正在编写某种微小的一次性写入实用程序。

DI在这方面的问题在于它是不透明的。容器是一个黑盒子。对象只是从某个地方出现而您却不知道-谁构造它们以及何时构造?什么传递给构造函数?我要与谁共享此实例?谁知道...

而且,当您主要使用接口时,IDE的所有“转到定义”功能都会烟消云散。要在不运行程序的情况下跟踪程序的流程是非常困难的,只是一步一步地查看在该特定位置仅使用了WHICH接口实现。有时会有一些技术障碍阻止您逐步执行。即使可以,如果涉及通过DI容器扭曲的肠子,整个事情也会很快成为令人沮丧的练习。

为了有效处理使用DI的代码,您必须熟悉它并且已经知道该怎么做。


3

快速启用切换实现(例如,用DbLogger代替ConsoleLogger)

虽然一般来说DI当然是一件好事,但我建议不要盲目地将它用于所有事情。例如,我从不注入记录器。DI的优点之一是使依赖关系明确和清晰。ILogger将几乎所有类都列为依赖项没有意义-只是混乱。记录器的责任是提供所需的灵活性。我所有的记录器都是静态最终成员,当我需要非静态记录器时,我可以考虑添加一个记录器。

增加班数

这是给定的DI框架或模拟框架的缺点,而不是DI本身的缺点。在大多数地方,我的课程取决于具体的课程,这意味着需要零样板。Guice(一个Java DI框架)默认将一个类与其自身绑定,而我只需要在测试中重写绑定(或手动进行绑定)。

创建不必要的接口

我仅在需要时创建接口(这种情况很少见)。这意味着有时候,我必须用接口替换所有出现的类,但是IDE可以为我完成此操作。

只有一个实现时,应该使用依赖注入吗?

是的,但要避免增加任何样板

我们应该禁止创建除语言/框架对象之外的新对象吗?

不会。会有很多值(不可变)和数据(可变)类,在这些类中,实例是刚刚创建并传递的,注入它们毫无意义-因为它们从未存储在另一个对象中(或仅存储在另一个对象中)这样的对象)。

对于他们来说,您可能需要注入工厂,但是大多数时候没有任何意义(例如,想象中@Value class NamedUrl {private final String name; private final URL url;};您实际上不需要这里的工厂,也没有注入物)。

如果我们不打算对特定的类进行单元测试,那么注入单个实现是个坏主意(假设我们只有一个实现,因此我们不想创建“空”接口)吗?

恕我直言,只要它不会导致代码膨胀,就可以了。注入依赖项,但不要创建接口(也不要创建疯狂的配置XML!),因为以后可以轻松地进行操作。

实际上,在我当前的项目中,有四个类(数百个类),我决定从DI中排除它们因为它们是在太多地方(包括数据对象)使用的简单类。


大多数DI框架的另一个缺点是运行时开销。可以将其移到编译时(对于Java,没有Dagger,对其他语言一无所知)。

另一个缺点是魔术无处不在,可以对其进行调低(例如,使用Guice时我禁用了代理创建)。


-4

我必须说,我认为整个依赖注入概念都被高估了。

DI是现代等同于全球价值的东西。您要注入的东西是全局单例和纯代码对象,否则,您将无法注入它们。为了使用给定的库(JPA,Spring Data等),大多数情况下会强制使用DI。在大多数情况下,DI为培育和培养意大利面条提供了理想的环境。

老实说,测试类的最简单方法是确保所有依赖项都在可以覆盖的方法中创建。然后创建一个从实际类派生的Test类,并重写该方法。

然后,您实例化Test类并测试其所有方法。你们中的某些人不清楚这一点-您正在测试的方法是属于被测类的方法。所有这些方法测试都在单个类文件中进行-与被测类关联的单元测试类。这里的开销为零-这就是单元测试的工作方式。

在代码中,这个概念看起来像这样……

class ClassUnderTest {

   protected final ADependency;
   protected final AnotherDependency;

   // Call from a factory or use an initializer 
   public void initializeDependencies() {
      aDependency = new ADependency();
      anotherDependency = new AnotherDependency();
   }
}

class TestClassUnderTest extends ClassUnderTest {

    @Override
    public void initializeDependencies() {
      aDependency = new MockitoObject();
      anotherDependency = new MockitoObject();
    }

    // Unit tests go here...
    // Unit tests call base class methods
}

结果与使用DI完全相同-即,将ClassUnderTest配置为进行测试。

唯一的不同是此代码完全简洁,完全封装,易于编码,易于理解,速度更快,使用的内存更少,不需要替代配置,不需要任何框架,永远不会成为4页的原因(WTF!)堆栈跟踪,其中包括您编写的完全为零的(0)类,对于从初学者到Guru甚至只有一点点OO知识的人来说都是显而易见的(您会认为,但会误会)。

话虽这么说,我们当然不能使用它-它太明显了,不够时髦。

归根结底,我对DI的最大担忧是我看到的那些项目惨遭失败,所有这些都是庞大的代码库,其中DI是将所有内容结合在一起的粘合剂。DI不是一种体系结构-它实际上仅在少数情况下才有意义,其中大多数情况都是为了使用另一个库(JPA,Spring Data等)而被强制执行。在大多数情况下,在一个精心设计的代码库中,DI的大多数使用将在您日常开发活动所处的水平以下进行。


6
您没有描述等效项,而是描述了依赖项注入的相反情况。在模型中,每个对象都需要了解其所有依赖项的具体实现,但是使用DI成为“主要”组件的责任-将适当的实现粘合在一起。请记住,DI与其他 DI紧密相关,在这种情况下,您不希望高级组件对低级组件有硬性依赖。
Logan Pickup

1
当您只有一个级别的类继承和一个级别的依赖项时,它会很整洁。当它扩展时,它肯定会变成地狱吗?
伊万

4
如果使用接口并将initializeDependencies()移到构造函数中,则相同但更整洁。下一步,添加构造参数意味着您可以删除所有TestClass。
伊万

5
这有很多错误。就像其他人所说的,“ DI等效”示例根本不是依赖注入,它是对立的,它说明了对该概念的完全缺乏了解,还引入了其他潜在的陷阱:部分初始化的对象是代码异味,就像Ewan建议将初始化移到构造函数,然后通过构造函数参数传递它们。然后你有DI ...
Mindor先生

3
添加到@ Mr.Mindor:还有一个更通用的反模式“顺序耦合”,它不仅适用于初始化。如果必须以特定顺序运行对象的方法(或更普遍地说,是对API的调用),例如bar只能在之后调用foo,那么这是一个错误的API。它声称提供了功能(bar),但是我们实际上不能使用它(因为foo可能没有被调用)。如果要坚持使用initializeDependencies(anti?)模式,则至少应将其设置为私有/受保护,并从构造函数中自动调用它,因此该API是真诚的。
华宝

-6

您的问题确实可以归结为“单元测试不好吗?”

您要注入的替代类中有99%是支持单元测试的模拟。

如果在没有DI的情况下进行单元测试,则将遇到如何获取类以使用模拟数据或模拟服务的问题。让我们说“逻辑的一部分”,因为您可能没有将其分离为服务。

有其他方法可以做到这一点,但是DI是一种很好且灵活的方法。一旦将其用于测试,实际上您将被迫在任何地方使用它,因为您需要其他代码,甚至是实例化具体类型的所谓的“穷人DI”。

很难想象有如此糟糕的劣势,以至于单元测试的优势不堪重负。


13
我不同意您关于DI是关于单元测试的主张。促进单元测试只是DI的优点之一,并且可以说不是最重要的优点之一。
罗伯特·哈维

5
我不同意您的前提,即单元测试和DI如此接近。通过使用模拟/存根,我们使测试套件更具欺骗性:被测系统与真实系统之间的距离越来越远。从客观上讲这很糟糕。有时,这样做的好处在于:模拟的FS调用不需要清理;模拟的HTTP请求快速,确定性并可脱机工作;相比之下,每次我们new在方法中使用硬编码时,我们都知道在生产中运行的同一代码正在测试期间运行。
华宝

8
不,不是“单元测试不好吗?”,而是“嘲笑(a)确实有必要,而(b)值得增加复杂性吗?”那是一个非常不同的问题。单元测试不错(实际上没有人在争论这一点),总体而言,它绝对值得。但是,并不是所有的单元测试都需要模拟,而且模拟确实会带来巨大的成本,因此至少应该明智地使用它。
康拉德·鲁道夫'18

6
@Ewan在您发表最后评论后,我认为我们不同意。我是说大多数单元测试不需要DI [frameworks],因为大多数单元测试不需要模拟。实际上,我什至可以将其用作启发代码质量的方法:如果大多数代码如果没有DI / mock对象就无法进行单元测试,那么您编写的错误代码就太正确了。大多数代码应高度分离,具有单一职责和通用性,并且可以单独进行简单测试。
康拉德·鲁道夫'18

5
@Ewan您的链接为单元测试提供了一个很好的定义。按照这个定义,我的Order示例是单元测试:它正在测试一个方法(的total方法Order)。您抱怨它正在从2个类中调用代码,我的回答是什么呢?我们不是在一次测试“两个类”,而是在测试total方法。我们不应该在乎方法是如何工作的:这些是​​实现细节。测试它们会导致脆弱性,紧密耦合等。我们关心方法的行为(返回值和副作用),而不关心类/模块/ CPU寄存器/等。它在过程中使用。
华宝
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.