五、用依赖注入替换全局

此时,我们所有的类和函数都被合并到一个中心位置,所有相关的include语句都被删除。我们更愿意开始为我们的类编写测试,但很可能我们有很多global变量嵌入其中。如果在一个位置修改global会在另一个位置更改其值,则这些操作会在一定距离内引起很多麻烦。然后,下一步是从类中删除global关键字的所有用法,并注入必要的依赖项。

什么是依赖注入?

依赖注入意味着我们从外部将依赖项推送到类中,而不是在类内部将依赖项拉到类中。(使用global将一个变量从全局范围拉入当前范围,因此它与注入相反。)依赖注入作为一个概念非常简单,但有时作为一个规程很难坚持。

全局依赖性

从一个简单的例子开始,假设一个Example类需要一个数据库连接。在这里,我们在类方法内创建连接:

classes/Example.php
1 <?php
2 class Example
3 {
4 public function fetch()
5 {
6 $db = new Db('hostname', 'username', 'password');
7 return $db->query(...);
8 }
9 }
10 ?>

我们正在需要它的方法内部创建Db依赖项。这有几个问题。其中包括:

  • 每次调用此方法时,我们都会创建一个新的数据库连接,这可能会耗尽我们的资源。
  • 如果我们需要更改连接参数,我们需要在创建连接的每个位置修改它们。
  • 很难从这个类的外部看出它的依赖关系是什么。

在编写了这样的代码之后,许多开发人员发现了global关键字,并意识到他们可以在一个设置文件中创建一次连接,然后从全局范围将其拉入:

setup.php
1 <?php
2 // some setup code, then:
3 $db = new Db('hostname', 'username', 'password');
4 ?>
classes/Example.php
1 <?php
2 class Example
3 {
4 public function fetch()
5 {
6 global $db;
7 return $db->query(...);
8 }
9 }
10 ?>

尽管我们仍在使用依赖关系,但这种技术解决了多个数据库连接使用有限资源的问题,因为相同的数据库连接在代码库中被重用。该技术还可以在一个位置(即setup.php文件)而不是几个位置更改连接参数。然而,仍然存在一个问题,并增加了一个问题:

  • 我们仍然无法从类的外部看到它的依赖关系是什么。
  • 如果$db变量曾被任何调用代码更改,该更改将反映在整个代码库中,从而导致调试问题。

最后一点是致命的。如果一个方法曾经设置过$db = 'busted';,那么$db值现在是一个字符串,而不是整个代码库中的数据库连接对象。同样,如果修改了$db对象,则会修改整个代码库。这可能会导致非常困难的调试会话。

更换流程

因此,我们希望从代码库中删除所有global调用,以便更容易进行故障排除,并揭示类中的依赖关系。下面是我们将使用依赖注入替换global调用的一般过程:

  1. 在我们的一个类中找到一个global变量。
  2. 将该类中的所有global变量移动到构造函数中,并将其值保留为属性,并使用属性而不是全局变量。
  3. 抽查课程是否仍然有效。
  4. 将构造函数中的global调用转换为构造函数参数。
  5. 转换类的所有实例化以传递依赖项。
  6. 抽查、提交、推送并通知 QA。
  7. 在我们的类文件中重复下一个global调用,直到没有剩余。

在这个过程中,我们一次工作一个类而不是一次工作一个变量。前者比后者花费的时间少得多,而且更面向单元。

找到一个全局变量

这很容易使用项目范围的搜索功能。我们在中心类目录位置内搜索global,并返回包含该关键字的类文件列表。

将全局变量转换为属性

假设我们的搜索显示了一个Example类,其代码如下:

classes/Example.php
1 <?php
2 class Example
3 {
4 public function fetch()
5 {
6 global $db;
7 return $db->query(...);
8 }
9 }
10 ?>

我们现在将全局变量移动到构造函数中设置的属性,并将fetch()方法转换为使用该属性:

classes/Example.php
1 <?php
2 class Example
3 {
4 protected $db;
5
6 public function __construct()
7 {
8 global $db;
9 $this->db = $db;
10 }
11
12 public function fetch()
13 {
14 return $this->db->query(...);
15 }
16 }
17 ?>

提示

如果在同一个类中有多个global调用,我们应该将它们全部转换为该类中的属性。我们希望一次学习一门课,因为这样可以使后面的过程更容易。

抽查班级

现在我们已经在这个类中转换了对属性的global调用,我们需要测试应用程序以确保它仍然有效。然而,由于还没有正式的测试系统,我们通过浏览或以其他方式调用使用修改类的文件来进行伪测试或抽查。

如果愿意,我们可以在确定应用程序仍然有效后在这里进行临时提交。我们还不会推送到中央存储库或通知 QA;我们想要的只是一个点,如果以后的更改需要撤消,我们可以回滚到该点。

