依赖注入和单例设计模式

108

我们如何确定何时使用依赖注入或单例模式。

我在很多网站上读到过他们说“使用依赖注入而不是单例模式”。但我不确定是否完全同意他们的观点。对于我的小型或中型项目,我肯定看到单例模式的用途很直接。

例如Logger。我可以使用Logger.GetInstance().Log(...)。但是,我为什么需要将我创建的每个类都注入日志记录器的实例呢?

7个回答

114

单例模式就像共产主义一样:在理论上听起来不错,但实践中却会遇到很多问题。

单例模式过分强调对象访问的便捷性。它完全忽略了上下文,要求每个使用者都使用一个应用程序域范围内的对象,没有为不同的实现留下选项。它将基础知识嵌入到您的类中(调用GetInstance()),同时添加了表现力。它实际上降低了您的表现力,因为您无法更改一个类使用的实现,而不更改所有类的实现。您不能添加一次性功能。

此外,当类 Foo 依赖于 Logger.GetInstance() 时, Foo 有效地隐藏了其依赖关系,这意味着您不能完全理解 Foo 或自信地使用它,除非您阅读其源代码并发现其依赖于 Logger 。如果您没有源代码,那么这将限制您了解和有效使用所依赖的代码的能力。

作为使用静态属性/方法实现的单例模式仅仅是围绕实施基础设施的一种hack。它以无数种方式限制了您,而在可替代方案中没有任何明显的好处。虽然您可以随意使用它,但是由于存在促进更好设计的可行替代方案,因此它永远不应成为推荐实践。


13
尽管如此,在一般中等规模的应用程序中,单例仍然更快且出错率更低。通常情况下,我对于(自定义)日志记录器没有多个可能的实现,所以为什么**我需要:1. 为此创建一个接口,并在每次添加/更改公共成员时更改它;2. 维护依赖注入配置;3. 隐藏整个系统中只有一个此类型对象的事实;4. 受到过早关注点分离导致的严格功能限制。 - Uri Abramson
1
@UriAbramson:看起来你已经做出了自己偏好的权衡决定,所以我不会试图说服你。 - Bryan Watts
2
@UriAbramson:好的,你是否同意在测试期间交换实现以进行隔离很重要? - Bryan Watts
@UriAbramson:那么,您是在已经实施 DI 的上下文中询问如何执行一次性单例模式? - Bryan Watts
8
单例和依赖注入不是互斥的。单例可以实现一个接口,因此可以用来满足对另一个类的依赖。它是单例并不强制每个使用者都必须通过其“GetInstance”方法/属性获取引用。 - Oliver
显示剩余6条评论

73
如果您想验证在测试中记录了哪些内容,您需要进行依赖注入。此外,日志记录器很少是单例的 - 通常每个类都有一个日志记录器。
观看这个关于面向对象设计可测试性的演示,并了解为什么单例是不好的:观看这个演示
单例的问题在于它们代表难以预测的全局状态,特别是在测试中。
请记住,对象可以是事实上的单例,但仍然可以通过依赖注入获取,而不是通过Singleton.getInstance()方法获取。
我只是列出了Misko Hevery在他的演讲中提出的一些重要观点。观看完整的演示后,您将全面了解为什么最好让对象定义其依赖项是什么,而不定义如何创建它们的方式。

我有一个疑问,像Spring或NestJs这样的IoC容器,默认情况下会创建单例实例,与通过实现接口的单例并通过构造函数注入有什么不同(假设我们不使用任何框架)? - Hector

20

其他人已经很好地解释了单例模式的问题。我只想补充一下关于Logger特定情况的说明。我同意你通常可以通过静态getInstance()getRootLogger()方法作为单例来访问Logger(或根记录器)。(除非你想查看测试所记录的内容 - 但根据我的经验,我几乎没有遇到过这种必须要求的情况。当然,对于其他人来说可能更紧迫)。

在我看来,通常单例日志记录器并不是一个问题,因为它不包含与您正在测试的类相关的状态。也就是说,日志记录器的状态(及其可能的更改)对被测试类的状态没有任何影响。因此,它不会使您的单元测试变得更加困难。

