覆盖依赖项而不是依赖注入?

10

在阐述长问题之前,我将用一个简短版的问题来引入:

问题简述

允许对象实例化自己的依赖关系有什么问题,并提供构造函数参数(或setter方法)来简单地覆盖默认实例化有什么问题?

class House
{
   protected $door;
   protected $window;
   protected $roof;

   public function __construct(IDoor $door = null, IWindow $window = null, IRoof $roof = null)
   {
      $this->door   = ($door)   ? $door   : new Door;
      $this->window = ($window) ? $window : new Window;
      $this->roof   = ($roof)   ? $roof   : new Roof;
   }
}

问题的详细版本

我提出这个问题的动机是因为依赖注入需要您跳过许多障碍才能为对象提供所需的内容。IoC容器、工厂、服务定位器......所有这些都引入了大量的额外类和抽象,使您的应用程序的API变得复杂,并且我认为,在许多情况下也会使测试变得同样困难。

难道一个对象不确实知道它需要哪些依赖项才能正常运行吗???

如果依赖注入的两个主要目的是代码可重用性和单元测试的可测试性,则能够使用存根或其他对象覆盖默认实例就可以很好地实现这一点。

同时,如果您需要在应用程序中添加House类,则仅需要编写House类,而不需要在其上面编写工厂和/或DI容器。此外,使用房子的任何客户端代码都可以包括房子,而不需要从上面某处获得房子工厂或抽象服务定位器。一切都变得非常直接,没有中间人代码,只有在需要时才实例化。

我是否完全错误地认为,如果一个对象具有依赖关系,它应该能够自行加载依赖关系,同时提供一种机制来重载这些依赖关系(如果需要)?

示例

#index.php (front controller)

$db = new PDO(...);
$cache = new Cache($dbGateway);
$session = new Session($dbGateway);
$router = new Router;

$router::route('/some/route', function() use ($db, $cache, $session) 
{   
   $controller = new SomeController($db, $cache, $session);
   $controller->doSomeAction();
});



#SomeController.php

class SomeController
{
   protected $db;
   protected $cache;
   protected $session;

   public function __construct(PDO $db, ICache $cache, ISession $session)
   {
      $this->db = $db;
      $this->cache = $cache;
      $this->session = $session;
   }

   public function doSomeAction()
   {
      $user = new \Domain\User;
      $userData = new \Data\User($this->db);

      $user->setName('Derp');
      $userData->save($user);
   }
}

在一个有很多不同模型/数据类和控制器的庞大应用中,我觉得必须将DB对象通过每个控制器(它们并不需要)传递,只为了将其提供给每个数据映射器(它们却需要),这有点不太合理。

而且,通过控制器传递服务定位器或DI容器,只是为了定位数据库,然后再把它传递给数据映射器,也似乎有些不太合理。

同样的情况也适用于通过工厂或抽象工厂传递到控制器,然后通过像$this->factory->make('\Data\User');这样的繁琐方式实例化新对象似乎很笨拙。 特别是因为你需要编写抽象工厂类,然后是实际的工厂,为你想要的对象连接依赖项。


实际上,您可以使用空参数进行类型提示,因此您问题中的那一部分是不正确的。https://dev59.com/bWoy5IYBdhLWcg3wfOB4#8523033 - Schleis
哇,那真是让我大开眼界!感谢你指出来,Schleis。 - AgmLauncher
你能否编辑你的问题而不是在一个“EDIT”中进行编辑?现在不清楚哪些部分应该忽略。 - markus
在此之后,我想到的问题是:这为什么有益呢?就像许多“优化”一样,你只是节省了一些输入字符。哦,因为你问了:一个“好的类”只知道它期望哪种对象类型(比如接口),但不知道具体的类。这通常是代码异味(“紧耦合”)。 - KingCrunch
但是,如果您可以提供一种重载/覆盖默认依赖项的方法,那么它仍然是紧耦合吗?如果我想传递一个GlassDoor对象(它实现了IDoor接口)而不是使用默认的Door,我可以这样做。回答您的问题,好处在于简化整个应用程序。使用IoC框架并编写DI容器并将其传递不像直接使用对象和让这些对象管理其自己的依赖性那样简单。 - AgmLauncher
2
让一个对象实例化自己的依赖关系有什么问题吗?嗯,问题在于“自己”的依赖关系将来自全局作用域。 - Yang
3个回答

