六、用依赖注入替换新建

即使我们删除了类中的所有global调用,它们也可能保留其他隐藏的依赖项。特别是,我们可能在不适当的位置创建新的对象实例,将类紧密地耦合在一起。这些事情使得编写测试和查看内部依赖关系变得更加困难。

嵌入式实例化

在一个假设的ItemsGateway类中转换和global调用后,我们可能会得到如下结果:

classes/ItemsGateway.php
1 <?php
2 class ItemsGateway
3 {
4 protected $db_host;
5 protected $db_user;
6 protected $db_pass;
7 protected $db;
8
9 public function __construct($db_host, $db_user, $db_pass)
10 {
11 $this->db_host = $db_host;
12 $this->db_user = $db_user;
13 $this->db_pass = $db_pass;
14 $this->db = new Db($this->db_host, $this->db_user, $this->db_pass);
15 }
16
17 public function selectAll()
18 {
19 $rows = $this->db->query("SELECT * FROM items ORDER BY id");
20 $item_collection = array();
21 foreach ($rows as $row) {
22 $item_collection[] = new Item($row);
23 }
24 return $item_collection;
25 }
26 }
27 ?>

这里有两个依赖项注入问题:

  1. 首先,该类可能是从一个使用了global $db_host$db_user$db_pass的函数转换而来,然后在内部构造了一个Db对象。我们最初删除global调用时去掉了 globals,但它保留了Db依赖项。这就是我们所说的一次性创建依赖项。
  2. 其次,selectAll()方法创建新的Item对象,因此依赖于Item类。我们无法从类的外部看到此依赖关系。这就是我们所说的重复创建依赖项。

据我所知,一次性创建依赖项和重复创建依赖项不是行业标准术语。它们仅用于本书的目的。如果您知道具有行业标准术语的类似概念,请告知作者。

依赖项注入的要点是从外部将依赖项推入,从而揭示类中的依赖项。在类中使用new关键字与此相反,因此我们需要通过代码库将该关键字从非Factory类中删除。

什么是工厂对象?

依赖注入的关键之一是,一个对象可以创建其他对象它可以在其他对象上操作,但不能同时在上操作。每当我们需要在另一个对象中创建一个对象时,我们都会让一个名为工厂的东西用newInstance()方法来完成这项工作,并将工厂注入到需要创建的对象中。new关键字仅限于在工厂对象内使用。这允许我们在需要创建不同类型的对象时随时切换工厂对象。

更换流程

然后,下一步是从我们的非工厂类中删除new关键字的所有用法,并注入必要的依赖项。我们还将根据需要使用工厂对象来处理重复的创建依赖关系。这是一般流程我们将遵循:

  1. 查找包含new关键字的类。如果这个类已经是一个Factory,我们可以忽略它继续。
  2. 对于类中的每个一次性创建:
    • 将每个实例化提取到构造函数参数。
    • 将构造函数参数指定给属性。
    • 删除所有仅用于new调用的构造函数参数和类属性。
  3. 对于类中的每个重复创建:
    • 将每个创建代码块提取到一个新的Factory类中。
    • 为每个Factory创建一个构造函数参数,并将其分配给属性。
    • 修改类中以前的创建逻辑,使用工厂
  4. 在整个项目中更改修改类的所有实例化调用,以便将必要的依赖项对象传递给构造函数。
  5. 抽查、提交、推送并通知 QA。
  6. 重复下一个不在工厂对象内的new调用。

查找新关键字

与其他步骤一样,我们首先使用项目范围的搜索工具,使用以下正则表达式在类文件中查找new关键字:

搜索:

new\s+

我们要寻找两种创造:一次性的和重复的。我们怎样才能分辨出区别呢?一般来说:

  • 如果实例化已分配给属性,并且从未更改,则很可能是一次性创建。通常,我们在构造函数中看到这一点。
  • 如果实例化发生在非构造函数方法中,则很可能是重复创建,因为每次调用该方法时都会发生实例化。

提取一次创建到依赖注入

假设我们在搜索new关键字时找到上面列出的ItemsGateway类,并遇到构造函数:

classes/ItemsGateway.php
1 <?php
2 class ItemsGateway
3 {
4 protected $db_host;
5 protected $db_user;
6 protected $db_pass;
7 protected $db;
8
9 public function __construct($db_host, $db_user, $db_pass)
10 {
11 $this->db_host = $db_host;
12 $this->db_user = $db_user;
13 $this->db_pass = $db_pass;
14 $this->db = new Db($this->db_host, $this->db_user, $this->db_pass);
15 }
16 // ...
17 }
18 ?>

