四、整合类和函数

现在我们有了一个自动加载程序,我们可以开始删除所有只加载类和函数定义的include调用。完成后,剩下的include调用将是那些正在执行逻辑的调用。这将使我们更容易看到哪些include调用正在形成遗留应用程序中的逻辑路径,哪些仅提供定义。

我们将从一个代码库结构相对良好的场景开始。之后,我们将回答一些与布局相关的问题,这些问题不太容易修改。

在本章中,我们将使用术语include不仅涵盖include,还包括requireinclude_oncerequire_once

合并类文件

首先,我们将将所有应用程序类合并到我们的中心目录位置,如前一章所确定的。这样做将把他们放在我们的自动加载器可以找到他们的地方。以下是我们将遵循的一般流程:

  1. 查找一个include语句,该语句将拉入一个类定义文件。
  2. 将该类定义文件移动到我们的中心类目录位置,确保它位于与 PSR-0 规则匹配的子路径中。
  3. 在原始文件和代码库include引入该类定义的所有其他文件中,删除该include语句。
  4. 抽查以确保所有文件现在都通过浏览或以其他方式运行来自动加载该类。
  5. 提交、推送和通知 QA。
  6. 重复此操作,直到不再有include调用拉入类定义。

对于我们的示例,我们将假设我们有一个具有此部分文件系统布局的遗留应用程序:

/path/to/app/
classes/          # our central class directory location
Mlaphp/
Autoloader.php    # A hypothetical autoloader class
foo/ bar/ baz.php # a page script
includes/         # a common "includes" directory
setup.php         # setup code
index.php         # a page script
lib/              # a directory with some classes in it
sub/ Auth.php     # class Auth { ... }
Role.php          # class Role { ... }
User.php          # class User { ... }

您自己的遗留应用程序可能与此不完全匹配,但您已经明白了这一点。

找到一个候选人,包括

我们首先挑选一个文件,任何文件,然后检查它是否有include调用。其中的代码可能如下所示:

1 <?php
2 require 'includes/setup.php';
3 require_once 'lib/sub/User.php';
4
5 // ...
6 $user = new User();
7 // ...
8 ?>

我们可以看到有一个新的User类正在实例化。在检查lib/sub/User.php文件时,我们可以看到它是其中定义的唯一类。

移动类文件

识别出加载类定义的include语句后,我们现在将该类定义文件移动到中心类目录位置,以便我们的 autoloader 函数可以找到它。生成的文件系统布局现在如下所示(请注意,User.php现在位于classes/中):

/path/to/app/
classes/                 # our central class directory location
Mlaphp/ Autoloader.php   # A hypothetical autoloader class
User.php                 # class User { ... }
foo/ bar/ baz.php        # a page script
includes/                # a common "includes" directory
setup.php                # setup code
db_functions.php         # a function definition file
index.php                # a page script
lib/                     # a directory with some classes in it
sub/
Auth.php                 # class Auth { ... }
Role.php                 # class Role { ... } ~~

删除相关的 include 调用

现在的问题是我们的原始文件试图include类文件从它的旧位置,一个不再存在的位置。我们需要从代码中删除该调用:

index.php
1 <?php
2 require 'includes/setup.php';
3
4 // ...
5 // the User class is now autoloaded
6 $user = new User();
7 // ...
8 ?>

但是,代码可能会在其他地方尝试加载现在丢失的lib/sub/User.php文件。

这是一个项目范围的搜索工具派上用场的地方。我们在这里有不同的选项,这取决于您选择的编辑器/IDE 和操作系统。

  • 在诸如 TextMate、SublimateText 和 PHPStorm 之类的 GUI 编辑器中,通常有一个在项目中查找菜单项,我们可以使用该菜单项一次在所有应用程序文件中搜索字符串或正则表达式。
  • 在其他编辑器(如 Emacs 和 Vim)中,通常有一个键绑定,它将搜索特定目录及其子目录中的所有文件,以查找字符串或正则表达式。
  • 最后,如果您是老派,您可以在命令行中使用grep来搜索特定目录及其子目录中的所有文件。

关键是找到所有引用lib/sub/User.phpinclude调用。因为include调用可以以不同的方式形成,所以我们需要使用这样的正则表达式来搜索include调用:

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

