c# - 为什么要使用依赖注入?


我试图理解依赖注入 (DI),并再次失败。 这似乎很愚蠢。 我的代码从来不是一团糟。 我几乎不写虚拟函数和接口(虽然我只做了一次蓝月亮),所有的配置都被神奇地用json.net(有时使用XML序列化器)序列化成一个类。

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

在建立一个使用DI的网站或桌面应用程序时,有什么真实的情况? 我可以很容易地想出为什么有人可能想要在游戏中使用接口/虚函数的情况,但是在非游戏代码中使用非常罕见(难以记忆单个实例)。



Answers


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

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

(实际上,也有名词的界面,但我想在这里概括一下。)

所以,例如一个接口可能是诸如IDisposableIEnumerableIPrintable 。 一个类是一个或多个这些接口的实际实现: ListMap可能都是IEnumerable实现。

为了得到这个观点:通常你的课程是相互依赖的。 例如,你可以有一个访问数据库的Database类(hah,surprise!;-)),但是你也希望这个类做访问数据库的日志记录。 假设你有另一个类Logger ,那么DatabaseLogger有一个依赖。

到现在为止还挺好。

您可以使用以下行在您的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配置文件,现在他已经日志启用,您的支持可以使用日志文件来帮助您的客户。

而现在,当你取代一些名字时,你最终得到了一个服务定位器的简单实现,它是反转控制的两种模式之一 (因为你反过来控制谁决定实例化什么类)。

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

依赖注入现在是这一行的下一步:只要摆脱这个单一的依赖到服务定位器:而不是各种各样的类要求服务定位器的具体接口的实现,你再次 - 控制谁实例化什么。

使用依赖注入,你的Database类现在有一个构造函数,需要一个ICanLog类型的参数:

public Database(ICanLog logger) { ... }

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

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

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

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

但基本上就是这样。

希望有所帮助。

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




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

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

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框架可以帮助的。 但是如果你不明白为什么这个方法是有价值的,我建议首先从手动依赖注入入手,这样你可以更好地理解那里的各种框架可以为你做什么。




正如其他答案所述,依赖注入是一种在使用它的类之外创建依赖关系的方法。 你从外面注入他们,并把他们的创作从班级里面拿走。 这也是为什么依赖注入是控制反转 (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#函数中可以作为变量传递,所以可以使用Func<DateTime>来实现相同的接口。 或者,如果采取动态方法,只需传递任何具有等效方法的对象( 鸭子键入 ),并且根本不需要接口。

你几乎不需要多于一个记录器。 尽管如此,依赖注入对于静态类型代码(例如Java或C#)是必不可少的。

而且......还应该注意的是,一个对象只能在运行时正确地实现它的目的,如果它的所有依赖关系都可用的话,那么在建立属性注入方面就没有多少用处。 在我看来,当构造函数被调用时,所有的依赖关系都应该被满足,所以构造函数注入是一个需要解决的问题。

我希望有所帮助。




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

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

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每一种方法,只是通过使用依赖注入(或者控制反转 - 因为它有时被称为,意味着控制程序运行的人只有在运行时才知道,而不是并发症),这只会变得更加复杂。非常整洁和可维护。

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

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




使用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)。 从长远来看,它可以帮助您与客户和其他技术部门就测试,版本和替代方案的开发等主题进行沟通。 当你在硬编码类中有一个隐式接口的时候,那么当你使用D(b)I进行显式定义的时候,它是否会随着时间的推移而少得多地传播。 这一切都归结为维修,这是随着时间的推移,而不是一次。 :-)