质疑依赖关系注入框架的论点之一:为什么创建对象图很难?


13

像Google Guice这样的依赖注入框架为其使用(来源)提供了以下动机:

要构造一个对象,首先要建立它的依赖关系。但是要构建每个依赖项,您需要其依赖项,依此类推。因此,在构建对象时,确实需要构建对象图。

手工构建对象图是劳动密集型(...),并且使测试变得困难。

但是我不赞成这种观点:即使没有依赖注入框架,我也可以编写易于实例化且易于测试的类。例如,Guice动机页面中的示例可以用以下方式重写:

class BillingService
{
    private final CreditCardProcessor processor;
    private final TransactionLog transactionLog;

    // constructor for tests, taking all collaborators as parameters
    BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
    {
        this.processor = processor;
        this.transactionLog = transactionLog;
    }

    // constructor for production, calling the (productive) constructors of the collaborators
    public BillingService()
    {
        this(new PaypalCreditCardProcessor(), new DatabaseTransactionLog());
    }

    public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard)
    {
        ...
    }
}

因此,对于依赖项注入框架可能还有其他参数(在此问题范围之外!),但是容易创建可测试对象图不是其中之一,不是吗?


1
我认为,依赖注入的另一个常被忽略的优点是,它迫使您知道对象所依赖的对象。在对象中没有任何神奇的事物出现,您可以注入显式标记的属性,也可以直接通过构造函数进行注入。更不用说它如何使您的代码更具可测试性。
本杰明·格林巴姆2014年

请注意,我并不是在寻找依赖注入的其他优点-我只想了解“没有依赖注入就很难实现对象实例化”的论点
敬请见谅2014年

这个答案似乎提供了一个很好的例子,说明为什么要为对象图提供某种容器(简而言之,仅使用3个对象,您的想法就不够大了)。
Izkata 2014年

@Izkata不,这与大小无关。使用上述方法,该帖子中的代码将仅为new ShippingService()
2014年

@oberlies仍然是;然后,您必须挖掘9个类定义,以找出该ShippingService使用的是哪个类,而不是一个位置
Izkata 2014年

Answers:


12

关于进行依赖项注入的最佳方法的争论一直在进行着,古老而又古老的讨论。

  • spring的原始剪切实例化了一个普通对象,然后通过setter方法注入了依赖项。

  • 但是随后,一大群人坚持认为,通过构造函数参数注入依赖项是正确的方法。

  • 然后,近来,随着使用反射变得越来越普遍,直接设置私有成员的值而没有设置器或构造函数args成为了流行。

因此,您的第一个构造函数与依赖项注入的第二种方法一致。它使您可以做一些不错的事情,例如注入用于测试的模拟。

但是无参数构造函数存在此问题。由于实例化了和的实现类,因此在PayPal和数据库上创建了硬的,编译时依赖项。它负责正确地构建和配置整个依赖关系树。PaypalCreditCardProcessorDatabaseTransactionLog

  • 想象一下,PayPay处理器是一个非常复杂的子系统,并且另外引入了许多支持库。通过在该实现类上创建编译时依赖关系,您可以创建到整个依赖关系树的牢不可破的链接。对象图的复杂性刚刚增加了一个数量级,可能是两个。

  • 依赖关系树中的许多项将是透明的,但是其中许多也将需要实例化。奇怪的是,您将无法仅实例化一个PaypalCreditCardProcessor

  • 除了实例化,每个对象都需要从配置中应用属性。

如果您仅对接口具有依赖关系,并允许外部工厂构建和注入依赖关系,则它们会砍掉整个PayPal依赖关系树,并且代码的复杂性将在接口处停止。

还有其他好处,例如在配置时(即在运行时而不是编译时)指定实现类,或者具有更具动态依赖性的规范,该规范随环境(测试,集成,生产)而变化。

例如,假设PayPalProcessor有3个从属对象,而每个从属对象又有两个。所有这些对象都必须从配置中提取属性。原样的代码将负责构建所有内容,从配置中设置属性等,等等-DI框架将处理的所有问题。

乍一看,使用DI框架保护自己不受什么侵害似乎并不明显,但随着时间的推移,它加起来并变得很痛苦。(大声笑,我是从尝试过艰苦的尝试中得出的经验)

...