在检查类时,我们发现$this->db作为属性被赋值一次。这似乎是一次性创建。此外,似乎至少有一些现有构造函数参数仅用于Db实例化。

我们继续完全删除实例化调用以及仅用于实例化调用的属性,并用单个 Db 参数替换构造函数参数:

classes/ItemsGateway.php
1 <?php
2 class ItemsGateway
3 {
4 protected $db;
5
6 public function __construct(Db $db)
7 {
8 $this->db = $db;
9 }
10
11 // ...
12 }
13 ?>

提取重复创作到工厂

如果我们发现一个重复的创造而不是一次性的创造,我们就有一个不同的任务要完成。让我们回到ItemsGateway类,但这次我们将研究selectAll()方法。

classes/ItemsGateway.php
1 <?php
2 class ItemsGateway
3 {
4 protected $db;
5
6 public function __construct(Db $db)
7 {
8 $this->db = $db;
9 }
10
11 public function selectAll()
12 {
13 $rows = $this->db->query("SELECT * FROM items ORDER BY id");
14 $item_collection = array();
15 foreach ($rows as $row) {
16 $item_collection[] = new Item($row);
17 }
18 return $item_collection;
19 }
20 }
21 ?>

我们可以看到,new关键字出现在方法内部的循环中。这显然是一个重复创造的例子。

首先,我们将创建代码提取到它自己的新类中。因为代码创建了一个Item对象,所以我们将调用类ItemFactory。其中,我们将创建一个返回Item对象新实例的方法:

classes/ItemFactory.php
1 <?php
2 class ItemFactory
3 {
4 public function newInstance(array $item_data)
5 {
6 return new Item($item_data);
7 }
8 }
9 ?>

工厂的唯一目的是创建新对象。它不应该有任何其他功能。将其他行为放在Factory中以集中公共逻辑是很有诱惑力的。抵制这种诱惑!

现在我们已经将创建代码提取到一个单独的类中,我们将修改ItemsGateway以获取ItemFactory参数,将其保留在属性中,并使用ItemFactory创建Item对象。

classes/ItemsGateway.php
1 <?php
2 class ItemsGateway
3 {
4 protected $db;
5
6 protected $item_factory;
7
8 public function __construct(Db $db, ItemFactory $item_factory)
9 {
10 $this->db = $db;
11 $this->item_factory = $item_factory;
12 }
13
14 public function selectAll()
15 {
16 $rows = $this->db->query("SELECT * FROM items ORDER BY id");
17 $item_collection = array();
18 foreach ($rows as $row) {
19 $item_collection[] = $this->item_factory->newInstance($row);
20 }
21 return $item_collection;
22 }
23 }
24 ?>

变更实例化调用

因为我们已经更改了构造函数签名,ItemsGateway的所有现有实例化现在都被破坏了。我们需要找到代码中实例化ItemsGateway类的所有位置,并更改实例化以传递正确构造的Db对象和ItemFactory

为此,我们使用项目范围内的搜索工具,使用正则表达式搜索更改后的类名:

搜索:

new\s+ItemsGateway\(

这样做将为我们提供项目中所有实例化的列表。我们需要检查每个结果并手动更改,以实例化依赖项并将其传递给ItemsGateway

例如,如果搜索结果中的页面脚本如下所示:

page_script.php
1 <?php
2 // $db_host, $db_user, and $db_pass are defined in the setup file
3 require 'includes/setup.php';
4
5 // ...
6
7 // create a gateway
8 $items_gateway = new ItemsGateway($db_host, $db_user, $db_pass);
9
10 // ...
11 ?>

我们需要将其更改为类似以下内容:

page_script.php
1 <?php
2 // $db_host, $db_user, and $db_pass are defined in the setup file
3 require 'includes/setup.php';
4
5 // ...
6
7 // create a gateway with its dependencies
8 $db = new Db($db_host, $db_user, $db_pass);
9 $item_factory = new ItemFactory;
10 $items_gateway = new ItemsGateway($db, $item_factory);
11
12 // ...
13 ?>

对更改的类的每个实例化执行此操作。

抽查、提交、推送、通知 QA

现在我们已经在整个代码库中更改了类和类的实例化,我们需要确保我们的遗留应用程序工作正常。同样,我们没有正式的测试过程,所以我们需要运行或以其他方式调用应用程序中使用更改的类的部分并查找错误。

一旦我们确信应用程序仍然正常运行,我们就提交代码,将其推送到我们的中央存储库,并通知 QA 我们已经准备好让他们测试我们的新添加。

做。。。虽然

在类中搜索下一个new关键字,然后重新开始该过程。当我们发现new关键字只存在于工厂类中时,我们的工作就完成了。

常见问题

异常和 SPL 类呢?

在本章中,我们将重点删除所有使用new关键字的内容,但工厂对象内部除外。我相信这个规则有两个合理的例外:例外类本身,以及某些内置 PHP 类,比如 SPL 类。

创建一个ExceptionFactory类,将其注入抛出异常的对象,然后使用ExceptionFactory创建要抛出的Exception对象,这与本章中描述的过程完全一致。我甚至觉得这有点过分了。我认为Exception对象是Factory对象之外无new对象规则的合理例外。

类似地,我认为内置 PHP 类也常常是规则的一个例外。比如说,有一个ArrayObject 工厂ArrayIteratorFactory来创建由 SPL 本身提供的ArrayObjectArrayIterator类会很好,但这可能有点太多了。直接在使用它们的对象内部创建这些类型的对象通常是可以的。

然而,我们需要小心。在需要它的类中直接创建一个复杂或强大的对象,如PDO连接,这可能超出了我们的范围。这里很难描述一个好的经验法则;当有疑问时,在依赖项注入方面会犯错误。

中介依赖性呢?

有时我们会发现有依赖项的类,而依赖项本身也有依赖项。这些中间依赖项被传递给外部类,外部类只携带它们以便内部对象可以用它们实例化。

例如,假设我们有一个Service类,它需要一个ItemsGateway,它本身需要一个Db连接。在删除global变量之前,Service类可能如下所示:

classes/Service.php
1 <?php
2 class Service
3 {
4 public function doThis()
5 {
6 // ...
7 $db = global $db;
8 $items_gateway = new ItemsGateway($db);
9 $items = $items_gateway->selectAll();
10 // ...
11 }
12
13 public function doThat()
14 {
15 // ...
16 $db = global $db;
17 $items_gateway = new ItemsGateway($db);
18 $items = $items_gateway->selectAll();
19 // ...
20 }
21 }
22 ?>

删除global变量后,我们只剩下一个new关键字,但我们仍然需要Db对象作为ItemsGateway的依赖项:

classes/Service.php
1 <?php
2 class Service
3 {
4 protected $db;
5
6 public function __construct(Db $db)
7 {
8 $this->db = $db;
9 }
10
11 public function doThis()
12 {
13 // ...
14 $items_gateway = new ItemsGateway($this->db);
15 $items = $items_gateway->selectAll();
16 // ...
17 }
18
19 public function doThat()
20 {
21 // ...
22 $items_gateway = new ItemsGateway($this->db);
23 $items = $items_gateway->selectAll();
24 // ...
25 }
26 }
27 ?>

我们如何成功地删除此处的new关键字?项网关需要Db连接。Service从未直接使用Db连接;仅用于建造项目通道

在这种情况下,解决方案是注入完全构造的项网关。首先,我们修改Service类以接收其真正的依赖项ItemsGateway

classes/Service.php
1 <?php
2 class Service
3 {
4 protected $items_gateway;
5
6 public function __construct(ItemsGateway $items_gateway)
7 {
8 $this->items_gateway = $items_gateway;
9 }
10
11 public function doThis()
12 {
13 // ...
14 $items = $this->items_gateway->selectAll();
15 // ...
16 }
17
18 public function doThat()
19 {
20 // ...
21 $items = $this->items_gateway->selectAll();
22 // ...
23 }
24 }
25 ?>

其次,在整个遗留应用程序中,我们将服务的所有实例化更改为传递ItemsGateway

例如,页面脚本在任何地方使用global变量时都可能会这样做:

page_script.php (globals)
1 <?php
2 // defines the $db connection
3 require 'includes/setup.php';
4
5 // creates the service with globals
6 $service = new Service;
7 ?>

然后我们将其更改为在删除全局变量后注入中间依赖项:

page_script.php (intermediary dependency)
1 <?php
2 // defines the $db connection
3 require 'includes/setup.php';
4
5 // inject the Db object for the internal ItemsGateway creation
6 $service = new Service($db);
7 ?>

但我们最终应该改变它以注入真正的依赖性:

page_script.php (real dependency)
1 <?php
2 // defines the $db connection
3 require 'includes/setup.php';
4
5 // create the gateway dependency and then the service
6 $items_gateway = new ItemsGateway($db);
7 $service = new Service($items_gateway);
8 ?>

这不是很多代码吗?

我有时听到这样的抱怨:使用依赖注入意味着需要大量额外的代码来完成与以前相同的事情。

这是真的。有这样一个调用,其中类在内部管理自己的依赖项。

没有依赖注入:

1 <?php
2 $items_gateway = new ItemsGateway;
3 ?>

这显然比通过创建依赖项和使用Factory对象来使用依赖项注入更少的代码。

使用依赖项注入:

1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $item_factory = new ItemFactory;
4 $items_gateway = new ItemsGateway($db, $item_factory);
5 ?>

然而,这里真正的问题不是更多的代码。这些问题更易于测试、更清晰、更解耦。

在看第一个例子时,我们如何判断项 Gateway需要操作什么?它会影响系统的其他哪些部分?如果不检查整个班级并查找globalnew关键字,就很难判断。

在看第二个示例时,很容易知道类需要操作什么,我们可以期望它创建什么,以及它与系统的哪些部分交互。此外,这些东西使以后测试类变得更容易。

工厂应该创建集合吗?

在上面的例子中,我们的Factory类只创建一个newInstance()对象。如果我们定期创建对象集合,那么在我们的Factory中添加newCollection()方法可能是合理的。例如,考虑到上面的项工厂,我们可能会执行以下操作:

classes/ItemFactory.php
1 <?php
2 class ItemFactory
3 {
4 public function newInstance(array $item_data)
5 {
6 return new Item($item_data);
7 }
8
9 public function newCollection(array $items_data)
10 {
11 $collection = array();
12 foreach ($items_data as $item_data) {
13 $collection[] = $this->newInstance($item_data);
14 }
15 return $collection;
16 }
17 }
18 ?>

我们可以使用为集合创建ItemCollection类,而不是使用数组。如果是这样,在我们的ItemFactory中使用new关键字来创建ItemCollection实例是合理的。(此处省略ItemCollection类)。

classes/ItemFactory.php
1 <?php
2 class ItemFactory
3 {
4 public function newInstance(array $item_data)
5 {
6 return new Item($item_data);
7 }
8
9 public function newCollection(array $item_rows)
10 {
11 $collection = new ItemCollection;
12 foreach ($item_rows as $item_data) {
13 $item = $this->newInstance($item_data);
14 $collection->append($item);
15 }
16 return $collection;
17 }
18 }
19 ?>

事实上,我们可能希望有一个单独的ItemCollectionFactory,使用注入的ItemFactory创建 Item 对象,并使用自己的newInstance()方法返回新的ItemCollection

关于Factory对象的正确使用有很多不同。关键是将对象创建(和相关操作)与对象操作分开。

我们能自动完成这些注射吗?

到目前为止,我们所做的所有依赖项注入都是手动注入,我们自己创建依赖项,然后在创建所需对象时注入它们。这可能是一个乏味的过程。谁想一次又一次地创建一个Db对象,以便将它注入到各种Gateway类中?难道没有办法让它自动化吗?

是的,有。它被称为ContainerContainer可以用各种同义词来表示它的用法。依赖注入Container旨在始终且仅在非Factory类之外使用,而名称为Service Locator的相同Container实现旨在用于insideFactory对象。

使用Container会带来明显的优势:

  • 我们可以创建只有在调用时才实例化的共享服务。例如,Container可以包含一个Db实例,只有当我们请求Container建立数据库连接时,才会创建该实例;该连接创建一次,然后反复使用。
  • 我们可以将复杂对象创建放在Container中,其中需要多个服务作为其构造函数参数的对象可以从其自身创建逻辑中的Container中检索这些服务。

但使用Container也有缺点:

  • 我们必须彻底改变我们对对象创建的想法,以及这些对象在应用程序中的位置。最终这是一件好事,但在过渡期间可能会有麻烦。
  • 一个用作服务定位器的Container将我们的global变量替换为一个新奇的玩具,该玩具与global有许多相同的问题。Container隐藏依赖项,因为它仅从需要依赖项的类内部调用。

在对遗留应用程序进行现代化的这个阶段,开始使用Container自动化依赖注入以供使用是非常诱人的。我建议我们现在不要添加一个,因为我们的遗留应用程序还有很多需要现代化。我们最终会增加一个,但这将是我们现代化进程的最后一步。

回顾和下一步

我们现在在使我们的应用程序现代化方面取得了巨大的进步。删除globalnew关键字以支持依赖注入已经提高了代码库的质量,并使跟踪 bug 变得更加容易,如果只是因为修改此处的变量不再导致远处的变量受到影响的话。我们的页面脚本可能会更长一些,因为我们必须创建依赖项,但是现在我们可以更清楚地看到我们正在与系统的哪些部分交互。

我们的下一步是检查新重构的类,并开始为它们编写测试。这样,当我们开始对类进行更改时,我们就会知道是否破坏了以前存在的任何行为。