5
您的问题提得很好,我很喜欢人们质疑那些对于“单元测试和可维护性”来说是显然的事情(无论是哪个“你如果不这么做就是一个糟糕的程序员”的话题,它总是与单元测试和可维护性有关)。因此,您在这里问了正确的问题:DI是否真正支持单元测试和可维护性,如果是,那么如何支持?并且为了预先防范:如果使用正确,它确实支持...
关于分解:
依赖注入(DI)和控制反转(IoC)是增强面向对象编程中封装和关注点分离核心概念的机制。因此,要回答这个问题,必须说明为什么封装和关注点分离是很酷的事情。两者都是分解的核心机制:封装(是的,我们有模块)和关注点分离(我们以一种有意义的方式拥有模块)。关于这个主题可以写很多,但是现在,必须足够地说,它是关于减少复杂性的。系统的分解允许您将一个系统 - 无论多大 - 分解成人类大脑能够管理的块。虽然有点哲学,但这非常重要:如果没有人类大脑的限制,整个可维护性主题就不会那么重要。好吧,我们来说:分解是一种技巧,将系统的“感知复杂性”减少到我们可以管理的块中。
但是,像往常一样,它也有代价:分解还增加了复杂性,正如您所说的DI。那么它还有意义吗?是的,因为:
“人为添加”的复杂性与系统固有的复杂性无关。
基本上就是这样,在抽象层面上。它具有影响:您需要根据正在构建的系统(或它可能达到的复杂度)的固有复杂度选择分解程度和花费的工作量。
使用DI进行分解:
特别是关于DI:根据以上内容,有足够小的系统,其中DI的额外复杂性不足以证明减少的感知复杂性。不幸的是,网上的每个教程都处理其中之一,这并不能帮助理解整个问题。
然而,大多数(或至少许多)实际项目达到了固有复杂度的程度,投资于额外的分解是值得的,因为减少了感知复杂度可以加速后续开发并减少错误。而依赖注入是实现这一目标的技术之一:
DI 支持对接口(What)和实现(How)的分离: 如果只是关于玻璃门,我同意:如果这对某人的大脑来说太多了,那他或她可能不应该成为一名程序员。但在现实生活中,事情更加复杂:DI 允许您专注于真正重要的事情:作为一个房子,只要我能相信它可以关闭和打开,我就不关心我的门。也许现在根本没有门?你在这个时候根本不需要担心。当注册组件到容器中时,您可以再次聚焦:我想要什么样的门?您不需要再关心门或房子本身了:它们很好,您已经知道。您已经将问题分离开来:定义如何组合事物(组件)和实际组合它们(容器)。就我个人的经验而言,这就是全部内容。听起来有些笨拙,但在现实生活中,这是一个巨大的成就。
再简单点来说,DI 的实际优势包括:
在系统演进时,总会有尚未开发的部分。在大多数情况下,指定行为比实现行为少得多。如果没有 DI,则在没有门被开发之前,您无法开发您的房子,因为没有东西可以实例化。使用 DI,您不需要担心:您仅使用接口设计房子,为这些接口编写模拟测试,你就好了:即使没有窗户和门,房子也能工作。
您可能知道以下情况:您已经花费数天时间在某些事情上(比方说一扇玻璃门),而且您感到很自豪。六个月后 - 在此期间,您学到了很多东西 - 再次查看它,发现它很糟糕。然后您将其放弃。如果没有 DI,则需要更改您的房子,因为它使用了您刚刚丢弃的类。而使用 DI,您的房子不会改变。它可能位于自己的程序集中:您甚至不需要重新编译房屋程序集,因为它没有被修改过。在复杂的情况下,这是一个巨大的优势。
当您下次阅读关于 DI 的相关内容时,也许有了这些想法之后,它们变得更容易想象 DI 的好处了……

3

虽然其他答案都很好,但我会从实际角度来回答这个问题。

想象一下,你有一个内容管理系统(Content Management System),你可以根据自己的意愿调整其配置。假设,这个配置存储在数据库中。因此,你应该像这样实例化它:

$dsn = '....';
$pdo = new PDO($dsn, $params);

$config_adapter = new MySQL_Config_Adapter($pdo);

$config_manager = new Config_Manager($config_adapter);
// $config_manager is ready to be used

现在,让我们看看如果允许一个类实例化其自己的依赖关系会发生什么。
class Foo
{
    public function __construct($config = null)
    {
         if ($config !== null) {
             global $pdo;

             $config_adapter = new MySQL_Config_Adapter($pdo);

             $config_manager = new Config_Manager($config_adapter);

             $this->config = $config_manager;
        } else {
             // Ok, it was injected
             $this->config = $config;
        }
    }
}

这里有三个明显的问题:
  • 全局状态
所以,你基本上可以决定是否要有一个全局状态。如果提供了一个 $config 实例,那么就表示你不想要全局状态。否则,就表示你需要。
  • 紧耦合
那么,如果你决定从 MySQL 切换到 MongoDB,或者甚至是从基于文件的 PHP 数组切换到存储 CMS 配置的其他方式,那么你将不得不重写很多负责依赖项初始化的代码。
  • 不明显的单一职责原则违反
一个类应该只有一个改变的原因。一个类应该只服务于一个特定目的。这意味着 Foo 类有多个职责——它还负责依赖管理。

