为什么要使用依赖注入?


536

我试图了解依赖项注入(DI),但再次失败了。看起来真傻。我的代码永远不会混乱。我几乎不编写虚拟函数和接口(尽管我在一个蓝色的月亮中只做过一次),并且我所有的配置都使用json.net(有时使用XML序列化器)神奇地序列化为一个类。

我不太了解它能解决什么问题。看起来好像是这样说的:“嗨。当您运行此函数时,返回此类型的对象并使用这些参数/数据。”
但是...我为什么要使用它?请注意,我也从来不需要使用object它,但是我知道这是为了什么。

在构建使用DI的网站或桌面应用程序时,有哪些实际情况?对于某些人为什么要在游戏中使用接口/虚拟功能,我可以很容易地提出一些案例,但是在非游戏代码中使用它非常少见(很少见,我什至不记得一个实例)。


3
这可能也是有用的信息:martinfowler.com/articles/injection.html
ta.speot.is




5
DI的另一个非常简单的解释:codearsenal.net/2015/03/…–
ybonda

Answers:


840

首先,我想解释一个我对此答案所作的假设。这并不总是正确的,但是经常:

接口是形容词;类是名词。

(实际上,也有一些接口也是名词,但是我想在这里概括一下。)

因此,例如,一个接口可以是这样的东西IDisposableIEnumerableIPrintable。类是这些接口中一个或多个接口的实际实现:List或者Map都可以是的实现IEnumerable

明白这一点:通常,您的类相互依赖。例如,您可能有一个Database访问数据库的类(哈哈,惊喜!;-)),但是您还希望该类记录有关访问数据库的日志。假设您还有另一个类Logger,然后Database对进行依赖Logger

到目前为止,一切都很好。

您可以Database使用以下代码行在类中对该依赖关系进行建模:

var logger = new Logger();

一切都很好。当您意识到自己需要一堆记录器的时候就很好了:有时您想登录到控制台,有时要登录到文件系统,有时要使用TCP / IP和远程日志记录服务器,等等。

当然,你也不要想改变所有的代码(同时你拥有它gazillions)和替换所有行

var logger = new Logger();

通过:

var logger = new TcpLogger();

首先,这没什么好玩的。其次,这容易出错。第三,对于受过训练的猴子来说,这是愚蠢的重复性工作。所以你会怎么做?

显然,引入一个ICanLog由所有各种记录器实现的接口(或类似接口)是一个很好的主意。因此,代码中的第1步是您要做的:

ICanLog logger = new Logger();

现在,类型推断不再更改类型,您始终只有一个要开发的接口。下一步是您不想new Logger()一遍又一遍。因此,您可以将创建新实例的可靠性放在一个单一的中央工厂类中,并获得如下代码:

ICanLog logger = LoggerFactory.Create();

工厂自己决定要创建哪种记录器。您的代码不再需要关心,如果您想更改所使用的记录器的类型,则只需在工厂内部进行一次更改。

现在,当然,您可以概括该工厂,并使其适用于任何类型:

ICanLog logger = TypeFactory.Create<ICanLog>();

此TypeFactory在某个地方需要配置数据,以便在请求特定接口类型时实例化哪个实际类,因此您需要一个映射。当然,您可以在代码内部进行此映射,但是类型更改意味着重新编译。但是您也可以将此映射放入XML文件中,例如。这样,即使在编译时间(!)之后,您也可以更改实际使用的类,这意味着可以动态地进行更改,而无需重新编译!

为您提供一个有用的示例:考虑一种无法正常登录的软件,但是当您的客户由于问题而致电并寻求帮助时,您发送给他的只是一个更新的XML配置文件,现在他拥有已启用日志记录,并且您的支持人员可以使用日志文件来帮助您的客户。

现在,当您稍微替换名称时,最终得到Service Locator的简单实现,这是控制反转的两种模式之一(因为您可以反转控制权,由谁来决定要实例化的确切类)。

总而言之,这减少了代码中的依赖性,但是现在所有代码都具有对中央单一服务定位器的依赖性。

