有没有替代“杂种注入”的方法?(也称作通过默认构造函数进行的“穷人注入”)

121

我最常被诱惑在一些情况下使用“混合注入”。当我有一个“适当的”依赖注入构造函数时:

public class ThingMaker {
    ...
    public ThingMaker(IThingSource source){
        _source = source;
    }

但对于我打算作为公共API的类(其他开发团队将使用的类),我从来找不到比写一个带有最有可能需要的依赖项的默认“流氓”构造函数更好的选择:

    public ThingMaker() : this(new DefaultThingSource()) {} 
    ...
}
显然,这里的缺点是会创建一个静态依赖于DefaultThingSource的类。理想情况下,消费者应该总是可以注入他们想要的IThingSource。然而,这太难用了;消费者希望新建一个ThingMaker并开始制作东西,几个月之后再需要时注入其他内容。在我看来,这只留下了几个选择:
1.省略构造函数,强制ThingMaker的消费者理解IThingSource,并理解ThingMaker如何与IThingSource交互,然后找到或编写一个具体类,在其构造函数中调用该实例来注入。
2.省略构造函数并提供单独的工厂、容器或其他引导类/方法;以某种方式让消费者知道他们无需编写自己的IThingSource;强制ThingMaker的消费者查找和理解工厂或引导程序,并使用它。
3.保留构造函数,使消费者能够“new up”一个对象并运行它,并处理对DefaultThingSource的可选静态依赖。
嗯,第三个选择似乎很有吸引力。还有其他更好的选择吗?选项1或2似乎不值得这样做。

1
为什么依赖于DefaultThingSource比依赖于IThingSource更糟糕?选择第三个方案。 - Fernando
11
由于DefaultThingSource可能是一个非常具体的实现,依赖于另一个程序集中的外部系统,这个系统在几个月后可能会过时;IThingSource永远是正确的,不会与特定的实现绑定。 - Patrick Szalapski
显然我不知道DefaultThingSource有多复杂。但是,我敢打赌,如果你保持简单并设计得好,实际上你会发现你的代码不会像你担心的那样很快过时 - 当然不会很快到需要这么多痛苦来证明它。 - Fernando
2
这就是好的依赖反转所面临的问题 - 它可能更灵活,设计更好,但对于新手程序员来说并不容易使用。 - Phil
请注意,这种做法也被称为“穷人的依赖注入”。 - JCallico
显示剩余3条评论
12个回答

64
据我理解,这个问题涉及如何公开一个具有适当默认值的松耦合API。在这种情况下,您可能拥有一个良好的本地默认值,在这种情况下,依赖项可以视为可选的。处理可选依赖项的一种方法是使用属性注入而不是构造函数注入 - 实际上,这是属性注入的典型场景。
然而,Bastard Injection真正危险的情况是当默认值是外部默认值时,因为这意味着默认构造函数会带来一种不必要的与实现默认值的程序集的耦合。据我所知,然而,这个问题的预期默认值将源自同一程序集,在这种情况下,我并没有看到任何特别的危险。
无论如何,您还可以考虑一个门面,就像我之前的一个答案中描述的那样:Dependency Inject (DI) "friendly" library 顺便说一句,这里使用的术语基于我的书中的模式语言。

3
+1 这个友好的 DI 库回应在这个领域绝对是必读的,并将使您能够从第一原理开始深入思考这些问题。 - Ruben Bartelink
2
对于本地默认值和外部默认值之间的区别有一个好的思路。在这种情况下,确实是一个本地默认值,尽管消费者可能有理由去覆盖它。在这种情况下,提供两个构造函数暗示着“使用默认值或注入你自己的值”的最简单方式。仅使用一个默认构造函数进行属性注入并不足以宣传可注入的依赖关系。您同意吗? - Patrick Szalapski
4
我同意在这种情况下使用过载构造函数是可以的。我不认为属性注入不足以宣传用户可以覆盖依赖项,因为它是一个众所周知的模式,但我承认过载构造函数稍微更加显眼 :) - Mark Seemann
5
非常推荐@MarkSeemann的书,特别是对于依赖注入和控制反转的问题。 - Nick Hodges
@MarkSeemann - 关于门面模式,我很难理解的是我所做的只是将该死的注入移动到另一个类中的想法。您认为在配置文件中声明默认依赖项并通过延迟绑定加载它的想法如何? - zomf
1
@zomf 强制库用户使用配置文件设置并不是一种特别友好的方法。它将客户端锁定为使用配置系统,这是不灵活的。如果您想从数据库而不是配置文件加载配置值怎么办?如果您想在单元测试中改变配置值怎么办?如果您想通过UI使配置值可配置怎么办?...顺便说一句,我在这里扩展了DI友好的库建议,包括更多的代码示例:http://blog.ploeh.dk/2014/05/19/di-friendly-library。 - Mark Seemann