在实践中,即使对于一个很小的程序,我也发现我最终以DI风格编写,并将这些类分解为实现/工厂对。也就是说,如果我不使用像Spring这样的DI框架,那么我只是将一些简单的工厂类组合在一起。

这提供了关注点的分离,以便我的班级可以做到这一点,而工厂班级则负责构建和配置内容。

不是必需的方法,而是FWIW

...

更一般而言,DI /接口模式通过执行以下两项操作来降低代码的复杂性:

  • 将下游依赖关系抽象为接口

  • 将上游依赖项“提升”出代码并放入某种容器中

最重要的是,由于对象实例化和配置是一项非常熟悉的任务,因此DI框架可以通过标准化表示法和使用诸如反射之类的技巧来实现很多规模经济。将这些相同的问题分散在类周围最终会导致混乱程度超出人们的想象。


该代码将承担构建所有内容的责任 -类仅负责了解其协作者实现是什么。不需要知道这些合作者又需要什么。所以这还不算什么。
2014年

1
但是,如果PayPalProcessor构造函数具有2个参数,那么这些参数将从何而来?
罗布

没有。就像BillingService一样,PayPalProcessor具有一个零参数的构造函数,该构造函数可创建所需的协作者。
2014年

将下游依赖关系抽象为接口 -示例代码也这样做。处理器字段的类型为CreditCardProcessor,而不是实现类型。
2014年

2
@oberlies:您的代码示例横跨两种样式,即构造函数参数样式和零参数可构造样式。谬论是,零参数可构造并非总是可能的。DI或Factory的优点在于,它们以某种方式允许使用看起来像零参数构造函数的对象进行构造。
rwong 2014年

4
  1. 当您在游泳池的浅水处游泳时,一切都会变得“轻松便捷”。一旦超过十几个对象,就不再方便了。
  2. 在您的示例中,您的帐单流程已永久永久地绑定到PayPal。假设您要使用其他信用卡处理器?假设您要创建受网络限制的专用信用卡处理器?还是需要测试信用卡号处理方式?您已经创建了不可移植的代码:“编写一次,只能使用一次,因为它取决于为其设计的特定对象图。”

通过在过程的早期绑定对象图(即,将其硬接线到代码中),您需要同时存在合同和实现。如果其他人(甚至是您)想要将该代码用于稍微不同的用途,则他们必须重新计算整个对象图并重新实现它。

DI框架允许您使用一堆组件,并在运行时将它们连接在一起。这使系统成为“模块化”,由许多模块组成,这些模块可用于彼此的接口而不是彼此的实现。


当我遵循您的论点时,DI的理由应该是“手动创建对象图很容易,但是导致难以维护的代码”。但是,有人声称“手工创建对象图很困难”,而我只是在寻找支持该主张的论点。因此,您的帖子没有回答我的问题。
2014年

1
我想如果你减少问题“创建一个对象图...”,那么它很简单。我的观点是,DI解决了您永远不会只处理一个对象图的问题。你与他们的家人打交道。
BobDalgleish 2014年

实际上,如果共享协作者,仅创建一个对象图就已经很棘手
2014年

4

“实例化我自己的协作者”方法可能适用于依赖关系,但对于依赖关系图(通常是有向无环图(DAG))肯定不能很好地工作。在依赖项DAG中,多个节点可以指向同一个节点-这意味着两个对象将同一个对象用作协作者。实际上,无法使用问题中描述的方法来构造这种情况。

如果我的某些协作者(或协作者的协作者)应该共享某个对象,则需要实例化该对象并将其传递给我的协作者。因此,实际上,除了直接合作者之外,我还需要了解更多信息,而且这显然无法扩展。


1
但是从图论的意义上讲,依赖树必须是一棵树。否则,您将有一个循环,这简直无法满足。
锡安

1
@Xion依赖图必须是有向无环图。不需要像在树中那样在两个节点之间只有一条路径。
2014年

@Xion:不一定。考虑UnitOfWork具有单个DbContext和多个存储库的。所有这些存储库都应使用相同的DbContext对象。使用OP提出的“自我实例化”,这变得不可能。
扁平的

1

我没有使用Google Guice,但花了很多时间将.Net中的旧的旧N层应用程序迁移到IoC架构(如依赖依赖项解耦关系的Onion Layer)。

为什么要进行依赖注入?