现在,依赖注入是该行的下一步:摆脱对服务定位器的单一依赖关系:代替各种类向服务定位器要求特定接口的实现,您-再次-还原对实例化对象的控制。 。

通过依赖注入,您的Database类现在有了一个构造函数,该构造函数需要一个type类型的参数ICanLog

public Database(ICanLog logger) { ... }

现在,您的数据库中始终有一个记录器可供使用,但不再知道该记录器来自何处。

这就是DI框架起作用的地方:您再次配置映射,然后要求DI框架为您实例化您的应用程序。由于Application该类需要ICanPersistData实现,因此将插入的实例Database-但为此必须首先创建配置为的记录器类型的实例ICanLog。等等 ...

因此,简而言之:依赖关系注入是在代码中删除依赖关系的两种方法之一。它对于编译后的配置更改非常有用,并且对于单元测试来说是一件好事(因为这使得注入存根和/或模拟非常容易)。

实际上,有些事情没有服务定位器是无法做的(例如,如果您事先不知道特定接口需要多少个实例:DI框架始终每个参数仅注入一个实例,但是您可以调用当然,在循环内包含一个服务定位器),因此,每个DI框架通常都提供一个服务定位器。

但基本上就是这样。

PS:我在这里介绍的是一种称为构造函数注入的技术,还有一种属性注入,其中不是构造函数参数,而是使用属性来定义和解析依赖项。将属性注入视为可选依赖项,将构造函数注入视为强制性依赖项。但是,对此的讨论超出了此问题的范围。


7
当然,您也可以这样做,但是您必须在每个类中实现此逻辑,这将为实现的可交换性提供支持。这意味着有很多重复的,冗余的代码,这也意味着一旦确定现在需要它,就需要触摸一个现有的类并将其部分重写。DI允许您在任何任意类上使用此方法,而不必以特殊方式编写它们(除了将依赖项定义为构造函数中的参数外)。
Golo Roden

136
这里是东西我从来没有得到有关DI:它使结构大大变得更复杂。但是,正如我所看到的,使用非常有限。示例肯定总是相同的:可互换的记录器,可互换的模型/数据访问。有时可以互换视图。就是这样。这几种情况真的可以证明复杂得多的软件架构吗?–全面披露:我已经使用DI取得了很大的效果,但是那是针对一个非常特殊的插件体系结构,我不会从中概括。
康拉德·鲁道夫

17
@GoloRoden,为什么要调用ICanLog接口而不是ILogger接口?我和经常这样做的另一个程序员一起工作,我永远无法理解约定?对我来说,这就像调用IEnumerable ICanEnumerate?
DermFrench

28
我将其称为ICanLog,因为我们经常使用毫无意义的单词(名词)。例如,什么是经纪人?经理吗 甚至不是以唯一的方式定义存储库。把所有这些都当作名词是OO语言的一种典型疾病(请参见steve-yegge.blogspot.de/2006/03/…)。我要表达的是,我有一个可以为我做日志记录的组件-那么为什么不这样称呼它呢?当然,这也是以I作为第一人称,因此是ICanLog(ForYou)。
Golo Roden

18
@David单元测试效果很好–毕竟,一个单元独立于其他事物(否则它不是一个单元)。什么无DI容器的工作是模拟测试。公平地说,我不相信模拟的好处超过了在所有情况下添加DI容器所增加的复杂性。我进行严格的单元测试。我很少嘲笑。
康拉德·鲁道夫2013年

499

我认为很多时候人们对依赖注入和依赖注入框架(或通常称为容器)之间的区别感到困惑。

依赖注入是一个非常简单的概念。代替此代码:

public class A {
  private B b;

  public A() {
    this.b = new B(); // A *depends on* B
  }

  public void DoSomeStuff() {
    // Do something with B here
  }
}

public static void Main(string[] args) {
  A a = new A();
  a.DoSomeStuff();
}

您编写这样的代码:

public class A {
  private B b;

  public A(B b) { // A now takes its dependencies as arguments
    this.b = b; // look ma, no "new"!
  }

