现代化遗留的-PHP-应用(一)

46 阅读59分钟

现代化遗留的 PHP 应用(一)

原文:zh.annas-archive.org/md5/06777b89258a8f4db4e497a7883acfb3

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我已经以各种身份专业编程超过 30 年。我仍然觉得这是一个具有挑战性和有益的职业。我每天都在学习关于我的职业的新课程,我认为对于每个致力于这项工艺的程序员来说都是如此。

更具挑战性和有益的是帮助其他程序员学习我所学到的东西。我现在已经用 PHP 工作了 15 年,在许多不同类型的组织中担任过各种职务,从初级开发人员到工程副总裁。在这段时间里,我对遗留 PHP 应用程序的共同点有了很多了解。这本书是从我现代化这些代码库的笔记和记忆中提炼出来的。我希望它能成为其他程序员的指引,带领他们走出糟糕的代码和工作环境的泥沼,走向更好的生活。

这本书也是我为了弥补我留下的遗留代码而写的。我只能说,当时我不知道现在我知道的东西。部分原因,我写这本书是为了赎罪我过去的编码罪过。我希望它能帮助你避免我的以前的错误。

第一章:传统应用程序

在其最简单的定义中,传统应用程序是指您作为开发人员从其他人那里继承的任何应用程序。它是在您到达之前编写的,您在构建过程中几乎没有或根本没有决策权。

然而,在开发人员中,“传统”这个词有更多的含义。它带有组织不良、难以维护和改进、难以理解、未经测试或无法测试等负面含义。该应用程序作为产品提供收入,但作为程序,它是脆弱的,对变化敏感。

由于这是一本专门讨论基于 PHP 的传统应用程序的书,我将提供一些我在实践中看到的 PHP 特定特征。对于我们的目的,在 PHP 中的传统应用程序是指符合以下两个或更多描述的应用程序:

  • 它使用直接放置在 Web 服务器文档根目录中的页面脚本。

  • 它在某些目录中有特殊的索引文件,以防止访问这些目录。

  • 它在一些文件的顶部有特殊的逻辑,如果某个值未设置,则会使用die()exit()

  • 它的架构是基于包含而不是基于类或对象的。

  • 它的类相对较少。

  • 存在的任何类结构都是杂乱的、不连贯的,或者是不一致的。

  • 它更多地依赖于函数而不是类方法。

  • 它的页面脚本、类和函数将模型、视图和控制器的关注点合并到同一个范围内。

  • 它显示出一次或多次未完成的重写尝试的证据,有时作为失败的框架集成。

  • 它没有为开发人员运行的自动化测试套件。

这些特征对于任何曾经处理过非常古老的 PHP 应用程序的人来说可能很熟悉。它们描述了我所说的典型 PHP 应用程序。

典型 PHP 应用程序

大多数 PHP 开发人员并没有接受过正式的编程培训,或者几乎完全是自学的。他们通常是从其他非技术专业转入这门语言。不知何故,他们被赋予了创建网页的任务,因为他们被视为组织中最懂技术的人。由于 PHP 是一种宽容的语言,并且在没有太多纪律的情况下赋予了很多权力,因此很容易在没有太多培训的情况下制作工作的网页甚至应用程序。

这些和其他因素强烈影响了典型 PHP 应用程序的基础。它们通常不是用流行的全栈框架甚至微框架编写的。相反,它们通常是一系列页面脚本,直接放置在 Web 服务器文档根目录中,客户端可以直接浏览。需要重复使用的任何功能都已经被收集到一系列“包含”文件中。有用于常见配置和设置、页眉和页脚、常见表单和内容、函数定义、导航等的“包含”文件。

典型 PHP 应用程序中对“包含”文件的依赖是我称之为基于包含的架构的原因。传统应用程序在程序的各个部分之间使用“包含”调用来将它们耦合成一个整体。这与面向类的架构形成对比,即使应用程序不遵循良好的面向对象编程原则,至少行为被捆绑到类中。

文件结构

典型的基于包含的 PHP 应用程序通常看起来像这样:

