583

我试图理解依赖注入(DI),但我又一次失败了。只是看起来很傻。我的代码从不乱七八糟;我几乎不写虚函数和接口(尽管我曾经做过一次),而且我的所有配置都使用 json.net 神奇地序列化为一个类(有时使用 XML 序列化程序)。

我不太明白它解决了什么问题。它看起来像是在说:“嗨。当你遇到这个函数时,返回一个这种类型的对象并使用这些参数/数据。”
但是...我为什么要使用它?请注意,我也从未需要使用object过,但我了解它的用途。

在构建使用 DI 的网站或桌面应用程序时,有哪些实际情况?我可以很容易地想出一些案例来解释为什么有人可能想在游戏中使用接口/虚拟函数,但是在非游戏代码中使用它是极其罕见的(非常罕见以至于我记不起一个实例)。

4

6 回答 6

892

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

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

(其实也有名词的接口,但我想在这里概括一下。)

因此,例如,接口可能是诸如IDisposableIEnumerable或之类的东西IPrintable。一个类是这些接口中的一个或多个的实际实现:List或者Map两者都可以是IEnumerable.

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

到现在为止还挺好。

您可以Database使用以下行在您的类中对此依赖项进行建模:

var logger = new Logger();

一切都很好。当你意识到你需要一堆记录器时,这很好:有时你想记录到控制台,有时记录到文件系统,有时使用 TCP/IP 和远程记录服务器,等等......

当然,您不想更改所有代码(同时您拥有大量代码)并替换所有行

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类现在有一个需要类型参数的构造函数ICanLog

public Database(ICanLog logger) { ... }

现在你的数据库总是有一个记录器可以使用,但它不再知道这个记录器来自哪里。

这就是 DI 框架发挥作用的地方:您再次配置映射,然后要求 DI 框架为您实例化您的应用程序。由于Application该类需要一个ICanPersistData实现,Database因此注入了一个实例——但为此它必须首先创建一个为ICanLog. 等等 ...

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

在实践中,有些事情没有服务定位器是无法做到的(例如,如果您事先不知道特定接口需要多少个实例:DI 框架总是为每个参数注入一个实例,但您可以调用一个循环内的服务定位器,当然),因此大多数情况下每个 DI 框架也提供一个服务定位器。

但基本上,就是这样。

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

于 2013-01-13T07:21:33.747 回答
548

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

依赖注入是一个非常简单的概念。而不是这段代码:

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 框架可以提供帮助的地方。但是,如果您无法理解为什么这种方法很有价值,我建议您首先从手动依赖注入开始,这样您就可以更好地了解现有的各种框架可以为您做什么。

于 2013-01-13T08:22:29.607 回答
40

正如其他答案所述,依赖注入是一种在使用它的类之外创建依赖项的方法。您从外部注入它们,并从课堂内部控制它们的创建。这也是为什么依赖注入是控制反转(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,它会为你构建一个这样的报价对象:

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# 等静态类型代码来说是必不可少的。

并且... 还应该注意的是,一个对象只有在其所有依赖项都可用的情况下才能在运行时正确地实现其目的,因此设置属性注入并没有太大用处。在我看来,当构造函数被调用时,所有的依赖都应该得到满足,所以构造函数注入是可以使用的。

于 2013-01-13T12:20:09.370 回答
15

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

例如,我们是一家中央支付提供商,与世界各地的许多支付提供商合作。但是,当提出请求时,我不知道要调用哪个支付处理器。我可以用大量的 switch case 编写一个类,例如:

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

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

于 2013-01-13T14:01:14.187 回答
7

使用 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 使其显式时相比,随着时间的推移,它的可通信性会大大降低。这一切都归结为维护,这是随着时间的推移而不是一次。:-)

于 2013-01-13T09:46:12.657 回答
3

坦率地说,我相信人们使用这些依赖注入库/框架是因为他们只知道如何在运行时做事,而不是加载时间。所有这些疯狂的机器都可以通过设置您的CLASSPATH环境变量(或其他语言等效项,如PYTHONPATH, LD_LIBRARY_PATH)来指向特定类的替代实现(都具有相同的名称)。因此,在接受的答案中,您只需留下您的代码

var logger = new Logger() //sane, simple code

并且适当的记录器将被实例化,因为 JVM(或您拥有的任何其他运行时或 .so 加载器)将从通过上述环境变量配置的类中获取它。

不需要让所有东西都成为接口,不需要疯狂地产生破碎的对象来注入东西,不需要疯狂的构造函数将每一块内部机器暴露给世界。只需使用您正在使用的任何语言的本机功能,而不是想出在任何其他项目中都不起作用的方言。

PS:这也适用于测试/模拟。您可以很好地设置您的环境以在加载时加载适当的模拟类,并跳过模拟框架的疯狂。

于 2020-08-31T16:21:08.280 回答