依赖注入的目的实际上并不是为了可测试性,而是要采取紧密耦合的应用程序并尽可能地放松耦合。(这是使您的代码更容易适应适当的单元测试的理想产品)

我为什么要担心耦合问题?

耦合或紧密的依赖关系可能是非常危险的事情。(尤其是在编译语言中)在这种情况下,您可能拥有很少使用的库,dll等,而该库,dll等的问题实际上使整个应用程序脱机。(您的整个应用程序之所以死亡,是因为一个不重要的部分有问题……这很糟糕……真的很糟糕)现在,当您取消耦合时,您实际上可以设置应用程序,以便即使该DLL或库完全丢失,它也可以运行!确保需要该库或DLL的那一部分无法正常工作,但其余的应用程序会尽其所能。

为什么我需要依赖注入来进行正确的测试

真的,您只需要松散耦合的代码,就可以通过Dependency Injection实现。您可以在没有IoC的情况下松散耦合,但是通常这会增加工作量和适应性(我敢肯定有人会例外)

在您给出的情况下,我认为仅设置依赖项注入会容易得多,因此我可以模拟不感兴趣的代码作为该测试的一部分。只需告诉您方法“嘿,我知道我告诉过您调用存储库,但是现在这里的数据是“应该”返回眨眼 ”,因为该数据永不更改,您知道您只是在测试使用该数据的部件,而不是实际检索数据。

基本上,在进行测试时,您需要进行从头到尾测试一项功能的集成测试(功能测试),以及分别测试每段代码(通常在方法或功能级别)的完整单元测试。

想法是要确保整个功能都正常运行,否则,您想知道不起作用的确切代码。

CAN而不依赖注入来完成,但通常是你的项目的发展变得越来越繁琐到位,这样做没有依赖注入。(总是假设您的项目会成长!最好不要进行有用的技能练习,而不是找到一个快速启动的项目,并且在事情已经开始后需要进行认真的重构和重新设计。)


1
实际上,使用大部分而非全部依赖关系图编写测试对于DI来说是一个很好的论据。
2014年

1
值得注意的是,DI可以以编程方式轻松地交换出整个代码块。我见过这样的情况:系统将清除并重新注入一个完全不同的类的接口,以响应远程服务的中断和性能问题。也许有更好的方法来处理它,但效果出奇的好。
RualStorge 2014年

0

正如我在另一个答案中提到的那样,这里的问题是,您希望类A依赖于某个类,B而不用硬编码将哪个B用于A的源代码中。在Java和C#中这是不可能的,因为这是导入类的唯一方法是用全球唯一的名称来引用它。

使用接口可以解决硬编码的类依赖关系,但是您仍然需要动手使用接口的实例,并且不能调用构造函数,或者回到第1方。否则可能会建立依赖关系,从而将这种责任推给其他人。它的依赖关系也做同样的事情。因此,现在每次您需要一个类的实例时,都需要手动构建整个依赖关系树,而在类A直接依赖于B的情况下,您可以直接调用new A()并让该构造函数调用new B(),依此类推。

依赖项注入框架试图通过允许您指定类之间的映射并为您构建依赖关系树来解决此问题。问题是,当您搞砸了映射时,您会在运行时而不是像在支持映射模块作为一流概念的语言中那样在编译时发现。


0

我认为这是一个很大的误解。

Guice是一个依赖项注入框架。它使DI 自动执行。他们在您引用的摘录中指出的一点是,Guice无需手动创建示例中显示的“可测试的构造函数”。它与依赖注入本身绝对无关。

此构造函数:

BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
{
    this.processor = processor;
    this.transactionLog = transactionLog;
}

已经使用依赖注入。您基本上只是说使用DI很容易。

Guice解决的问题是,要使用该构造函数,您现在必须在某处有一个对象图构造函数代码,手动将已实例化的对象作为该构造函数的参数传递。通过Guice,您可以在一个地方配置与之CreditCardProcessorTransactionLog接口相对应的实际实现类。配置完成后,每次BillingService使用Guice 创建时,这些类都会自动传递给构造函数。

这就是依赖注入框架的作用。但是您介绍的构造函数本身已经是依赖注入原理的实现。IoC容器和DI框架是使相应原理自动化的手段,但是没有什么可以阻止您手动做所有事情,这就是重点。

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.