**/path/to/docroot/**
bin/                         # command-line tools
cache/                    # cache files
common/                # commonly-used include files
classes/                 # custom classes
Image.php            #
Template.php       #
functions/             # custom functions
db.php                 #
log.php                #
cache.php           #
setup.php            # configuration and setup
css/                     # stylesheets
img/                    # images
index.php           # home page script
js/                       # JavaScript
lib/                     # third-party libraries
log/                    # log files
page1.php        # other page scripts
page2.php        #
page3.php        #
sql/                   # schema migrations
sub/                  # sub-page scripts
index.php         #
subpage1.php #
subpage2.php #
theme/             # site theme files
header.php      # a header template
footer.php        # a footer template
nav.php           # a navigation template ~~

所示的结构是一个简化的示例。有许多可能的变化。在一些传统应用程序中,我曾看到成百上千的主要页面脚本和数十个子目录,这些子目录有它们自己独特的层次结构用于额外的页面。关键是传统应用程序通常位于文档根目录中,具有用户可以直接浏览的页面脚本,并且使用“包含”文件来管理大部分程序行为,而不是类和对象。

页面脚本

传统应用程序将使用单独的页面脚本作为公共行为的访问点。每个页面脚本负责设置全局环境,执行请求的逻辑,然后将输出传递给客户端。

附录 A,典型的传统页面脚本包含了一个真实应用程序中典型传统页面脚本的经过消毒、匿名化的版本。我已经自作主张使缩进保持一致(原本,缩进有些随机),并将其包装在 60 个字符中,以便更好地适应电子阅读器屏幕。现在去看看它,但要小心。如果你变瞎了或者因此经历了创伤后应激障碍,我不会对此负责!当我们检查它时,我们发现了许多使维护和改进变得困难的问题:

  • include语句执行设置和呈现逻辑

  • 内联函数定义

  • 全局变量

  • 模型、视图和控制器逻辑都集成在一个单独的脚本中

  • 信任用户输入

  • 可能的 SQL 注入漏洞

  • 可能的跨站脚本漏洞

  • 未引用的数组键生成通知

  • 未用大括号包裹的if块(稍后在块中添加一行实际上不会成为块的一部分)

  • 复制和粘贴重复

附录 A,典型的传统页面脚本示例相对来说比传统页面脚本要温和一些。我见过其他脚本,其中混合了 JavaScript 和 CSS 代码,还有远程文件包含和各种安全漏洞。它也只有(!)大约 400 行长。我见过数千行长的页面脚本,生成了几种不同的页面变体,都包含在一个单独的switch语句中,有十几个case条件。

重写还是重构?

许多开发人员在面对典型的 PHP 应用程序时,只能忍受一段时间,然后就想要放弃并从头开始重写。从轨道上摧毁它;这是这些热情洋溢、充满活力的程序员的呐喊口号。其他开发人员,由于他们的死亡行军经历而失去了热情,对这样的建议感到谨慎和警惕。他们完全意识到代码库很糟糕,但他们所知道的魔鬼(或在我们的情况下,代码)总比他们不知道的魔鬼好。

重写的利弊

完全重写是一个非常诱人的想法。主张重写的开发人员觉得他们将能够第一次就做对所有正确的事情。他们将能够编写单元测试,强制执行最佳实践,根据现代模式定义分离关注点,并使用最新的框架,甚至编写自己的框架(因为他们最了解自己的需求)。因为现有应用程序可以作为参考实现,他们确信在重写应用程序时几乎不需要试错。所需的行为已经存在;所有开发人员需要做的就是将它们复制到新系统中。在现有系统中难以实现的行为可以作为重写的一部分从一开始就添加进去。

尽管重写听起来很诱人,但它充满了许多危险。Joel Spolsky 在 2000 年关于旧版网景导航器网络浏览器重写的评论如下:

网景公司犯了软件公司可能犯的最严重的战略错误,决定从头开始重写他们的代码。路易·蒙图利是网景导航器原始版本的五位编程超级巨星之一,他给我发电子邮件说,我完全同意,这是我从网景辞职的主要原因之一。这个决定让网景损失了 3 年时间。这是三年时间,公司无法添加新功能,无法应对来自 Internet Explorer 的竞争威胁,不得不坐视微软完全吞掉他们的市场份额。
--乔尔·斯波尔斯基,《网景疯了》

网景因此破产了。

乔什·科尔讲述了一个类似的故事,涉及 TextMate:

Macromates 是一家成功的文本编辑器 Textmate 的独立公司,决定重写 Textmate 2 的代码基础。他们花了 6 年时间才发布了一个测试版,这在当今的时间里是一个漫长的时间,他们失去了很多市场份额。当他们发布测试版时,已经太迟了,6 个月后他们放弃了这个项目,并将其推到 Github 上作为一个开源项目。 
--乔什·科尔,《TextMate 2 和为什么不应该重写你的代码》

弗雷德·布鲁克斯称对进行完全重写的冲动为第二系统效应。他在 1975 年写道:

第二个是人类设计的最危险的系统。…一般的倾向是过度设计第二个系统,使用在第一个系统上小心翼翼地搁置的所有想法和装饰。…第二系统效应…倾向于完善那些由于基本系统假设的变化而已经过时的技术。…项目经理如何避免第二系统效应?坚持要求至少有两个系统经验的高级架构师。 
--弗雷德·布鲁克斯,《神话般的程序员月工作》,第 53-58 页。

开发人员在四十年前和今天是一样的。我期待他们在未来四十年也是一样的;人类始终是人类。过度自信、不够悲观、对历史的无知以及成为自己的客户的愿望都会让开发人员很容易地产生理性化,认为这一次会有所不同,当他们尝试重写时。

为什么重写不起作用?

重写很少成功的原因有很多,但我只会集中在一个一般原因上:资源、知识、沟通和生产力的交集。(一定要阅读《神话般的程序员月工作》(第 13-26 页),了解与将资源和计划安排视为可互换元素相关的问题的出色描述。)

与所有事物一样,我们只有有限的资源来对抗重写项目。组织中只有一定数量的开发人员。这些开发人员将不得不同时对现有程序进行维护编写程序的全新版本。任何参与一个项目的开发人员将无法参与另一个项目。

上下文切换问题

一个想法是让现有的开发人员在旧应用程序和新应用程序上花一部分时间。然而,将开发人员在两个项目之间移动不会带来生产力的均衡分配。由于上下文切换的认知负荷,开发人员在每个项目上的生产力将不到一半。

知识问题

为了避免在维护和重写之间切换开发人员带来的生产力损失,组织可能会尝试雇佣更多的开发人员。然后一些人可以专门负责旧项目,另一些人可以专门负责新项目。不幸的是,这种方法揭示了 F·A·哈耶克所说的知识问题。最初应用于经济领域,知识问题同样适用于编程。

如果我们让新开发人员参与重写项目,他们将不了解现有系统、现有问题、业务目标,甚至可能不了解进行重写的最佳实践。他们将需要在这些方面接受培训,很可能是由现有的开发人员进行培训。这意味着已经被指定维护现有程序的现有开发人员将不得不花费大量时间向新员工传授知识。所涉及的时间量是非常可观的,这种知识的传递将不得不持续,直到新开发人员和现有开发人员一样熟练。这意味着资源的线性增加导致生产力的不成比例增加:程序员数量增加 100%将导致产出不到 50%的增加,有时甚至更少。

或者,我们可以让现有的开发人员参与重写项目,让新员工负责维护现有程序。这也暴露了一个知识问题,因为新开发人员完全不熟悉该系统。他们将从哪里获取他们需要做工作的知识?当然是从现有的开发人员那里,他们仍然需要花费宝贵的时间向新员工传授知识。我们再次看到,开发人员的线性增加导致生产力的不成比例增加。

时间表问题

为了解决知识问题和相关的沟通成本,有些人可能会觉得处理项目的最佳方式是将所有现有的开发人员都专门用于重写,并延迟对现有系统的维护和升级,直到重写完成。这是一个很大的诱惑,因为开发人员会急于解决自己的问题,并成为自己的客户-对他们想要拥有的功能和他们想要进行的修复感到兴奋。这些愿望会导致他们高估自己进行全面重写的能力,低估完成所需的时间。而管理者则会接受开发人员的乐观态度,可能会在时间表中添加一些缓冲以确保安全。

当开发人员意识到任务实际上比他们最初想象的要大得多和更加压倒性时,他们的过度自信和乐观主义将变成沮丧和痛苦。重写将比预期的时间长得多,不是一点点,而是一个数量级或更多。在重写期间,现有程序将被搁置-存在错误和缺少功能-令现有客户失望,无法吸引新客户。重写项目最终将成为一场惊慌的死亡行军,不惜一切代价完成,结果将是一个与第一个一样糟糕的代码库,只是以不同的方式。它只是第一个系统的复制品,因为时间表的压力将决定新功能要延迟到初始发布之后。

迭代重构

考虑到完全重写所带来的风险,我建议进行重构。重构意味着通过小步骤改进程序的质量,而不改变程序的功能。整个系统引入了一个相对较小的变化。然后测试系统以确保它仍然正常工作,最后将系统投入生产。第二个小变化建立在前一个变化的基础上,依此类推。随着时间的推移,系统变得更容易维护和改进。

重构方法显然不如完全重写吸引人。它违背了大多数开发人员的核心感知。开发人员必须继续长时间地使用系统,无论其中存在什么问题。他们不能立刻切换到最新、最热门的框架。他们不能成为自己的客户,并满足第一次就把事情做对的愿望。作为一种长期策略,重构方法并不吸引那些更看重快速开发新应用而不是修补现有应用的文化。开发人员通常更喜欢开始自己的新项目,而不是维护他人开发的旧项目。

然而,作为一种降低风险的策略,使用迭代的重构方法无疑优于重写。与重写项目中的任何类似部分相比,单个重构本身要小得多。它们可以在比重写更短的时间内应用,并且在每次迭代结束时都会使现有代码库保持工作状态。现有应用程序从未停止运行或进展。迭代的重构可以集成到一个更大的过程中,其中安排允许进行错误修复、功能添加和重构以改进下一个周期。

最后,任何单个重构步骤的目标不是完美,而是改进。我们并不试图在长时间内实现一个不可能的目标。我们正在朝着可以在短时间内实现的易于可视化的目标迈出小步。每个小的重构胜利都将提高士气,并激发对下一个重构步骤的热情。随着时间的推移,这些许多小胜利积累成一个大胜利:一个永远不停地为企业创造收入的现代化代码库。

遗留框架

到目前为止,我们一直在讨论基于页面、包含导向系统的遗留应用程序。然而,还有大量基于公共框架的遗留代码。

基于框架的遗留应用程序

PHP 领域中的每个不同的公共框架都有其独特的问题。使用CakePHPcakephp.org/)编写的应用程序遇到的遗留问题与使用 CodeIgniter、Solar、Symfony 1、Zend Framework 1 等编写的应用程序遇到的问题不同。每个不同的框架及其各种变体都鼓励应用程序中不同类型的紧耦合。因此,需要重构使用其中一个框架构建的应用程序的具体步骤与需要为另一个框架进行重构的步骤非常不同。

因此,本书的各个部分可能作为指导遗留应用程序不同部分的重构的指南,但整体上,本书并不针对基于这些公共框架的应用程序进行重构。

内部、私有或其他非公开框架由组织内部的架构师直接控制,很可能会受益于本书中包含的重构。

重构到框架

我有时听说开发人员明智地希望避免完全重写,而是希望重构或迁移到公共框架。这听起来像是两全其美,结合了迭代方法和开发人员使用最新技术的愿望。

我对遗留 PHP 应用程序的经验是,它们对框架集成的抵抗几乎和对单元测试一样强烈。如果应用程序已经处于可以将其逻辑移植到框架的状态,那么首先移植它的必要性就很小。

然而,当我们完成本书中的重构时,应用很可能会处于一个更适合进行公共框架迁移的状态。开发人员是否仍然愿意这样做是另一回事。

回顾和下一步

在这一点上,我们意识到重写虽然吸引人,但是是一种危险的方法。迭代的重构方法听起来更像是实际工作,但它的好处是可以实现和现实的。

下一步是通过解决一些先决条件来为重构方法做好准备。之后,我们将通过一系列相对较小的步骤来现代化我们的遗留应用程序,每章一步,每一步都分解成易于遵循的过程,并提供常见问题的答案。

让我们开始吧!

第二章:先决条件

在我们开始现代化我们的应用程序之前,我们需要确保我们有必要的先决条件来进行重构工作。这些先决条件如下:

  • 一个修订控制系统

  • PHP 5.0 或更高版本

  • 具有多文件搜索和替换功能的编辑器或集成开发环境

  • 某种风格指南

  • 一个测试套件

修订控制

修订控制(也称为源代码控制或版本控制)允许我们跟踪我们对代码库所做的更改。我们可以进行更改,然后提交到源代码控制,进行更多更改并提交它们,然后将我们的更改推送给团队中的其他开发人员。如果我们发现错误,我们可以恢复到代码库的早期版本,到错误不存在的地方重新开始。

如果你没有使用 Git、Mercurial、Subversion 或其他修订控制系统等源代码控制工具,那么这是你需要首先安装的。即使你不对你的 PHP 应用进行现代化,使用源代码控制也会对你有很大的好处。

在许多方面我更喜欢 Mercurial,但我承认 Git 被更广泛地使用,因此我必须推荐新用户使用 Git 作为源代码控制系统。

虽然本书讨论如何设置和使用源代码控制系统已经超出了范围,但有一些很好的 Git 书籍和 Mercurial 书籍可以免费获取。

PHP 版本

为了应用本书中列出的重构,我们至少需要安装 PHP 5.0。是的,我知道 PHP 5.0 已经过时了,但我们在谈论遗留应用程序。完全有可能业务所有者多年来没有升级他们的 PHP 版本。PHP 5.0 是最低要求,因为那时类自动加载变得可用,我们依赖自动加载作为我们的第一个改进之一。(如果由于某种原因我们被困在 PHP 4.x 上,那么这本书将没有什么用处。)

如果可能的话,我们应该升级到最新版本的 PHP。我建议使用你选择操作系统上最新版本的 PHP。在本书的最新更新时,最新版本分别是 PHP 5.6.11、5.5.27 和 5.4.43。

从旧的 PHP 版本升级可能本身就需要修改应用程序,因为 PHP 的次要版本之间存在变化。要小心和注意细节地处理这个问题:查看发布说明和所有中间版本的发布说明,检查代码库,识别问题,进行修复,本地抽查,提交,推送,并通知 QA。

编辑器/集成开发环境

在本书中,我们将在整个遗留代码库中进行大量搜索和修改。我们需要一个文本编辑器或集成开发环境,可以让我们同时在多个文件中查找和替换文本。这些包括:

  • Emacs

  • PHPStorm

  • SublimeText

  • TextMate

  • Vim

  • Zend Studio

很可能还有其他的。

或者,如果我们的 CLI-fu 很强,我们可能希望在命令行中同时跨多个文件使用 grep 和sed

风格指南

在整个代码库中使用一致的“风格指南”编码风格是一个重要的考虑因素。我见过的大多数遗留代码库都是多年来各个作者喜欢的风格混合在一起。这种混合的一个例子是混合使用制表符和空格来缩进代码块:项目初期的开发人员使用 2 个空格缩进,项目中期的开发人员使用制表符,最近的开发人员使用 4 个空格。这导致一些子块完全与其父块不一致,要么缩进太多,要么不够,使得很难扫描块的开头或结尾。

我们都渴望一致、熟悉的编码风格。几乎没有比将陌生或不受欢迎的编码风格重新格式化为更可取的风格更强烈的冲动。但是修改现有的风格,无论它有多丑陋或不一致,都可能引起微妙的错误和行为变化,甚至只是在条件语句中添加或删除大括号。另一方面,我们希望代码是一致和熟悉的,以便我们可以以最少的认知摩擦来阅读它。

在这里很难给出好的建议。我建议唯一修改现有风格的原因是当单个文件内部不一致时。如果它很丑陋或陌生,但在整个代码库中是一致的,重新格式化可能会引起更多问题。

如果您决定重新格式化,请在将代码从一个文件移动到另一个文件时进行,或者在将文件从一个位置移动到另一个位置时进行。这将大规模的提取和重定位与更微妙的样式修改相结合,使得可以在单次操作中测试这些更改。

最后,您可能希望转换为全新的风格,即使现有的风格在整个代码库中都是一致的。抵制这种冲动。如果您对全面重构的渴望是压倒性的,无法忽视,那么请使用公开记录的非项目特定的编码风格,而不是尝试创建或应用您自己的个人或项目特定的风格。本书中的代码使用 PSR-1 和 PSR-2 风格建议来反映这一建议。

测试套件

由于这是一本关于遗留应用的书,期望代码库有一套单元测试套件是非常乐观的。大多数遗留应用程序,特别是包含导向、基于页面的应用程序,都对单元测试非常抵抗。没有要测试的单元,只有紧密耦合的功能的一团乱麻。

然而,测试遗留应用是可能的。关键在于不是测试系统单元应该做什么,而是测试系统作为一个整体已经做了什么。成功测试的标准是系统在更改后生成与更改前相同的输出。这种测试称为表征测试

本书的范围不包括讨论如何编写表征测试套件。已经有一些很好的工具可以编写这些测试,例如 Selenium 和 Codeception。在我们开始重构代码库之前进行这种测试是非常宝贵的。我们将能够在每次更改后运行测试,以确保应用程序仍然正常运行。

我不会假装先决条件:测试套件"我们可能会花时间编写这些测试。如果我们一开始就对测试感兴趣,我们可能已经有某种测试套件了。这里的问题是一个非常人性化的问题,不是为了做正确的事情或甚至是理性期望,而是基于奖励的激励。编写测试的奖励是长期的,而立即对代码库进行改进的奖励感觉立即得到回报,即使我们必须忍受手动检查应用程序输出。

如果你有时间、自律和资源,最好的选择是为你知道你将要重构的应用程序部分创建一系列的特性测试。这是最负责任和最专业的方法。作为第二选择,如果你有一个 QA 团队,他们已经有一系列应用程序范围的测试,你可以委托测试过程给他们,因为他们已经在做了。也许他们会在你对代码库进行更改时向你展示如何在本地运行测试套件。最后,作为最不专业但最有可能的选择,当你进行更改时,你将不得不通过手工伪测试或抽查应用程序。这可能是你已经习惯做的事情。随着你的代码库的改进,改进自己的实践的回报将变得更加明显;就像重构一般,目标是通过小的增量使事情变得比以前更好,而不是坚持立即完美。

回顾和下一步

在这一点上,我们应该已经准备好所有的先决条件,特别是我们的修订控制系统和一个现代版本的 PHP。现在我们可以开始我们的重构的第一步:向代码库添加一个自动加载器。

第三章:实现自动加载器

在这一步中,我们将设置自动类加载。之后,当我们需要一个类文件时,我们将不需要includerequire语句来为我们加载它。在继续之前,您应该查看 PHP 自动加载器的文档 - www.php.net/manual/en/language.oop5.autoload.php

PSR-0

PHP 领域中有许多不同的自动加载器建议。我们将使用基于名为PSR-0的东西来现代化我们的旧应用程序。

PSR-0 是 PHP 框架互操作性组的一个推荐,用于构建您的类文件。该推荐起源于许多项目使用“类到文件”命名约定的长期历史,从 PHP 4 时代开始。该约定最初由 Horde 和 PEAR 发起,后来被早期的 PHP 5 项目(如 Solar 和 Zend Framework)以及后来的项目(如 Symfony2)采用。

我们使用 PSR-0 而不是更新的 PSR-4 建议,因为我们正在处理旧代码,这些代码可能是在 PHP 5.3 命名空间出现之前开发的。在 PHP 5.3 之前编写的代码无法访问命名空间分隔符,因此遵循类到文件命名约定的作者通常会在类名中使用下划线作为伪命名空间分隔符。PSR-0 为旧的非 PHP-5.3 伪命名空间做出了让步,使其更适合我们的旧代码需求,而 PSR-4 则不适用。

根据 PSR-0,类名直接映射到文件系统的子路径。给定一个完全合格的类名,任何 PHP 5.3 命名空间分隔符都会转换为目录分隔符,并且名称中的下划线也会转换为目录分隔符。(命名空间部分中的下划线会转换为目录分隔符。)结果会以基本目录位置为前缀,并以.php为后缀,创建一个文件路径,类文件可以在其中找到。例如,完全合格的类名\Foo\Bar\Baz_Dib将在 UNIX 风格的文件系统上的子路径Foo/Bar/Baz/Dib.php中找到。

类的单一位置

在实现 PSR-0 自动加载器之前,我们需要选择代码库中的一个目录位置,用于保存代码库中将来使用的所有类。一些项目已经有了这样的位置;它可能被称为includesclassessrclib或类似的名称。

如果已经存在这样的位置,请仔细检查它。它是否包含类文件,还是包含其他类型的文件?如果它除了类文件之外还有其他东西,或者没有这样的位置存在,那么创建一个新的目录位置,并将其命名为 classes(或其他适当描述的名称)。

这个目录将是整个项目中使用的所有类的中央位置。稍后,我们将开始将类从项目中分散的位置移动到这个中央位置。

添加自动加载器代码

一旦我们有了一个中央目录位置用于存放我们的类文件,我们需要设置一个自动加载器来在该位置查找类。我们可以将自动加载器创建为静态方法、实例方法、匿名函数或常规全局函数。(我们使用哪种方法并不像实际进行自动加载那样重要。)然后我们将在我们的引导或设置代码中早期使用spl_autoload_register()进行注册,以便在调用任何类之前进行注册。

作为全局函数

也许实现我们的新自动加载器代码最直接的方法是作为全局函数。下面,我们找到要使用的自动加载器代码;函数名以mlaphp_为前缀,以确保它不会与任何现有的函数名冲突。

**setup.php**
1 <?php
2 // ... setup code ...
3
4 // define an autoloader function in the global namespace
5 function mlaphp_autoloader($class)
6 {
7 // strip off any leading namespace separator from PHP 5.3
8 $class = ltrim($class, '\\');
9
10 // the eventual file path
11 $subpath = '';
12
13 // is there a PHP 5.3 namespace separator?
14 $pos = strrpos($class, '\\');
15 if ($pos !== false) {
16 // convert namespace separators to directory separators
17 $ns = substr($class, 0, $pos);
18 $subpath = str_replace('\\', DIRECTORY_SEPARATOR, $ns)
19 . DIRECTORY_SEPARATOR;
20 // remove the namespace portion from the final class name portion
21 $class = substr($class, $pos + 1);
22 }
23
24 // convert underscores in the class name to directory separators
25 $subpath .= str_replace('_', DIRECTORY_SEPARATOR, $class);
26
27 // the path to our central class directory location
28 $dir = '/path/to/app/classes';
29
30 // prefix with the central directory location and suffix with .php,
31 // then require it.
32 $file = $dir . DIRECTORY_SEPARATOR . $subpath . '.php';
33 require $file;
34 }
35
36 // register it with SPL
37 spl_autoload_register('mlaphp_autoloader');
38 ?>

请注意,$dir变量表示中央类目录的基本路径的绝对目录。作为 PHP 5.3 及更高版本的替代方案,可以在该变量中使用__DIR__常量,以便绝对路径不再是硬编码的,而是相对于函数所在文件的路径。例如:

1 <?php
2 // go "up one directory" for the central classes location
3 $dir = dirname(__DIR__) . '/classes';
4 ?>

如果由于某种原因我们卡在 PHP 5.2 上,__DIR__常量是不可用的。在这种情况下,可以用dirname(dirname(__FILE__))替换dirname(__DIR__)

作为闭包

如果我们使用的是 PHP 5.3,我们可以将自动加载器代码创建为一个闭包,并在一个步骤中将其注册到 SPL 中:

**setup.php**
1 <?php
2 // ... setup code ...
3
4 // register an autoloader as an anonymous function
5 spl_autoload_register(function ($class) {
6 // ... the same code as in the global function ...
7 });
8
9 // ... other setup code ...
10 ?>

作为静态或实例方法

这是我设置自动加载器的首选方式。我们不使用函数,而是将自动加载器代码创建为一个类的实例方法或静态方法。我推荐实例方法而不是静态方法,但您的情况将决定哪种更合适。

首先,我们在我们的中央类目录位置创建我们的自动加载器类文件。如果我们使用的是 PHP 5.3 或更高版本,我们应该使用一个合适的命名空间;否则,我们使用下划线作为伪命名空间分隔符。

以下是一个 PHP 5.3 的示例。在 PHP 5.3 之前的版本中,我们将省略namespace声明,并将类命名为Mlaphp_Autoloader。无论哪种方式,文件都应该在子路径Mlaphp/Autoloader.php中:

**/path/to/app/classes/Mlaphp/Autoloader.php**
1 <?php
2 namespace Mlaphp;
3
4 class Autoloader
5 {
6 // an instance method alternative
7 public function load($class)
8 {
9 // ... the same code as in the global function ...
10 }
11
12 // a static method alternative
13 static public function loadStatic($class)
14 {
15 // ... the same code as in the global function ...
16 }
17 }
18 ?>