  public void DoSomeStuff() {
    // Do something with B here
  }
}

public static void Main(string[] args) {
  B b = new B(); // B is constructed here instead
  A a = new A(b);
  a.DoSomeStuff();
}

就是这样。说真的 这给您带来很多好处。两个重要的功能是从中央位置(Main()功能)控制功能的能力,而不是在整个程序中扩展功能;以及更轻松地独立测试每个类的能力(因为您可以将模拟或其他伪造的对象传递给其构造函数)真正的价值)。

缺点当然是您现在拥有一个宏功能,该功能可以知道程序使用的所有类。这就是DI框架可以提供的帮助。但是,如果您无法理解这种方法为何有价值,那么我建议您首先从手动依赖注入开始,这样您就可以更好地了解各种框架可以为您做什么。


7
为什么我更喜欢第二个代码而不是第一个代码?第一个只有new关键字,这有什么帮助?
user962206

17
@ user962206考虑如何独立于B
jk

66
@ user962206,还要考虑如果B在其构造函数中需要一些参数会发生什么:为了实例化它,A必须知道那些参数,这可能与A完全无关(它只想依赖于B ,而不取决于B所依赖的)。将已经构造的B(或该问题的B的任何子类或模拟)传递给A的构造函数可解决此问题,并使A仅依赖B :)
流行于2013年

17
@ acidzombie24:像许多设计模式一样,DI并不是真正有用的,除非您的代码库足够大以至于简单的方法成为问题。我的直觉是,在您的应用程序具有大约20,000行以上的代码和/或对其他库或框架的20种以上的依赖关系之前,DI实际上不会得到改善。如果您的应用程序小于此大小,则您可能仍然更喜欢以DI风格进行编程,但是差别不会那么大。
Daniel Pryden 2013年

2
@DanielPryden我认为代码大小并不像代码的动态性那么重要。如果您定期添加适合同一接口的新模块,则不必经常更改相关代码。
FistOfFury

35

正如其他答案所述,依赖注入是一种在使用它的类之外创建依赖的方法。您可以从外部注入它们,并从班级内部控制它们的创建。这也是为什么依赖项注入是控制反转(IoC)原理的实现。

IoC是原则,其中DI是模式。就我的经验来看,您可能“需要多个记录器”的原因从未真正得到满足,但实际原因是,无论何时进行测试,您确实需要它。一个例子:

我的特色:

当我查看报价时,我想标记为我已自动查看它,这样我就不会忘记这样做。

您可以这样测试:

[Test]
public void ShouldUpdateTimeStamp
{
    // Arrange
    var formdata = { . . . }

    // System under Test
    var weasel = new OfferWeasel();

    // Act
    var offer = weasel.Create(formdata)

    // Assert
    offer.LastUpdated.Should().Be(new DateTime(2013,01,13,13,01,0,0));
}

因此,在中的某处OfferWeasel,它会为您构建一个offer对象,如下所示:

public class OfferWeasel
{
    public Offer Create(Formdata formdata)
    {
        var offer = new Offer();
        offer.LastUpdated = DateTime.Now;
        return offer;
    }
}

这里的问题是,该测试很可能总是会失败,因为设置的日期将与声明的日期不同,即使您只是DateTime.Now输入测试代码,它也可能会延迟几毫秒,因此将被关闭。总是失败。现在,更好的解决方案是为此创建一个界面,该界面可让您控制要设置的时间:

public interface IGotTheTime
{
    DateTime Now {get;}
}

public class CannedTime : IGotTheTime
{
    public DateTime Now {get; set;}
}

public class ActualTime : IGotTheTime
{
    public DateTime Now {get { return DateTime.Now; }}
}

public class OfferWeasel
{
    private readonly IGotTheTime _time;

    public OfferWeasel(IGotTheTime time)
    {
        _time = time;
    }

    public Offer Create(Formdata formdata)
    {
        var offer = new Offer();
        offer.LastUpdated = _time.Now;
        return offer;
    }
}

接口是抽象的。一种是真实的东西,另一种是让您伪造需要的时间。然后可以像下面这样更改测试:

[Test]
public void ShouldUpdateTimeStamp
{
    // Arrange
    var date = new DateTime(2013, 01, 13, 13, 01, 0, 0);
    var formdata = { . . . }

    var time = new CannedTime { Now = date };

    // System under test
    var weasel= new OfferWeasel(time);

    // Act
    var offer = weasel.Create(formdata)

    // Assert
    offer.LastUpdated.Should().Be(date);
}

这样,通过注入依赖项(获取当前时间)来应用“控制反转”原理。这样做的主要原因是为了简化隔离的单元测试,还有其他方法可以做到。例如,这里不需要接口和类,因为在C#中,函数可以作为变量传递,所以可以使用a代替接口 Func<DateTime>来实现相同的功能。或者,如果您采用动态方法,则只需传递具有等效方法的任何对象(鸭子类型),并且根本不需要接口。

您几乎不需要多个记录器。但是,依赖注入对于诸如Java或C#的静态类型代码至关重要。

并且... 还应该注意,如果对象的所有依赖项都可用,则该对象只能在运行时正确地实现其目的,因此在设置属性注入时没有太多用处。我认为,在调用构造函数时应满足所有依赖关系,因此必须使用构造函数注入。

希望对您有所帮助。


4
这实际上看起来是一个糟糕的解决方案。我肯定会写更像Daniel Pryden答案建议的代码,但是对于特定的单元测试,我只需要执行DateTime.Now,然后在函数之前和之后检查时间是否在两者之间?添加更多的接口/更多的代码行对我来说似乎不是一个好主意。

3
我不喜欢通用的A(B)示例,而且我从未觉得记录器必须具有100个实现。这是我最近遇到的一个示例,它是解决该问题的5种方法之一,其中一种方法实际上是使用PostSharp包括的。它说明了基于经典类的ctor注入方法。您能否提供一个更好的现实世界示例,说明您在哪里遇到了DI的良好用法?
2013年

2
我从未见过DI的好用处。那就是为什么我写这个问题。

2
我没有发现有帮助。我的代码总是很容易做测试。看起来,DI对于具有不良代码的大型代码库而言是好的。

1
请注意,即使在小型函数程序中,每当您拥有f(x),g(f)时,您就已经在使用依赖项注入,因此JS中的每个延续都将被视为依赖项注入。我的猜测是您已经在使用它;)
2013年

15

我认为经典的答案是创建一个更加解耦的应用程序,该应用程序不知道在运行时将使用哪种实现。

例如,我们是一家中央支付提供商,与世界各地的许多支付提供商合作。但是,发出请求时,我不知道该打电话给哪个付款处理器。我可以用大量的开关盒来编程一个类,例如:

class PaymentProcessor{

    private String type;

    public PaymentProcessor(String type){
        this.type = type;
    }

    public void authorize(){
        if (type.equals(Consts.PAYPAL)){
            // Do this;
        }
        else if(type.equals(Consts.OTHER_PROCESSOR)){
            // Do that;
        }
    }
}

现在,假设您现在需要将所有这些代码维护在一个类中,因为它们未正确解耦,您可以想象,对于要支持的每个新处理器,都需要创建一个新的if // switch case每种方法只会变得更加复杂,但是,通过使用依赖注入(或有时称为控制反转-有时称为控制反转,这意味着只有在运行时才知道控制程序运行的人,而不是复杂的人),您可以实现一些目标非常整洁和可维护。

class PaypalProcessor implements PaymentProcessor{

    public void authorize(){
        // Do PayPal authorization
    }
}

class OtherProcessor implements PaymentProcessor{

    public void authorize(){
        // Do other processor authorization
    }
}

class PaymentFactory{

    public static PaymentProcessor create(String type){

        switch(type){
            case Consts.PAYPAL;
                return new PaypalProcessor();

            case Consts.OTHER_PROCESSOR;
                return new OtherProcessor();
        }
    }
}

interface PaymentProcessor{
    void authorize();
}

**代码不会编译,我知道:)