33

我的权衡是对@BrokenGlass的改编:

1)唯一的构造函数是参数化构造函数

2)使用工厂方法创建一个ThingMaker并传递默认源。

public class ThingMaker {
  public ThingMaker(IThingSource source){
    _source = source;
  }

  public static ThingMaker CreateDefault() {
    return new ThingMaker(new DefaultThingSource());
  }
}
显然,这并没有消除你的依赖性,但它确实让我更清楚地知道这个对象有依赖项,如果调用者想要深入了解依赖关系,可以深入了解。如果需要,您可以使该工厂方法更加明确(CreateThingMakerWithDefaultThingSource),以帮助理解。 我比覆盖IThingSource工厂方法更喜欢这种方法,因为它继续支持组合。 当DefaultThingSource过时时,您还可以添加一个新的工厂方法,并有明确的方式找到所有使用DefaultThingSource的代码并标记其进行升级。

你已经在问题中列出了可能性。对于方便起见,可以将工厂类放在其他地方,或在类本身内部提供一些方便性。唯一不太可取的选项是基于反射,进一步隐藏依赖性。


3
我还是不理解为什么这比默认构造函数好 - 它具有所有缺点,没有额外的好处;它只会让依赖关系对于任何阅读代码的人更加难以理解。 - Patrick Szalapski
18
在我看来,使用工厂方法比使用默认构造函数更能明确表达依赖关系。这个类的设计方式似乎表明这个依赖关系很重要(它是在构造函数中注入的),所以我会让它更加明确。默认构造函数告诉我:“不用担心这个依赖关系,我只创建了一个额外的构造函数用于测试或单次情况”。而工厂方法告诉我:“有一个依赖关系需要注意,但这里是你最可能需要的实现”。 - Steve Jackson
2
这个不应该是大写字母C的“CreateDefault”吗? - Hinek
2
这个解决方案没有清楚地说明这个类的使用方式。当你第一次使用一个类时,你首先看的是构造函数,如果我只看到一个构造函数,我会认为我必须提供一个IThingSource实例才能使用ThingMaker。如果我是这样一个类的用户,很可能我永远不会发现CreateDefault方法。 - JCallico
我还是不太信服,史蒂夫。虽然点赞数可能表明我错了,但我认为拥有默认构造函数并不意味着“我只创建了一个额外的构造函数用于测试或一次性情况”。相反,我认为它意味着“使用默认值或注入你自己的值”。你还说过默认构造函数意味着“不用担心这个依赖项”,我同意这一点。如果他们不想担心其他问题,他们可以使用默认构造函数。 - Patrick Szalapski
1
@Patrick - 好的。如果您想更加强调依赖关系,则这只是一种替代方法。在这些决策中总是存在权衡,这实际上是一个问题——您想把重点放在哪里。如果DefaultThingSource对于大多数调用者来说是主要选项,那么我建议在默认构造函数中使用它。如果您希望调用者考虑提供自己的IThingSource,那么我认为这是可以接受的折衷方案。如果您想强制用户提供IThingSource,那么我开始倾向于使用工厂类。 - Steve Jackson

8

一种替代方案是在您的ThingMaker类中拥有一个名为CreateThingSource()工厂方法,它可以为您创建依赖项。

如果您需要进行测试或确实需要另一种类型的IThingSource,那么您将不得不创建ThingMaker的子类并重写CreateThingSource()以返回您想要的具体类型。显然,这种方法只有在您主要需要能够注入依赖项进行测试时才值得使用,但对于大多数/所有其他目的,您不需要另一个IThingSource


4
我考虑过这个选项,但它具有私生子构造函数选项的所有缺点,却没有任何好处!对吗? - Patrick Szalapski
1
大部分同意 - 我确实使用默认构造函数来创建“默认”依赖项,并且认为这没有任何问题。但是,这种情况不同,因为它允许您在任何构造函数中都不需要该依赖项 - 这样您就可以避免另一个所谓的“混蛋构造函数”,从而使公共接口更易于解析。但是,只有在更改依赖关系仅与类的可测试性相关时,才有意义。 - BrokenGlass