然后,在设置或引导文件中,require_once类文件,根据需要实例化它,并使用 SPL 注册方法。请注意,我们在这里使用数组可调用格式,第一个数组元素是类名或对象实例,第二个元素是要调用的方法:

**setup.php**
1 <?php
2 // ... setup code ...
3
4 // require the autoloader class file
5 require_once '/path/to/app/classes/Mlaphp/Autoloader.php';
6
7 // STATIC OPTION: register a static method with SPL
8 spl_autoload_register(array('Mlaphp\Autoloader', 'loadStatic'));
9
10 // INSTANCE OPTION: create the instance and register the method with SPL
11 $autoloader = new \Mlaphp\Autoloader();
12 spl_autoload_register(array($autoloader, 'load'));
13
14 // ... other setup code ...
15 ?>

请选择实例方法或静态方法,而不是两者兼有。一个不是另一个的备用。

使用__autoload()函数

如果由于某种原因我们卡在 PHP 5.0 上,我们可以使用__autoload()函数来代替 SPL 自动加载器注册。这样做有一些缺点,但在 PHP 5.0 下这是我们唯一的选择。我们不需要在 SPL 中注册它(事实上,我们不能这样做,因为 SPL 直到 PHP 5.1 才被引入)。在这种实现中,我们将无法混合和匹配其他自动加载器;只允许一个__autoload()函数。如果__autoload()函数已经被定义,我们需要将这段代码与函数中已经存在的任何代码合并:

**setup.php**
1 <?php
2 // ... setup code ...
3
4 // define an __autoload() function
5 function __autoload($class)
6 {
7 // ... the global function code ...
8 }
9
10 // ... other setup code ...
11 ?>

我强烈建议不要在 PHP 5.1 及更高版本中使用这种实现。

自动加载器优先级

无论我们如何实现我们的自动加载器代码,我们都需要在代码库中调用任何类之前使其可用。在我们的代码库中注册自动加载器作为最初的逻辑之一可能不会有害,可能在设置或引导脚本中。

常见问题

如果我已经有一个自动加载器呢?

一些传统应用程序可能已经有一个自定义的自动加载器。如果是这种情况,我们有一些选择:

  1. 使用现有的自动加载器:如果已经有一个应用程序类文件的中央目录位置,这是我们最好的选择。

  2. 修改现有的自动加载器以添加 PSR-0 行为:如果自动加载器不符合 PSR-0 建议,这是一个很好的选择。

  3. 在 SPL 中注册本章描述的 PSR-0 自动加载器,以及现有的自动加载器。当现有的自动加载器不符合 PSR-0 建议时,这是另一个很好的选择。

其他传统代码库可能已经有第三方自动加载器,比如 Composer。如果 Composer 存在,我们可以获取它的自动加载器实例,并像这样添加我们的中央类目录位置进行自动加载:

1 <?php
2 // get the registered Composer autoloader instance from the vendor/
3 // subdirectory
4 $loader = require '/path/to/app/vendor/autoload.php';
5
6 // add our central class directory location; do not use a class prefix as
7 // we may have more than one top-level namespace in the central location
8 $loader->add('', '/path/to/app/classes');
9 ?>

有了这个,我们可以利用 Composer 来实现我们自己的目的,从而使我们自己的自动加载器代码变得不必要。

自动加载的性能影响是什么?

有一些理由认为使用自动加载器可能会导致轻微的性能下降,与使用include相比,但证据参差不齐且情况依赖。如果自动加载相对较慢,那么可以预期会有多大的性能损失?

我断言,在现代化遗留应用程序时,这可能并不是一个重要的考虑因素。与遗留应用程序中可能存在的其他性能问题相比,自动加载所带来的性能损失微不足道,比如数据库交互。

在大多数遗留应用程序中,甚至在大多数现代应用程序中,试图优化自动加载的性能是试图优化错误的资源。可能存在其他更严重的资源,只是我们看不到或没有考虑到。

如果自动加载是你的遗留应用中性能最差的瓶颈,那么你的状况非常好。(在这种情况下,你应该退还这本书,然后告诉我你是否在招聘,因为我想为你工作。)