如果您不熟悉正则表达式,下面是我们要查找的内容的细分:

^               Starting at the beginning of each line,
[ \t]*          followed by zero or more spaces and/or tabs,
(include|...)   followed by any of these words,
.*             followed by any characters at all,
User\.php      followed by User.php, and we don't care what comes after.

(正则表达式使用.表示any character,因此我们必须指定User\.php表示文字点,而不是任何字符。)

如果我们使用常规的表达式搜索来查找遗留代码库中的字符串,我们将看到所有匹配行及其相应文件的列表。不幸的是,我们需要检查每一行,看看它是否真的是对lib/sub/User.php文件的引用。例如,搜索结果中可能会出现以下行:

include_once("/usr/local/php/lib/User.php");

然而,很明显,这不是我们正在寻找的User.php文件。

我们可以对正则表达式进行更严格的要求,以便专门搜索lib/sub/User.php,但这很可能会错过一些include调用,特别是那些在lib/sub/目录下的文件中的调用。例如,sub/中文件中的include可能如下所示:

include 'User.php';

因此,最好在搜索过程中稍微放松,以获得所有可能的候选人,然后手动完成搜索结果。

检查每个搜索结果行,如果是一个include拉入了User类,则删除它并保存文件。保留每个修改文件的列表,因为我们以后需要测试它们。

最后,我们将删除整个代码库中对该类的所有include调用。

抽查代码库

删除给定类的include语句后,我们现在需要确保应用程序正常工作。不幸的是,因为我们没有适当的测试过程,这意味着我们需要通过浏览或调用修改后的文件来进行伪测试或抽查。在实践中,这通常并不困难,但很乏味。

当我们抽查时,我们专门查找文件未找到类未定义错误。这分别意味着一个文件试图include丢失的类文件,或者自动加载程序未能找到该类文件。

为了进行测试,我们需要设置 PHP 错误报告,以便它直接向我们显示错误,或者将错误记录到我们在测试代码库时检查的文件中。此外,错误报告级别需要足够严格,以便我们实际看到错误。一般来说,error_reporting(E_ALL)是我们想要的,但因为这是一个遗留的代码库,它可能显示的错误比我们能够承受的要多(特别是变量未定义注意事项)。因此,设置error_reporting(E_WARNING)可能更有效率。错误报告值可以在设置或引导文件中设置,也可以在正确的php.ini文件中设置。

提交、推送、通知 QA

在测试完成且所有错误都已修复后,将代码提交给源代码管理,并(如果需要)将其推送到中央代码存储库。如果你有一个 QA 团队,现在是时候通知他们需要新一轮测试,并向他们提供要测试的文件列表。

做。。。虽然

这是将单个类从include转换为自动加载的过程。返回代码库,找到下一个拉入类文件的include并再次开始该过程。继续操作,直到所有类都合并到中心类目录位置,并且它们的相关include行都被删除。是的,这是一个乏味、乏味和耗时的过程,但它是实现遗留代码库现代化的必要步骤。

将函数合并到类文件中

并非所有遗留应用程序都使用大量的类。通常,核心逻辑中有大量用户定义的函数,而不是类。

使用函数本身并不是一个问题,但它确实意味着我们需要include定义函数的文件。但是自动加载只对类有效。最好能找到一种自动加载函数文件和类文件的方法。这将帮助我们排除更多的include电话。

这里的解决方案是将函数移动到类文件中,并在这些类上作为静态方法调用函数。这样,自动加载程序可以为我们加载类文件,然后我们可以调用该类中的方法。

这个过程比我们合并类文件时要复杂得多。以下是我们将遵循的一般流程:

  1. 查找一个include语句,该语句用于拉入函数定义文件。
  2. 将该函数定义文件转换为静态方法的类文件;我们需要为类选择一个唯一的名称,并且可能需要将函数重命名为更合适的方法名。
  3. 在原始文件和代码库中使用该文件中任何函数的所有其他文件中,将对这些函数的调用更改为静态方法调用。
  4. 通过浏览或以其他方式调用受影响的文件,抽查新的静态方法调用是否有效。
  5. 将类文件移动到中心类目录位置。
  6. 在原始文件和代码库include引入该类定义的所有其他文件中,删除相关的include语句。
  7. 再次抽查,确保所有文件现在都通过浏览或运行自动加载该类。
  8. 提交、推送和通知 QA。
  9. 重复此操作,直到不再有include调用拉入函数定义文件。

