十二、替换类中的包含

即使我们现在有了模型-视图-控制器分离,我们的类中可能仍然有许多 include 调用。我们希望我们的遗留应用程序不受其面向包含的遗留的工件的影响,因为仅包含一个文件就会导致执行逻辑。为此,我们需要在整个类中将 include 调用替换为方法调用。

在本章中,我们将使用“包括”一词不仅包括include,还包括requireinclude_oncerequire_once

嵌入式 include 调用

假设我们通过一个嵌入式include方法将一些动作逻辑提取到控制器方法中。代码接收新用户的信息,调用include执行一些常见的验证功能,然后处理验证的成功或失败:

classes/Controller/NewUserPage.php
1 <?php
2 public function __invoke()
3 {
4 // ...
5 $user = $this->request->post['user'];
6 include 'includes/validators/validate_new_user.php';
7 if ($user_is_valid) {
8 $this->user_transactions->addNewUser($user);
9 $this->response->setVars('success' => true);
10 } else {
11 $this->response->setVars(array(
12 'success' => false,
13 'user_messages' => $user_messages
14 ));
15 }
16
17 return $this->response;
18 }
19 ?>

下面是一个示例,说明包含的文件可能是什么样子:

includes/validators/validate_new_user.php
1 <?php
2 $user_messages = array();
3 $user_is_valid = true;
4
5 if (! Validate::email($user['email'])) {
6 $user_messages[] = 'Email is not valid.';
7 $user_is_valid = false;
8 }
9
10 if (! Validate::strlen($foo['username'], 6, 8)) {
11 $user_messages[] = 'Username must be 6-8 characters long.';
12 $user_is_valid = false;
13 }
14
15 if ($user['password'] !== $user['confirm_password']) {
16 $user_messages[] = 'Passwords do not match.';
17 $user_is_valid = false;
18 }
19 ?>