类名如何映射到文件名?

PSR-0 规则可能令人困惑。以下是一些类到文件映射的示例,以说明其期望:

Foo           => Foo.php
Foo_Bar       => Foo/Bar.php
Foo           => Foo/Bar.php
Foo_Bar\Bar   => Foo_Bar/Baz.php
Foo\Bar\Baz   => Foo/Bar/Baz.php # ???
Foo\Baz_Bar   => Foo/Bar/Baz.php # ???
Foo_Bar_Baz   => Foo/Bar/Baz.php # ???

我们可以看到最后三个示例中存在一些意外行为。这是由于 PSR-0 的过渡性质造成的:Foo\Bar\Baz, Foo\Bar_BazFoo_Bar_Baz都映射到同一个文件。为什么会这样?

回想一下,PHP 5.3 之前的代码库没有命名空间,因此使用下划线作为伪命名空间分隔符。PHP 5.3 引入了真正的命名空间分隔符。PSR-0 标准必须同时适应这两种情况,因此它将相对类名(即完全合格名称的最后部分)中的下划线视为目录分隔符,但命名空间部分中的下划线则保持不变。

这里的教训是,如果你使用的是 PHP 5.3,你不应该在相对类名中使用下划线(尽管在命名空间中使用下划线是可以的)。如果你使用的是 PHP 5.3 之前的版本,你别无选择,只能使用下划线,因为只有类名,没有实际的命名空间部分;在这种情况下,将下划线解释为命名空间分隔符。

回顾和下一步

到目前为止,我们并没有对我们的遗留应用进行太多修改。我们添加并注册了一些自动加载器代码,但实际上还没有被调用。