6
如果您必须有一个“默认”依赖项,也称为贫民依赖注入,则必须在某个地方初始化和“连接”依赖项。
我将保留这两个构造函数,但是专门为初始化添加了一个工厂。
public class ThingMaker
{
    private IThingSource _source;

    public ThingMaker(IThingSource source)
    {
        _source = source;
    }

    public ThingMaker() : this(ThingFactory.Current.CreateThingSource())
    {
    }
}

现在在工厂中创建默认实例并允许覆盖该方法:

public class ThingFactory
{
    public virtual IThingSource CreateThingSource()
    {
        return new DefaultThingSource();
    }
}

更新:

为什么使用两个构造函数: 使用两个构造函数可以清晰地展示该类的使用方式。无参数构造函数表示:只需创建实例,该类将执行其所有职责。现在第二个构造函数说明该类依赖于IThingSource,并提供了一种使用不同于默认实现的实现。

为什么使用工厂模式: 1- 纪律性:创建新实例不应该是该类的职责,使用工厂类更加合适。 2- DRY原则:想象一下,在同一个API中,其他类也依赖于IThingSource并且做着相同的事情。只需一次重写返回IThingSource的工厂方法,您的API中的所有类都会自动开始使用新实例。

我认为将ThingMaker与IThingSource的默认实现耦合起来并没有问题,只要这个实现对整个API有意义,并且您提供了覆盖此依赖项以进行测试和扩展目的的方法。


1
那么为什么还要有工厂呢?直接把逻辑放在ThingMaker默认构造函数中不就行了。 - Patrick Szalapski
如果你绝对需要一个公共构造函数,那么这是__必要的__。如果你不是这种情况,那么这是一个坏主意。你能在你的回答中明确这一点吗?否则我会觉得它应该被downvote,因为它没有添加任何其他答案没有做到的东西,并且由于它没有消除耦合问题,它是一个明显更糟糕的实现。 - Ruben Bartelink
@Ruben Bartelink:我已经更新了我的答案,解释了设计选择。 - JCallico
“创建新实例[本身]不应该是这个类的责任。” 真的吗?你不能说每个类都是这样的吗?我认为工厂方法/类有其存在的意义,但我不能总是用它们来处理所有事情。 - Patrick Szalapski
@Patrick:这是一个偏好问题。我个人尝试将有意义的类新实例的创建隔离到API中的一个地方,因此使用工厂。此外,通过使用工厂,可以轻松地从中心位置修改默认的IThingSource。正如我之前所说,这是个人偏好。 - JCallico
显示剩余3条评论

6
我投#3票。这将使您的生活 - 以及其他开发人员的生活 - 更加轻松。

6
可以请问您需要如何做的解释吗?第三种方法避免了哪些问题? - Adam Lear
1
#1 对消费者来说需要额外的工作。 #2 对消费者和帕特里克都需要额外的工作。无论是#1还是#2,都没有提供足够的好处来抵消对DefaultThingSource依赖的感知劣势。 - Fernando
我认为@Mark Seemann的答案最接近于在现实世界中提供良好的设计,但这个答案也很简单而且不错。 :) - Patrick Szalapski

2

您对这种依赖关系的面向对象不满意,但您并没有真正说明它最终会引起什么问题。

  • ThingMaker是否以任何不符合IThingSource的方式使用DefaultThingSource? 不是。
  • 是否可能有一天你会被迫退休无参数构造函数?由于您现在能够提供默认实现,这是不太可能的。

我认为这里最大的问题是选择名称,而不是是否使用该技术。


我不喜欢对DefaultThingSource的静态依赖。 - Patrick Szalapski
你为什么不喜欢依赖? - Amy B
我同意你的观点,David。除了我不会使用“OO杂质”这个词组,因为依赖关系并没有什么不纯净的地方。是否拥有它只是一个判断的问题。我同意拥有它并不是一个问题。 - Fernando
@David B:我想这是因为一旦用户开始使用AwesomeNewThing,它仍将具有对DefaultThingSource的过时未使用依赖。 - Zan Lynx
那个依赖关系并不是公开的信息,可以在不影响用户的情况下进行更改。 - Amy B