将全局属性转换为构造函数参数

一旦我们确定类在适当的属性下工作,我们需要将构造函数中的global调用转换为使用传递的参数。鉴于我们上面的Example类,转换后的版本可能如下所示:

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

我们在这里所做的只是删除了global调用,并添加了一个构造函数参数。我们需要为构造函数中的每个global执行此操作。

由于global用于对象的特定类,因此我们向该类键入提示参数(在本例中为Db。如果可能,我们应该将 typehint 改为接口,因此如果Db对象实现了DbInterface,我们应该将 typehint 改为DbInterface。这将有助于测试和以后的重构。我们也可以根据需要将提示键入arraycallable。并非所有global调用都是针对类型化值的,因此并非所有参数都需要类型提示(例如,当参数预期为字符串时)。

将实例化转换为使用参数

在将global变量转换为构造函数参数后,我们会发现整个遗留应用程序中的每个类实例化现在都被破坏了。这是因为构造函数签名已更改。考虑到这一点,我们现在需要搜索整个代码库(不仅仅是类)以获取类的实例化,并将实例化更改为新签名。

为了搜索实例,我们使用项目范围的搜索工具,使用正则表达式查找类名称中的new关键字的用法:

new\s+Example\W

表达式搜索new关键字,后跟至少一个空格字符,后跟终止的非单词字符(如括号、空格或分号)。

格式问题

遗留的代码库因格式混乱而臭名昭著,这意味着该表达式在某些情况下是不完美的。这里给出的表达式可能找不到实例化,例如,new关键字位于一行,类名位于下一行,但位于下一行,而不是同一行。

使用的类别名

在 PHP5.3 和之后的版本中,类可以用 use 语句别名为另一个类名,如下所示:

1 <?php
2 use Example as Foobar;
3 // ...
4 $foo = new Foobar;
5 ?>

在这种情况下,我们需要进行两次搜索:一次使用\s+Example\s+as来发现各种别名,另一次使用别名搜索新关键字。

当我们在代码库中发现类的实例化时,我们修改它们以根据需要传递参数。例如,如果页面脚本如下所示:

page_script.php
1 <?php
2 // a setup file that creates a $db variable
3 require 'includes/setup.php';
4 // ...
5 $example = new Example;
6 ?>

我们需要将参数添加到实例化中:

page_script.php
1 <?php
2 // a setup file that creates a $db variable
3 require 'includes/setup.php';
4 // ...
5 $example = new Example($db);
6 ?>

新的实例化需要匹配新的构造函数签名,因此如果构造函数接受多个参数,我们需要传递所有参数。

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

我们已经完成了本课程的转换过程。我们现在需要抽查转换的实例化,但(一如既往)这不是一个自动化的过程,因此我们需要运行或以其他方式调用带有更改代码的文件。如果有问题,请返回并修复它们。

一旦我们这样做了,并且确保没有错误,我们就可以提交更改后的代码,将其推送到我们的中央存储库,并通知 QA 需要在遗留应用程序上运行其测试套件。

做。。。虽然

这是将单个类从使用global调用转换为使用依赖注入的过程。返回类文件,通过global调用找到下一个类,然后再次开始该过程。继续这样做,直到类中不再有global调用。

常见问题

如果我们在静态方法中找到一个全局变量,会怎么样?

有时我们会发现静态类方法使用global变量,如下所示:

1 <?php
2 class Foo
3 {
4 static public function doSomething($baz)
5 {
6 global $bar;
7 // ... do something with $bar ...
8 }
9 }
10 ?>

这是一个问题,因为没有可以将global变量作为属性移动到的构造函数。这里有两种选择。

第一个选项是将所有需要的全局变量作为参数传递给静态方法本身,从而更改方法的签名:

1 <?php
2 class Foo
3 {
4 static public function doSomething($bar, $baz)
5 {
6 // ... do something with $bar ...
7 }
8 }
9 ?>

然后,我们将在代码库中搜索Foo::doSomething(的所有用法,并每次传递$bar值。因此,我建议将新参数添加到签名的开头,而不是结尾,因为这使搜索和替换更加容易。例如:

搜索:

Foo::doSomething\(

替换为:

Foo::doSomething\($bar,

第二个选项是更改类,使其必须实例化,并使所有方法都成为实例方法。转换后的类可能如下所示:

1 <?php
2 class Foo
3 {
4 protected $bar;
5
6 public function __construct($bar)
7 {
8 $this->bar = $bar;
9 }
10
11 public function doSomething($baz)
12 {
13 // ... do something with $this->bar ...
14 }
15 }
16 ?>

在此之后,我们需要:

  1. 在代码库中搜索所有Foo::静态调用;
  2. 在进行这些静态调用之前,创建具有其$bar依赖项(例如$foo = new Foo($bar);Foo实例,以及
  3. 将对Foo::doSomething()的呼叫替换为$foo->doSomething()

是否有替代转换流程?

上面描述的过程是一个逐类的过程,我们首先将单个类中的全局变量移动到构造函数中,然后从全局属性更改为该类中的实例属性,最后更改该类的实例化。

或者,我们可以选择一个经过修改的流程:

  1. 将所有类中的所有全局变量更改为属性,然后测试/提交/推送/QA。
  2. 将所有类中的所有全局属性更改为构造函数参数,并更改所有类的实例化,然后测试/提交/推送/QA。

对于较小的代码基,这可能是一个合理的替代方案,但它会带来一些问题,例如:

  1. 在将全局变量转换为属性时,global调用的搜索变得有点困难,因为我们将在已转换和未转换的类中看到global关键字。
  2. 每个主要步骤的提交将更大,更难阅读。

出于这些原因和其他原因,我认为最好按照所描述的流程进行操作。它适用于大型和小型代码库,并将增量更改保留在较小且易于读取的部分。

变量中的类名是什么?

有时我们会发现类是基于变量值实例化的。例如,这将基于$class变量的值创建一个对象:

page_script.php
1 <?php
2 // $type is defined earlier in the file, and then:
3 $class = $type . '_Record';
4 $record = new $class;
5 ?>

如果$typeBlog,则$record对象将属于Blog_Record类。

在搜索类实例化以转换为使用构造函数参数时,很难发现这种情况。恐怕我没有什么好的建议可以自动找到这些类型的实例。我们所能做的最好的事情就是在没有任何类名的情况下搜索new\s+\$,并手动单独修改调用。

超级地球人呢?

当移除全局变量时,超全局代表了一个具有挑战性的特殊情况。它们在每个范围内都是自动的全局的,因此它们具有全局的所有缺点。我们不会通过搜索global关键字找到它们(尽管我们可以按名称搜索它们)。因为它们确实是全局的,所以我们需要从类中删除它们,就像我们需要删除global关键字一样。

我们可以在需要时将每个超全局的副本传递给类。在我们只需要一个的情况下,这可能很好,但通常我们需要两个或三个以上的超球。此外,传递一份$_SESSION也不会像预期的那样起作用;PHP 使用实际的超全局$_SESSION来写入会话数据,因此对副本所做的更改将不予考虑。

作为解决方案,我们可以使用Request数据结构类。Request封装了每个非$_SESSION超球体的副本。同时,Request维护了对$_SESSION的引用,以便真正的$_SESSION超全局遵守对对象属性的更改。

注意,Request本身不是 HTTP 请求对象。它只是 PHP 请求环境的一种表示,包括服务器、环境和会话值,其中许多值在 HTTP 消息中找不到。

例如,假设我们有一个使用$_POST$_SERVER$_SESSION的类:

1 <?php
2 class PostTracker
3 {
4 public function incrementPostCount()
5 {
6 if ($_SERVER['REQUEST_METHOD'] != 'POST') {
7 return;
8 }
9
10 if (isset($_POST['increment_count'])) {
11 $_SESSION['post_count'] ++;
12 }
13 }
14 }
15 ?>

为了替换这些调用,我们首先在设置代码中创建一个共享的Request对象。

includes/setup.php
1 <?php
2 // ...
3 $request = new \Mlaphp\Request($GLOBALS);
4 // ...
5 ?>

然后我们可以通过将共享的Request对象注入到任何需要它的类中,并使用Request属性而不是超全局来与超全局解耦:

1 <?php
2 use Mlaphp\Request;
3
4 class PostTracker
5 {
6 public function __construct(Request $request)
7 {
8 $this->request = $request;
9 }
10
11 public function incrementPostCount()
12 {
13 if ($this->request->server['REQUEST_METHOD'] != 'POST') {
14 return;
15 }
16
17 if (isset($this->request->post['increment_count'])) {
18 $this->request->session['post_count'] ++;
19 }
20 }
21 }
22 ?>

提示

如果跨作用域维护对超全局值的更改很重要,请确保在整个应用程序中使用相同的Request对象。对一个Request对象中的值的修改不会反映在另一个Request对象中,但$session值除外(因为它们都是对$_SESSION的引用)。

那$GLOBALS 呢?

PHP 还提供了一个超全局变量:$GLOBALS。在我们的类和方法中使用这个超全局应该被视为使用了global关键字。例如,$GLOBALS['foo']相当于global $foo。我们应该像使用global一样将其从类中删除。

回顾和下一步

现在,我们已经删除了类中的所有global调用,以及超全局函数的所有用法。这是我们的代码库质量的另一个重大改进。我们知道变量可以局部修改,而不会影响代码库的其他部分。

但是,我们的类中可能仍然有隐藏的依赖项。为了使我们的类更易于测试,我们需要发现并揭示这些依赖关系。这是下一章的主题。