应该如何正确处理?

public function __construct(IConfig $config)
{
    $this->config = $config;
}

由于它与特定适配器的耦合不紧密,因此很容易进行单元测试或替换适配器(例如,将MySQL替换为其他内容)。

如何覆盖默认参数?

如果您正在覆盖默认的对象,那么您正在做一些错误的事情,并且这表明您的类正在做太多的事情。

构造函数的基本目的是初始化类的状态。如果您已经初始化了状态,然后通过依赖项设置器方法更改该状态,那么您会end up with broken encapsulation,它声明 一个对象应完全控制其状态和实现

回到您的代码示例

让我们看一下您的代码示例。

   public function __construct(IDoor $door = null, IWindow $window = null, IRoof $roof = null)
   {
      $this->door   = ($door)   ? $door   : new Door;
      $this->window = ($window) ? $window : new Window;
      $this->roof   = ($roof)   ? $roof   : new Roof;
   }

在这里,你的意思是:如果某些参数未提供,则从全局范围导入该参数的实例。问题在于,你的 House 知道依赖项来自哪里,而它应该完全不知道这样的信息。
现在让我们提出一些真实世界的情况:
  • 如果你想改变门的颜色?
  • 如果你想改变窗户的大小?
  • 如果你想为另一个房子使用相同的门,但是窗户大小不同?
如果你要坚持你编写代码的方式,那么你最终会面临大量的代码重复。有了“纯粹”的 DI 的想法,这将非常简单:
$door = new Door();
$door->setColor('black');

$window = new Window();
$window->setSize(500, 500);

$a_house = new House($door, $window, $roof);

// As I said, I want house2 to have the same door, but different window size
$window->setSize(1000, 1000);

$b_house = new House($door, $window, $roof);
再次强调:依赖注入的核心观点是对象可以共享相同的实例。 还有一件事,
服务定位器/控制反转容器负责对象存储。它们只是简单地存储/检索对象,就像$pdo一样。
工厂只是抽象一个类的实例化过程。
因此, 它们不是“依赖注入”的一部分,而是利用了它。
就是这样。

1
你最后的代码示例可能存在错误。由于在php中对象是通过引用传递的,你改变了两个房子的窗户大小。如果你想让这两个房屋对象拥有不同大小的窗户,你需要创建一个新的窗户实例或克隆你已经有的窗户并将其传递给$b_house。 - Schleis
1
你用这句话“问题在于你的House知道依赖项来自哪里,而它应该完全不知道这些信息。”说服了我,所以点个赞。然而,我觉得答案的其他部分让情况听起来比实际情况更糟糕。请看下面的评论... - Travesty3
  1. OP从未提到过从全局作用域导入变量(即global $pdo)的事情。
  2. “如果您初始化了状态,然后通过依赖项setter方法更改该状态…”-在他的示例中,所有这些都是在构造函数中完成的,而不是通过setter方法完成的。
  3. “如果您想为另一个房子使用相同的门,但窗户大小不同怎么办?”您可以以相同的方式完成!在外部创建对象并将其传递。如果没有传递,对象仅在构造函数中创建。
- Travesty3
是的,你说得对,OP从来没有提到过从全局作用域导入变量的事情,但是..他问了一个问题:如果我们允许一个类实例化它自己的依赖关系会发生什么,正如你从代码示例中看到的那样,我暗示他最终会得到全局状态。 - Yang
一个对象应该完全控制其状态和实现。这听起来似乎与依赖注入不太搭配。如果一个类依赖于初始化它的东西来提供它所需的内容,那么它就无法完全控制其状态或实现。我不希望人们认为我不喜欢依赖注入。我想表达的是,我觉得使用能够加载自己依赖项的自给自足的对象更容易、更直接。 - AgmLauncher

2
执行此类操作的问题在于,如果您的依赖项还有必须指定的依赖项,则您的构造函数需要知道如何构造其依赖项,然后您的构造函数开始变得非常复杂。
使用您的示例: Roof对象需要一个坡度角度。默认角度取决于房屋的位置(平屋顶与10英尺厚雪不太适合)或者业务规则的新/更改。因此,现在您的House需要计算要传递给Roof的角度。您可以通过传递位置(House当前仅需要用于计算角度或创建要传递给Roof构造函数的“默认”位置)来解决这个问题。无论哪种方式,构造函数现在都必须做一些工作来创建默认屋顶。
这可能会发生在您的任何依赖项中,一旦其中一个依赖项需要确定/计算某些内容,则您的对象必须了解其依赖关系以及如何创建它们。这是它不应该做的事情。
这并不一定会在每种情况下发生,在某些情况下,您可以通过您建议的方法来摆脱困境。然而,您正在冒险。
试图为人们使事情变得“容易”可能会导致设计变得不灵活且难以更改代码。

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