另一种选择是通过构造函数将日志记录器注入到几乎每个应用程序中的所有类中。为了接口的一致性,即使涉及的类目前不记录任何内容,也应该进行注入。否则,当您在某些时候发现需要从此类记录日志时,您需要一个日志记录器,因此需要添加一个构造函数参数以实现依赖注入,破坏所有客户端代码的稳定性。我不喜欢这两个选项,我认为使用DI进行日志记录只是为了遵循一个理论规则而使我的生活变得更加复杂,没有任何实际的好处。

因此,我的结论是:如果一个类被(几乎)普遍使用但不包含与您的应用程序相关的状态,则可以安全地将其实现为单例


1
即使如此,单例模式也可能会带来麻烦。如果你意识到你的记录器在某些情况下需要一些额外的参数,你要么得创建一个新方法(让记录器变得更加丑陋),要么就得打破所有使用该记录器的消费者,即使他们并不关心这个改变。 - kyoryu
4
@kyoryu,我说的是“通常”的情况,这意味着使用(事实上)标准的日志框架。(通常可以通过属性/XML文件进行配置。)当然,总会有例外。如果我知道我的应用程序在这方面很特殊,我确实不会使用单例。但是,因为“这可能在某个时候有用”,而过度设计几乎总是一种浪费。 - Péter Török
如果你已经在使用 DI,那就不需要额外的工程了。顺便说一句,我同意你的观点,我投了赞成票。很多记录器需要一些“类别”信息或类似的东西,并且添加额外参数可能会带来烦恼。将其隐藏在接口后面可以帮助保持使用代码的清晰度,并且可以方便地切换到其他日志记录框架。 - kyoryu
1
@kyoryu,很抱歉我的错误假设。我看到了一个踩和一条评论,所以我错误地连接了它们:-( 我从未遇到过需要切换到不同日志框架的情况,但是我理解在某些项目中这可能是一个合理的关注点。 - Péter Török
5
如果我说错了,请纠正我,但单例模式应该是针对LogFactory而不是logger。另外,LogFactory很可能是apache commons或者Slf4j日志门面。因此,切换日志实现方式是轻而易举的。使用DI注入LogFactory的真正痛点不是你现在必须去applicationContext中使你应用程序中的每个实例都可以访问它吗? - Dave

11

这主要与测试有关,但不完全只是如此。单例模式之所以流行,是因为它们易于使用,但是单例模式有许多缺点。

  • 难以测试。这意味着我该如何确保记录器执行正确的操作。
  • 难以进行测试。如果我正在测试使用记录器的代码,但它不是我的测试重点,仍然需要确保我的测试环境支持记录器。
  • 有时你不想要单例,而是更灵活的选择。

DI(依赖注入)可以让您轻松地使用依赖类 - 只需将其放在构造函数参数中,系统会自动提供 - 同时为您提供测试和构建灵活性。


不完全是。它涉及到共享的可变状态和静态依赖关系,这会在长期运行中带来困扰。测试只是一个明显的例子,而且通常是最令人痛苦的例子。 - kyoryu

5

关于使用Singleton而不是依赖注入的唯一情况是,如果Singleton表示一个不可变值,例如List.Empty或类似的内容(假设是不可变列表)。

对于Singleton的核心问题应该是“如果这是一个全局变量而不是Singleton,我是否可以接受?” 如果不能,那么您正在使用Singleton模式来掩盖全局变量,并且应该考虑其他方法。


1

刚刚看了一下Monostate的文章 - 它是Singleton的一个不错的替代品,但它有一些奇怪的属性:

class Mono{
    public static $db;
    public function setDb($db){
       self::$db = $db;
    }

}

class Mapper extends Mono{
    //mapping procedure
    return $Entity;

    public function save($Entity);//requires database connection to be set
}

class Entity{
public function save(){
    $Mapper = new Mapper();
    $Mapper->save($this);//has same static reference to database class     
}

$Mapper = new Mapper();
$Mapper->setDb($db);

$User = $Mapper->find(1);
$User->save();

这不是有点可怕吗 - 因为 Mapper 在执行 save() 时确实依赖于数据库连接 - 但如果先前已创建另一个 Mapper,则可以跳过获取其依赖项的步骤。 虽然很整洁,但也有点凌乱,对吧?


0

第一个链接无法使用。这是原始目标吗?http://staff.cs.utu.fi/~jounsmed/doos_06/material/SingletonAndMonostate.pdf - Dalibor Filus

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