找到一个候选人,包括

我们选择一个文件,任何文件,查看include调用。所选文件中的代码可能如下所示:

1 <?php
2 require 'includes/setup.php';
3 require_once 'includes/db_functions.php';
4
5 // ...
6 $result = db_query('SELECT * FROM table_name');
7 // ...
8 ?>

我们可以看到正在使用一个db_query()函数,在检查includes/db_functions.php文件时,我们可以看到该函数以及其中定义的其他几个函数。

将函数文件转换为类文件

让我们假设这个db_functions.php文件看起来像这样:

includes/db_functions.php
1 <?php
2 function db_query($query_string)
3 {
4 // ... code to perform a query ...
5 }
6
7 function db_get_row($query_string)
8 {
9 // ... code to get the first result row
10 }
11
12 function db_get_col($query_string)
13 {
14 // ... code to get the first column of results ...
15 }
16 ?>

要将此函数文件转换为类文件,我们需要为要创建的类选择一个唯一的名称。在本例中,从文件名和函数名来看,这些都是与数据库相关的调用,这似乎非常清楚。因此,我们将这个类称为 Db。

现在我们有了一个名称,我们将创建这个类。这些函数将成为类中的静态方法。我们现在还不打算移动文件;保留其当前文件名。

然后我们进行更改,将文件转换为类定义。如果我们更改函数名,我们需要保留一个旧名称和新名称的列表,以供以后使用。更改后,它将类似于以下内容(请注意更改的方法名称):

includes/db_functions.php
1 <?php
2 class Db
3 {
4 public static function query($query_string)
5 {
6 // ... code to perform a query ...
7 }
8
9 public static function getRow($query_string)
10 {
11 // ... code to get the first result row
12 }
13
14 public static function getCol($query_string)
15 {
16 // ... code to get the first column of results ...
17 }
18 }
19 ?>

变化非常温和:我们将函数包装在一个唯一的类名中,将它们标记为public static,并对函数名进行了微小的更改。我们对函数本身的函数签名或代码没有做任何更改。

将函数调用更改为静态方法调用

我们已经将db_functions.php的内容从函数定义转换为类定义。如果我们现在尝试运行应用程序,它将失败并出现“未定义函数”错误。因此,下一步是查找整个应用程序中所有相关的函数调用,并将它们重命名为新类上的静态方法调用。

要做到这一点,没有简单的方法。这是项目范围内的搜索和替换变得非常方便的另一种情况。使用我们首选的项目范围搜索工具,搜索old函数调用,并将其替换为new静态方法调用。例如,使用正则表达式,我们可以执行以下操作:

搜索:

db_query\s*\(

替换为:

Db::query(

正则表达式表示左括号,而不是右括号,因为我们不需要在函数调用中查找参数。这有助于区分可能以我们正在搜索的函数名为前缀的函数名,例如db_query_raw()。正则表达式还允许在函数名和左括号之间使用可选空格,因为一些样式指南建议使用此类空格。

对旧函数文件中的每个old函数名执行此搜索和替换,将每个名称转换为新类文件中的new静态方法调用。

抽查静态方法调用

当我们完成将旧函数名重命名为新的静态方法调用时,我们需要运行代码库以确保一切正常。同样,没有简单的方法可以做到这一点。您可能需要浏览或以其他方式调用在此过程中更改的每个文件。

移动类文件

此时,我们已经用类定义替换了函数定义文件的内容,“测试”表明新的静态方法调用工作正常。现在我们需要将文件移动到我们的中心类目录位置,并正确命名它。

目前,我们的类定义在includes/db_functions.php文件中。该文件中的类名为Db,因此将该文件移动到其新的自动加载位置classes/Db.php。之后,文件系统将如下所示:

/path/to/app/
classes/          # our central class directory location
Db.php            # class Db { ... }
Mlaphp/
Autoloader.php    # A hypothetical autoloader class
User.php          # class User { ... }
foo/
bar/
baz.php           # a page script
includes/         # a common "includes" directory
setup.php         # setup code
index.php         # a page script
lib/              # a directory with some classes in it
sub/
Auth.php          # class Auth { ... }
Role.php          # class Role { ... }

做。。。虽然

最后,我们遵循与移动类文件时相同的结束过程:

  • 在整个代码库中删除函数定义文件的相关include调用
  • 抽查代码库
  • 提交、推送、通知 QA

现在,对我们在代码库中找到的每个函数定义文件重复它。

常见问题

我们是否应该删除自动加载器包含呼叫?

如果我们将 autoloader 代码作为静态或实例方法放置在类中,我们对include调用的搜索将显示该类文件的包含。如果删除该include调用,自动加载将失败,因为类文件尚未加载。这是一个鸡和蛋的问题。解决方案是将自动加载器include留在原位,作为引导或设置代码的一部分。如果我们对删除include调用非常认真,那么这可能是代码库中仅存的include

我们应该如何为候选人包括电话选择文件?

有几种方法可以做到这一点。我们可以做到以下几点:

  • 我们可以手动遍历整个代码库,一个文件一个文件地工作。
  • 我们可以生成一个类和函数定义文件列表,然后生成一个包含这些文件的文件列表。
  • 我们可以搜索每个include调用,并查看相关文件,看看它是否有类或函数定义。

如果包含定义了多个类怎么办?

有时一个类定义文件中可能有多个类定义。这可能会干扰自动加载过程。如果名为Foo.php的文件同时定义了FooBar类,那么Bar类将永远不会自动加载,因为文件名是错误的。

解决方案是将单个文件拆分为多个文件。也就是说,为每个类创建一个文件,并根据 PSR-0 命名和自动加载预期为每个文件命名它所包含的类。

如果每个文件一个类的规则不合适怎么办?

我有时会听到抱怨在检查文件系统时,每个文件一个类的规则在某种程度上是浪费的,或者在美学上是不令人满意的。加载这么多文件不是拖累性能吗?如果一些类只需要与其他类一起使用,比如只在一个地方使用的Exception会怎么样?我在这里有一些回应:

  • 当然,加载两个文件而不是一个文件会降低性能。问题是减少了多少,与相比减少了多少?我断言,与遗留应用程序中其他更可能出现的性能问题相比,加载多个文件的阻力是一个舍入错误。更有可能的是,我们还有其他更大的性能问题。如果这确实是一个问题,那么使用像 APC 这样的字节码缓存将减少或完全消除这些相对较小的性能影响。
  • 一致性,一致性,一致性。如果一个类文件在某些时候只有一个类,而在另一些时候一个类文件中有多个类,那么这种不一致性将成为项目中每个人的认知摩擦的来源。遗留应用程序的主要主题之一是不一致性;让我们通过遵守每个文件一个类的规则,尽可能减少这种不一致性。

如果我们觉得某些类自然地属于同一类,那么将从属类或子类放在主类或父类下面的子目录中是完全可以接受的。根据 PSR-0 命名规则,应该为更高的类或名称空间命名子目录。

例如,如果我们有一系列与Foo类相关的Exception类:

Foo.php                      # class Foo { ... }
Foo/
NotFoundException.php        # class Foo_NotFoundException { ... }
MalformedDataException.php   # class Foo_MalformedDataException { ... }

以这种方式重命名类将在实例化或引用它们的整个代码库中更改相关类名。

如果类或函数是内联定义的,该怎么办?

我见过这样的情况,页面脚本中定义了一个或多个类或函数,通常情况下,这些类或函数仅由该特定页面脚本使用。

在这些情况下,请从脚本中删除类定义,并将它们放在中心类目录位置中自己的文件中。确保按照 PSR-0 自动加载程序规则为文件的类名命名。类似地,将函数定义作为静态方法移动到它们自己的相关类文件中,并将函数调用重命名为静态方法调用。

如果定义文件也执行逻辑怎么办?

我还看到了相反的情况,在这种情况下,类文件具有一些逻辑,这些逻辑在加载文件时被执行。例如,类定义文件可能如下所示:

/path/to/foo.php
1 <?php
2 echo "Doing something here ...";
3 log_to_file('a log entry');
4 db_query('UPDATE table_name SET incrementor = incrementor + 1');
5
6 class Foo
7 {
8 // the class
9 }
10 ?>

在上述情况下,当加载文件时,将执行类定义之前的逻辑,即使从未实例化或以其他方式调用该类。

与使用页面脚本内联定义类相比,这种情况更难处理。该类应该是可加载的,没有副作用,其他逻辑应该是可执行的,而不必加载该类。

一般来说,处理这一问题的最简单方法是修改我们的搬迁流程。从原始文件中剪切类定义,并将其放置在中心类目录位置中自己的文件中。保留原始文件及其可执行代码,并保留所有相关的include调用。这允许我们拉出类定义,以便可以自动加载,但是include原始文件的脚本仍然获得可执行行为。

例如,根据上述组合的可执行代码和类定义,我们可以得到以下两个文件:

/path/to/foo.php
1 <?php
2 echo "Doing something here ...";
3 log_to_file('a log entry');
4 db_query('UPDATE table_name SET incrementor = incrementor + 1');
5 ?>
/path/to/app/classes/Foo.php
1 <?php
2 class Foo
3 {
4 // the class
5 }
6 ?>

这很混乱,但它保留了现有的应用程序行为,同时允许自动加载。

如果两个类的名称相同怎么办?

当我们开始移动类时,我们可能会发现application flow A使用Foo类,而application flow B也使用Foo类,但同名的两个类实际上是在不同文件中定义的不同类。它们从不相互冲突,因为两个不同的应用程序流从不相交。

在这种情况下,当我们将类移动到中心类目录位置时,我们必须重命名其中一个或两个类。例如,将其中一个命名为FooOne,另一个命名为FooTwo,或者选择您自己更具描述性的名称。根据 PSR-0 自动加载规则,将它们分别放在以类名命名的单独类文件中,并在整个代码库中重命名对这些类的所有引用。

第三方图书馆呢?

当我们整合类和函数时,我们可能会在遗留应用程序中找到一些第三方库。我们不想移动或重命名第三方库中的类和函数,因为这会使以后升级库变得太困难。我们必须记住哪些类被移动到了哪里,哪些函数被重命名为什么。

幸运的是,第三方库已经使用了某种自动加载。如果它带有自己的自动加载器,我们可以在设置或引导代码中将该自动加载器添加到 SPL autoloader 注册表堆栈中。如果它的自动加载由另一个自动加载系统管理,如 Composer 中的自动加载系统,我们可以将自动加载程序添加到 SPL autoloader 注册表堆栈中,同样在我们的设置或引导代码中。

如果第三方库不使用自动加载,并且依赖于其自身代码和遗留应用程序中的include调用,那么我们就有点为难了。我们不想修改库中的代码,但同时我们想从遗留应用程序中删除include调用。这里的两个解决方案是最差选项:

  • 修改应用程序的主自动加载程序以允许一个或多个第三方库
  • 为第三方库编写额外的自动加载程序,并将其添加到 SPL autoloader 注册表堆栈中。

这两个选项都超出了本书的范围。您将需要检查有问题的库,确定它的类命名方案,并自行提出适当的自动加载程序代码。

最后,就如何在遗留应用程序中组织第三方库而言,最好将它们都整合到代码库中自己的中心位置。例如,这可能位于名为3rdparty/external_libs/的目录下。如果我们移动一个库,我们应该移动整个包,而不仅仅是它的类文件,这样我们可以在以后正确地升级它。这也将允许我们将中央第三方目录从对include调用的搜索中排除,这样我们就不会从不想修改的文件中获得额外的搜索结果。

全系统图书馆怎么样?

全系统图书馆馆藏,如 Horde 和 PEAR 提供的馆藏,是第三方图书馆的特例。它们通常位于旧式应用程序的服务器文件系统之外,因此它们可供在该服务器上运行的所有应用程序使用。与这些系统范围库相关的include语句通常取决于include_path设置,否则由绝对路径引用。

当试图消除只拉入类和函数定义的include调用时,这会带来一个特殊的问题。如果我们足够幸运地使用 PEAR 安装的库,我们可以修改现有的 autoloader 以查看两个目录,而不是一个目录。这是因为 PSR-0 命名约定源于部落/梨约定。后续自动加载器代码由此更改:

1 <?php
2 // convert underscores in the class name to directory separators
3 $subpath .= str_replace('_', DIRECTORY_SEPARATOR, $class);
4
5 // the path to our central class directory location
6 $dir = '/path/to/app/classes'
7
8 // prefix with the central directory location and suffix with .php,
9 // then require it.
10 require $dir . DIRECTORY_SEPARATOR . $subpath . '.php';
11 ?>

为此:

1 <?php
2 // convert underscores in the class name to directory separators
3 $subpath .= str_replace('_', DIRECTORY_SEPARATOR, $class);
4
5 // the paths to our central class directory location and to PEAR
6 $dirs = array('/path/to/app/classes', '/usr/local/pear/php');
7 foreach ($dirs as $dir) {
8 $file = $dir . DIRECTORY_SEPARATOR . $subpath . '.php';
9 if (file_exists($file)) {
10 require $file;
11 }
12 }
13 ?>

对于函数,我们可以使用实例方法而不是静态方法吗?

当我们将用户定义的全局函数合并到类中时,我们将它们重新定义为静态方法。这使得它们的全球范围保持不变。如果我们觉得特别勤奋,我们可以将它们从静态方法更改为实例方法。这涉及到更多的工作,但最终它可以使测试更容易,并且是一种更干净的技术方法。在我们前面的Db示例中,使用实例而不是静态方法如下所示:

classes/Db.php
1 <?php
2 class Db
3 {
4 public function query($query_string)
5 {
6 // ... code to perform a query ...
7 }
8
9 public function getRow($query_string)
10 {
11 // ... code to get the first result row
12 }
13
14 public function getCol($query_string)
15 {
16 // ... code to get the first column of results ...
17 }
18 }
19 ?>

当使用实例方法而不是静态方法时,唯一增加的步骤是我们需要在调用类的方法之前实例化类。也就是说,与此相反:

1 <?php
2 Db::query(...);
3 ?>

我们会这样做:

1 <?php
2 $db = new Db();
3 $db->query(...);
4 ?>

尽管在开始时需要做更多的工作,但我推荐使用实例方法而不是静态方法。除此之外,它还为我们提供了一个可以在实例化时调用的构造函数方法,并且在许多情况下它使测试更容易。

如果愿意,您可能希望先转换为静态方法,然后再将静态方法转换为实例方法,以及所有相关的方法调用。然而,您的时间表和偏好将决定您选择哪种方法。

我们可以自动化这个过程吗?

正如我之前所指出的,这是一个乏味、乏味和耗时的过程。根据代码库的大小,可能需要几天或几周的时间来完全整合自动加载的类和函数。如果有某种方法可以使过程自动化,使其更快、更可靠,那就太好了。

不幸的是,我还没有发现任何使这个过程更容易的工具。据我所知,这种重构最好还是手工完成,并非常注重细节。在这里,有强迫倾向和长时间不间断地专注于这项任务可能是有益的。

回顾和下一步

至此,我们在使遗留应用程序现代化方面迈出了一大步。我们已经开始从面向包含的架构转换为面向类的架构。即使我们后来发现了遗漏的类或函数,也没关系;我们可以根据需要多次执行上述过程,直到所有定义都移动到中心位置。

在应用程序中,我们可能仍然有很多include语句,但是那些仍然存在的语句与应用程序流有关,而不是与类和函数定义的拉入有关。剩下的任何include调用都在执行逻辑。我们现在可以更好地看到应用程序的流程。

我们已经为新功能建立了一个结构。任何时候我们需要添加一个新的行为,我们都可以将它放在一个新的类中,并且该类将在我们需要时自动加载。我们可以停止编写新的独立函数;相反,我们将在类上编写新方法。这些新方法将更易于单元测试。

然而,我们为自动加载而整合的现有类中可能包含全局变量和其他依赖项。这使得它们彼此紧密地结合在一起,很难为它们编写测试。考虑到这一点,下一步是检查现有类中的依赖关系,并尝试打破这些依赖关系以提高应用程序的可维护性。