无论如何。拥有一个自动加载器对于我们现代化遗留应用的下一步至关重要。使用自动加载器将允许我们开始移除仅加载类和函数的include语句。剩下的include语句将是逻辑流程包含,向我们展示系统的哪些部分是逻辑的,哪些是仅定义的。这是我们从基于 include 的架构向基于类的架构过渡的开始。

第四章:合并类和函数

现在我们已经有了一个自动加载程序,我们可以开始删除所有只加载类和函数定义的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语句,用于加载类定义,现在我们将该类定义文件移动到中心类目录位置,以便我们的自动加载程序函数可以找到它。现在的文件系统布局如下(请注意,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类文件,而这个位置已经不存在了。我们需要从代码中删除这个调用:

**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 和操作系统。

  • 在 GUI 编辑器中,如 TextMate、SublimeText 和 PHPStorm,通常有一个在项目中查找的菜单项,我们可以用它来一次性搜索所有应用程序文件中的字符串或正则表达式。

  • 在 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.

(正则表达式使用.表示任何字符,所以我们必须指定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';

因此,最好宽松一点地搜索以获得每个可能的候选项,然后手动处理结果。

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

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

抽查代码库

在删除给定类的include语句之后,我们现在需要确保应用程序正常工作。不幸的是,因为我们没有建立测试流程,这意味着我们需要通过浏览或以其他方式调用修改后的文件来进行伪测试或抽查。实际上,这通常并不困难,但很繁琐。

当我们进行抽查时,我们特别寻找文件未找到类未定义错误。这意味着分别尝试include缺失的类文件,或者自动加载程序无法找到类文件。

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

提交、推送、通知 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

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

常见问题

我们应该删除自动加载器的 include 调用吗?

如果我们将我们的自动加载器代码放在一个类中作为静态或实例方法,我们搜索include调用将会显示该类文件的包含。如果你移除了那个include调用,自动加载将会失败,因为类文件没有被加载。这是一个鸡生蛋蛋生鸡的问题。解决方法是将自动加载器的include保留在我们的引导或设置代码中。如果我们完全勤奋地删除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 ?>

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

如果两个类有相同的名称会怎么样?

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

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

第三方库呢?

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

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

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

  • 修改我们应用程序的主自动加载器,以允许一个或多个第三方库

  • 为第三方库编写额外的自动加载器,并将其添加到 SPL 自动加载器注册表堆栈中。

这两个选项都超出了本书的范围。您需要检查相关的库,确定其类命名方案,并自行编写适当的自动加载器代码。

最后,在如何组织旧应用程序中的第三方库方面,将它们全部整合到自己的中心位置可能是明智的选择。例如,这可能是在一个名为3rdparty/external_libs/的目录下。如果我们移动一个库,我们应该移动整个包,而不仅仅是它的类文件,这样我们以后可以正确地升级它。这还将使我们能够从我们不想修改的文件中排除中心第三方目录,以免得到额外的include调用搜索结果。

那么系统范围的库呢?

系统范围的库集合,比如 Horde 和 PEAR 提供的库,是第三方库的特例。它们通常位于服务器文件系统外部,以便可以供运行在该服务器上的所有应用程序使用。与这些系统范围库相关的include语句通常依赖于include_path设置,或者是通过绝对路径引用的。

当试图消除仅引入类和函数定义的include调用时,这些选项会带来特殊的问题。如果我们足够幸运地使用了 PEAR 安装的库,我们可以修改现有的自动加载器,使其在两个目录而不是一个目录中查找。这是因为 PSR-0 命名约定源自 Horde/PEAR 约定。尾随的自动加载器代码从这个:

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调用都在执行逻辑。我们现在可以更好地看到应用程序的流程。

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

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

第五章:用依赖注入替换全局变量

到目前为止,我们所有的类和函数都已经整合到一个中心位置,并且所有相关的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 ?>

提示

如果在同一个类中有多个“全局”调用,我们应该将它们全部转换为该类中的属性。我们希望一次只处理一个类,因为这样可以使后续过程更容易进行。

抽查类

现在我们已经将“全局”调用转换为此一个类中的属性,我们需要测试应用程序以确保它仍然可以正常工作。然而,由于尚未建立正式的测试系统,我们通过浏览或调用使用修改后的类的文件来进行伪测试或抽查。

如果愿意,我们可以在确定应用程序仍然正常工作后进行临时提交。我们暂时不会推送到中央仓库或通知 QA;我们只是想要一个可以回滚的点,以便在以后需要撤销更改时使用。

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

一旦我们确定类在属性放置的情况下可以正常工作,我们需要将构造函数中的“全局”调用转换为使用传递的参数。鉴于我们上面的“示例”类,转换后的版本可能如下所示:

**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 ?>

我们所做的只是移除“全局”调用,并添加构造函数参数。我们需要对构造函数中的每个“全局”都这样做。

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

将实例化转换为使用参数

在将“全局”变量转换为构造函数参数后,我们会发现遗留应用程序中类的每个实例化现在都已经失效。这是因为构造函数签名已经改变。考虑到这一点,我们现在需要搜索整个代码库(不仅仅是类)以查找类的实例化,并将实例化更改为新的签名。

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

**new\s+Example\W**

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

注意

格式问题

遗留代码库以格式混乱而闻名,这意味着在某些情况下,这个表达式并不完美。这里给出的表达式可能无法找到实例化,例如,当new关键字在一行上,类名紧随其后,但在下一行而不是同一行上时。

使用 use 的类别名

在 PHP 5.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. 在进行静态调用之前创建Foo的实例及其$bar依赖项(例如,$foo = new Foo($bar);),并

  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调用,以及所有对超全局变量的使用。这是我们代码质量的又一个重大改进。我们知道变量可以在本地修改,而不影响代码库的其他部分。

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

第六章:用依赖注入替换 new

即使我们在类中删除了所有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调用时摆脱了全局变量,但是保留了这个Db依赖。这就是我们所谓的一次性创建依赖。

  2. 其次,selectAll()方法创建新的Item对象,因此依赖于Item类。我们无法从类的外部看到这种依赖关系。这就是我们所谓的重复创建依赖。

注意

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

依赖注入的目的是从外部推送依赖项,从而揭示我们类中的依赖关系。在类内部使用new关键字与这个想法相悖,因此我们需要通过代码库来删除非Factory类中的该关键字,并注入必要的依赖项。

注意

什么是工厂对象?

依赖注入的关键之一是一个对象可以创建其他对象,或者它可以操作其他对象,但不能两者兼而有之。每当我们需要在另一个对象内部创建一个对象时,我们让Factory来完成这项工作,Factory有一个newInstance()方法,并将该Factory注入到需要进行创建的对象中。new关键字仅限于在Factory对象内部使用。这使我们能够随时切换Factory对象,以便创建不同类型的对象。

替换过程

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

  1. 查找带有new关键字的类。如果该类已经是一个Factory,我们可以忽略它并继续。

  2. 对于类中的每个一次性创建:

  • 将每个实例化提取到构造函数参数中。

  • 将构造函数参数分配给属性。

  • 删除仅用于new调用的构造函数参数和类属性。

  1. 对于类中的每个重复创建:
  • 将每个创建代码块提取到一个新的Factory类中。

  • 为每个Factory创建一个构造函数参数,并将其分配给一个属性。

  • 修改类中先前的创建逻辑,以使用Factory

  1. 修改项目中对修改后的类的所有实例化调用,以便将必要的依赖对象传递给构造函数。

  2. 抽查,提交,推送,并通知 QA。

  3. 重复处理下一个不在Factory对象内部的new调用。

查找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的唯一目的是创建新对象。它不应该有任何其他功能。将其他行为放在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关键字仅存在于Factory类中时,我们的工作就完成了。

常见问题

异常和 SPL 类怎么办?

在本章中,我们集中于删除所有对new关键字的使用,除了Factory对象内部。我相信有两个合理的例外情况:Exception类本身,以及某些内置的 PHP 类,例如 SPL 类。

按照本章描述的过程创建一个ExceptionFactory类,将其注入到抛出异常的对象中,然后使用ExceptionFactory创建要抛出的Exception对象是完全一致的。即使对我来说,这似乎有点过分。我认为Exception对象是规则之外的一个合理例外。

同样,我认为内置的 PHP 类通常也是规则的例外。虽然拥有一个ArrayObjectFactory或者ArrayIteratorFactory来创建 SPL 本身提供的ArrayObjectArrayIterator类会很好,但可能有点过分。通常直接在使用它们的对象内部创建这些类型的对象是可以的。

然而,我们需要小心。在需要的类内部直接创建像PDO连接这样复杂或者强大的对象可能会超出我们的范围。很难描述一个好的经验法则;当有疑问时,最好依赖注入。

中间依赖怎么样?

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

例如,假设我们有一个需要ItemsGatewayService类,它本身需要一个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关键字?ItemsGateway需要一个Db连接。Service并不直接使用Db连接;它只用于构建ItemsGateway

在这种情况下的解决方案是注入一个完全构建的ItemsGateway。首先,我们修改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 ?>

其次,在整个传统应用程序中,我们改变了所有Service的实例化,以传递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 ?>

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

在查看第一个例子时,我们如何知道ItemsGateway需要什么来运行?它会影响系统的哪些其他部分?没有检查整个类并寻找globalnew关键字是非常困难的。

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

工厂应该创建集合吗?

在上面的例子中,我们的Factory类只创建一个对象的newInstance()。如果我们经常创建对象的集合,可能合理地向我们的Factory添加一个newCollection()方法。例如,给定我们上面的ItemFactory,我们可能会做如下事情:

**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

有许多关于正确使用工厂对象的变种。关键是将对象创建(以及相关操作)与对象操作分开。

我们能自动化所有这些注入吗?

到目前为止,我们一直在进行手动注入依赖,我们自己创建依赖,然后在创建所需的对象时注入它们。这可能是一个乏味的过程。谁愿意一遍又一遍地创建Db对象,只是为了将其注入到各种Gateway类中?难道没有一种自动化的方法吗?

有的,就是叫做容器容器可能有各种同义词,表示它的用途。依赖注入容器旨在始终且仅在非工厂类外部使用,而以服务定位器为名的相同容器实现旨在在非工厂对象内部使用。

使用容器带来了明显的优势:

  • 我们可以创建共享服务,只有在调用它们时才实例化。例如,容器可以容纳一个Db实例,只有当我们要求容器获取数据库连接时才会创建;连接被创建一次,然后一遍又一遍地重复使用。

  • 我们可以将复杂的对象创建放在容器内部,需要多个服务作为构造函数参数的对象可以从容器内部检索这些服务,并在它们自己的创建逻辑中使用。

但是使用容器也有缺点:

  • 我们必须彻底改变我们对对象创建的思考方式,以及这些对象在应用程序中的位置。最终这是件好事,但在过渡期可能会有麻烦。

  • 作为服务定位器使用的容器用一个新的花哨玩具取代了我们的global变量,它有许多与global相同的问题。容器隐藏了依赖,因为它只能从需要依赖的类内部调用。

在我们现代化遗留应用程序的这个阶段,很容易开始使用容器来自动化依赖注入。我建议我们现在不要添加,因为我们的遗留应用程序还有很多需要现代化的地方。我们最终会添加,但这将是我们现代化过程的最后一步。

回顾和下一步

我们现在在现代化我们的应用程序上取得了巨大的进步。删除globalnew关键字,改用依赖注入已经改善了代码库的质量,并且使得追踪错误变得更加容易,因为在这里修改变量不再会导致远处的变量受到影响。我们的页面脚本可能会有些更长,因为我们必须创建依赖,但现在我们可以更清楚地看到我们与系统的哪些部分进行交互。

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