2
这种注入方式的示例通常非常简单:“在类B的默认构造函数中,使用new A()调用重载构造函数,然后就可以了!”
但实际上,依赖关系常常非常复杂。例如,如果B需要一个非类依赖项,比如数据库连接或应用程序设置,该怎么办?这时,你会将类BSystem.Configuration命名空间绑定在一起,增加了其复杂性和耦合性,同时降低了其内聚性,这些细节实际上可以通过省略默认构造函数来外部化。
这种注入方式向读者传达了一种信息:你已经认识到了解耦设计的好处,但不愿意承诺。我们都知道,当有人看到那个方便、易于操作的默认构造函数时,他们会调用它,无论从此刻开始该程序变得多么僵硬。他们不能理解程序的结构,除非阅读默认构造函数的源代码,而当你仅分发程序集时这并不是一个选项。你可以记录连接字符串名称和应用程序设置键的约定,但此时代码并不能自我说明,而且开发人员需要找到正确的调用方法。
优化代码,使编写代码的人可以不理解自己在说什么,是一种虚假的诱惑,一种反模式,最终会导致在解开魔法方面花费更多时间,而节约的时间则非常有限。要么解耦,要么不解耦;同时采用两种方法会削弱它们的重点。

抱歉,你还没有说服我。两个构造函数——一个使简单的事情变得容易,另一个使困难的事情成为可能。你想让我做的似乎会使简单的事情变得困难。 - Patrick Szalapski
@Patrick Szalapski:我的观点是:1)没有源代码或文档,你的代码使用者无法理解他们程序的结构;2)并非每个依赖项的构造函数都很简单;3)你的对象承担了更多的知识和责任,削弱了它们的专注度。如果这些观点不能说服你,那么永远不会有什么能够说服你。以那种风格构建整个系统,并自行评估是否使事情变得更容易。 - Bryan Watts

1

就我所知,Java中所有标准代码都是这样做的:

public class ThingMaker  {
    private IThingSource  iThingSource;

    public ThingMaker()  {
        iThingSource = createIThingSource();
    }
    public virtual IThingSource createIThingSource()  {
        return new DefaultThingSource();
    }
}

任何不想要一个DefaultThingSource对象的人都可以重写createIThingSource方法。(如果可能的话,调用createIThingSource的位置应该在构造函数之外。) C#并不像Java那样鼓励重写方法,而且用户可以提供自己的IThingSource实现可能不像在Java中那么明显。(也不像如何提供它那么明显。) 我猜第三种方法是最好的选择,但我还是想提一下这个问题。

1
+0:-.5: 不应该在构造函数中调用虚函数(对于面向对象编程通常100%确定,对于Java通常99%确定)。 -.5: 这并没有解决耦合问题。+1:这是一种有趣的技巧变化,即使您不将其用作实际解决方案,也可能会帮助人们思考。 - Ruben Bartelink
1
@Ruben Bartelink:是的,在构造函数中应该避免虚拟调用:https://dev59.com/V3VD5IYBdhLWcg3wAWoO - JCallico
我将虚拟调用放在构造函数中,以使示例尽可能简单。但是,在这种情况下,是我把它放在那里的。在Java中,“标准代码”不会在构造函数中调用createIThingSource()! - RalphChapin

0

只是一个想法 - 或许更加优雅,但遗憾的是不能摆脱依赖:

  • 移除“混蛋构造函数”
  • 在标准构造函数中,将源参数默认设置为null
  • 然后检查源是否为null,如果是,则将其分配为“new DefaultThingSource()”,否则分配为消费者注入的任何内容

3
这会更加隐蔽默认依赖,这对未来的开发者来说并不理想。 - Patrick Szalapski

0

在您的库内部拥有一个内部工厂,将DefaultThingSource映射到IThingSource,并从默认构造函数调用它。

这使您能够“新建”ThingMaker类而不需要参数或任何关于IThingSource的知识,并且不会直接依赖于DefaultThingSource。


这听起来像是第三种选项,但只是分成了多个类。它并没有摆脱静态依赖关系。 - Patrick Szalapski
ThingMaker 不再静态依赖于 DefaultThingSource,因为它使用工厂。如果你现在有一个新的 IThingSource 实现,你只需要在一个地方更改映射,即你的工厂。 - Jonathan van de Veen
是的,但ThingMaker对工厂有静态依赖性,而工厂对我们默认的Thing源有静态依赖性。我们想彻底摆脱静态依赖性。 - Patrick Szalapski
啊,那么这只是一个理论练习吗?因为我无法看出在你的情况下完全没有静态依赖的实际优势。 - Jonathan van de Veen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接