让我们暂时忽略验证代码的细节。这里的要点是include文件和使用它的任何代码都是紧密耦合的。任何使用该文件的代码在包含它之前都必须初始化一个$user变量。任何使用该文件的代码都希望在其作用域中引入两个新变量($user_messages$user_is_valid

我们希望将此逻辑解耦,以便include文件中的逻辑不会侵入它所使用的类方法的范围。我们通过将include文件的逻辑提取到它自己的类来实现这一点。

更换流程

提取包含到它们自己的类的难度取决于类文件中剩余的include调用的数量和复杂性。如果包含的内容很少且相对简单,则该过程将很容易完成。如果存在许多复杂的相互依赖的过程,那么该过程将相对难以完成。

一般情况下,流程如下:

  1. classes/目录中搜索类中的include调用。
  2. 对于那个include调用,搜索整个代码库以查找包含的文件被使用了多少次。
  3. 如果包含的文件仅使用一次,并且仅在该类中使用:
    1. 直接通过include调用复制包含的文件代码的内容。
    2. 测试修改后的类,并删除 include 文件。
    3. 重构复制的代码,使其遵循我们现有的所有规则:无全局、无new、注入依赖项、返回而不是输出,以及无include调用。
  4. 如果包含的文件被多次使用:
    1. 将包含文件的内容原样复制到新的类方法。
    2. 用新类的内联实例化和新方法的调用替换发现的include调用。
    3. 测试include被替换的类,找出耦合变量;通过引用将它们添加到新方法签名中。
    4. 在整个代码库中搜索对同一文件的include调用,并用内联实例化和调用替换每个调用;抽查修改过的文件,测试修改过的类。
    5. 删除原include文件;对整个遗留应用程序进行单元测试和抽查。
    6. 为新类编写一个单元测试,并重构新类,使其遵循我们现有的所有规则:无全局、无超全局、无new、注入依赖项、返回非输出和无包含。
    7. 最后,用依赖注入替换每个类文件中新类的每个内联实例化,并进行测试。
  5. 提交、推送、通知 QA。
  6. 重复上述步骤,直到我们的任何一个类中都没有include调用。

搜索包含呼叫

首先,正如我们在前面章节中所做的,我们使用项目范围内的搜索工具来查找include呼叫。在这种情况下,仅使用以下正则表达式搜索classes/目录:

^[ \t]*(include|include_once|require|require_once)

这应该会给我们一个在classes/目录中的候选include调用列表。

我们选择一个include文件进行处理,然后在整个代码库中搜索同一文件的其他内容。例如,如果我们找到这个候选者include。。。

1 <?php
2 require 'foo/bar/baz.php';
3 ?>

我们将在整个代码库中搜索对文件名baz.phpinclude调用:

^[ \t]*(include|include_once|require|require_once).*baz\.php

我们只搜索文件名,因为根据include调用的位置,相对目录路径可能指向同一个文件。由我们来确定这些include调用中哪些引用了相同的文件。

一旦我们有了一个指向同一文件的include调用列表,我们就会计算包含该文件的调用数。如果只有一个电话,我们的工作相对简单。如果有不止一个电话,我们的工作就更复杂了。

替换单个包含呼叫

如果一个文件只被用作include调用的目标一次,则删除include相对容易。

首先,我们复制include文件的全部内容。我们回到发生include的类,删除include调用,并将include文件的全部内容粘贴到它的位置。

接下来,我们运行该类的单元测试,以确保它仍然正常工作。如果他们失败了,我们高兴!在继续之前,我们发现需要更正错误。如果他们过去了,我们也会欢欣鼓舞,继续前进。

既然include调用已被替换,并且文件内容已成功移植到类中,我们将删除 include 文件。它不再需要了。

最后,我们可以返回到新移植代码所在的类文件。我们根据迄今为止学到的所有规则重构它:不使用全局或超全局,不在工厂外使用new关键字,注入所有需要的依赖项,返回值而不是生成输出,以及(递归地)不使用include调用。我们一直在运行单元测试,以确保不会破坏任何预先存在的功能。

替换多个 include 调用

如果将一个文件用作多个include调用的目标,则需要更多的工作来替换它们。

复制包含文件到类的方法

首先,我们将将include代码复制到它自己的类方法中。为此,我们需要选择一个适合包含文件用途的类名。或者,我们可以根据包含文件的路径命名该类,以便跟踪代码最初来自何处。

至于方法名,我们再次选择了适合include代码目的的名称。就我个人而言,如果这个类只包含一个方法,我会选择__invoke()方法。然而,如果最终有多个方法,我们需要为每个方法选择一个合理的名称。

一旦我们选择了一个类名和方法,我们就在适当的文件位置创建新类,并将include代码直接复制到新方法中。(我们还没有删除 include 文件本身。)

替换原来的包含调用

既然有一个类要处理,我们就回到搜索中发现的include调用,用新类的内联实例化替换它,然后调用新方法。

例如,假设原始调用代码如下所示:

Calling Code
1 <?php
2 // ...
3 include 'includes/validators/validate_new_user.php';
4 // ...
5 ?>

如果我们将include代码提取到Validator\NewUserValidator类作为其__invoke()方法体,我们可能会用以下内容替换include调用:

Calling Code
1 <?php
2 // ...
3 $validator = new \Validator\NewUserValidator;
4 $validator->__invoke();
5 // ...
6 ?>

在类中使用内联实例化违反了我们关于依赖项注入的规则之一。我们不想在工厂类之外使用new关键字。我们在这里这样做只是为了促进重构过程。稍后,我们将用注入替换此内联实例化。

通过测试发现耦合变量

我们已经成功地将调用代码与include文件解耦,但这给我们留下了一个问题。因为调用代码内联执行了include代码,所以新提取的代码所需的变量不再可用。我们需要将执行所需的所有变量传递给新的类方法,并在方法完成时使其变量可供调用代码使用。

为此,我们对调用include的类运行单元测试。测试将向我们揭示新方法需要哪些变量。然后我们可以通过引用将它们传递到方法中。使用引用可以确保两个代码块在完全相同的变量上运行,就像include仍然在内联执行一样。这将最大限度地减少我们需要对调用代码和新提取的代码所做的更改。

例如,假设我们已将include文件中的代码提取到此类和方法:

classes/Validator/NewUserValidator.php
1 <?php
2 namespace Validator;
3
4 class NewUserValidator
5 {
6 public function __invoke()
7 {
8 $user_messages = array();
9 $user_is_valid = true;
10
11 if (! Validate::email($user['email'])) {
12 $user_messages[] = 'Email is not valid.';
13 $user_is_valid = false;
14 }
15
16 if (! Validate::strlen($foo['username'], 6, 8)) {
17 $user_messages[] = 'Username must be 6-8 characters long.';
18 $user_is_valid = false;
19 }
20
21 if ($user['password'] !== $user['confirm_password']) {
22 $user_messages[] = 'Passwords do not match.';
23 $user_is_valid = false;
24 }
25 }
26 }
27 ?>

当我们测试调用此代码的类而不是include时,测试将失败,因为新方法无法使用的$user值,并且调用代码无法使用$user_messages$user_is_valid变量。我们为失败而高兴,因为它告诉我们下一步需要做什么!我们通过引用将每个缺少的变量添加到方法签名中:

classes/Validator/NewUserValidator.php
1 <?php
2 public function __invoke(&$user, &$user_messages, &$user_is_valid)
3 ?>

然后,我们将变量从调用代码传递到方法:

classes/Validator/NewUserValidator.php
1 <?php
2 $validator->__invoke($user, $user_messages, $user_is_valid);
3 ?>

我们继续运行单元测试,直到它们全部通过,并根据需要添加变量。当所有的测试都通过时,我们欢欣鼓舞!所有需要的变量现在都在这两个范围内可用,代码本身将保持解耦和可测试性。

调用代码可能并不需要提取代码中的所有变量,反之亦然。我们应该让单元测试失败来指导我们哪些变量需要作为引用传入。

替换其他包含调用和测试

既然我们已经将原始调用代码从include文件中解耦,我们需要将所有其他剩余代码从同一文件中解耦。根据我们之前的搜索结果,我们转到每个文件,用新类的内联实例化替换相关的include调用。然后,我们添加一行,用所需的变量调用新方法。

请注意,我们可能会替换类内的代码,或者替换非类文件(如视图文件)内的代码。如果我们替换一个类中的代码,我们应该为该类运行单元测试,以确保替换不会破坏任何东西。如果我们替换非类文件中的代码,我们应该为该文件运行测试(如果该文件存在)(例如视图文件测试),或者如果不存在该文件的测试,则抽查该文件。

删除包含文件和测试

一旦我们替换了对文件的所有include调用,我们将删除该文件。我们现在应该对整个遗留应用程序运行所有测试和抽查,以确保不会错过对该文件的include调用。如果测试或抽查失败,我们需要在继续之前进行补救。

编写测试并重构

既然遗留应用程序的工作方式与在我们将include代码提取到它自己的类之前的工作方式一样,我们就为新类编写了一个单元测试。

一旦我们通过了新类的单元测试,我们就会根据我们到目前为止学到的所有规则重构该类中的代码:不使用全局或超全局,不在工厂外使用new关键字,注入所有需要的依赖项,返回值而不是生成输出,以及(递归地)不使用include调用。我们将继续运行测试,以确保不会破坏任何预先存在的功能。

转换为依赖注入和测试

当我们新重构的类的单元测试通过时,我们继续用依赖注入替换所有内联实例化。我们只在我们的类文件中这样做;在我们的视图文件和其他非类文件中,内联实例化不是什么大问题

例如,我们可以在类中看到这种内联实例化和调用:

classes/Controller/NewUserPage.php
1 <?php
2 namespace Controller;
3
4 class NewUserPage
5 {
6 // ...
7
8 public function __invoke()
9 {
10 // ...
11 $user = $this->request->post['user'];
12
13 $validator = new \Validator\NewUserValidator;
14 $validator->__invoke($user, $user_messages, $u
15
16 if ($user_is_valid) {
17 $this->user_transactions->addNewUser($user
18 $this->response->setVars('success' => true
19 } else {
20 $this->response->setVars(array(
21 'success' => false,
22 'user_messages' => $user_messages
23 ));
24 }
25
26 return $this->response;
27 }
28 }
29 ?>

我们将移动$validator到通过构造函数注入的属性,并在方法中使用该属性:

classes/Controller/NewUserPage.php
1 <?php
2 namespace Controller;
3
4 class NewUserPage
5 {
6 // ...
7
8 public function __construct(
9 \Mlaphp\Request $request,
10 \Mlaphp\Response $response,
11 \Domain\Users\UserTransactions $user_transactions,
12 \Validator\NewUserValidator $validator
13 ) {
14 $this->request = $request;
15 $this->response = $response;
16 $this->user_transactions = $user_transactions;
17 $this->validator = $validator;
18 }
19
20 public function __invoke()
21 {
22 // ...
23 $user = $this->request->post['user'];
24
25 $this->validator->__invoke($user, $user_messages, $user_is_valid);
26
27 if ($user_is_valid) {
28 $this->user_transactions->addNewUser($user);
29 $this->response->setVars('success' => true);
30 } else {
31 $this->response->setVars(array(
32 'success' => false,
33 'user_messages' => $user_messages
34 ));
35 }
36
37 return $this->response;
38 }
39 }
40 ?>

现在我们需要搜索代码库并替换修改后的类的每个实例化,以传递新的依赖对象。我们一边运行测试,确保一切正常运行。

提交、推送、通知 QA

此时我们要么替换了一个单个include调用,要么替换了多个include调用到同一个文件。因为我们一直在进行测试,我们现在可以提交新的代码和测试,将其全部推送到公共存储库,并通知 QA 我们有新的工作要他们审查。

做。。。虽然

我们通过在类文件中搜索下一个include调用再次开始。当所有的include调用都被类方法调用替换后,我们就完成了。

常见问题扫描一个类从多个包含文件接收逻辑?

在示例中,我们展示了include代码本身被提取到一个类中。如果我们有许多相关的include文件,将它们收集到同一个类中可能是合理的,每个类都有自己的方法名。例如,NewUserValidator逻辑可能只是许多与用户相关的验证器中的一个。我们可以合理地想象,这个类被重命名为UserValidator,使用了validateNewUser()validateExistingUser()等方法。

如何处理源自非类文件的 include 调用?

在搜索include呼叫时,我们只在classes/目录中查找发起呼叫。可能还有来自其他位置的include呼叫,例如views/

就重构而言,我们并不特别关心源自类之外的include调用。如果只从非类文件调用一个include,我们可以安全地将该include保持在其现有状态。

我们这里的主要目标是从类文件中删除include调用,而不一定是从整个遗留应用程序中删除。在这一点上,不管怎样,我们类之外的大多数或所有include调用都可能是表示逻辑的一部分。

回顾和下一步

从类中提取所有 include 调用后,我们将最终删除遗留体系结构的最后一个主要构件。我们可以在没有任何副作用的情况下加载一个类,并且逻辑只能作为调用方法的结果来执行。这是我们向前迈出的一大步。

我们现在可以开始关注遗留应用程序的总体端到端体系结构。

现在,整个遗留应用程序仍然位于 web 服务器文档根目录中。用户直接浏览到每个页面脚本。这意味着 URL 耦合到文件系统。此外,每个页面脚本都有相当多的重复逻辑:加载设置脚本,使用依赖项注入实例化控制器,调用控制器,并发送响应。

因此,我们的下一个主要目标是开始在遗留应用程序中使用前端控制器。前端控制器将由一些引导逻辑、路由器和调度器组成。这将使我们的应用程序与文件系统分离,并允许我们开始完全删除页面脚本。

但在此之前,我们需要将应用程序中的公共资源与非公共资源分开。