+1,因为听起来您是在使用虚拟方法/接口的地方需要它。但是那仍然很少。我仍然会以new ThatProcessor()使用框架的方式传递它

@ItaiS您可以避免使用类工厂设计模式进行无数次切换。使用反射System.Reflection.Assembly.GetExecutingAssembly()。CreateInstance()
domenicr 2015年

@domenicr当然!但我想解释一下一个简单的例子
板井鹭

除了工厂级的要求外,我同意上述解释。从我们实施工厂类的那一刻起,它只是一个粗略的选择。我在Bruce Erkel的“同态与虚函数”一章中找到了上面的最佳解释。真正的DI应该没有选择,并且对象类型应在运行时通过界面自动确定。这也是真正的多态行为。
Arvind Krmar '16

例如(根据c ++),我们有一个公共接口,该接口仅获取对基类的引用,并且无需选择即可实现其派生类的行为。void tune(Instrument&i){i.play(middleC); } int main(){风笛;调(长笛); }乐器是基类,风是从它衍生出来的。根据c ++,虚函数使通过公共接口实现派生类的行为成为可能。
Arvind Krmar '16

6

使用DI的主要原因是您希望将实现知识的责任放在存在知识的地方。DI的想法非常符合接口封装和设计。如果前端从后端请求一些数据,那么后端如何解决该问题对前端来说并不重要。这取决于请求处理程序。

长期以来,这在OOP中已经很普遍。很多时候创建代码段,例如:

I_Dosomething x = new Impl_Dosomething();

缺点是实现类仍是硬编码的,因此前端具有使用哪种实现的知识。DI通过接口使设计更进一步,前端唯一需要了解的就是接口的知识。在DYI和DI之间是服务定位器的模式,因为前端必须提供一个密钥(位于服务定位器的注册表中)才能解决其请求。服务定位器示例:

I_Dosomething x = ServiceLocator.returnDoing(String pKey);

DI示例:

I_Dosomething x = DIContainer.returnThat();

DI的要求之一是容器必须能够找出哪个类是哪个接口的实现。因此,DI容器是否需要强类型设计,并且每个接口只能同时使用一种实现。如果同时需要更多接口实现(例如计算器),则需要服务定位器或工厂设计模式。

D(b)I:依赖注入和按接口设计。但是,这种限制并不是一个很大的实际问题。使用D(b)I的好处是它为客户端和提供者之间的通信提供服务。界面是对一个对象或一组行为的透视图。后者在这里至关重要。

我更喜欢在编码中与D(b)I一起管理服务合同。他们应该一起去。在我看来,在没有组织服务合同管理的情况下将D(b)I用作技术解决方案并不是很有好处,因为DI只是封装的额外一层。但是,当您将其与组织管理一起使用时,您实际上可以利用D(b)I提供的组织原理。从长远来看,它可以帮助您构建与客户和其他技术部门在测试,版本控制和替代产品开发等方面的沟通。当您具有硬编码类中的隐式接口时,与使用D(b)I使其显式时相比,随着时间的推移,它的可传递性大大降低了。一切都归结为维护,这是随着时间的推移而不是一次完成的。:-)


1
“缺点是实现类仍然是硬编码的” <-大多数时候,只有一个实现,就像我说的那样,我无法想到非游戏代码需要一个尚未内置的接口(.NET )。

@ acidzombie24可能是...但是,如果需要使用接口,可以将从一开始就使用DI实现解决方案的努力与以后更改非DI解决方案的努力进行比较。我几乎总是选择第一个选项。最好现在支付100美元,而不是明天必须支付100.000美元。
Golo Roden

1
@GoloRoden实际上,使用D(b)I之类的技术来维护是关键问题。那是应用程序成本的80%。从一开始就使用界面明确要求的行为的设计可以为组织节省大量时间和金钱。
Loek Bergman

在必须付款之前,我不会真正理解,因为到目前为止,我已经支付了$ 0,而到目前为止我仍然只需要支付$ 0。但是我确实要支付$ 0.05来保持每条线或函数的清洁。
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.