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

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

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

第七章:编写测试

此时,我们的遗留应用程序已经部分现代化,以至于我们所有现有的类都在一个中心位置。这些类现在使用依赖注入摆脱了globalnew。现在应该为这些类编写测试,以便如果我们需要更改它们,我们知道它们的现有行为仍然完好无损。

对抗测试的抵抗

我们可能不急于现在花时间编写测试。我们不想失去我们正在感受到的前进动力。正当我们相信我们正在取得一些真正的进展时,停下来编写测试感觉就像是在做无用功。这会削弱我们对长期以来一直受苦的糟糕代码库进行一系列改进的乐趣。

对于不愿意编写测试的抵抗是可以理解的。我自己也是一个对自动化测试转变缓慢的人。如果一个人不习惯编写测试,那么编写测试的行为会感到陌生、不熟悉、具有挑战性和无效。很容易说,我可以看到代码是有效的,因为应用程序是有效的。

然而,如果我们不编写测试,我们就注定要在以后不断地遭受痛苦。我们正在使我们的遗留应用程序变得更糟:当我们更改应用程序的某一部分时,我们会感到恐惧,因为我们不知道更改会导致应用程序的其他部分出现什么问题。

因此,尽管编写测试可能很糟糕,但“已编写测试”也是很棒的。这很棒,因为当我们对类进行更改时,我们可以运行自动化测试套件,它会立即告诉我们在更改后是否有任何问题。

测试之道

即使我们已经熟悉编写测试,围绕测试的所有戒律也可能令人生畏:

  • 不要与文件系统交互;而是构建一个虚拟文件系统。

  • 不要与数据库交互;而是构建一组数据夹具。

  • 重写你的类,使用接口而不是具体类,并为所有依赖项编写测试替身。

这些都是使测试看起来像是一个不可逾越的挑战的教条命令。当其他事情都做完时,当然我们可以稍后再构建我们的测试!问题是,永远不会有一刻是其他事情都做完了,因此测试永远不会出现。

作为对测试戒律的解药,我建议遵循测试之道www.artima.com/weblogs/viewpost.jsp?thread=203994)。测试之道的核心信息是更多的测试因果报应,少一些测试戒律。

这些是我们需要从测试之道中了解的关于现代化我们的遗留应用程序的主要观点:

  • 测试的最佳时机是在代码刚写好的时候。

  • 编写需要编写的测试。

  • 今天的不完美测试比将来某一天的完美测试更好。

  • 今天写你能写的测试。

类中的代码已经陈旧。毕竟,那些代码是遗留应用程序的一部分。但是现在,我们已经花了很多时间重新组织类,并使用依赖注入来替换它们的globalnew关键字,这些类中的代码在我们的思想中又变得新鲜起来。现在是写这些类的测试的时候了,因为它们的操作仍然在最近的记忆中。

我们不应该被困扰于编写符合测试戒律的适当单元测试。相反,我们应该尽力编写最好的测试,即使测试不完美:

  • 如果我们可以编写一个表征测试,只检查输出如何,那么我们应该这样做。

  • 如果我们可以编写与数据库、网络或文件系统交互的功能或集成测试,那么我们应该这样做。

  • 如果我们可以编写一个松散的单元测试,结合具体类,那么我们应该这样做。

  • 如果我们可以编写严格的单元测试,使用测试替身完全隔离被测试的类,那么我们应该这样做。

随着我们在测试中变得更加熟练,一个不完美的测试可以得到完善。一个不存在的测试根本无法得到完善。

我们将尽可能快地编写我们可以编写的测试。等待编写测试只会增加反对编写测试的惯性。代码在我们的脑海中会变得更加陈旧,使得编写测试变得更加困难。今天编写测试将给我们一种成就感,并增加我们编写测试的惯性。

设置测试套件

本书的范围并不包括完全解释编写测试的技术和方法。相反,我们将简要总结设置自动化测试套件和编写简单测试所涉及的过程。有关 PHP 测试的更全面的处理,请参阅The Grumpy Programmer's PHPUnit Cookbookgrumpy-phpunit.com/)by Chris Hartjes

安装 PHPUnit

PHP 领域有许多不同的测试系统,但最常用的是 PHPUnit。我们需要在开发和测试服务器上安装 PHPUnit,以便编写和执行我们的测试。完整的安装说明在 PHPUnit 网站上。

通过 Composer 安装 PHPUnit 的一种简单方法是:

**$ composer global require phpunit/phpunit=~4.5**

另一种方法是直接安装 PHPUnit 的.phar

**$ wget https://phar.phpunit.de/phpunit.phar**
**$ chmod +x phpunit.phar**
**$ sudo mv phpunit.phar /usr/local/bin/phpunit**

创建一个 tests/目录

安装 PHPUnit 后,我们需要在我们的遗留应用程序中创建一个tests/目录。名称和位置并不那么重要,重要的是目的和位置是明显的。最明显的地方可能是在遗留应用程序的根目录,尽管它不应该直接被浏览器访问。

tests/目录中,我们需要创建一个以我们的中心类目录位置命名的子目录。如果我们所有的应用程序类都在一个名为classes/的目录中,那么我们应该有一个tests/classes/目录。我们的测试结构的想法是模仿我们的应用程序类的结构。

除了tests/classes/子目录之外,tests/目录还应包含两个文件。第一个是bootstrap.php文件,PHPUnit 在运行时将执行该文件。它的目的是帮助设置测试的执行环境。默认情况下,PHPUnit 不会使用应用程序的自动加载器代码,因此创建和注册自动加载器是bootstrap.php文件的经典用法。以下是一个使用之前章节中的自动加载器的示例:

**tests/bootstrap.php**
1 <?php
2 require "../classes/Mlaphp/Autoloader.php";
3 $loader = new \Mlaphp\Autoloader;
4 spl_autoload_register(array($loader, 'load'));
5 ?>

还在tests/目录中,我们需要创建一个phpunit.xml文件。这告诉 PHPUnit 如何引导自己以及测试的位置:

**tests/phpunit.xml**
1 <phpunit bootstrap="./bootstrap.php">
2 <testsuites>
3 <testsuite>
4 <directory>./classes</directory>
5 </testsuite>
6 </testsuites>
7 </phpunit>

创建tests/目录及其内容后,我们的遗留应用程序目录结构应该如下所示:

**/path/to/app/**

classes/        # our central class directory location
Auth.php        # class Auth { ... }
Db.php          # class Db { ... }
Mlaphp/
Autoloader.php  # A hypothetical autoloader class
Role.php        # class Role { ... }
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
tests/          # tests directory
bootstrap.php   # phpunit bootstrap code
classes/        # test cases
phpunit.xml     # phpunit setup file

选择一个要测试的类

现在我们已经有了一个tests/目录,我们实际上可以为我们的应用程序类之一编写一个测试。开始的最简单方法是选择一个没有依赖项的类。此时,我们应该对代码库足够熟悉,以至于知道哪些类有依赖项,哪些没有。如果找不到没有依赖项的类,我们应该选择依赖项最少或依赖项最简单的类。

我们想要在这里从小处着手并取得一些早期的成功。每一次成功都会给我们继续进行更大、更复杂的测试的动力和动机。这些小的成功将累积成最终的大成功:一组经过全面测试的类。

编写一个测试用例

假设我们选择了一个名为Foo的类,它没有依赖项,并且有一个名为doSomething()的方法。现在我们将为这个类的方法编写一个测试。

首先,在我们的tests/classes/目录中创建一个骨架测试文件。它的位置应该模仿被测试的类的位置。我们在类名后面添加Test,并扩展PHPUnitFramework_TestCase_,以便我们可以访问测试类中的各种assert*()方法:

**tests/classes/FooTest.php**
1 <?php
2 class FooTest extends \PHPUnit_Framework_TestCase
3 {
4 }
5 ?>

如果我们现在尝试用 phpunit 运行测试,测试将会失败,因为它里面没有测试方法:

**tests $ phpunit**
**PHPUnit 3.7.30 by Sebastian Bergmann.**
**Configuration read from tests/phpunit.xml**
**F**
**Time: 45 ms, Memory: 2.75Mb**
**There was 1 failure:**
**1) Warning**
**No tests found in class "FooTest".**
**FAILURES!**
**Tests: 1, Assertions: 0, Failures: 1.**
**tests $**

信不信由你,这都没问题!正如《测试之道》所告诉我们的那样,我们在测试通过时感到高兴,测试失败时也同样如此。这里的失败告诉我们 PHPUnit 成功找到了我们的测试类,但在该类中没有找到任何测试。这告诉我们接下来该做什么。

下一步是为被测试类的公共方法添加一个测试方法。所有测试方法都以单词 test 开头,因此我们将使用名为 testDoSomething() 的方法来测试 doSomething() 方法。在其中,我们将创建一个 _Foo_ 类的实例,调用它的公共 doSomething() 方法,并断言它的实际输出与我们期望的输出相同:

**tests/classes/FooTest.php**
1 <?php
2 class FooTest extends \PHPUnit_Framework_TestCase
3 {
4 public function testDoSomething()
5 {
6 $foo = new Foo;
7 $expect = 'Did the thing!';
8 $actual = $foo->doSomething();
9 $this->assertSame($expect, $actual);
10 }
11 }
12 ?>

现在我们可以再次用 phpunit 运行我们的测试套件。只要 doSomething() 方法返回字符串 Did the thing!,我们的测试就会通过。

**tests $ phpunit**
**PHPUnit 3.7.30 by Sebastian Bergmann.**
**Configuration read from tests/phpunit.xml**
**.**
**Time: 30 ms, Memory: 2.75Mb**
**OK (1 test, 1 assertion)**
**tests $**

我们为我们的测试通过而感到高兴!

如果 doSomething() 返回任何不同的东西,那么测试将会失败。这意味着如果我们在后续工作中更改了 doSomething(),我们将知道它的行为已经改变。我们会为它的失败感到高兴,知道我们在进入生产之前就捕捉到了一个 bug,然后修复代码,直到所有测试都通过。

做……当

在编写通过的测试后,我们将其提交到版本控制并推送到我们的中央仓库。我们继续为应用程序类中的每个公共方法编写测试,一边编写一边提交和推送。当应用程序类中的所有公共方法都有通过的测试时,我们选择另一个类进行测试,并从头开始一个新的测试类。

常见问题

我们可以跳过这一步,以后再做吗?

不。

真的吗,我们可以以后再做这个吗?

我明白。我真的明白。在我们现代化过程的这一点上,测试似乎没有回报。如果整个章节都没有说服你测试的好处,那么我现在也没什么别的可以说服你的了。如果你想跳过这一步,无论你在这里读到什么建议,你都会跳过它。

所以让我们假设我们在这一点上避免测试的理由是完全合理的,并且与我们特定的情境相适应。考虑到这一点,让我们看看如果现在做不到,那么在项目过程中我们可以做些什么来完成这些测试。继续下一章(不建议!),然后承诺执行以下一个或多个选项:

  1. 每天至少完成一个新的测试类。

  2. 每次在代码库中使用一个方法时,都要检查是否有针对它的测试。如果没有,就在使用该方法之前编写一个测试。

  3. 在修复 bug 或构建功能时,创建一个在任务过程中使用的方法列表,然后在任务完成后为这些方法编写测试。

  4. 当我们添加一个新的类方法时,为其编写相应的测试。

  5. 将测试编写的工作委托给另一个开发人员,也许是一名初级开发人员。然后我们可以享受现代化的“乐趣”,而初级开发人员可以承担编写测试的看似无聊的工作,但要小心……很快,初级开发人员将比我们更了解代码库。

这些选项让我们能够建立一个测试套件,并且仍然感觉自己在其他方面取得了进展。创建一个自动化测试套件是现代化遗留应用程序的一个不可妥协的方面。现在就编写测试,或者在进行过程中编写,但尽早编写,而不是晚些时候。

那些难以测试的类怎么办?

即使依赖注入已经就位,遗留应用程序中的一些类仍然很难编写测试。类的测试可能会有很多难点,我无法在本书中充分解决这些问题。请参考以下作品:

  • Michael Feathers与遗留代码有效工作。书中的示例都是用 Java 编写的,但情况与 PHP 中的情况类似。Feathers 展示了如何打破依赖关系,引入接缝,以及改进遗留类的可测试性。

  • Fowler 等人的重构。这本书也使用 Java 作为示例,但由于 Adam Culp 的贡献,我们将相同的示例转换为了 PHP。与 Fowler 的《企业应用架构模式》一样,重构书将为您提供一种词汇来描述您可能已经知道如何做的事情,同时还会向您介绍新的技术。

这些出版物中的信息将帮助我们改进我们的类的质量,而不改变类的行为。

我们之前的特性测试呢?

我们根据本章写的测试可能不是替代先决条件章节中现有特性测试的替代品。拥有两组测试很可能是一种祝福,而不是诅咒。在某个时候,特性测试可能会被转换为供 QA 团队使用的验收测试。在那之前,不妨偶尔运行两组测试。

我们应该测试私有和受保护的方法吗?

可能不会。这其中有一些教条主义的原因,我在这里不会详细说明,但简而言之:检查类的内部工作过于深入的测试会变得难以处理。

相反,我们应该只测试我们类的公共方法。这些方法暴露的任何行为可能是我们关心的唯一行为。这个规则有一些例外,但在我们的测试生涯的这个阶段,例外不如规则重要。

我们写完测试后可以更改测试吗?

有一天,我们需要改变应用程序类方法的现有行为。在这些情况下,修改相关的测试以适应新行为是可以的。但是,当我们这样做时,我们必须确保运行整个测试套件,而不仅仅是该应用程序类的测试。运行整个测试套件将帮助我们确保更改不会破坏其他类的行为。

我们需要测试第三方库吗?

如果我们的遗留应用程序使用第三方库,它们可能已经附带了测试。我们应该不时地运行这些测试。

如果第三方库没有附带测试,我们可能会根据我们的优先级选择编写一些测试。如果我们依赖于库在升级之间表现相同,编写一些我们自己的测试来确保预期的行为保持不变是明智的。

为第三方库构建测试可能很困难,如果它没有以易于测试的方式编写。如果该库是自由软件或开源软件,也许这是一个为项目做出贡献的机会。然而,我们的主要优先事项可能是我们自己的遗留应用程序,而不是第三方库。

代码覆盖率呢?

代码覆盖率是 PHPUnit 提供的报告,告诉我们测试了多少行代码。(严格来说,它告诉我们测试了多少语句)。

特定情况可能只测试类的一部分,或者方法的一部分,并留下一些未经测试的代码。被测试的部分称为代码的覆盖部分,而未经测试的部分称为未覆盖部分。

我们主要需要担心的是代码库中未覆盖的部分。如果未覆盖的代码发生任何变化,测试将无法检测到,因此我们可能会面临错误和其他退化。

如果可以的话,我们应该尽早并经常发现我们测试的代码覆盖率。这些覆盖率报告将帮助我们确定下一步需要测试什么,以及代码库的哪些部分需要重构,以便更容易测试。

更多的代码覆盖率是更好的。然而,达到 100%的行覆盖率可能是不可行的(而且实际上也不是最终目标,最终目标是 100%的条件/决策覆盖率等)。不过,如果我们能够达到 100%的覆盖率,那就应该努力去做。

关于这个话题的更多信息,请查阅 PHPUnit 关于代码覆盖率的文档phpunit.de/manual/3.7/en/code-coverage-analysis.html

回顾和下一步

当我们完成了本章中简要概述的测试编写时,我们将为未来的错误创建了一个很好的陷阱。每当我们运行测试时,对预期行为的任何更改都会作为失败突出,以便我们进行更正。这确保了在我们继续重构时,我们对整个旧代码库所做的贡献将比伤害更多。

此外,因为我们现在有了一个可用的测试套件,我们可以为从旧代码库中提取出的任何新行为添加测试到我们的应用程序类中。每当我们创建一个新的应用程序类方法时,我们也会为该方法创建一个通过的测试。每当我们修改一个应用程序类方法时,我们将运行测试套件,以便在它们进入生产环境之前找到错误和破坏。当我们的测试通过时,我们会感到高兴,当它们失败时,我们也会感到高兴;每一种结果对于现代化我们的旧应用程序来说都是一个积极的迹象。

有了这个,我们可以继续我们的现代化进程。下一步是将数据检索和持久化行为从页面脚本中提取出来,放入一系列类中。通常,这意味着将所有的 SQL 调用移动到一个单独的层中。

第八章:将 SQL 语句提取到网关

现在我们已经将所有基于类的功能移动到一个中央目录位置(并且对这些类有一个合理的测试套件),我们可以开始从我们的页面脚本中提取更多的逻辑并将该逻辑放入类中。这将有两个好处:首先,我们将能够保持应用程序的各种关注点分开;其次,我们将能够测试提取的逻辑,以便在部署到生产环境之前很容易注意到任何故障。

这些提取中的第一个将是将所有与 SQL 相关的代码移动到自己的一组类中。对于我们的目的,SQL 是任何读取和写入数据存储系统的代名词。这可能是一个无 SQL 系统,一个 CSV 文件,一个远程资源或其他任何东西。我们将在本章集中讨论 SQL 导向的数据存储,因为它们在遗留应用程序中是如此普遍,但这些原则适用于任何形式的数据存储。

嵌入式 SQL 语句

目前,我们的页面脚本(可能还有一些我们的类)直接与数据库交互,使用嵌入式 SQL 语句。例如,一个页面脚本可能有一些类似以下的逻辑:

**page_script.php**
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $post_id = $_GET['post_id'];
4 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
5 $rows = $db->query($stm);
6 foreach ($rows as $row) {
7 // output each row
8 }
9 ?>

使用嵌入式 SQL 字符串的问题很多。除其他事项外,我们希望:

  • 在与代码的其余部分隔离的情况下测试 SQL 交互

  • 减少代码库中重复的 SQL 字符串数量

  • 收集相关的 SQL 命令以进行概括和重用

  • 隔离并消除诸如 SQL 注入之类的安全漏洞

这些问题和更多问题使我们得出结论,我们需要将所有与 SQL 相关的代码提取到一个 SQL 层,并用对我们的 SQL 相关类方法的调用替换嵌入式 SQL 逻辑。我们将通过创建一系列“网关”类来实现这一点。这些“网关”类唯一要做的事情就是从我们的数据源获取数据,并将数据发送回去。

本章中的“网关”类在技术上更像是表数据网关。然而,您可以选择设置适合您数据源的任何类型的“网关”。

提取过程

一般来说,这是我们将要遵循的过程:

  1. 搜索整个代码库以查找 SQL 语句。

  2. 对于每个尚未在“网关”中的语句,将语句和相关逻辑移动到相关的“网关”类方法中。

  3. 为新的“网关”方法编写测试。

  4. 用“网关”类方法的调用替换原始文件中的语句和相关逻辑。

  5. 测试、提交、推送并通知 QA。

  6. 重复上述步骤,直到下一个 SQL 语句不在“网关”类之外。

搜索 SQL 语句

与前几章一样,在这里我们使用项目范围的搜索功能。使用类似以下的正则表达式来识别代码库中 SQL 语句关键字的位置:

搜索:

**(SELECT|INSERT|UPDATE|DELETE)**

我们可能会发现我们的代码库还使用其他 SQL 命令。如果是这样,我们应该将它们包括在搜索表达式中。

如果代码库在 SQL 关键字的大小写方面不一致,对我们来说更容易的是代码库始终只使用一个大小写,无论是大写还是小写。然而,这在遗留代码中并不总是约定俗成的。如果我们的代码库在 SQL 关键字的大小写方面不一致,并且我们的项目范围搜索工具有不区分大小写的选项,我们应该在这次搜索中使用该选项。否则,我们需要扩展搜索项,以包括 SQL 关键字的小写(也许是混合大小写)变体。

最后,搜索结果可能会包括误报。例如,叙述文本如“选择以下选项之一”将出现在结果列表中。我们需要逐个检查结果,以确定它们是否是 SQL 语句还是仅仅是叙述文本。

将 SQL 移动到网关类

将 SQL 提取到“网关”的任务是细节导向的,具体情况具体分析。遗留代码库本身的结构将决定这项任务的一个或多个正确方法。

首先,提取一个普通的 SQL 语句如下似乎很简单:

1 <?php
2 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
3 $rows = $db->query($stm);
4 ?>

但事实证明,即使在这个简单的例子中,我们也需要做出很多决定:

  • 我们应该如何命名Gateway类和方法?

  • 我们应该如何处理查询的参数?

  • 我们如何避免安全漏洞?

  • 适当的返回值是什么?

命名空间和类名

为了确定我们的命名空间和类名,我们需要首先决定是按层还是按实体进行组织。

  • 如果我们按照实现层进行组织,我们类的顶层命名空间可能是GatewayDataSource\Gateway。这种命名安排将根据代码库中的操作目的来结构化类。

  • 如果我们按领域实体进行组织,顶层命名空间可能是Comments,甚至是Domain\Comments。这种命名安排将根据业务逻辑领域内的目的来结构化类。

遗留的代码库很可能会决定前进的方向。如果已经有按照某种方式组织的代码,那么最好继续使用已建立的结构,而不是重新做现有的工作。我们希望避免在代码库中设置冲突或不一致的组织结构。

在这两者之间,我建议按领域实体进行组织。我认为将与特定领域实体类型相关的功能集中在其相关的命名空间内更有意义,而不是将操作实现分散在几个命名空间中。我们还可以在特定领域功能内进一步分隔实现部分,这在按层进行组织时不容易做到。

为了反映我的领域实体偏见,本章其余部分的示例将按照领域的方式进行结构化,而不是按照实现层进行结构化。

一旦我们为我们的Gateway类确定了一个组织原则,我们就可以很容易地找到好的类名。例如,我们在 PHP 5.3 及更高版本中与评论相关的Gateway可能被命名为Domain\Comments\CommentsGateway。如果我们使用的是 PHP 5.2 或更早版本,我们将需要避免使用正确的命名空间,并在类名中使用下划线;例如,Domain_Comments_CommentsGateway

方法名

然而,选择适当的方法名可能会更加困难。再次,我们应该寻找现有遗留代码库中的惯例。常见的习语可能是get()数据,find()数据,fetch()数据,select()数据,或者完全不同的其他内容。

我们应该尽可能坚持任何现有的命名约定。虽然方法名本身并不重要,但命名的一致性很重要。一致性将使我们更容易查看对Gateway对象的调用,并理解发生了什么,而无需阅读底层方法代码,并在代码库中搜索数据访问调用。

如果我们的遗留代码库没有显示出一致的模式,那么我们就需要为新的Gateway方法选择一致的命名约定。因为Gateway类应该是一个简单的层,用于包装 SQL 调用,本章的示例将使用诸如selectinsert等方法名来标识被包装的行为。

最后,方法名可能应该指示正在执行的select()的类型。我们是选择一个记录还是所有记录?我们是按特定标准选择?查询中还有其他考虑吗?这些和其他问题将给我们一些提示,告诉我们如何命名Gateway方法。

一个初始的 Gateway 类方法

在将逻辑提取到类方法时,我们应该小心遵循我们在之前章节中学到的关于依赖注入的所有教训。除其他事项外,这意味着:不使用全局变量,用Request对象替换超全局变量,不在Factory类之外使用new关键字,以及(当然)根据需要通过构造函数注入对象。

根据上述命名原则和原始的SELECT语句来检索评论行,我们可以构建一个类似于这样的Gateway

**classes/Domain/Comments/CommentsGateway.php**
1 <?php
2 namespace Domain\Comments;
3
4 class CommentsGateway
5 {
6 protected $db;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectAllByPostId($post_id)
14 {
15 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
16 return $this->db->query($stm);
17 }
18 }
19 ?>

这实际上是原始页面脚本的逻辑的几乎完全复制。但是,它至少留下了一个主要问题:它直接在查询中使用输入参数。这使我们容易受到 SQL 注入攻击。

注意

什么是 SQL 注入

关于小鲍比表的经典 XKCD 漫画应该有助于说明问题。恶意形成的输入参数直接用于数据库查询,以更改查询,从而损害或利用数据库。

击败 SQL 注入

当我们创建我们的Gateway方法时,我们不应假设参数值是安全的。无论我们是否期望参数在每次调用时都被硬编码为常量值,或者以其他方式保证是安全的。在某个时候,有人会更改调用Gateway方法的代码的一部分,我们将会有安全问题。相反,我们需要将每个参数值视为不安全,并相应处理。

因此,为了击败 SQL 注入尝试,我们应该在我们的Gateway方法中执行每个查询的三件事中的一件(实际上,在代码库中的任何 SQL 语句中):

  1. 最好的解决方案是使用准备语句和参数绑定,而不是查询字符串插值。

  2. 第二好的解决方案是在将其插入查询字符串之前,对每个参数使用数据库层的“引用和转义”机制。

  3. 第三好的解决方案是在将其插入查询字符串之前转义每个输入参数。

提示

或者,我们可以通过将预期的数值转换为intfloat来完全避免字符串的问题。

让我们首先检查第三好的解决方案,因为它更有可能已经存在于我们的遗留代码库中。我们使用数据库的escape功能来转义每个参数,然后在查询字符串中使用它,并为数据库适当地引用它。因此,我们可以像这样重写selectAllByPostId()方法,假设使用 MySQL 数据库:

<?php
2 public function selectAllByPostId($post_id)
3 {
4 $post_id = "'" . $this->db->escape($post_id) . "'";
5 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
6 return $this->db->query($stm);
7 }
8 ?>

对值进行转义以插入字符串是第三好的解决方案,原因有几个。主要原因是转义逻辑有时不够。像mysql_escape_string()函数对我们的目的来说根本不够好。甚至mysql_real_escape_string()方法也有一个缺陷,这将允许攻击者根据当前字符集成功进行 SQL 注入尝试。然而,这可能是底层数据库驱动程序可用的唯一选项。

第二好的解决方案是一种称为引用和转义的转义的变体。这个功能只能通过PDO::quote()方法使用,比转义更安全,因为它还会自动将值包装在引号中,并处理适当的字符集。这避免了仅仅转义和自己添加引号时固有的字符集不匹配问题。

一个重写的selectAllByPostId()方法可能看起来像这样,使用暴露PDO::quote()方法的Db对象:

<?php
2 public function selectAllByPostId($post_id)
3 {
4 $post_id = $this->db->quote($post_id);
5 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
6 return $this->db->query($stm);
7 }
8 ?>

当我们记得使用它时,这是一种安全的方法。当然,问题在于,如果我们向方法添加参数,可能会忘记引用它,然后我们又容易受到 SQL 注入攻击。

最后,最好的解决方案:准备语句和参数绑定。这些只能通过 PDO(几乎适用于所有数据库)和mysqli扩展使用。每个都有自己的处理语句准备的变体。我们将在这里使用PDO样式的示例。

我们使用命名占位符而不是将值插入查询字符串,以指示参数应放置在查询字符串中的位置。然后,我们告诉PDO将字符串准备为PDOStatement对象,并在通过准备的语句执行查询时将值绑定到命名占位符。PDO自动使用参数值的安全表示,使我们免受 SQL 注入攻击。

以下是使用公开PDO语句准备逻辑和执行的Db对象进行重写的示例:

1 <?php
2 public function selectAllByPostId($post_id)
3 {
4 $stm = "SELECT * FROM comments WHERE post_id = :post_id";
5 $bind = array('post_id' => $post_id);
6
7 $sth = $this->db->prepare($stm);
8 $sth->execute($bind);
9 return $sth->fetchAll(PDO::FETCH_ASSOC);
10 }
11 ?>

这里的巨大好处是我们从不在查询字符串中使用参数变量。我们总是只使用命名占位符,并将占位符绑定到准备好的语句中的参数值。这种习惯用法使我们清楚地知道何时不正确地使用了插入的变量,而且PDO会自动投诉如果有额外或缺少的绑定值,因此意外进行不安全的更改的机会大大减少了。

编写一个测试

现在是时候为我们的新类方法编写测试了。我们此时编写的测试可能不够完美,因为我们需要与数据库交互。然而,一个不完美的测试总比没有测试好。正如《测试之道》所告诉我们的,我们在能够的时候编写测试。

我们的新Gateway方法的测试可能看起来像这样:

**tests/classes/Domain/Comments/CommentsGatewayTest.php**
1 <?php
2 namespace Domain\Comments;
3
4 use Db;
5
6 class CommentsGatewayTest
7 {
8 protected $db;
9
10 protected $gateway;
11
12 public function setUp()
13 {
14 $this->db = new Db('test_host', 'test_user', 'test_pass');
15 $this->gateway = new CommentsGateway($this->db);
16 }
17
18 public function testSelectAllByPostId()
19 {
20 // a range of known IDs in the comments table
21 $post_id = mt_rand(1,100);
22
23 // get the comment rows
24 $rows = $this->gateway->selectAllByPostId($post_id);
25
26 // make sure they all match the post_id
27 foreach ($rows as $row) {
28 $this->assertEquals($post_id, $row['post_id']);
29 }
30 }
31 }
32 ?>

现在我们运行我们的测试套件,看看测试是否通过。如果通过,我们会庆祝并继续前进!如果没有通过,我们将继续完善Gateway方法和相关测试,直到两者都正常工作。

提示

完善我们的测试

正如前面所述,这是一个非常不完美的测试。除其他事项外,它取决于一个可用的数据库连接,并且首先需要在数据库中种子数据。通过依赖数据库,我们依赖它处于正确的状态。如果数据库中没有正确的数据,那么测试将失败。失败不是来自我们正在测试的代码,而是来自大部分超出我们控制的数据库。改进测试的一个机会是将Gateway类更改为依赖于DbInterface而不是具体的Db类。然后,我们将为测试目的创建一个实现DbInterfaceFakeDb类,并将一个FakeDb实例注入到Gateway中,而不是一个真正的Db实例。这样做将使我们更深入地了解 SQL 查询字符串的正确性,以及对返回给Gateway的数据具有更大的控制。最重要的是,它将使测试与对可用数据库的依赖解耦。目前,出于迅速进行的考虑,我们将使用不完美的测试。

替换原始代码

现在我们有一个可工作且经过测试的Gateway方法,我们用调用Gateway方法替换原始代码。而旧代码看起来像这样:

**page_script.php (before)**
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $post_id = $_GET['post_id'];
4 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
5 $rows = $db->query($stm);
6 foreach ($rows as $row) {
7 // output each row
8 }
9 ?>

新版本将如下所示:

**page_script.php (after)**
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $comments_gateway = new CommentsGateway($db);
4 $rows = $comments_gateway->selectAllByPostId($_GET['post_id']);
5 foreach ($rows as $row) {\
6 // output each row
7 }
8 ?>

请注意,我们几乎没有修改操作逻辑。例如,我们没有添加以前不存在的错误检查。我们修改的最远程度是通过准备好的语句来保护查询免受 SQL 注入。

测试,提交,推送,通知 QA

与之前的章节一样,现在我们需要抽查旧应用程序。虽然我们对新的Gateway方法有一个单元测试,但我们仍然需要抽查我们修改过的应用程序的部分。如果我们之前准备了一个表征测试来覆盖我们遗留应用程序的这一部分,我们现在可以运行它。否则,我们可以通过浏览或以其他方式调用应用程序的更改部分来进行此操作。

一旦我们确信已成功用调用我们的新Gateway方法替换了嵌入式 SQL,我们就将更改提交到版本控制,包括我们的测试。然后我们推送到中央仓库,并通知 QA 团队我们的更改。

做...直到

完成后,我们再次搜索代码库,查找 SQL 关键字以指示嵌入式查询字符串的用法。如果它们存在于Gateway类之外,我们将继续将查询提取到适当的Gateway中。一旦所有 SQL 语句都已移动到Gateway类中,我们就完成了。

常见问题

那么插入、更新和删除语句呢?

到目前为止,我们只看了SELECT语句,因为它们很可能是我们传统代码库中最常见的情况。然而,还会有大量的INSERTUPDATEDELETE,以及其他语句。在提取到Gateway时,这些基本上与SELECT相同,但也有一些细微的差异。

特别是INSERTUPDATE语句可能包含大量的参数,指示要插入或更新的列值。将太多的参数添加到提取的Gateway方法签名中将使其难以处理。

在这些情况下,我们可以使用数据数组来指示列名及其对应的值。但我们需要确保只插入或更新正确的列。

例如,假设我们从页面脚本中开始,保存一个新的评论,包括评论者的姓名、评论内容、评论者的 IP 地址以及评论所附加的帖子 ID:

**page_script.php**
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3
4 $name = $db->escape($_POST['name']);
5 $body = $db->escape($_POST['body']);
6 $post_id = (int) $_POST['id'];
7 $ip = $db->escape($_SERVER['REMOTE_ADDR']);
8
9 $stm = "INSERT INTO comments (post_id, name, body, ip) "
10 .= "VALUES ($post_id, '{$name}', '{$body}', '{$ip}'";
11
12 $db->query($stm);
13 $comment_id = $db->lastInsertId();
14 ?>

当我们将这些提取到CommentsGateway中的方法时,我们可以为每个要插入的列值设置一个参数。在这种情况下,只有四列,但如果有十几列,方法签名将更难处理。

作为每列一个参数的替代方案,我们可以将数据数组作为单个参数传递,然后在方法内部处理。这个使用数据数组的示例包括一个带有占位符的预处理语句,以防止 SQL 注入攻击:

1 <?php
2 public function insert(array $bind)
3 {
4 $stm = "INSERT INTO comments (post_id, name, body, ip) "
5 .= "VALUES (:post_id, :name, :body, :ip)";
6 $this->db->query($stm, $bind);
7 return $this->db->lastInsertId();
8 }
9 ?>

一旦我们在CommentsGateway中有了这样的方法,我们可以修改原始代码,使其更像下面这样:

**page_script.php**
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $comments_gateway = new CommentsGateway($db);
4
5 $input = array(
6 'name' => $_POST['name'],
7 'body' => $_POST['body'],
8 'post_id' => $_POST['id'],
9 'ip' => $_SERVER['REMOTE_ADDR'],
10 );
11
12 $comment_id = $comments_gateway->insert($input);
13 ?>

重复的 SQL 字符串怎么办?

在这个过程中,我们可能会遇到的一件事是,在我们的传统应用程序中,查询字符串中存在大量的重复,或者是带有变化的重复。

例如,我们可能会在传统应用程序的其他地方找到一个类似于这样的与评论相关的查询:

1 <?php
2 $stm = "SELECT * FROM comments WHERE post_id = $post_id LIMIT 10";
3 ?>

查询字符串与本章开头的示例代码相同,只是附加了一个LIMIT子句。我们应该为这个查询创建一个全新的方法,还是修改现有的方法?

这是需要专业判断和对代码库的熟悉。在这种情况下,修改似乎是合理的,但在其他情况下,差异可能足够大,需要创建一个全新的方法。

如果我们选择修改CommentsGateway中的现有方法,我们可以重写selectAllByPostId()以包括一个可选的LIMIT

1 <?php
2 public function selectAllByPostId($post_id, $limit = null)
3 {
4 $stm = "SELECT * FROM comments WHERE post_id = :post_id";
5 if ($limit) {
6 $stm .= " LIMIT " . (int) $limit;
7 }
8 $bind = array('post_id' => $post_id);
9 return $this->db->query($stm, $bind);
10 }
11 ?>

现在我们已经修改了应用程序类,我们需要运行现有的测试。如果测试失败,那我们就庆幸!我们发现了我们的改变有缺陷,而测试阻止了这个 bug 进入生产。如果测试通过,我们也庆幸,因为事情仍然像改变之前一样工作。

最后,在现有测试通过后,我们修改CommentsGatewayTest,以检查新的LIMIT功能是否正常工作。这个测试仍然不完美,但它传达了要点。

tests/classes/Domain/Comments/CommentsGatewayTest.php
1 <?php
2 public function testSelectAllByPostId()
3 {
4 // a range of known IDs in the comments table
5 $post_id = mt_rand(1,100);
6
7 // get the comment rows
8 $rows = $this->gateway->selectAllByPostId($post_id);
9
10 // make sure they all match the post_id
11 foreach ($rows as $row) {
12 $this->assertEquals($post_id, $row['post_id']);
13 }
14
15 // test with a limit
16 $limit = 10;
17 $rows = $this->gateway->selectAllByPostId($post_id, $limit);
18 $this->assertTrue(count($rows) <= $limit);
19 }
20 }
21 ?>

我们再次运行测试,以确保我们的新的LIMIT功能正常工作,并不断完善代码和测试,直到通过为止。

然后我们继续用Gateway的调用替换原始的嵌入式 SQL 代码,进行抽查,提交等等。

注意

我们需要谨慎处理。在看到一个查询的变体之后,我们将能够想象出许多其他可能的查询变体。由此产生的诱惑是在实际遇到这些变体之前,就预先修改我们的Gateway方法来适应想象中的变体。除非我们实际在遗留代码库中看到了特定的变体,否则我们应该克制自己,不要为那种变体编写代码。我们不希望超前于代码库当前实际需要的情况。目标是在可见的路径上小步改进,而不是在想象的迷雾中大步跨越。

复杂的查询字符串怎么办?

到目前为止,示例都是相对简单的查询字符串。这些简单的示例有助于保持流程清晰。然而,在我们的遗留代码库中,我们可能会看到非常复杂的查询。这些查询可能是由多个条件语句构建而成,使用多个不同的参数在查询中使用。以下是一个复杂查询的示例,摘自附录 A,典型的遗留页面脚本

1 <?php
2 // ...
3 define("SEARCHNUM", 10);
4 // ...
5 $page = ($page) ? $page : 0;
6
7 if (!empty($p) && $p!="all" && $p!="none") {
8 $where = "`foo` LIKE '%$p%'";
9 } else {
10 $where = "1";
11 }
12
13 if ($p=="hand") {
14 $where = "`foo` LIKE '%type1%'"
15 . " OR `foo` LIKE '%type2%'"
16 . " OR `foo` LIKE '%type3%'";
17 }
18
19 $where .= " AND `bar`='1'";
20 if ($s) {
21 $s = str_replace(" ", "%", $s);
22 $s = str_replace("'", "", $s);
23 $s = str_replace(";", "", $s);
24 $where .= " AND (`baz` LIKE '%$s%')";
25 $orderby = "ORDER BY `baz` ASC";
26 } elseif ($letter!="none" && $letter) {
27 $where .= " AND (`baz` LIKE '$letter%'"
28 . " OR `baz` LIKE 'The $letter%')";
29 $orderby = "ORDER BY `baz` ASC";
30 } else {
31 $orderby = "ORDER BY `item_date` DESC";
32 }
33 $query = mysql_query(
34 "SELECT * FROM `items` WHERE $where $orderby
35 LIMIT $page,".SEARCHNUM;
36 );
37 ?>

对于这种复杂的安排,我们需要非常注意细节,将相关的查询构建逻辑提取到我们的Gateway中。主要考虑因素是确定查询构建逻辑中使用了哪些变量,并将其设置为我们新的Gateway方法的参数。然后我们可以将查询构建逻辑移动到我们的Gateway中。

首先,我们可以尝试将嵌入的与 SQL 相关的逻辑提取到Gateway方法中:

1 <?php
2 namespace Domain\Items;
3
4 class ItemsGateway
5 {
6 protected $mysql_link;
7
8 public function __construct($mysql_link)
9 {
10 $this->mysql_link = $mysql_link;
11 }
12
13 public function selectAll(
14 $p = null,
15 $s = null,
16 $letter = null,
17 $page = 0,
18 $searchnum = 10
19 ) {
20 if (!empty($p) && $p!="all" && $p!="none") {
21 $where = "`foo` LIKE '%$p%'";
22 } else {
23 $where = "1";
24 }
25
26 if ($p=="hand") {
Extract SQL Statements To Gateways 84
27 $where = "`foo` LIKE '%type1%'"
28 . " OR `foo` LIKE '%type2%'"
29 . " OR `foo` LIKE '%type3%'";
30 }
31
32 $where .= " AND `bar`='1'";
33 if ($s) {
34 $s = str_replace(" ", "%", $s);
35 $s = str_replace("'", "", $s);
36 $s = str_replace(";", "", $s);
37 $where .= " AND (`baz` LIKE '%$s%')";
38 $orderby = "ORDER BY `baz` ASC";
39 } elseif ($letter!="none" && $letter) {
40 $where .= " AND (`baz` LIKE '$letter%'"
41 . " OR `baz` LIKE 'The $letter%')";
42 $orderby = "ORDER BY `baz` ASC";
43 } else {
44 $orderby = "ORDER BY `item_date` DESC";
45 }
46
47 $stm = "SELECT *
48 FROM `items`
49 WHERE $where
50 $orderby
51 LIMIT $page, $searchnum";
52
53 return mysql_query($stm, $this->mysql_link);
54 }
55 }
56 ?>

注意

尽管我们已经删除了一些依赖项(例如对mysql_connect()链接标识符的隐式全局依赖),但这第一次尝试仍然存在许多问题。其中,它仍然容易受到 SQL 注入的影响。我们需要在查询中使用mysql_real_escape_string()对每个参数进行转义,并将LIMIT值转换为整数。

一旦我们完成了提取及其相关的测试,我们将把原始代码更改为以下内容:

1 <?php
2 // ...
3 define("SEARCHNUM", 10);
4 // ...
5 $page = ($page) ? $page : 0;
6 $mysql_link = mysql_connect($db_host, $db_user, $db_pass);
7 $items_gateway = new \Domain\Items\ItemsGateway($mysql_link);
8 $query = $items_gateway->selectAll($p, $s, $letter, $page, SEARCHNUM);
9 ?>

非 Gateway 类内的查询怎么办?

本章的示例显示了嵌入在页面脚本中的 SQL 查询字符串。同样可能的是,我们也会在非 Gateway 类中找到嵌入的查询字符串。

在这些情况下,我们遵循与页面脚本相同的流程。一个额外的问题是,我们将不得不将Gateway依赖项传递给该类。例如,假设我们有一个Foo类,它使用doSomething()方法来检索评论:

1 <?php
2 class Foo
3 {
4 protected $db;
5
6 public function __construct(Db $db)
7 {
8 $this->db = $db;
9 }
10
11 public function doSomething($post_id)
12 {
13 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
14 $rows = $this->db->query($stm);
15 foreach ($rows as $row) {
16 // do something with each row
17 }
18 return $rows;
19 }
20 }
21 ?>

我们提取 SQL 查询字符串及其相关逻辑,就像我们在页面脚本中所做的那样。然后我们修改Foo类,将Gateway作为依赖项,而不是Db对象,并根据需要使用Gateway

1 <?php
2 use Domain\Comments\CommentsGateway;
3
4 class Foo
5 {
6 protected $comments_gateway;
7
8 public function __construct(CommentsGateway $comments_gateway)
9 {
10 $this->comments_gateway = $comments_gateway;
11 }
12
13 public function doSomething($post_id)
14 {
15 $rows = $this->comments_gateway->selectAllByPostId($post_id);
16 foreach ($rows as $row) {
17 // do something with each row
18 }
19 return $rows;
20 }
21 }
22 ?>

我们可以从基类 Gateway 类扩展吗?

如果我们有许多具有类似功能的Gateway类,将一些功能收集到AbstractGateway中可能是合理的。例如,如果它们都需要Db连接,并且都有类似的select*()方法,我们可以做如下操作:

classes/AbstractGateway.php
1 <?php
2 abstract class AbstractGateway
3 {
4 protected $table;
5
6 protected $primary_key;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectOneByPrimaryKey($primary_val)
14 {
15 $stm = "SELECT * FROM {$this->table} "
16 .= "WHERE {$this->primary_key} = :primary_val";
17 $bind = array('primary_val' => $primary_val);
18 return $this->db->query($stm, $bind);
19 }
20 }
21 ?>

然后我们可以从基类AbstractGateway扩展一个类,并调整特定表的扩展属性:

1 <?php
2 namespace Domain\Items;
3
4 class ItemsGateway extends \AbstractGateway
5 {
6 protected $table = 'items';
7 protected $primary_key = 'item_id';
8 }
9 ?>

基本的selectOneByPrimaryKey()方法可以与各种Gateway类一起使用。根据需要,我们仍然可以在特定的Gateway类上添加其他具体的方法。

注意

对于这种方法要谨慎。我们应该只抽象出已经存在于我们已经提取的行为中的功能。抵制提前创建我们在遗留代码库中实际上还没有看到的功能的诱惑。

多个查询和复杂的结果结构怎么办?

本章中的示例已经显示了针对单个表的单个查询。我们可能会遇到使用多个查询针对几个不同的表,然后将结果合并为复杂领域实体或集合的逻辑。以下是一个例子:

1 <?php
2 // build a structure of posts with author and statistics data,
3 // with all comments on each post.
4 $page = (int) $_GET['page'];
5 $limit = 10;
6 $offset = $page * $limit; // a zero-based paging system
7 $stm = "SELECT *
8 FROM posts
9 LEFT JOIN authors ON authors.id = posts.author_id
10 LEFT JOIN stats ON stats.post_id = posts.id
11 LIMIT {$limit} OFFSET {$offset}"
12 $posts = $db->query($stm);
13
14 foreach ($posts as &$post) {
15 $stm = "SELECT * FROM comments WHERE post_id = {$post['id']}";
16 $post['comments'] = $db->query($stm);
17 }
18 ?>

注意

这个例子展示了一个经典的 N+1 问题,其中为主集合的每个成员发出一个查询。获取博客文章的第一个查询将跟随 10 个查询,每个博客文章一个,以获取评论。因此,总查询数为 10,加上初始查询为 1。对于 50 篇文章,总共将有 51 个查询。这是遗留应用程序中性能拖慢的典型原因。有关 N+1 问题的详细讨论和解决方案,请参见Solving The N+1 Problem in PHP (leanpub.com/sn1php)

第一个问题是确定如何将查询拆分为Gateway方法。有些查询必须一起进行,而其他查询可以分开。在这种情况下,第一个和第二个查询可以分开到不同的Gateway类和方法中。

下一个问题是确定哪个Gateway类应接收提取的逻辑。当涉及多个表时,有时很难确定,因此我们必须选择查询的主要主题。上面的第一个查询涉及到文章、作者和统计数据,但从逻辑上看,我们主要关注的是文章。

因此,我们可以将第一个查询提取到PostsGateway中。我们希望尽可能少地修改查询本身,因此我们保留连接和其他内容不变:

1 <?php
2 namespace Domain\Posts;
3
4 class PostsGateway
5 {
6 protected $db;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectAllWithAuthorsAndStats($limit = null, $offset = null)
14 {
15 $limit = (int) $limit;
https://leanpub.com/sn1php
16 $offset = (int) $offset;
17 $stm = "SELECT *
18 FROM posts
19 LEFT JOIN authors ON authors.id = posts.author_id
20 LEFT JOIN stats ON stats.post_id = posts.id
21 LIMIT {$limit} OFFSET {$offset}"
22 return $this->db->query($stm);
23 }
24 }
25 ?>

完成后,我们继续根据第一个查询编写新功能的测试。我们修改代码并进行测试,直到测试通过。

第二个查询,与评论相关的查询,与我们之前的例子相同。

在完成提取及其相关测试后,我们可以修改页面脚本,使其如下所示:

1 <?php
2 $db = new Database($db_host, $db_user, $db_pass);
3 $posts_gateway = new \Domain\Posts\PostsGateway($db);
4 $comments_gateway = new \Domain\Comments\CommentsGateway($db);
5
6 // build a structure of posts with author and statistics data,
7 // with all comments on each post.
8 $page = (int) $_GET['page'];
9 $limit = 10;
10 $offset = $page * $limit; // a zero-based paging system
11 $posts = $posts_gateway->selectAllWithAuthorsAndStats($limit, $offset);
12
13 foreach ($posts as &$post) {
14 $post['comments'] = $comments_gateway->selectAllByPostId($post['id']);
15 }
16 ?>

如果没有数据库类会怎么样?

许多遗留代码库没有数据库访问层。相反,这些遗留应用程序直接在其页面脚本中使用mysql扩展。对mysql函数的调用分散在整个代码库中,并未收集到单个类中。

如果我们可以升级到PDO,我们应该这样做。然而,由于各种原因,可能无法从mysql升级。PDO的工作方式与mysql不完全相同,从mysql习语更改为PDO习语可能一次性做得太多。此时进行迁移可能会使测试变得比我们想要的更加困难。

另一方面,我们可以将mysql调用按原样移入我们的Gateway类中。起初这样做似乎是合理的。然而,mysql扩展内置了一些全局状态。任何需要链接标识符(即服务器连接)的mysql函数在没有传递链接标识符时会自动使用最近的连接资源。这与依赖注入的原则相违背,因为如果可能的话,我们宁愿不依赖全局状态。

因此,我建议我们不直接迁移到 PDO,也不将msyql函数调用保持原样,而是将mysql调用封装在一个类中,该类代理方法调用到mysql函数。然后,我们可以使用类方法而不是mysql函数。类本身可以包含链接标识符,并将其传递给每个方法调用。这将为我们提供一个数据库访问层,我们的Gateway对象可以使用,而不会太大地改变mysql的习惯用法。

这样一个包装器的一个操作示例实现是MysqlDatabase类。当我们创建一个MysqlDatabase的实例时,它会保留连接信息,但实际上不会连接到服务器。只有在我们调用实际需要服务器连接的方法时才会连接。这种延迟加载的方法有助于减少资源使用。此外,MysqlDatabase类明确添加了链接标识参数,这在相关的mysql函数中是可选的,这样我们就不会依赖于mysql扩展的隐式全局状态。

要用MysqlDatabase调用替换mysql函数调用:

  1. 在整个代码库中搜索mysql_前缀的函数调用。

  2. 在每个文件中,如果有带有mysql_函数前缀的函数调用...

  • 创建或注入一个MysqlDatabase的实例。

  • MysqlDatabase对象变量和一个箭头操作符(->)替换每个mysql_函数前缀。如果我们对风格很挑剔,我们还可以将剩余的方法名部分从snake_case()转换为camelCase()

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

  2. 继续搜索mysql_前缀的函数调用,直到它们都被替换为MysqlDatabase方法调用。

例如,假设我们有这样一个遗留代码:

**Using mysql functions**
1 <?php
2 mysql_connect($db_host, $db_user, $db_pass);
3 mysql_select_db('my_database');
4 $result = mysql_query('SELECT * FROM table_name LIMIT 10');
5 while ($row = mysql_fetch_assoc($result)) {
6 // do something with each row
7 }
8 ?>

使用上述过程,我们可以将代码转换为使用MysqlDatabase对象:

使用 MysqlDatabase 类

1 <?php
2 $db = new \Mlaphp\MysqlDatabase($db_host, $db_user, $db_pass);
3 $db->select_db('my_database'); // or $db->selectDb('my_database')
4 $result = $db->query('SELECT * FROM table_name LIMIT 10');
5 while ($row = $db->fetch_assoc($result)) {
6 // do something with each row
7 }
8 ?>

这段代码,反过来可以使用一个注入的MysqlDatabase对象提取到一个Gateway类中。

注意

对于我们的页面脚本,最好在现有的设置文件中创建一个MysqlDatabase实例并使用它,而不是在每个页面脚本中单独创建一个。实现的延迟连接性意味着如果我们从未对数据库进行调用,就永远不会建立连接,因此我们不需要担心不必要的资源使用。现有的遗留代码库将帮助我们确定这是否是一个合理的方法。

一旦我们的Gateway类使用了一个注入的MysqlDatabase对象,我们就可以开始计划从封装的mysql函数迁移到具有不同习惯用法和用法的PDO。因为数据库访问逻辑现在由Gateway对象封装,所以迁移和测试将比如果我们替换了遍布整个代码库的mysql调用要容易。

审查和下一步

当我们完成了这一步,我们所有的 SQL 语句将在Gateway类中,而不再在我们的页面脚本或其他非Gateway类中。我们还将对我们的Gateway类进行测试。

从现在开始,每当我们需要向数据库添加新的调用时,我们只会在Gateway类中这样做。每当我们需要获取或保存数据时,我们将使用Gateway方法,而不是编写嵌入式 SQL。这使我们在数据库交互和未来的模型层和实体对象之间有了明确的关注点分离。

现在我们已经将数据库交互分离到了它们自己的层中,我们将检查整个遗留应用程序中对Gateway对象的所有调用。我们将检查页面脚本和其他类如何操作返回的结果,并开始提取定义我们模型层的行为。

第九章:将域逻辑提取到事务中

在上一章中,我们将所有 SQL 语句提取到了网关对象的一层。这样封装了应用程序与数据库之间的交互。

然而,我们通常需要对从数据库获取的数据应用一定数量的业务或域逻辑,以及返回数据库。逻辑可以包括数据验证,添加或修改值以用于演示或计算目的,将更简单的记录收集到更复杂的记录中,使用数据执行相关操作等。这种域逻辑通常嵌入到页面脚本中,使得该逻辑难以重用和测试。

本章描述了将域行为提取到单独层的一种方法。在许多方面,本章构成了本书的核心:到目前为止,一切都导致了我们对遗留应用程序的这一核心关注点,而之后的一切将引导我们进入这个核心功能周围和上面的层。

注意

域还是模型?

遗留应用程序中的域逻辑是模型-视图-控制器中的模型部分。然而,遗留代码库不太可能有提供业务域的完整模型的单独实体对象。因此,在本章中,我们将讨论域逻辑而不是模型逻辑。如果我们足够幸运已经有了单独的模型对象,那就更好了。

嵌入式域逻辑

尽管我们已经提取了 SQL 语句,页面脚本和类可能正在操作结果并执行与检索数据相关的其他操作。这些操作和动作是域逻辑的核心,目前它们与其他非域关注点一起嵌入。

我们可以通过查看附录 B 中的代码,网关之前的代码和附录 C 中的代码,网关之后的代码,来看到从嵌入式 SQL 到使用网关类的进展。这里的代码太长,无法在此处呈现。我们要注意的是,即使在提取嵌入式 SQL 语句之后,代码仍然在将结果呈现给用户之前对传入和传出的数据进行了大量处理。

将域逻辑嵌入页面脚本中使得难以独立测试该逻辑。我们也无法轻松地重用它。如果我们想要搜索在如何处理域实体(在本例中是一系列文章)方面的重复和重复,我们需要审查整个应用程序中的每个页面脚本。

这里的解决方案是将域逻辑提取到一个或多个类中,以便我们可以独立于任何特定页面脚本对它们进行测试。然后我们可以实例化域逻辑类并在任何我们喜欢的页面脚本中使用它们。

在应用该解决方案之前,我们需要确定如何为我们的域逻辑结构目标类。

域逻辑模式

Martin Fowler 的企业应用架构模式PoEAA)目录了四种域逻辑模式:

  • 事务脚本:它主要将[域]逻辑组织为单个过程,直接调用数据库或通过一个薄的数据库包装器。每个事务都将有自己的事务脚本,尽管常见的子任务可以分解为子过程。

  • 域模型:它创建了一组相互连接的对象,其中每个对象代表一些有意义的个体,无论是像公司那样大,还是像订单表上的一行那样小。

  • 表模块:它使用数据库中每个表一个类的方式组织域逻辑,并且一个类的单个实例包含将对数据进行操作的各种过程,如果你有很多订单,域模型将每个订单一个订单对象,而表模块将有一个对象来处理所有订单。

  • 服务层:它从客户端层的接口角度定义了应用程序的边界和可用操作集。它封装了应用程序的业务逻辑,在实现其操作时控制事务并协调响应。

注意

我强烈建议购买 PoEAA 的纸质版,并完整阅读模式描述和示例。这本书对专业程序员来说是一个绝对必备的参考书。我发现自己每周都要查阅它(有时更频繁),它总是能提供清晰和洞察力。

现在我们面临的选择是:鉴于我们遗留应用程序的现有结构,哪种模式最适合当前的架构?

在这一点上,我们将放弃服务层,因为它暗示着一个在我们遗留应用程序中可能不存在的复杂程度。同样,我们也将放弃领域模型,因为它暗示着一个封装行为的良好设计的业务实体对象集。如果遗留应用程序已经实现了这些模式中的一个,那就更好了。否则,这就只剩下表模块和交易脚本模式了。

在上一章中,当我们将 SQL 语句提取到Gateway类中时,这些Gateway类很可能遵循了表数据网关模式,特别是如果它们足够简单,只与每个Gateway类交互一个表。这使得表模块模式似乎是我们领域逻辑的一个很好的选择。

然而,剩下的每个页面脚本或嵌入领域逻辑的类可能不太可能一次只与一个表交互。更频繁地,遗留应用程序在一个类或脚本中跨多个表有许多交互。因此,当我们提取领域逻辑时,我们将首先使用交易脚本模式。

交易脚本无可否认是一种简单的模式。通过它,我们将领域逻辑从页面脚本中提取出来,基本完整地转移到一个类方法中。我们只对逻辑进行修改,以便将数据正确地输入和输出到类方法中,以便原始代码仍然能够正常运行。

尽管我们可能希望有比交易脚本更复杂的东西,但我们必须记住,我们在这里的目标之一是尽量避免对现有逻辑进行太大的改变。我们是重构,而不是重写。我们现在想要的是将代码移动到适当的位置,以便进行适当的测试和重用。因此,交易脚本可能是包装我们遗留的领域逻辑的最佳方式,就像它存在的那样,而不是我们希望它成为的样子。

一旦我们将领域逻辑提取到自己的层中,我们就能更清晰地看到这个逻辑,减少干扰。在那时,如果真的需要的话,我们可以开始计划将领域层重构为更复杂的东西。例如,我们可以构建一个使用表模块或领域模型来协调各种领域交互的服务层。服务层向页面脚本呈现的接口可能与交易脚本接口完全保持不变,尽管底层架构可能已经完全改变。但这是另一天的任务。

注意

活动记录呢?

Ruby on Rails 以使用活动记录模式而闻名,许多 PHP 开发人员喜欢这种数据库交互方式。它确实有其优势。然而,Fowler 将活动记录分类为数据源架构模式,而不是领域逻辑模式,因此我们不会在这里讨论它。

提取过程

在本书中描述的重构过程中,提取领域逻辑将是最困难、耗时和细节导向的。这是一件非常艰难的事情,需要非常小心和注意。领域逻辑是我们遗留应用程序的核心,我们需要确保只提取出正确的部分。这意味着成功完全取决于我们对现有遗留应用程序的熟悉程度和能力。

幸运的是,我们之前对现代化遗留代码库的练习已经让我们对整个应用程序有了广泛的了解,以及对我们必须提取和重构的特定部分有了深入的了解。这应该让我们有信心成功完成这项任务。这是一项要求很高,但最终令人满意的活动。

一般来说,我们按照以下步骤进行:

  1. 搜索整个代码库,查找存在于“交易”类之外的“网关”类的使用情况。

  2. 在发现“网关”使用的地方,检查围绕“网关”操作的逻辑,以发现该逻辑的哪些部分与应用程序的领域行为相关。

  3. 提取相关的领域逻辑到一个或多个与领域元素相关的“交易”类中,并修改原始代码以使用“交易”类而不是嵌入的领域逻辑。

  4. 抽查以确保原始代码仍然正常工作,并根据需要修改提取的逻辑以确保正确运行。

  5. 为提取的“交易”逻辑编写测试,并随着测试代码的完善而完善测试,直到测试通过。

  6. 当所有原始测试和新测试都通过时,提交代码和测试,推送到公共存储库,并通知质量保证部门。

  7. 再次搜索“网关”类的使用情况,并继续提取领域逻辑,直到“网关”的使用仅存在于“交易”中。

搜索“网关”的使用情况

与早期章节一样,我们使用项目范围的搜索功能来查找我们创建“网关”类实例的位置:

搜索:

**new .*Gateway**

新的“网关”实例可能直接在页面脚本中使用,这种情况下我们已经找到了一些候选代码来提取领域逻辑。如果“网关”实例被注入到一个类中,我们现在需要深入到该类中找到“网关”的使用位置。围绕该使用的代码将成为我们提取领域逻辑的候选代码。

发现和提取相关的领域逻辑

提示

在将逻辑提取到类方法时,我们应该小心遵循我们在之前章节中学到的关于依赖注入的所有经验教训。除其他事项外,这意味着:不使用全局变量,用“请求”对象替换超全局变量,不在“工厂”类之外使用new关键字,以及(当然)根据需要通过构造函数注入对象。

在使用“网关”找到一些候选代码之后,我们需要检查围绕“网关”使用的代码,以进行这些和其他操作:

  • 数据的规范化、过滤、清理和验证

  • 数据的计算、修改、创建和操作

  • 使用数据进行顺序或并发操作和动作

  • 保留来自这些操作和动作的成功/失败/警告/通知消息

  • 保留值和变量以供以后的输入和输出

这些和其他逻辑片段很可能与领域相关。

要成功地将领域逻辑提取到一个或多个“交易”类和方法中,我们将不得不执行这些和其他活动:

  • 分解或重新组织提取的领域逻辑以支持方法

  • 分解或重新组织原始代码以包装新的“交易”调用

  • 保留、返回或报告原始代码所需的数据

  • 添加、更改或删除与提取的领域逻辑相关的原始代码中的变量

  • 为“交易”类和方法创建和注入依赖项

注意

发现和提取最好被视为学习的过程。像这样拆解遗留应用程序是一种了解应用程序构造的方式。因此,我们不应害怕多次尝试提取。如果我们的第一次尝试失败,变得丑陋,或者结果不佳,我们应该毫不内疚地放弃工作,重新开始,学到更多关于什么有效和什么无效的知识。就我个人而言,我经常在完成对领域逻辑的提取之前进行两到三次尝试。这就是修订控制系统让我们的生活变得更加轻松的地方;我们可以分阶段工作,只有在满意结果时才提交,如果需要从干净的状态重新开始,可以回滚到较早的阶段。

提取示例

举例来说,回想一下我们在附录 B 中开始的代码,网关之前的代码。在本章的前面,我们提到我们已经将嵌入的 SQL 语句提取到ArticlesGateway类中,最终得到了附录 C 中的代码,网关之后的代码。现在我们从那里转到附录 D,事务脚本之后的代码,在那里我们已经将领域逻辑提取到一个ArticleTransactions类中。

提取的领域逻辑在其完成形式中似乎并不特别复杂,但实际工作起来却非常详细。请查看附录 C 和附录 D 进行比较。我们应该找到以下内容:

  • 我们发现页面脚本中执行了两个单独的事务:一个用于提交新文章,一个用于更新现有文章。依次,这些都需要在数据库中操作用户的信用计数,以及各种数据规范化和支持操作。

  • 我们将相关的领域逻辑提取到了一个ArticleTransactions类和两个单独的方法中,一个用于创建,一个用于更新。我们为ArticleTransactions方法命名,以执行领域逻辑,而不是为底层技术操作的实现命名。

  • 输入过滤已封装为ArticleTransactions类中的支持方法,以便在两个事务方法中重复使用。

  • 新的ArticleTransactions类接收ArticlesGatewayUsersGateway依赖项来管理数据库交互,而不是直接进行 SQL 调用。

  • 一些仅与领域逻辑相关的变量已从页面脚本中删除,并作为属性放入Transactions类中。

  • 原始页面脚本中的代码已大大减少。现在它基本上是一个对象创建和注入机制,将用户输入传递到领域层,并在稍后获取数据进行输出。

  • 由于领域逻辑现在被封装起来,原始代码现在无法看到$failure变量,因为它在整个事务过程中被修改。该代码现在必须从ArticleTransactions类中获取失败信息,以供稍后呈现。

提取后,我们有一个classes/目录结构,看起来类似以下内容。这是在我们将 SQL 提取到Gateway类时使用领域导向的类结构的结果:

**/path/to/app/classes/**
1 Domain/
2 Articles/
3 ArticlesGateway.php
4 ArticleTransactions.php
5 Users/
6 UsersGateway.php

注意

这不一定是我们最终的重构。ArticleTransactions的进一步修改仍然是可能的。例如,与其注入UsersGateway,也许将与用户相关的各种领域逻辑提取到UserTransactions类中并注入可能更有意义。Transactions方法之间仍然存在很多重复。我们还需要更好的错误检查和条件报告在Transactions方法中。这些和其他重构是次要的,只有在主要提取领域逻辑之后才会更加明显和更容易处理。

抽查剩余的原始代码

一旦我们从原始代码中提取了一个或多个Transactions,我们需要确保在使用Transactions而不是嵌入式领域逻辑时,原始代码能够正常工作。与以前一样,我们通过运行我们预先存在的特性测试来做到这一点。如果我们没有特性测试,我们必须浏览或以其他方式调用已更改的代码。如果这些测试失败,我们会感到高兴!我们发现了提取的错误,并有机会在部署到生产之前修复它。如果“测试”通过,我们同样会感到高兴,并继续前进。

为提取的事务编写测试

我们现在知道原始代码可以使用新提取的Transactions逻辑。然而,新的类和方法需要它们自己的一套测试。与提取领域逻辑相关的一切都一样,编写这些测试可能会很详细和苛刻。逻辑可能很复杂,有很多分支和循环。我们不应该因此而放弃测试。至少,我们需要编写覆盖领域逻辑的主要情况的测试。

如果必要,我们可以重构提取的逻辑,将它们分开成更容易测试的方法。分解提取的逻辑将使我们更容易看到流程并找到重复的逻辑元素。但是,我们必须记住,我们的目标是维护现有的行为,而不是改变遗留应用程序呈现的行为。

提示

有关如何使提取的逻辑更具可测试性的见解和技术,请参阅 Martin Fowler 等人的重构refactoring.com/)以及 Michael Feathers 的与遗留代码有效地工作www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/01311)。

再次抽查,提交,推送,通知 QA

最后,由于我们对提取的Transactions逻辑的测试和相关重构可能引入了一些意外的变化,我们再次使用我们的特性测试或以其他方式调用相关代码来抽查原始代码。如果这些失败,我们会感到高兴!我们发现了我们的更改并不像我们想象的那么好,我们有机会在代码和测试离我们太远之前纠正它们。

当原始代码测试和提取的Transactions测试都通过时,我们再次感到高兴!现在我们可以提交我们所有的新工作,将其推送到中央仓库,并通知 QA 我们的现代化代码已经准备好供他们审查。

Do ... While

我们通过寻找在Transactions类之外使用的另一个Gateway来重新开始提取过程。我们继续提取和测试,直到所有Gateway调用发生在Transactions类内部。

常见问题

我们是在谈论 SQL 事务吗?

事务脚本一词指的是一种架构模式,并不意味着领域逻辑必须包装在 SQL 事务中。很容易混淆这两个概念。

话虽如此,牢记 SQL 事务可能有助于我们提取领域逻辑。一个有用的经验法则是,领域逻辑的各个部分应该根据它们在单个 SQL 事务中的适应程度进行拆分。假设的事务将作为一个整体提交或回滚。

这种目的的独特性将帮助我们确定领域逻辑的边界在哪里。我们实际上并没有添加 SQL 事务,只是以这种方式思考可以让我们对领域逻辑的边界有一些洞察。

重复的领域逻辑怎么办?

当我们将 SQL 语句提取到Gateway类时,有时会发现查询是相似但并非完全相同的。我们必须确定是否有办法将它们合并成一个方法。

同样,我们可能会发现我们的传统领域逻辑的某些部分已经被复制并粘贴到两个或更多的位置。当我们发现这些情况时,我们与Gateway类有相同的问题。这些逻辑片段是否足够相似,可以合并成一个方法,还是必须是不同的方法(甚至完全不同的Transactions)?

答案取决于具体情况。在某些情况下,重复的代码将是明显的逻辑复制,这意味着我们可以重用现有的Transactions方法。如果不是,我们需要提取到一个新的Transactions类或方法中。

还有一种中间路径,领域逻辑作为一个整体是不同的,但是在不同的“交易”中有相同的逻辑支持元素。在这些情况下,我们可以将支持逻辑重构为抽象基类Transactions类的方法,然后从中扩展新的Transactions。或者,我们可以将逻辑提取到一个支持类中,并将其注入到我们的Transactions中。

打印和回显是否属于领域逻辑的一部分?

我们的Transactions类不应该使用printecho。领域逻辑应该只返回或保留数据。

当我们发现领域逻辑中间存在输出生成时,我们应该提取该部分,使其位于领域逻辑之外。一般来说,这意味着在Transactions类中收集输出,然后通过一个单独的方法返回它或使其可用。将输出生成留给表示层。

交易可以是一个类而不是一个方法吗?

在示例中,我们展示了Transactions作为与特定领域实体相关的一组方法,例如ArticleTransactions。与该实体相关的领域逻辑的每个部分都包装在一个类方法中。

然而,将领域逻辑分解为每个交易一个类的结构也是合理的。事实上,一些交易可能足够复杂,以至于它们确实需要它们自己的单独类。使用单个类来表示单个领域逻辑交易没有任何问题。

例如,之前的ArticleTransactions类可能被拆分为一个带有支持方法的抽象基类,以及为每个提取出的领域逻辑部分创建的两个具体类。每个具体类都扩展了AbstractArticleTransaction,如下所示:

**classes/**
1 Domain/
2 Articles/
3 ArticlesGateway.php
4 Transaction/
5 AbstractArticleTransaction.php
6 SubmitNewArticleTransaction.php
7 UpdateExistingArticleTransaction.php
8 Users/
9 UsersGateway.php

如果我们采用每个交易一个类的方法,我们应该如何命名单个交易类上的主要方法,实际执行交易的方法?如果我们的传统代码库中已经存在主要方法的常见约定,我们应该遵守该约定。否则,我们需要选择一个一致的方法名称。个人而言,我喜欢利用__invoke()魔术方法来实现这个目的,但您可能希望使用exec()或其他适当的术语来指示我们正在执行或以其他方式执行交易。

“Gateway”类中的领域逻辑怎么办?

当我们将 SQL 语句提取到Gateway类时,有可能将一些领域逻辑移入其中,而不是保留在原始位置。在我们重构工作的早期阶段,很容易混淆领域级输入过滤(确保数据符合特定领域状态)与数据库级过滤(确保数据可以安全地与数据库一起使用)。

现在我们可以更容易地区分这两者。如果我们发现我们的“网关”类中存在领域级别的逻辑,我们可能应该将其提取到我们的“交易”类中。我们需要确保相应的测试也要更新。

非领域类中嵌入的领域逻辑怎么办?

本章的示例显示了嵌入在页面脚本中的领域逻辑。同样可能的是,我们的类中也嵌入了领域逻辑。如果该类可以合理地被视为领域的一部分,并且仅包含与领域相关的逻辑,但未命名为领域,将该类移动到领域命名空间可能是明智的。

否则,如果该类除了领域逻辑之外还有其他责任,我们可以继续以与从页面脚本中提取逻辑相同的方式从中提取领域逻辑。提取后,原始类将需要将相关的“交易”类注入为依赖项。然后原始类应适当地调用“交易”。

回顾和下一步

在这一点上,我们已经将我们遗留代码库的核心,即位于我们应用程序中心的领域逻辑,提取到了自己独立且可测试的层中。这是我们现代化过程中最具挑战性的步骤,但这绝对是值得我们花费时间的。我们并没有对领域逻辑本身进行太多修改或改进。我们所做的任何更改都只是足够将数据输入到我们的新“交易”类中,然后再次用于后续使用。

在很多方面,我们所做的只是重新安排逻辑,使其能够独立地被访问。虽然领域逻辑本身可能仍然存在许多问题,但这些问题现在是可测试的问题。我们可以根据需要继续添加测试,以探索领域逻辑中的边缘情况。如果我们需要添加新的领域逻辑,我们可以创建或修改我们的“交易”类和方法来封装和测试该逻辑。

将领域逻辑提取到自己的层中的过程为我们进一步迭代地重构领域模型奠定了良好的基础。如果我们选择追求这一点,这种重构将引导我们走向更适合应用领域逻辑的架构。然而,该架构将取决于应用程序。有关为我们的应用程序开发良好领域模型的更多信息,请阅读 Eric Evans 的《领域驱动设计》(www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215)。

通过将领域逻辑提取到自己的层中,我们可以继续进行现代化过程的下一阶段。在这一点上,我们原始代码中只剩下了一些关注点。在这些关注点中,我们将下一个关注点放在呈现层上。

第十章:将演示逻辑提取到视图文件中

在传统应用程序中的页面脚本方面,很常见看到业务逻辑与演示逻辑交织在一起。例如,页面脚本做一些设置工作,然后包含一个头部模板,调用数据库,输出结果,计算一些值,打印计算出的值,将值写回数据库,并包含一个页脚模板。

我们已经采取了一些步骤,通过提取传统应用程序的域层,来解耦这些关注点。然而,在页面脚本中对域层的调用和其他业务逻辑仍然与演示逻辑混合在一起。除其他外,这种关注点的交织使得难以测试我们传统应用程序的不同方面。

在这一章中,我们将把所有的演示逻辑分离到自己的层中,这样我们就可以单独测试它,而不受业务逻辑的影响。

嵌入式演示逻辑

作为嵌入式演示逻辑的示例,我们可以看一下附录 E收集演示逻辑之前的代码

演示逻辑。该代码显示了一个已经重构为使用域Transactions的页面脚本,但仍然在其余代码中存在一些演示逻辑。

注意

演示逻辑和业务逻辑之间有什么区别?

对于我们的目的,演示逻辑包括生成发送给用户(如浏览器或移动客户端)的任何和所有代码。这不仅包括echoprint,还包括header()setcookie()。每个都会生成某种形式的输出。另一方面,“业务逻辑”是其他所有内容。

将演示逻辑与业务逻辑解耦的关键是将它们的代码放入单独的范围中。脚本应首先执行所有业务逻辑,然后将结果传递给演示逻辑。完成后,我们将能够单独测试我们的演示逻辑,而不受业务逻辑的影响。

为了实现这种范围的分离,我们将朝着在我们的页面脚本中使用Response对象的方向发展。我们所有的演示逻辑将在Response实例内执行,而不是直接在页面脚本中执行。这样做将为我们提供我们需要的范围分离,包括 HTTP 头和 cookie 在内的所有输出生成,与页面脚本的其余部分分离开来。

注意

为什么使用 Response 对象?

通常,当我们想到演示时,我们会想到一个视图或模板系统,为我们呈现内容。然而,这些类型的系统通常不会封装将发送给用户的完整输出集。我们不仅需要输出 HTTP 主体,还需要输出 HTTP 头。此外,我们需要能够测试是否设置了正确的头部,并且内容已经生成正确。因此,在这一点上,Response对象比单独的视图或模板系统更合适。对于我们的Response对象,我们将使用mlaphp.com/code提供的类。请注意,我们将在Response上下文中包含文件,这意味着该对象上的方法将对在该对象“内部”运行的include文件可用。

提取过程

提取演示逻辑并不像提取域逻辑那么困难。然而,它需要仔细的注意和大量的测试。

一般来说,流程如下:

  1. 找到一个包含演示逻辑混合在其余代码中的页面脚本。

  2. 在那个脚本中,重新排列代码,将所有演示逻辑收集到文件中所有其他逻辑之后的一个单独的块中,然后对重新排列的代码进行抽查。

  3. 将演示逻辑块提取到视图文件中,通过Response进行交付,并再次对脚本进行抽查,以确保脚本能够正确地与新的Response一起工作。

  4. 对演示逻辑进行适当的转义并再次进行抽查。

  5. 提交新代码,推送到公共存储库,并通知 QA。

  6. 重新开始包含演示逻辑混合在其他非演示代码中的下一个页面脚本。

搜索嵌入式演示逻辑

一般来说,我们应该很容易找到我们遗留应用程序中的演示逻辑。在这一点上,我们应该对代码库足够熟悉,以便大致知道页面脚本生成的输出在哪里。

如果我们需要一个快速启动,我们可以使用项目范围的搜索功能来查找所有echoprintprintfheadersetcookiesetrawcookie的出现。其中一些可能出现在类方法中;我们将在以后解决这个问题。现在,我们将集中精力在页面脚本上,这些调用发生在这些调用发生的地方。

重新排列页面脚本并进行抽查

现在我们有了一个候选的页面脚本,我们需要重新排列代码,以便演示逻辑和其他所有内容之间有一个清晰的分界线。在这个例子中,我们将使用附录 E 中的代码,收集之前的代码

首先,我们转到文件底部,并在最后一行添加一个/* PRESENTATION */注释。然后我们回到文件顶部。逐行和逐块地工作,将所有演示逻辑移动到文件末尾,在我们的/* PRESENTATION */注释之后。完成后,/* PRESENTATION */注释之前的部分应该只包含业务逻辑,之后的部分应该只包含演示逻辑。

鉴于我们在附录 E 中的起始代码,收集之前的代码,我们应该最终得到类似附录 F 中的代码,收集之后的代码。特别要注意的是,我们有以下内容:

  • 将业务逻辑未使用的变量,如$current_page,移到演示块下

  • header.php包含移到演示块下

  • 将仅对演示变量起作用的逻辑和条件,如设置$page_titleif,移到演示块中

  • 用一个$action变量替换$_SERVER['PHP_SELF']

  • 用一个$id变量替换$_GET['id']

注意

在创建演示块时,我们应该小心遵循我们从早期章节中学到的所有课程。即使演示代码是文件中的一个块(而不是一个类),我们也应该将该块视为类方法。除其他事项外,这意味着不使用全局变量、超全局变量或new关键字。这将使我们在以后将演示块提取到视图文件时更容易。

现在我们已经重新排列了页面脚本,使得所有演示逻辑都集中在最后,我们需要进行抽查,以确保页面脚本仍然正常工作。通常情况下,我们通过运行我们预先存在的特性测试来做到这一点。如果没有,我们必须浏览或以其他方式调用已更改的代码。

如果页面生成的输出与以前不同,我们的重新排列在某种程度上改变了逻辑。我们需要撤消并重新进行重新排列,直到页面按照应该的方式工作。

一旦我们的抽查成功,我们可能希望提交到目前为止的更改。如果我们接下来的一系列更改出现问题,我们可以将代码恢复到这一点作为已知的工作状态。

提取演示到视图文件并进行抽查

现在我们有了一个带有所有演示逻辑的工作页面脚本,我们将把整个块提取到自己的文件中,然后使用Response来执行提取的逻辑。

创建一个 views/目录

首先,我们需要一个地方来放置我们传统应用程序中的视图文件。虽然我更喜欢将呈现逻辑保持在业务逻辑附近,但这种安排将给我们在以后的现代化步骤中带来麻烦。因此,我们将在我们的传统应用程序中创建一个名为views/的新目录,并将我们的视图文件放在那里。该目录应该与我们的classes/tests/目录处于同一级别。例如:

**/path/to/app/**
1 classes/
2 tests/
3 views/

选择一个视图文件名称

现在我们有一个保存视图文件的地方,我们需要为即将提取的呈现逻辑选择一个文件名。视图文件应该以页面脚本命名,在views/下的路径应与页面脚本路径匹配。例如,如果我们从/foo/bar/baz.php页面脚本中提取呈现,目标视图文件应保存在/views/foo/bar/baz.php

有时,除了.php之外,使用其他扩展名对于我们的视图文件也是有用的。我发现使用一个指示视图格式的扩展名可能会有所帮助。例如,生成 HTML 的视图可能以.html.php结尾,而生成 JSON 的视图可能以.json.php结尾。

将呈现块移动到视图文件中

接下来,我们从页面脚本中剪切呈现块,并将其原样粘贴到我们的新视图文件中。

然后,在页面脚本中原始的呈现块的位置,我们在新的视图文件中创建一个Response对象,并用setView()指向我们的视图文件。我们还为以后设置了一个空的setVars()调用,最后调用了send()方法。

注意

我们应该始终在所有页面脚本中使用相同的变量名来表示Response对象。这里的所有示例都将使用名称$response。这不是因为名称$response很特别,而是因为这种一致性在以后的章节中将非常重要。

例如:

foo/bar/baz.php
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $response = new \Mlaphp\Response('/path/to/app/views');
6 $response->setView('foo/bar/baz.html.php');
7 $response->setVars(array());
8 $response->send();
9 ?>

此时,我们已成功将呈现逻辑与页面脚本解耦。我们可以删除/* PRESENTATION */注释。它已经达到了它的目的,不再需要。

然而,这种解耦基本上破坏了呈现逻辑,因为视图文件依赖于页面脚本中的变量。考虑到这一点,我们开始进行抽查和修改周期。我们浏览或以其他方式调用页面脚本,并发现特定变量对于呈现不可用。我们将其添加到setVars()数组中,并再次进行抽查。我们继续向setVars()数组添加变量,直到视图文件拥有所需的一切,我们的抽查运行变得完全成功。

注意

在这个过程的这一部分,最好设置error_reporting(E_ALL)。这样我们将得到每个未初始化变量在呈现逻辑中的 PHP 通知。

鉴于我们之前在附录 E 中的示例,收集之前的代码和附录 F 中的示例,收集之后的代码,我们最终到达附录 G,响应视图文件之后的代码。我们可以看到articles.html.php视图文件需要四个变量:$id, $failure, $input, 和 $action

1 <?php
2 // ...
3 $response->setVars(array(
4 'id' => $id,
5 'failure' => $article_transactions->getFailure(),
6 'input' => $article_transactions->getInput(),
7 'action' => $_SERVER['PHP_SELF'],
8 ));
9 // ...
10 ?>

一旦我们有一个工作的页面脚本,我们可能希望再次提交我们的工作,以便以后如果需要,我们有一个已知正确的状态可以回滚。

添加适当的转义

不幸的是,大多数传统应用程序很少或根本不关注输出安全性。最常见的漏洞之一是跨站脚本XSS)。

注意

什么是 XSS?

跨站脚本攻击是一种可能是由用户输入导致的攻击。例如,攻击者可以在表单输入或 HTTP 标头中输入恶意构造的 JavaScript 代码。如果该值然后在未经逃逸的情况下传递回浏览器,浏览器将执行该 JavaScript 代码。这有可能使客户端浏览器暴露于进一步的攻击。有关更多信息,请参阅OWASP 关于 XSS 的条目 (www.owasp.org/index.php/Cross-site_Scripting_%28XSS%29)。

防御 XSS 的方法是始终为使用的上下文逃逸所有变量。如果一个变量用作 HTML 内容,它需要作为 HTML 内容进行逃逸;如果一个变量用作 HTML 属性,它需要逃逸为 HTML 属性,依此类推。

防御 XSS 需要开发人员的勤奋。如果我们记住逃逸输出的一件事,那就应该是htmlspecialchars()函数。适当使用此函数将使我们免受大多数 XSS 攻击的侵害。

使用htmlspecialchars()时,我们必须确保每次传递引号常量和字符集。因此,仅调用htmlspecialchars($unescaped_text)是不够的。我们必须调用htmlspecialchars($unescaped_text, ENT_QUOTES, 'UTF-8')。因此,输出看起来像这样:

**unescaped.html.php**
1 <form action="<?php
2 echo $request->server['PHP_SELF'];
3 ?>" method="POST">

这需要像这样进行逃逸:

**escaped.html.php**
1 <form action="<?php
2 echo htmlspecialchars(
3 $request->server['PHP_SELF'],
4 ENT_QUOTES,
5 'UTF-8'
6 );
7 ?>" method="POST">

每当我们发送未经逃逸的输出时,我们需要意识到我们很可能会打开一个安全漏洞。因此,我们必须对我们用于输出的每个变量应用逃逸。

以这种方式重复调用htmlspecialchars()可能很麻烦,因此Response类提供了一个esc()方法,作为htmlspecialchars()的别名,并带有合理的设置:

**escaped.php**
1 <form action="<?php
2 echo $this->esc($request->server['PHP_SELF']);
3 ?>" method="POST">

请注意,通过htmlspecialchars()进行逃逸只是一个起点。虽然逃逸本身很简单,但很难知道特定上下文的适当逃逸技术。

很遗憾,本书的范围不包括提供逃逸和其他安全技术的全面概述。有关更多信息以及一个很好的独立逃逸工具,请参阅Zend\Escaper (framework.zend.com/manual/2.2/en/modules/zend.escaper) 库。

在我们逃逸Response视图文件中的所有输出之后,我们可以继续进行测试。

编写视图文件测试

为视图文件编写测试提出了一些独特的挑战。在本章之前,我们所有的测试都是针对类和类方法的。因为我们的视图文件是文件,所以我们需要将它们放入稍微不同的测试结构中。

tests/views/目录

首先,我们需要在我们的tests/目录中创建一个views/子目录。之后,我们的tests/目录应该看起来像这样:

**/path/to/app/tests/**
1 bootstrap.php
2 classes/
3 phpunit.xml
4 views/

接下来,我们需要修改phpunit.xml文件,以便它知道要扫描新的views/子目录进行测试:

**tests/phpunit.xml**
1 <phpunit bootstrap="./bootstrap.php">
2 <testsuites>
3 <testsuite>
4 <directory>./classes</directory>
5 <directory>./views</directory>
6 </testsuite>
7 </testsuites>
8 </phpunit>

编写视图文件测试

现在我们有了视图文件测试的位置,我们需要编写一个。

尽管我们正在测试一个文件,但是 PHPUnit 要求每个测试都是一个类。因此,我们将为正在测试的视图文件命名我们的测试,并将其放在tests/views/目录下,该目录模仿原始视图文件的位置。例如,如果我们有一个视图文件位于views/foo/bar/baz.html.php,我们将在tests/views/foo/bar/创建一个测试文件BazHtmlTest.php。是的,这有点丑陋,但这将帮助我们跟踪哪些测试与哪些视图相对应。

在我们的测试类中,我们将创建一个Response实例,就像我们页面脚本末尾的那个一样。我们将传递视图文件路径和所需的变量。最后,我们将要求视图,然后检查输出和标头,以查看视图是否正常工作。

考虑到我们的articles.html.php文件,我们的初始测试可能如下所示:

**tests/views/ArticlesHtmlTest.php**
1 <?php
2 class ArticlesHtmlTest extends \PHPUnit_Framework_TestCase
3 {
4 protected $response;
5 protected $output;
6
7 public function setUp()
8 {
9 $this->response = new \Mlaphp\Response('/path/to/app/views');
10 $this->response->setView('articles.html.php');
11 $this->response->setVars(
12 'id' => '123',
13 'failure' => array(),
14 'action' => '/articles.php',
15 'input' => array(
16 'title' => 'Article Title',
17 'body' => 'The body text of the article.',
18 'max_ratings' => 5,
19 'credits_per_rating' => 1,
20 'notes' => '...',
21 'ready' => 0,
22 ),
23 );
24 $this->output = $this->response->requireView();
25 }
26
27 public function testBasicView()
28 {
29 $expect = '';
30 $this->assertSame($expect, $this->output);
31 }
32 }
33 ?>

注意

为什么使用 requireView()而不是 send()?

如果我们使用send()Response将输出视图文件的结果,而不是将它们留在缓冲区供我们检查。调用requireView()会调用视图文件,但返回结果而不是生成输出。

当我们运行这个测试时,它会失败。我们会感到高兴,因为$expect的值为空,但输出应该有很多内容。这是正确的行为。(如果测试通过,可能有什么地方出错了。)

断言内容的正确性

现在我们需要我们的测试来查看输出是否正确。

最简单的方法是转储实际的$this->output字符串,并将其值复制到$expect变量中。如果输出字符串相对较短,使用assertSame($expect, $this->output)来确保它们是相同的应该完全足够。

然而,如果我们主视图文件包含的任何其他文件发生了变化,那么测试将失败。失败不是因为主视图已经改变,而是因为相关视图已经改变。这不是对我们有帮助的失败。

对于大型输出字符串,我们可以查找预期的子字符串,并确保它在实际输出中存在。然后,当测试失败时,它将与我们正在测试的特定子字符串相关,而不是整个输出字符串。

例如,我们可以使用strpos()来查看特定字符串是否在输出中。如果$this->output的大堆中不包含$expect针,strpos()将返回布尔值false。任何其他值都表示$needle存在。(如果我们编写自己的自定义断言方法,这种逻辑更容易阅读。)

1 <?php
2 public function assertOutputHas($expect)
3 {
4 if (strpos($this->output, $expect) === false) {
5 $this->fail("Did not find expected output: $expect");
6 }
7 }
8
9 public function testFormTag()
10 {
11 $expect = '<form method="POST" action="/articles.php">';
12 $this->assertOutputHas($expect);
13 }
14 ?>

这种方法的好处是非常直接,但可能不适用于复杂的断言。我们可能希望计算元素出现的次数,或者断言 HTML 具有特定的结构而不引用该结构的内容,或者检查元素是否出现在输出的正确位置。

对于这些更复杂的内容断言,PHPUnit 有一个assertSelectEquals()断言,以及其他相关的assertSelect*()方法。这些方法通过使用 CSS 选择器来检查输出的不同部分,但可能难以阅读和理解。

或者,我们可能更喜欢安装Zend\Dom\Query来更精细地操作 DOM 树。这个库也通过使用 CSS 选择器来拆分内容。它返回DOM节点和节点列表,这使得它非常适用于以细粒度的方式测试内容。

不幸的是,我无法就哪种方法对您最好给出具体建议。我建议从上面的assertOutputHas()方法类似的方法开始,当明显需要更强大的系统时,再转向Zend\Dom\Query方法。

在我们编写了确认演示工作正常的测试之后,我们继续进行流程的最后一部分。

提交,推送,通知 QA

在这一点上,我们应该对页面脚本和提取的演示逻辑进行了测试。现在我们提交所有的代码和测试,将它们推送到公共存储库,并通知 QA 我们已经准备好让他们审查新的工作。

Do ... While

我们继续在页面脚本中寻找混合业务逻辑和演示逻辑。当我们通过Response对象将所有演示逻辑提取到视图文件中时,我们就完成了。

常见问题

关于头部和 Cookies 呢?

在上面的例子中,我们只关注了echoprint的输出。然而,通常情况下,页面脚本还会通过header()setcookie()setrawcookie()设置 HTTP 头部。这些也会生成输出。

处理这些输出方法可能会有问题。Response类使用输出缓冲echoprint捕获到返回值中,但对于header()和相关函数的调用,没有类似的选项。因为这些函数的输出没有被缓冲,我们无法轻松地测试看到发生了什么。

这是一个Response对象真正帮助我们的地方。该类带有缓冲header()和相关本机 PHP 函数的方法,但直到send()时才调用这些函数。这使我们能够捕获这些调用的输入并在它们实际激活之前进行测试。

例如,假设我们在一个虚构的视图文件中有这样的代码:

**foo.json.php**
1 <?php
2 header('Content-Type: application/json');
3 setcookie('baz', 'dib');
4 setrawcookie('zim', 'gir');
5 echo json_encode($data);
6 ?>

除其他事项外,我们无法测试头部是否符合预期。PHP 已经将它们发送给客户端。

在使用Response对象的视图文件时,我们可以使用$this->前缀来调用Response方法,而不是本机 PHP 函数。Response方法缓冲本机调用的参数,而不是直接进行调用。这使我们能够在它们作为输出之前检查参数。

**foo.json.php**
1 <?php
2 $this->header('Content-Type: application/json');
3 $this->setcookie('baz', 'dib');
4 $this->setrawcookie('zim', 'gir');
5 echo json_encode($data);
6 ?>

注意

因为视图文件是在Response实例内执行的,所以它可以访问$this来获取Response属性和方法。Response对象上的header()setcookie()setrawcookie()方法具有与本机 PHP 方法完全相同的签名,但是它们将输入捕获到属性中以便稍后输出,而不是立即生成输出。

现在我们可以测试Response对象来检查 HTTP 正文以及 HTTP 头部。

**tests/views/FooJsonTest.php**
1 <?php
2 public function test()
3 {
4 // set up the response object
5 $response = new \Mlaphp\Response('/path/to/app/views');
6 $response->setView('foo.json.php');
7 $response->setVars('data', array('foo' => 'bar'));
8
9 // invoke the view file and test its output
10 $expect_body = '{"foo":"bar"}';
11 $actual_body = $response->requireView();
12 $this->assertSame($expect_output, $actual_output);
13
14 // test the buffered HTTP header calls
15 $expect_headers = array(
16 array('header', 'Content-Type: application/json'),
17 array('setcookie', 'baz', 'dib'),
18 array('setrawcookie', 'zim', 'gir'),
19 );
20 $actual_headers = $response->getHeaders();
21 $this->assertSame($expect_output, $actual_output);
22 }
23 ?>

注意

ResponsegetHeaders()方法返回一个子数组的数组。每个子数组都有一个元素 0,表示要调用的本机 PHP 函数名称,其余元素是函数的参数。这些是将在send()时调用的函数调用。

如果我们已经有一个模板系统呢?

许多时候,遗留应用程序已经有一个视图或模板系统。如果是这样,保持使用现有的模板系统可能就足够了,而不是引入新的Response类。

如果我们决定保留现有的模板系统,则本章的其他步骤仍然适用。我们需要将所有模板调用移动到页面脚本末尾的一个位置,将所有模板交互与其他业务逻辑分离。然后我们可以在页面脚本末尾显示模板。例如:

**foo.php**
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $template = new Template;
6 $template->assign($this->getVars());
7 $template->display('foo.tpl.php');
8 ?>

如果我们不发送 HTTP 头部,这种方法与使用Response对象一样具有可测试性。然而,如果我们混合调用header()和相关函数,我们的可测试性将更受限制。

为了未来保护我们的遗留代码,我们可以将模板逻辑移到视图文件中,并在页面脚本中与Response对象交互。例如:

**foo.php**
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $response = new Response('/path/to/app/views');
6 $response->setView('foo.html.php');
7 $response->setVars(array('foo' => $foo));
8 $response->send();
9 ?>
**foo.html.php**
1 <?php
2 // buffer calls to HTTP headers
3 $this->setcookie('foo', 'bar');
4 $this->setrawcookie('baz', 'dib');
5
6 // set up the template object with Response vars
7 $template = new Template;
8 $template->assign($this->getVars());
9
10 // display the template
11 $template->display('foo.tpl.php');
12 ?>

这使我们能够继续使用现有的模板逻辑和文件,同时通过Response对象为 HTTP 头部添加可测试性。

为了保持一致,我们应该使用现有的模板系统或者通过Response对象在视图文件中包装所有模板逻辑。我们不应该在一些页面脚本中使用模板系统,在其他页面脚本中使用Response对象。在后面的章节中,我们在页面脚本中与呈现层交互的方式将变得很重要。

流式内容怎么办?

大多数情况下,我们的呈现内容足够小,可以由 PHP 缓冲到内存中,直到准备发送。然而,有时我们的遗留应用程序可能需要发送大量数据,比如几十或几百兆字节的文件。

将大文件读入内存,以便我们可以将其输出给用户通常不是一个好的方法。相反,我们流式传输文件:我们读取文件的一小部分并将其发送给用户,然后读取下一小部分并将其发送给用户,依此类推,直到整个文件被传送。这样,我们就不必将整个文件保存在内存中。

到目前为止,示例只处理了将视图缓冲到内存中,然后一次性输出,而不是流式传输。对于视图文件来说,将整个资源读入内存然后输出是一个不好的方法。与此同时,我们需要确保在任何流式内容之前传送标头。

Response对象有一个处理这种情况的方法。Response方法setLastCall()允许我们设置一个用户定义的函数(可调用的),以在需要视图文件并发送标头后调用。有了这个,我们可以传递一个类方法来为我们流式传输资源。

例如,假设我们需要流式传输一个大图像文件。我们可以编写一个类来处理流逻辑,如下所示:

**classes/FileStreamer.php**
1 <?php
2 class FileStreamer
3 {
4 public function send($file, $dest = STDOUT)
5 {
6 $fh = fopen($file, 'rb');
7 while (! feof($fh)) {
8 $data = fread($fh, 8192);
9 fwrite($dest, $data);
10 }
11 fclose($fh);
12 }
13 }
14 ?>

这里还有很多需要改进的地方,比如错误检查和更好的资源处理,但它完成了我们示例的目的。

我们可以在页面脚本中创建一个FileStreamer的实例,视图文件可以将其用作setLastCall()的可调用参数:

**foo.php**
1 <?php
2 // ... business logic ...
3 $file_streamer = new FileStreamer;
4 $image_file = '/path/to/picture.tiff';
5 $content_type = 'image/tiff';
6
7 /* PRESENTATION */
8 $response = new Response('/path/to/app/views');
9 $response->setView('foo.stream.php');
10 $response->setVars(array(
11 'streamer' => $file_streamer,
12 'file' => $image_file,
13 'type' => $content_type,
14 ));
15 ?>
**views/foo.stream.php**
1 <?php
2 $this->header("Content-Type: {$type}");
3 $this->setLastCall(array($streamer, 'send'), $file);
4 ?>

send()时,Response将需要视图文件,设置一个标头和最后一个调用的参数。然后,Response发送标头和视图的捕获输出(在这种情况下是空的)。最后,它调用setLastCall()中的可调用和参数,流式传输文件。

如果我们有很多演示变量怎么办?

在本章的示例代码中,我们只有少数变量需要传递给演示逻辑。不幸的是,更有可能的情况是需要传递 10 个、20 个或更多的变量。这通常是因为演示由几个include文件组成,每个文件都需要自己的变量。

这些额外的变量通常用于诸如站点标题、导航和页脚部分之类的内容。因为我们已经将业务逻辑与演示逻辑解耦,并在一个单独的范围内执行演示逻辑,所以我们必须传递所有include文件所需的变量。

比如说我们有一个视图文件,其中包括一个header.php文件,就像这样:

**header.php**
1 <html>
2 <head>
3 <title><?php
4 echo $this->esc($page_title);
5 ?></title>
6 <link rel="stylesheet" href="<?php
7 echo $this->esc($page_style);
8 ?>"></link>
9 </head>
10 <body>
11 <h1><?php echo $this->esc($page_title); ?></h1>
12 <div id="navigation">
13 <ul>
14 <?php foreach ($site_nav as $nav_item) {
Extract Presentation Logic To View Files 117
15 $href = $this->esc($nav_item['href']);
16 $name = $this->esc($nav_item['name']);
17 echo '<li><a href="' . $href
18 . '"/a>' . $name
19 . '</li>' . PHP_EOL;
20 }?>
21 </ul>
22 </div>
23 <!-- end of header.php -->

我们的页面脚本将不得不传递$page_title$page_style$site_nav变量,以便页眉正确显示。这是一个相对温和的情况;可能会有更多的变量。

一个解决方案是将常用变量收集到一个或多个自己的对象中。然后我们可以将这些常用对象传递给Response供视图文件使用。例如,特定于页眉的显示变量可以放在HeaderDisplay类中,然后传递给Response

classes/HeaderDisplay.php
1 <?php
2 class HeaderDisplay
3 {
4 public $page_title;
5 public $page_style;
6 public $site_nav;
7 }
8 ?>

然后我们可以修改header.php文件以使用HeaderDisplay对象,页面脚本可以传递HeaderDisplay的实例,而不是所有单独的与页眉相关的变量。

提示

一旦我们开始将相关变量收集到类中,我们将开始看到如何将演示逻辑收集到这些类的方法中,从而减少视图文件中的逻辑量。例如,我们应该很容易想象在HeaderDisplay类上有一个getNav()方法,它返回我们导航小部件的正确 HTML。

那么生成输出的类方法怎么办?

在本章的示例代码中,我们集中在页面脚本中的呈现逻辑。然而,可能情况是,领域类或其他支持类使用echoheader()来生成输出。因为输出生成必须限制在呈现层,我们需要找到一种方法来移除这些调用,而不破坏我们的遗留应用程序。即使是用于呈现目的的类也不应该自行生成输出。

这里的解决方案是将每个echoprint等的使用转换为return。然后我们可以立即输出结果,或者将结果捕获到一个变量中,稍后再输出。

例如,假设我们有一个类方法看起来像这样:

1 <?php
2 public function namesAndRoles($list)
3 {
4 echo "<p>Names and roles:</p>";
5 foreach ($list as $item) {
6 echo "<dl>";
7 echo "<dt>Name</dt><dd>{$item['name']}</dd>";
8 echo "<dt>Role</dt><dd>{$item['role']}</dd>";
9 echo "</dl>";
10 }
11 }
12 ?>

我们可以将其转换为类似于这样的东西(并记得添加转义!):

1 <?php
2 public function namesAndRoles($list)
3 {
4 $html = "<p>Names and roles:</p>";
5 foreach ($list as $item) {
6 $name = htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');
7 $role = htmlspecialchars($item['role'], ENT_QUOTES, 'UTF-8');
8 $html .= "<dl>";
9 $html .= "<dt>Name</dt><dd>{$name}</dd>";
10 $html .= "<dt>Role</dt><dd>{$role}</dd>";
11 $html .= "</dl>";
12 }
13 return $html;
14 }
15 ?>

业务逻辑混入呈现逻辑怎么办?

当重新排列页面脚本以将业务逻辑与呈现逻辑分开时,我们可能会发现呈现代码调用Transactions或其他类或资源。这是一种混合关注点的恶劣形式,因为呈现依赖于这些调用的结果。

如果被调用的代码专门用于输出,那么就没有问题;我们可以保留调用。但是,如果被调用的代码与数据库或网络连接等外部资源进行交互,那么我们就需要分离关注点。

解决方案是从呈现逻辑中提取出一组等效的业务逻辑调用,将结果捕获到一个变量中,然后将该变量传递给呈现。

举个假设的例子,以下混合代码进行数据库调用,然后在一个循环中呈现它们:

1 <?php
2 /* PRESENTATION */
3 foreach ($post_transactions->fetchTopTenPosts() as $post) {
4 echo "{$post['title']} has "
5 . $comment_transactions->fetchCountForPost($post['id'])
6 . " comments.";
7 }
8 ?>

暂时忽略我们需要解决示例中提出的 N+1 查询问题,以及这可能更好地在Transactions级别解决。我们如何将呈现与数据检索分离?

在这种情况下,我们构建了一组等效的代码来捕获所需的数据,然后将该数据传递给呈现逻辑,并应用适当的转义。

1 <?php
2 // ...
3 $posts = $post_transactions->fetchTopTenPosts();
4 foreach ($posts as &$post) {
5 $count = $comment_transactions->fetchCountForPost($post['id']);
6 $post['comment_count'] = $count;
7 }
8 // ...
9
10 /* PRESENTATION */
11 foreach ($posts as $post) {
12 $title = $this->esc($post['title']);
13 $comment_count = $this->esc($post['comment_count']);
14 echo "{$title} has {$comment_count} comments."
15 }
16 ?>

是的,我们最终会两次循环相同的数据——一次在业务逻辑中,一次在呈现逻辑中。虽然从某些方面来说,这可能被称为低效,但效率不是我们的主要目标。关注点的分离是我们的主要目标,这种方法很好地实现了这一点。

如果一个页面只包含呈现逻辑呢?

我们遗留应用程序中的一些页面可能主要或完全由呈现代码组成。在这些情况下,似乎我们不需要Response对象。

然而,即使这些页面脚本也应该转换为使用Response和视图文件。我们现代化过程中的后续步骤将需要一个一致的接口来处理我们的页面脚本的结果,我们的Response对象是确保这种一致性的方法。

审查和下一步

我们现在已经浏览了所有的页面脚本,并将呈现逻辑提取到一系列单独的文件中。呈现代码现在在一个完全独立于页面脚本的范围内执行。这使我们非常容易看到脚本的剩余逻辑,并独立测试呈现逻辑。

将呈现逻辑提取到自己的层中后,我们的页面脚本正在减小。它们中所剩的只是一些设置工作和准备响应所需的操作逻辑。

那么,我们的下一步是将页面脚本中剩余的操作逻辑提取到一系列控制器类中。

第十一章:将动作逻辑提取到控制器中

到目前为止,我们已经提取了我们的模型领域逻辑和视图呈现逻辑。我们的页面脚本中只剩下两种逻辑:

  • 使用应用程序设置创建对象的依赖逻辑

  • 使用这些对象执行页面动作的动作逻辑(有时称为业务逻辑)

在本章中,我们将从我们的页面脚本中提取出一层Controller类。这些类将单独处理我们遗留应用程序中的剩余动作逻辑,与我们的依赖创建逻辑分开。

嵌入式动作逻辑

作为嵌入式动作逻辑与依赖逻辑混合的示例,我们可以查看上一章末尾的示例代码,在附录 G 中可以找到,响应视图文件后的代码。在其中,我们做了一些设置工作,然后检查一些条件并调用我们领域Transactions的不同部分,最后我们组合了一个Response对象来将我们的响应发送给客户端。

与混合呈现逻辑的问题一样,我们无法单独测试动作逻辑,而无法轻松更改依赖创建逻辑以使页面脚本更易于测试。

我们解决了嵌入式动作逻辑的问题,就像解决嵌入式呈现逻辑一样。我们必须将动作代码提取到自己的类中,以将页面脚本的各种剩余关注点分开。这也将使我们能够独立于应用程序的其余部分测试动作逻辑。

提取过程

现在,从我们的页面脚本中提取动作逻辑应该对我们来说是一个相对容易的任务。因为领域层已经被提取出来,以及呈现层,动作逻辑应该是显而易见的。工作本身仍然需要注意细节,因为主要问题将是从动作逻辑本身中分离出依赖设置部分。

一般来说,流程如下:

  1. 找到一个页面脚本,其中动作逻辑仍然与其余代码混合在一起。

  2. 在该页面脚本中,重新排列代码,使所有动作逻辑位于其自己的中心块中。抽查重新排列的代码,确保它仍然正常工作。

  3. 将动作逻辑的中心块提取到一个新的Controller类中,并修改页面脚本以使用新的Controller。使用Controller对页面脚本进行抽查。

  4. 为新的Controller类编写单元测试,并再次进行抽查。

  5. 提交新代码和测试,将它们推送到共享存储库,并通知质量保证团队。

  6. 查找另一个包含嵌入式动作逻辑的页面脚本,并重新开始;当所有页面脚本都使用Controller对象时,我们就完成了。

搜索嵌入式动作逻辑

此时,我们应该能够找到动作逻辑,而无需使用项目范围的搜索功能。我们遗留应用程序中的每个页面脚本可能都至少有一点动作逻辑。

重新排列页面脚本并进行抽查

当我们有一个候选页面脚本时,我们继续重新排列代码,使所有设置和依赖创建工作位于顶部,所有动作逻辑位于中间,$response->send()调用位于底部。在这里,我们将使用上一章末尾的代码作为起始示例,该代码可以在附录 G 中找到,响应视图文件后的代码

识别代码块

首先,我们转到脚本的顶部,在第一行(或者在包含设置脚本之后)放置一个/* 依赖 */注释。然后我们转到脚本的最末尾,到$response->send()行,并在其上方放置一个/* 完成 */注释。

现在我们达到了一个必须使用我们的专业判断的时刻。在页面脚本中设置和依赖工作之后的某一行,我们会发现代码开始执行某种动作逻辑。我们对这个转变发生的确切位置的评估可能有些随意,因为动作逻辑和设置逻辑很可能仍然交织在一起。即便如此,我们必须选择一个我们认为动作逻辑真正开始的时间点,并在那里放置一个/* 控制器 */注释。

将代码移动到相关块

一旦我们在页面脚本中确定了这三个块,我们就开始重新排列代码,以便只有设置和依赖创建工作发生在/* 依赖 *//* 控制器 */之间,只有动作逻辑发生在/* 控制器 *//* 完成 */之间。

一般来说,我们应该避免在依赖块中使用条件或循环,并且避免在控制器块中创建对象。依赖块中的代码应该只创建对象,控制器块中的代码应该只操作在依赖块中创建的对象。

鉴于我们在附录 G 中的起始代码,响应视图文件后的代码,我们可以在附录 H 中看到一个示例重新排列的结果,控制器重新排列后的代码。值得注意的是,我们将$user_id声明移到了控制器块,将Response对象创建移到了依赖块。中央控制器块中的原始动作逻辑在其他方面保持不变。

抽查重新排列后的代码

最后,在重新排列页面脚本之后,我们需要抽查我们的更改,以确保一切仍然正常工作。如果我们有特征测试,我们应该运行这些测试。否则,我们应该浏览或以其他方式调用页面脚本。如果它没有正确工作,我们需要撤消并重新进行重新排列,以修复我们引入的任何错误。

当我们的抽查运行成功时,我们可能希望提交到目前为止的更改。这将给我们一个已知工作的状态,如果将来的更改出现问题,我们可以回滚到这个状态。

提取一个控制器类

现在我们有一个正确工作的重新排列页面脚本,我们可以将中央控制器块提取到一个独立的类中。这并不困难,但我们将分几个子步骤来确保一切顺利进行。

选择一个类名

在我们可以提取到一个类之前,我们需要为我们将要提取到的类选择一个名称。

对于我们的领域层类,我们选择了顶层命名空间Domain。因为这是一个控制器层,我们将使用顶层命名空间Controller。我们使用的命名空间并不像一致地为所有控制器使用相同的命名空间那样重要。就个人而言,我更喜欢Controller,因为它足够广泛,可以包含不同类型的控制器,比如应用控制器。

该命名空间中的类名应该反映页面脚本在 URL 层次结构中的位置,其中在路径中有目录分隔符的地方使用命名空间分隔符。这种方法可以清楚地显示原始页面脚本目录路径,并且可以在类结构中很好地组织子目录。我们还在类名后缀加上Page以表明它是一个页面控制器。

例如,如果页面脚本位于/foo/bar/baz.php,那么类名应该是Controller\Foo\Bar\BazPage。然后,类文件本身将被放置在我们的中央类目录下的classes/Controller/Foo/Bar/BazPage.php

创建一个骨架类文件

一旦我们有了一个类名,我们就可以为其创建一个骨架类文件。我们添加两个空方法作为以后的占位符:__invoke()方法将接收页面脚本的动作逻辑,构造函数最终将接收类的依赖项。

**classes/Controller/Foo/Bar/BazPage.php**
1 <?php
2 namespace Controller\Foo\Bar;
3
4 class BazPage
5 {
6 public function __construct()
7 {
8 }
9
10 public function __invoke()
11 {
12 }
13 }
14 ?>

注意

为什么是 __invoke()?

就我个人而言,我喜欢利用__invoke()魔术方法来实现这个目的,但您可能希望使用exec()或其他适当的术语来指示我们正在执行或以其他方式运行控制器。无论我们选择什么方法名,我们都应该保持一致使用。

移动动作逻辑并进行抽查

现在我们准备将动作逻辑提取到我们的新Controller类中。

首先,我们从页面脚本中剪切控制器块,并将其原样粘贴到__invoke()方法中。我们在动作逻辑的末尾添加一行return $response,将Response对象发送回调用代码。

接下来,我们回到页面脚本。在提取的动作逻辑的位置,我们创建一个新的Controller实例并调用其__invoke()方法,得到一个Response对象。

我们应该在所有页面脚本中始终使用相同的变量名来表示Controller对象。这里的所有示例都将使用名称$controller。这不是因为名称$controller很特别,而是因为在后面的章节中,这种一致性将非常重要。

在这一点上,我们已经成功地将动作逻辑与页面脚本解耦。然而,这种解耦基本上破坏了动作逻辑,因为Controller依赖于页面脚本中的变量。

考虑到这一点,我们开始进行抽查和修改循环。我们浏览或以其他方式调用页面脚本,发现特定变量对Controller不可用。我们将其添加到__invoke()方法签名中,并再次进行抽查。我们继续向__invoke()方法添加变量,直到Controller拥有所需的一切,我们的抽查运行完全成功。

注意

在这个过程的这一部分,最好设置error_reporting(E_ALL)。这样我们将得到每个动作逻辑中未初始化变量的 PHP 通知。

在附录 H 中给出了我们重新排列的页面脚本,Controller 重排后的代码,我们初始提取到Controller的结果可以在附录 I 中看到,Controller 提取后的代码。原来提取的动作逻辑需要四个变量:$request$response$user$article_transactions

将 Controller 转换为依赖注入并进行抽查。

一旦我们在__invoke()方法中有一个可用的动作逻辑块,我们将把方法参数转换为构造函数参数,以便Controller可以使用依赖注入。

首先,我们剪切__invoke()参数,并将它们整体粘贴到__construct()参数中。然后编辑类定义和__construct()方法以将参数保留为属性。

接下来,我们修改__invoke()方法,使用类属性而不是方法参数。这意味着在每个所需变量前加上$this->

然后,我们回到页面脚本。我们剪切__invoke()调用的参数,并将它们粘贴到Controller的实例化中。

现在我们已经将Controller转换为依赖注入,我们需要再次抽查页面脚本,确保一切正常运行。如果不正常,我们需要撤销并重新进行转换,直到测试通过。

在这一点上,我们可以删除/* DEPENDENCY *//* CONTROLLER *//* FINISHED */注释。它们已经达到了它们的目的,不再需要。

鉴于附录 I 中对__invoke()的使用,我们可以看到在附录 J 中将Controller转换为依赖注入的样子。我们将Controller__invoke()参数移到__construct()中,将它们保留为属性,在__invoke()方法体中使用新属性,并修改页面脚本以在new时而不是__invoke()时传递所需的变量。

一旦我们有一个可工作的页面脚本,我们可能希望再次提交我们的工作,以便我们有一个已知正确的状态,以便以后可以恢复。

编写 Controller 测试

即使我们已经测试了我们的页面脚本,我们仍需要为我们提取的Controller逻辑编写单元测试。当我们编写测试时,我们需要将所有所需的依赖项注入到我们的Controller中,最好是作为测试替身,如伪造对象或模拟对象,这样我们就可以将Controller与系统的其余部分隔离开来。

当我们进行断言时,它们可能应该针对从__invoke()方法返回的Response对象。我们可以使用getView()来确保设置了正确的视图文件,使用getVars()来检查要在视图中使用的变量,使用getLastCall()来查看最终可调用的(如果有的话)是否已经正确设置。

提交,推送,通知 QA

一旦我们通过了单元测试,并且我们对原始页面脚本的测试也通过了,我们就可以提交我们的新代码和测试。然后我们推送到公共存储库,并通知质量保证团队,让他们审查我们的工作。

Do ... While

现在我们继续下一个包含嵌入式动作逻辑的页面脚本,并重新开始提取过程。当我们所有的页面脚本都使用依赖注入的Controller对象时,我们就完成了。

常见问题

我们可以向 Controller 方法传递参数吗?

在这些示例中,我们从__invoke()方法中删除了所有参数。但是,有时我们希望将参数作为最后一刻的信息传递给控制器逻辑。

一般来说,在我们的现代化过程中,我们应该避免这样做。这不是因为这是一种不好的做法,而是因为我们需要在稍后的现代化步骤中对我们的控制器调用具有非常高的一致性水平。最一致的做法是__invoke()根本不带参数。

如果我们需要向Controller传递额外的信息,我们应该通过构造函数来实现。特别是当我们要传递请求值时。

例如,而不是这样:

**page_script.php**
1 <?php
2 /* DEPENDENCY */
3 // ...
4 $response = new \Mlaphp\Response('/path/to/app/views');
5 $foo_transactions = new \Domain\Foo\FooTransactions(...);
6 $controller = new \Controller\Foo(
7 $response,
8 $foo_transactions
9 );
10
11 /* CONTROLLER */
12 $response = $controller->__invoke('update', $_POST['user_id']);
13
14 /* FINISHED */
15 $response->send();
16 ?>

我们可以这样做:

**page_script.php**
1 <?php
2 /* DEPENDENCY */
3 // ...
4 $response = new \Mlaphp\Response('/path/to/app/views');
5 $foo_transactions = new \Domain\Foo\FooTransactions(...);
6 $request = new \Mlaphp\Request($GLOBALS);
7 $controller = new \Controller\Foo(
8 $response,
9 $foo_transactions,
10 $request
11 );
12
13 /* CONTROLLER */
14 $response = $controller->__invoke();
15
16 /* FINISHED */
17 $response->send();
18 ?>

__invoke()方法体将使用$this->request->get['item_id']

一个 Controller 可以有多个动作吗?

在这些示例中,我们的Controller对象执行单个动作。但是,通常情况下,页面控制器可能包含多个动作,例如插入和更新数据库记录。

我们首次提取页面脚本中的动作逻辑应该保持代码基本完整,允许使用属性而不是局部变量等。但是,一旦代码在类中,将逻辑拆分为单独的动作方法是完全合理的。然后__invoke()方法可以变得不过是一个选择正确动作方法的switch语句。如果我们这样做,我们应该确保更新我们的Controller测试,并继续抽查页面脚本,以确保我们的更改不会破坏任何东西。

请注意,如果我们创建额外的Controller动作方法,我们需要避免从我们的页面脚本中调用它们。为了在稍后的现代化步骤中需要的一致性,__invoke()方法应该是页面脚本在其控制器块中调用的唯一Controller方法。

如果 Controller 包含 include 调用怎么办?

不幸的是,当我们重新排列页面脚本时,我们可能会发现我们的控制器块中仍然有几个include调用。(为设置和依赖目的而进行的include调用并不是什么大问题,特别是如果它们在每个页面脚本中都是相同的。)

在控制器块中使用include调用是我们遗留应用开始时采用的基于包含的架构的遗留物。这是一个特别难以解决的问题。我们希望将动作逻辑封装在类中,而不是在我们include它们时立即执行行为的文件中。

目前,我们必须接受在页面脚本的控制器块中使用include调用是丑陋但必要的想法。如果需要的话,我们应该避开视线,并将它们与页面脚本中的其余控制器代码一起复制到Controller类中。

作为安慰,我们将在下一章解决这些嵌入的include调用的问题。

回顾和下一步

将动作逻辑提取到Controllers层完成了我们遗留应用的一个巨大的现代化目标。现在我们已经建立了一个完整的模型视图控制器系统:模型的领域层,视图的表示层,以及连接两者的控制器层。

我们应该对我们的现代化进展感到非常满意。每个页面脚本中剩下的代码都是其原始形式的阴影。大部分逻辑是创建带有其依赖关系的Controller的连接代码。剩下的逻辑在所有页面脚本中都是相同的;它调用Controller并发送返回的Response对象。

然而,我们需要处理一个重要的遗留物件。为了完成对控制器逻辑的完全提取和封装,我们需要移除在我们的Controller类中嵌入的任何剩余的include调用。

第十二章:替换类中的包含

即使现在我们已经有了模型视图控制器分离,我们的类中可能仍然有许多包含调用。我们希望我们的遗留应用程序摆脱其包含导向遗产的痕迹,仅仅包含一个文件就会导致逻辑被执行。为了做到这一点,我们需要在整个类中用方法调用替换包含调用。

注意

在本章的目的是,我们将使用术语包含来覆盖不仅仅是include,还包括requireinclude_oncerequire_once

嵌入式包含调用

假设我们提取了一些嵌入式include的动作逻辑到一个Controller方法中。代码接收一个新用户的信息,调用一个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. 如果包含的文件只被使用一次,并且只在一个类中使用:

  4. 将包含文件的内容直接复制到include调用上。

  5. 测试修改后的类,并删除包含文件。

  6. 重构复制的代码,使其遵循我们现有的所有规则:没有全局变量,没有new,注入依赖项,返回而不是输出,没有include调用。

  7. 如果包含的文件被使用多次:

  8. 将包含文件的内容直接复制到一个新的类方法中。

  9. 用新类的内联实例化和新方法的调用替换发现的include调用。

  10. 测试替换了include的类,找到耦合的变量;通过引用将这些变量添加到新方法的签名中。

  11. 搜索整个代码库,查找对同一文件的include调用,并用内联实例化和调用替换每个调用;抽查修改后的文件并测试修改后的类。

  12. 删除原始的include文件;对整个遗留应用程序进行单元测试和抽查。

  13. 为新类编写单元测试,并重构新类,使其遵循我们现有的所有规则:没有全局变量,没有超全局变量,没有new,注入依赖项,返回而不是输出,没有包含。

  14. 最后,在我们的每个类文件中,用依赖注入替换新类的每个内联实例化,并在此过程中进行测试。

  15. 提交,推送,通知 QA。

  16. 重复,直到我们的任何类中都没有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调用已经被替换,文件内容已经成功移植到类中,我们删除include文件。它不再需要了。

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

替换多个 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 ?>

当我们测试调用这段代码的类时,测试将失败,因为新方法中的$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调用。如果测试或抽查失败,我们需要在继续之前解决它。

编写测试和重构

现在遗留应用程序的工作方式与我们将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 我们有新的工作需要他们审查。

Do ... While

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

常见问题一个类可以从多个 include 文件中接收逻辑吗?

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

那么在非类文件中发起的 include 调用呢?

在寻找include调用时,我们只在classes/目录中寻找原始调用。很可能还有include调用来自其他位置,比如views/

对于我们重构的目的,我们并不特别关心include调用是否来自我们类外部。如果一个include只从非类文件中调用,我们可以放心地保留该include的现有状态。

我们的主要目标是从类文件中删除include调用,而不一定是整个遗留应用程序。此时,很可能我们类外的大多数或所有include调用都是呈现逻辑的一部分。

审查和下一步

在我们从类中提取了所有的 include 调用之后,我们最终删除了遗留架构的最后一个主要部分。我们可以加载一个类而不产生任何副作用,并且逻辑只有在调用方法时才执行。这对我们来说是一个重要的进步。

现在我们可以开始关注我们遗留应用程序的整体架构。

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

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

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

第十三章:分离公共和非公共资源

在这一点上,我们已经在重新组织我们传统应用程序的核心方面取得了重大进展。然而,周围的架构仍然有很多需要改进的地方。

除其他事项外,我们整个应用程序仍然嵌入在文档根中。这意味着我们需要对我们打算保持私有的资源进行特殊保护,或者我们需要依赖模糊来确保客户不会浏览到不打算公开的资源。Web 服务器配置错误或未注意特定的安全措施可能会将我们的应用程序部分展示给公众。

因此,我们的下一步是将所有公共资源提取到一个新的文档根目录。这将防止非公共资源被意外传送,并为进一步重构建立结构。

混合资源

目前,我们的 Web 服务器充当我们传统应用程序的组合前端控制器、路由器和调度器。页面脚本的路由直接映射到文件系统,使用 Web 服务器文档根作为基础。而 Web 服务器文档根又直接映射到传统应用程序的根目录。

例如,如果 Web 服务器文档根是/var/www/htdocs,它目前同时充当应用程序根。因此,URL 路径/foo/bar.php直接映射到/var/www/htdocs/foo/bar.php

这对于公共资源可能没问题,但我们的应用程序中有很多部分是我们不希望外部直接访问的。例如,与配置和设置相关的目录不应该暴露给可能的外部检查。Web 服务器配置错误可能会暴露代码本身,使我们的密码和其他信息对恶意用户可用。

分离过程

尽管过程本身很简单,但我们正在进行的更改是基础性的。它影响了服务器配置以及传统应用程序结构。为了充分实现这一变化,我们需要与负责服务器部署的任何运营人员密切协调。

一般来说,流程如下:

  1. 与运营协调以沟通我们的意图。

  2. 在我们的传统应用程序中创建一个新的文档根目录,以及一个临时索引文件。

  3. 重新配置服务器指向新的文档根目录,并抽查新配置,看看我们的临时索引文件是否出现。

  4. 删除临时索引文件,然后将所有公共资源移动到新的文档根,并在此过程中进行抽查。

  5. 提交、推送,并与运营协调进行 QA 测试。

与运营人员协调

这是整个过程中最重要的一步。我们绝不能在未与负责服务器的人员(我们的运营人员)讨论我们的意图的情况下进行影响服务器配置的更改。

运营的反馈将告诉我们我们需要遵循的路径,以确保我们的更改有效。他们将就新的文档根目录名称和新的服务器配置指令向我们提供建议或指导。他们负责部署应用程序,因此我们希望尽力让他们的工作尽可能轻松。如果运营不满意,那么每个人都会不开心。

或者,如果我们没有运营人员并且负责自己的部署,我们的工作既更容易又更困难。更容易是因为我们没有协调和沟通成本。更困难是因为我们需要特定的、详细的服务器配置知识。在这种情况下要小心进行。

创建文档根目录

与我们的运营人员协调后,我们在传统应用程序结构中创建了一个文档根目录。我们的运营联系人将会就适当的目录名称向我们提供建议;在这种情况下,让我们假设该名称是docroot/

例如,如果我们当前有一个遗留的应用程序结构,看起来像这样:

var/www/htdocs/

classes/
 ... 
css/
 ... 
foo/
    bar/
        baz.php
images/
 ... 
includes/
 ... 
index.php
js/
tests/
 ... 
views/
 ... 

...我们在应用程序的顶层添加一个新的docroot/目录。在新的文档根目录中,我们添加一个临时的index.html文件。这将让我们以后知道我们的服务器重新配置是否正常工作。它可以包含任何我们喜欢的文本,比如“庆祝!新配置有效!”。

完成后,新的目录结构将更像这样:

/var/www/htdocs/

classes/
 ... 
css/
 ... 
docroot/
    index.html
foo/
    bar/
        baz.php
images/
 ... 
includes/
 ... 
index.php
    js/
 ... 
tests/
 ... 
views/
 ... 

重新配置服务器

我们现在重新配置我们的本地开发网络服务器,指向新的docroot/目录。我们的运维人员应该已经给了我们一些关于如何做这件事的指示。

在 Apache 中,我们可能需要编辑我们本地开发环境的配置文件,将相关的.conf文件中的DocumentRoot指令从主应用程序目录更改为新的目录:

DocumentRoot "/var/www/htdocs"

...到我们在应用程序中新创建的子目录:

DocumentRoot "/var/www/htdocs/docroot"

然后我们保存文件,并重新加载或重启服务器以应用我们的更改。

提示

适用的DocumentRoot指令可能在许多位置之一。它可能在主httpd.conf文件中,或者作为VirtualHost指令的一部分在单独的配置文件中。如果我们使用的不是 Apache,配置可能在一个完全不同的文件中。不幸的是,本书范围之外无法提供完整的 Web 服务器管理说明。请查阅您特定服务器的文档以获取更多信息。

应用我们的配置更改后,我们浏览遗留应用程序,看新的文档根是否被遵守。我们应该看到我们临时的index.html文件的内容。如果没有,我们做错了什么,需要重新检查我们的更改,直到它们按预期工作。

移动公共资源

现在我们已经配置了 Web 服务器指向我们的新docroot/目录,我们可以安全地删除我们的临时index.html文件。

这样做之后,我们的下一步是将所有公共资源从它们当前的位置移动到新的docroot/目录中。这包括我们所有的页面脚本、样式表、JavaScript 文件、图片等等。不包括任何用户不应该能够浏览到的东西:类、包含文件、设置、配置、命令行脚本、测试、视图文件等等。

我们希望在docroot/中保持与在应用程序基础目录中相同的相对位置,因此在移动时不应更改文件名或目录名。

当我们将我们的公共资源移动到新的位置时,我们应该偶尔通过应用程序浏览来检查我们修改后的结构。这将帮助我们及早发现任何问题,而不是晚些时候。

提示

我们移动的一些 PHP 文件可能仍然依赖于特定位置的include文件。在这些情况下,我们可能需要修改它们,指向相对于我们新的docroot/目录的路径。或者,我们可能需要修改我们的包含路径值,以便它们可以找到必要的文件。

完成后,我们将拥有一个看起来更像这样的目录结构:

/var/www/htdocs/

classes/
 ... 
docroot/
   css/
       ... 
   foo/
       bar/
          baz.php

   index.php
   js/
     ... 
   images/
     ... 
includes/
    ... 
tests/
    ... 
views/
    ... 

提交、推送、协调

当我们将所有的公共资源移动到新的docroot/目录,并且遗留应用程序在这个新结构中正常工作时,我们提交所有的更改并将它们推送到公共存储库。

在这一点上,我们通常会通知 QA 我们的更改以供测试。然而,因为我们对服务器配置进行了基础性的更改,我们需要与运维人员协调 QA 测试。运维人员可能需要部署新的配置到 QA 服务器。只有这样,QA 才能有效地检查我们的工作。

常见问题

这真的有必要吗?

大多数时候,将各种非公共资源留在文档根目录似乎是无害的。但对于我们接下来的步骤来说,非常重要的是我们要在我们的公共资源和非公共资源之间有一个分离。

审查和下一步操作

我们现在已经开始重构我们遗留应用的总体架构。通过创建一个文档根目录,将我们的公共资源与非公共资源分开,我们可以开始组建一个前端控制器系统来控制对我们应用的访问。

第十四章:将 URL 路径与文件路径解耦

尽管我们有一个文档根目录将我们的公共和非公共资源分开,但我们传统应用程序的用户仍然直接浏览我们的页面脚本。这意味着我们的 URL 直接与网络服务器上的文件系统路径耦合。

我们的下一步是解耦路径,这样我们就可以独立地将 URL 路由到任何我们想要的目标。这意味着我们需要建立一个前端控制器来处理我们传统应用程序的所有传入请求。

耦合路径

正如我们在上一章中所指出的,我们的网络服务器充当了我们传统应用程序的前端控制器、路由器和调度器的综合功能。页面脚本的路由仍然直接映射到文件系统,使用我们的docroot/目录作为所有 URL 路径的基础。

这给我们带来了一些结构性问题。例如,如果我们想公开一个新的或不同的 URL,我们必须修改文件系统中相关页面脚本的位置。同样,我们无法更改哪个页面脚本响应特定的 URL。在路由之前,没有办法拦截传入的请求。

这些以及其他问题,包括完成未来重构的能力,意味着我们必须为所有传入请求创建一个单一入口点。这个入口点被称为前端控制器。

在我们对传统应用程序实现的第一个前端控制器中,我们将添加一个路由器来将传入的 URL 路径转换为页面脚本路径。这将允许我们将页面脚本从文档根目录中移除,从而将 URL 与文件系统解耦。

解耦过程

与将我们的公共资源与非公共资源分开一样,我们将不得不对我们的网络服务器配置进行更改。具体来说,我们将启用 URL 重写,以便将所有传入请求指向一个前端控制器。我们需要与我们的运维人员协调这次重构,以便他们能够尽可能轻松地部署这些更改。

一般来说,这个过程如下:

  1. 与运维协调以沟通我们的意图。

  2. 在文档根目录中创建一个前端控制器脚本。

  3. 为我们的页面脚本创建一个pages/目录,以及一个页面未找到页面脚本和控制器。

  4. 重新配置网络服务器以启用 URL 重写。

  5. 抽查重新配置的网络服务器,确保前端控制器和 URL 重写正常工作。

  6. 将所有页面脚本从docroot/移动到pages/,并在此过程中进行抽查。

  7. 提交、推送,并与运维协调进行 QA 测试。

与运维协调

这是整个过程中最重要的一步。我们绝不能在没有与负责服务器的人员(即我们的运维人员)讨论我们意图的情况下进行影响服务器配置的更改。

在这种情况下,我们需要告诉我们的运维人员,我们必须启用 URL 重写。他们将告知或指导我们如何为我们特定的网络服务器执行此操作。

或者,如果我们没有运维人员并且负责我们自己的服务器,我们将需要自行确定如何启用 URL 重写。在这种情况下要小心进行。

添加一个前端控制器

一旦我们与运维人员协调好,我们将添加一个前端控制器脚本。我们还将添加一个页面未找到脚本、控制器和视图。

首先,在我们的文档根目录中创建前端控制器脚本。它使用Router类将传入的 URL 映射到页面脚本。我们称之为 front.php,或者其他表明它是前端控制器的名称:

docroot/front.php
1 <?php
2 // the router class file
3 require dirname(__DIR__) . '/classes/Mlaphp/Router.php';
4
5 // set up the router
6 $pages_dir = dirname(__DIR__) . '/pages';
7 $router = new \Mlaphp\Router($pages_dir);
8
9 // match against the url path
10 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
11 $route = $router->match($path);
12
13 // require the page script
14 require $route;
15 ?>

注意

我们requireRouter类文件,因为自动加载程序尚未注册。这只会在执行页面脚本时发生,而这只会在前端控制器逻辑结束时发生。我们将在下一章中解决这个问题。

创建一个pages/目录

前端控制器引用了一个$pages_dir。我们的想法是将所有页面脚本从文档根目录移动到这个新目录中。

首先,在我们的旧应用程序的顶层创建一个pages/目录,与classes/docroot/views/等目录并列。

然后,我们创建一个pages/not-found.php脚本,以及一个相应的控制器和视图文件。当Router无法匹配 URL 路径时,前端控制器将调用not-found.php脚本。not-found.php脚本应该像旧应用程序中的任何其他页面脚本一样设置自己,然后调用相应的视图文件以获取响应:

**pages/not-found.php**
1 <?php
2 require '../includes/setup.php';
3
4 $request = new \Mlaphp\Request($GLOBALS);
5 $response = new \Mlaphp\Response('/path/to/app/views');
6 $controller = new \Controller\NotFound($request, $response);
7
8 $response = $controller->__invoke();
9
10 $response->send();
11 ?>
**classes/Controller/NotFound.php**
1 <?php
2 namespace Controller;
3
4 use Mlaphp\Request;
5 use Mlaphp\Response;
6
7 class NotFound
8 {
9 protected $request;
10
11 protected $response;
12
13 public function __construct(Request $request, Response $response)
14 {
15 $this->request = $request;
16 $this->response = $response;
17 }
18
19 public function __invoke()
20 {
21 $url_path = parse_url(
22 $this->request->server['REQUEST_URI'],
23 PHP_URL_PATH
24 );
25
26 $this->response->setView('not-found.html.php');
27 $this->response->setVars(array(
28 'url_path' => $url_path,
29 ));
30
31 return $this->response;
32 }
33 }
34 ?>
**views/not-found.html.php**
1 <?php $this->header('HTTP/1.1 404 Not Found'); ?>
2 <html>
3 <head>
4 <title>Not Found</title>
5 </head>
6 <body>
7 <h1>Not Found</h1>
8 <p><?php echo $this->esc($url_path); ?></p>
9 </body>
10 </html>

重新配置服务器

现在我们已经放置了我们的前端控制器并为我们的页面脚本设置了目标位置,我们重新配置本地开发 Web 服务器以启用 URL 重写。我们的运维人员应该已经给了我们一些关于如何做这个的指示。

注意

不幸的是,本书范围之外无法提供有关 Web 服务器管理的完整说明。请查阅您特定服务器的文档以获取更多信息。

在 Apache 中,我们首先启用mod_rewrite模块。在某些 Linux 发行版中,这很容易,只需发出sudo a2enmod rewrite 命令。在其他情况下,我们需要编辑httpd.conf文件以启用它。

一旦启用了 URL 重写,我们需要指示 Web 服务器将所有传入的请求指向我们的前端控制器。在 Apache 中,我们可以向我们的旧应用程序添加一个docroot/.htaccess文件。或者,我们可以修改本地开发服务器的 Apache 配置文件之一。重写逻辑如下所示:

**docroot/.htaccess**
1 # enable rewriting
2 RewriteEngine On
3
4 # turn empty requests into requests for the "front.php"
5 # bootstrap script, keeping the query string intact
6 RewriteRule ^$ front.php [QSA]
7
8 # for all files and dirs not in the document root,
9 # reroute to the "front.php" bootstrap script,
10 # keeping the query string intact, and making this
11 # the last rewrite rule
12 RewriteCond %{REQUEST_FILENAME} !-f
13 RewriteCond %{REQUEST_FILENAME} !-d
14 RewriteRule ^(.*)$ front.php [QSA,L]

注意

例如,如果传入请求是/foo/bar/baz.php,Web 服务器将调用front.php脚本。这对于每个请求都是如此。各种超全局变量的值将保持不变,因此$_SERVER['REQUEST_URI']仍将指示/foo/bar/baz.php

最后,在启用了 URL 重写之后,我们重新启动或重新加载 Web 服务器以使我们的更改生效。

抽查

现在我们已经启用了 URL 重写以将所有请求指向我们的新前端控制器,我们应该浏览我们的旧应用程序,使用我们知道不存在的 URL 路径。前端控制器应该显示我们的not-found.php页面脚本的输出。这表明我们的更改正常工作。如果不是,我们需要回顾和修改到目前为止的更改,并尝试修复任何出错的地方。

移动页面脚本

一旦我们确定 URL 重写和前端控制器正常运行,我们可以开始将所有页面脚本从docroot/移动到我们的新pages/目录中。请注意,我们只移动页面脚本。我们应该将所有其他资源留在docroot/中,包括front.php前端控制器。

例如,如果我们开始时有这样的结构:

**/path/to/app/**
docroot/
css/
foo/
bar/
baz.php
front.php
images/
index.php
js/
pages/
not-found.php

我们应该最终得到这样的结构:

**/path/to/app/**
docroot/
css/
front.php
images/
js/
pages/
foo/
bar/
baz.php
index.php
not-found.php

我们只移动了页面脚本。图像、CSS 文件、Javascript 文件和前端控制器都保留在docroot/中。

因为我们在移动文件,我们可能需要更改我们的包含路径值,以指向新的相对目录位置。

当我们将每个文件或目录从docroot/移动到pages/时,我们应该抽查我们的更改,以确保旧应用程序继续正常工作。

由于之前描述的重写规则,我们的页面脚本应该继续工作,无论它们是在docroot/还是pages/中。我们要确保在继续之前将所有页面脚本移动到pages/中。

提交、推送、协调

当我们将所有页面脚本移动到新的pages/目录,并且我们的旧应用程序在这个新结构中正常工作时,我们提交所有更改并将它们推送到共同的存储库。

在这一点上,我们通常会通知质量保证部门我们的更改,让他们进行测试。然而,由于我们对服务器配置进行了更改,我们需要与运营人员协调质量保证测试。运营部门可能需要部署新配置到质量保证服务器上。只有这样,质量保证部门才能有效地检查我们的工作。

常见问题

我们真的解耦了路径吗?

敏锐的观察者会注意到我们的Router仍然使用传入的 URL 路径来查找页面脚本。这与原始设置之间的唯一区别是,路径被映射到pages/目录而不是docroot/目录。毕竟,我们真的将 URL 与文件系统解耦了吗?

是的,我们已经实现了我们的解耦目标。这是因为我们现在在 URL 路径和执行的页面脚本之间有一个拦截点。使用Router,我们可以创建一个路由数组,其中 URL 路径是键,文件路径是值。该映射数组将允许我们将传入的 URL 路径路由到任何我们喜欢的页面脚本。

例如,如果我们想将 URL 路径/foo/bar.php映射到页面脚本/baz/dib.php,我们可以通过Router上的setRoutes()方法来实现:

1 $router->setRoutes(array(
2 '/foo/bar.php' => '/baz/dib.php',
3 ));

然后,当我们将传入的 URL 路径/foo/bar.phpRouter进行match()时,我们返回的路由将是/baz/dib.php。然后我们可以执行该路由作为传入 URL 的页面脚本。我们将在下一章节中使用这种技术的变体。

回顾和下一步

通过将 URL 与页面脚本解耦,我们几乎已经完成了我们的现代化工作。只剩下两个重构。首先,我们将重复的逻辑从页面脚本移到前端控制器。然后我们将完全删除页面脚本,并用依赖注入容器替换它们。

第十五章:删除页面脚本中的重复逻辑

现在,我们的页面脚本中的逻辑非常重复。它们看起来都非常相似。每个页面加载一个设置脚本,为页面控制器实例化一系列依赖项,调用该控制器,并发送响应。

我们的前端控制器为我们提供了一个地方,可以执行每个页面脚本的通用元素并消除重复。一旦重复被移除,我们就可以开始消除页面脚本本身。

重复的逻辑

实质上,我们的每个页面脚本都遵循这个组织流程:

**Generic Page Script**
1 <?php
2 // one or more identical setup scripts
3 require 'setup.php';
4
5 // a series of dependencies to build a controller
6 $request = new \Mlaphp\Request($GLOBALS);
7 $response = new \Mlaphp\Response('/path/to/app/views');
8 $controller = new \Controller\PageName($request, $response);
9
10 // invoke the controller and send the response
11 $response = $controller->__invoke();
12 $response->send();
13 ?>

因为我们一直都很勤奋地使用相同的变量名来表示我们的控制器对象($controller),始终使用相同的方法名来调用它(__invoke()),并且始终使用相同的变量名来表示响应($response),我们可以看到每个页面脚本唯一不同的部分是中心部分。中心块构建了控制器对象。之前和之后的一切都是相同的。

此外,因为我们有一个前端控制器来处理所有传入的请求,现在我们有一个地方可以放置每个页面脚本的通用前后逻辑。这就是我们要做的。

移除过程

一般来说,移除过程如下:

  1. 修改前端控制器以添加设置、控制器调用和响应发送。

  2. 修改每个页面脚本以删除设置、控制器调用和响应发送。

  3. 检查,提交,推送,并通知 QA。

修改前端控制器

首先,我们修改前端控制器逻辑,执行每个页面脚本通用的逻辑。我们将其从上一章中列出的代码更改为以下内容:

**docroot/front.php**
1 <?php
2 // page script setup
3 require dirname(__DIR__) . '/includes/setup.php';
4
5 // set up the router
6 $pages_dir = dirname(__DIR__) . '/pages';
7 $router = new \Mlaphp\Router($pages_dir);
8
9 // match against the url path
10 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
11 $route = $router->match($path);
12
13 // require the page script
14 require $route;
15
16 // invoke the controller and send the response
17 $response = $controller->__invoke();
18 $response->send();
19 ?>

我们已经用一个需要Router类文件的行替换了一个需要设置脚本的行。(在自动加载的章节中,我们将自动加载器放入了我们的设置脚本中,所以现在它应该为我们自动加载Router类。)

我们还在页面脚本中需要$route文件后添加了两行。这些行调用了控制器并设置了响应。我们在这个共享逻辑中使用了控制器和响应对象的通用变量名。(如果你在页面脚本中选择了除$controller$response之外的其他变量名,也请在上面的脚本中替换它们。同样,如果你使用了除__invoke()之外的通用控制器方法,请也替换它。)

注意

请注意,设置工作将是特定于我们的旧应用程序。只要每个页面脚本的设置工作都是相同的(在这一点上应该是这样),将通用设置工作放在这里就可以了。

从页面脚本中删除逻辑

现在我们已经将设置、控制器调用和响应发送工作添加到前端控制器中,我们可以从每个页面脚本中删除相同的工作。这样做应该就像在pages/目录中进行项目范围的搜索并删除找到的行一样简单。

找到设置行可能需要使用正则表达式,因为设置脚本的相对位置可能导致使用相对目录遍历的行。以下正则表达式将找到includes/setup.php../includes/setup.phpdirname(__DIR__) . /includes/setup.php等:

搜索设置:

**1 ^\s*(require|require_once|include|include_once) .*includes/setup\.php.*$**

然而,找到控制器调用和响应发送行不应该需要使用正则表达式,因为它们在每个页面脚本中应该是相同的。

搜索控制器调用…

**1 $response = $controller->__invoke();**

搜索响应发送…

**1 $response->send();**

在每种情况下,删除找到的行。现在这些逻辑已经移动到前端控制器中,不再需要。

检查,提交,推送,并通知 QA

一旦重复的页面脚本逻辑被移除,我们可以通过运行特性测试或浏览或以其他方式调用应用程序中的每个页面来检查应用程序。

在确保应用程序仍然正常工作之后,我们提交新代码并将其推送到公共存储库。然后我们通知质量保证部门,我们有新的工作需要他们审查。

常见问题

如果设置工作不一致会怎么样?

在本书的示例中,我们只展示了一个脚本为每个页面脚本做设置工作。一些传统应用程序可能使用多个设置脚本。只要设置工作在每个页面脚本中是相同的,即使它由多个脚本组成,我们也可以将所有设置工作移动到前端控制器中。

然而,如果设置工作在每个页面脚本中不一致,我们就有问题要处理。如果在这一点上,页面脚本的设置过程不相同,我们应该在继续之前尽力解决这个问题。

在所有页面脚本中使设置工作相同是至关重要的。这可能意味着在前端控制器中包含所有页面脚本的不同设置工作,即使有些脚本不需要所有这些设置工作。如果必要,我们可以在下一章解决这种重叠。

如果我们无法强制执行相同的单阶段设置过程,我们可能需要进行双阶段或两阶段设置过程。首先,我们将常见的设置工作合并到前端控制器中,并将其从页面脚本中删除。多余的、特殊情况或特定页面的设置工作可以作为依赖项创建工作的退化但必要的部分留在页面脚本中。

如果我们使用了不一致的命名?

在前几章中,本书强调了一致命名的重要性。这一章是一致性得到回报的时刻。

如果我们发现在控制器对象变量和/或控制器方法名称的命名上不一致,也不是没有办法。我们可能无法进行一次性搜索和替换,但我们仍然可以手动处理每个页面脚本,并将名称更改为一致。然后前端控制器可以使用新的一致名称。

审查和下一步

通过这一步,我们将页面脚本减少到了一个基本的逻辑核心。现在它们所做的就是为控制器对象设置依赖项,然后创建控制器对象。前端控制器在此之前和之后都做了一切。

事实上,即使这个逻辑也可以从页面脚本中提取出来。一个称为依赖注入容器的对象可以接收对象创建逻辑作为一系列闭包,每个页面脚本一个闭包。容器可以为我们处理对象创建,我们可以完全删除页面脚本。

因此,我们最终的重构将把所有对象创建逻辑提取到一个依赖注入容器中。我们还将修改我们的前端控制器,实例化控制器对象,而不是要求页面脚本。这样做,我们将删除所有页面脚本,我们的应用程序将拥有一个完全现代化的架构。

第十六章:添加依赖注入容器

我们已经完成了现代化过程的最后一步。我们将通过将剩余逻辑移入依赖注入容器来删除页面脚本的最后痕迹。容器将负责协调应用程序中的所有对象创建活动。这样做,我们将再次修改我们的前端控制器,并开始添加指向控制器类而不是文件路径的路由。

注意

在现代化过程的最后一步中,最好安装 PHP 5.3 或更高版本。这是因为我们需要闭包来实现应用程序逻辑的关键部分。如果我们没有访问 PHP 5.3,还有一种不太可行但仍然可行的选项来实现依赖注入容器。我们将在本章的“常见问题”中解决这种情况。

什么是依赖注入容器?

依赖注入作为一种技术,是我们从本书的早期就开始练习的。重申一下,依赖注入的理念是将依赖从外部推入对象。这与通过 new 关键字在类内部创建依赖对象,或者通过globals关键字从当前范围外部引入依赖的做法相反。

注意

要了解控制反转的概述以及具体的依赖注入,请阅读 Fowler 在martinfowler.com/articles/injection.html上关于容器的文章。

为了完成我们的依赖注入活动,我们一直在页面脚本中手动创建必要的对象。对于任何需要依赖的对象,我们首先创建了该依赖,然后创建了依赖它的对象并传入依赖。这个创建过程有时会非常复杂,比如当依赖有依赖时。无论复杂程度和深度如何,目前做法的逻辑都嵌入在页面脚本中。

依赖注入容器的理念是将所有对象创建逻辑放在一个地方,这样我们就不再需要使用页面脚本来设置我们的对象。我们可以将每个对象创建逻辑放在容器中,使用一个唯一的名称,称为服务。

然后我们可以告诉容器返回任何定义的服务对象的新实例。或者,我们可以告诉容器创建并返回该服务对象的共享实例,这样每次获取它时,它都是同一个实例。精心组合容器服务的新实例和共享实例将允许我们简化依赖创建逻辑。

注意

在任何时候,我们都不会将容器传递给需要依赖的任何对象。这样做将使用一种称为服务定位器的模式。我们避免服务定位器活动,因为这样做违反了范围。当容器在一个对象内部,并且该对象使用它来检索依赖时,我们只是离我们开始的地方一步之遥;也就是说,使用global关键字。因此,我们不会传递容器 -- 它完全留在创建对象的范围之外。

PHP 领域中有许多不同的容器实现,每种实现都有其自身的优势和劣势。为了使事情与我们的现代化过程相适应,我们将使用Mlaphp\Di。这是一个精简的容器实现,非常适合我们的过渡需求。

添加 DI 容器

一般来说,添加 DI 容器的过程如下:

  1. 添加一个新的services.php包含文件来创建容器并管理其服务。

  2. 在容器中定义一个router服务。

  3. 修改前端控制器以包含services.php文件并使用router服务,然后对应用程序进行抽查。

  4. 从每个页面脚本中提取创建逻辑到容器中:

  5. 在容器中为页面脚本控制器类命名一个服务。

  6. 将页面脚本中的逻辑复制到容器服务中。根据需要重命名变量以使用 DI 容器属性。

  7. 将页面 URL 路径路由到容器服务名称(即控制器名称)。

  8. 检查并提交更改。

  9. 继续,直到所有页面脚本都已提取到容器中。

  10. 删除空的pages/目录,提交,推送,并通知 QA。

添加 DI 容器包含文件

为了防止我们现有的设置文件变得更大,我们将引入一个新的services.php设置文件。是的,这意味着在前端控制器中添加另一个include,但是如果我们一直很勤奋,我们的应用程序中几乎没有剩余的include。这个include的重要性将会很小。

首先,我们需要选择一个适当的位置放置文件。最好是与我们已有的任何其他设置文件一起,可能是在现有的includes/目录中。

然后我们创建了以下行的文件。(随着我们的继续,我们将在这个文件中添加更多内容。)因为这个文件将作为我们设置文件的最后一个加载,我们可以假设自动加载将会生效,所以没有必要加载Di类文件:

**includes/services.php**
1 <?php
2 $di = new \Mlaphp\Di($GLOBALS);
3 ?>

结果是新的$di实例加载了所有现有的全局变量值。这些值作为容器的属性被保留。例如,如果我们的设置文件创建了一个$db_user变量,现在我们还可以通过$di->db_user访问该值。这些是副本,而不是引用,因此对一个的更改不会影响另一个。

注意

为什么我们保留现有变量作为属性?

目前,我们的页面脚本直接访问全局变量来进行创建工作。然而,在后续步骤中,创建逻辑将不再在全局范围内。它将在 DI 容器中。因此,我们将 DI 容器填充了原本可以使用的变量的副本。

添加一个路由器服务

现在我们已经有了一个 DI 容器,让我们添加我们的第一个服务。

回想一下,DI 容器的目的是为我们创建对象。目前,前端控制器创建了一个Router对象,因此我们将在容器中添加一个router服务。(在下一步中,我们将让前端控制器使用这个服务,而不是自己创建一个Router。)

services.php文件中,添加以下行:

**includes/services.php**
1 <?php
2 // set a container service for the router
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router('/path/to/app/pages');
5 $router->setRoutes(array());
6 return $router;
7 });
8 ?>

让我们稍微检查一下服务定义。

  • 服务名称是router。我们将用全小写名称来命名那些预期作为共享实例创建的服务对象,并使用完全限定的类名来命名那些预期每次创建新实例的服务对象。因此,在这种情况下,我们的意图是通过容器只提供一个共享的router。(这是一个约定,而不是容器强制执行的规则。)

  • 服务定义是一个可调用的。在这种情况下,它是一个闭包。闭包不接收任何参数,但它确实使用了当前作用域中的$di对象。这使得定义代码可以在构建服务对象时访问容器属性和其他容器服务。

  • 我们创建并返回由服务名称表示的对象。我们不需要检查对象是否已经存在于容器中;如果我们要求一个共享实例,容器内部将为我们执行这项工作。

有了这段代码,容器现在知道如何创建一个router服务。这是一个懒加载的代码,只有当我们调用$di->newInstance()(获取服务对象的新实例)或者$di->get()(获取服务对象的共享实例)时才会执行。

修改前端控制器

现在我们有了一个 DI 容器和一个router服务定义,我们修改前端控制器来加载容器并使用router服务。

docroot/front.php
1 <?php
2 require dirname(__DIR__) . '/includes/setup.php';
3 require dirname(__DIR__) . '/includes/services.php';
4
5 // get the shared router service
6 $router = $di->get('router');
7
8 // match against the url path
9 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
10 $route = $router->match($path);
11
12 // container service, or page script?
13 if ($di->has($route)) {
14 // create a new $controller instance
15 $controller = $di->newInstance($route);
16 } else {
17 // require the page script
18 require $route;
19 }
20
21 // invoke the controller and send the response
22 $response = $controller->__invoke();
23 $response->send();
24 ?>

我们从之前的实现中做了以下更改:

  • 我们在设置包含的最后添加了对services.php容器文件的require

  • 我们不直接创建Router对象,而是从$di容器中get()一个共享实例的router服务对象。

  • 我们已经在调度逻辑上做了一些更改。在我们从$router获取$route之后,我们检查看看$di容器是否has()匹配的服务。如果是,它将$route视为新$controller实例的服务名称;否则,它将$route视为在pages/中创建$controller的文件。无论哪种方式,代码都会调用控制器并发送响应。

在这些更改之后,我们会抽查应用程序,以确保新的router服务能够正常工作。如果不能正常工作,我们会撤消并重做到这一点,直到应用程序像以前一样正常工作。

一旦应用程序正常工作,我们可能希望提交我们的更改。这样,如果将来的更改出现问题,我们就有一个已知工作状态可以恢复。

将页面脚本提取到服务中

现在是现代化我们的遗留应用程序的最后一步。我们将逐个删除页面脚本,并将它们的逻辑放入容器中。

创建一个容器服务

选择任何页面脚本,并确定它使用哪个类来创建其$controller实例。然后,在 DI 容器中,为该类名创建一个空的服务定义。

例如,如果我们有这个页面脚本:

**pages/articles.php**
1 <?php
2 $db = new Database($db_host, $db_user, $db_pass);
3 $articles_gateway = new ArticlesGateway($db);
4 $users_gateway = new UsersGateway($db);
5 $article_transactions = new ArticleTransactions(
6 $articles_gateway,
7 $users_gateway
8 );
9 $response = new \Mlaphp\Response('/path/to/app/views');
10 $controller = new \Controller\ArticlesPage(
11 $request,
12 $response,
13 $user,
14 $article_transactions
15 );
16 ?>

我们实例化的控制器类是Controller\ArticlesPage。在我们的services.php文件中,我们创建了一个空的服务定义:

**includes/services.php**
1 <?php
2 $di->set('Controller\ArticlesPage', function () use ($di) {
3 });
4 ?>

接下来,我们将页面脚本设置逻辑移到服务定义中。当我们这样做时,我们应该注意到我们期望从全局范围获得的任何变量,并使用$di->前缀来引用适当的容器属性。(回想一下,这些是在services.php文件的早期从$GLOBALS加载的。)我们还在定义的最后返回控制器实例。

完成后,服务定义将看起来像这样:

**includes/services.php**
1 <?php
2 $di->set('Controller\ArticlesPage', function () use ($di) {
3 // replace `$variables` with `$di->` properties
4 $db = new Database($di->db_host, $di->db_user, $di->db_pass);
5 // create dependencies
6 $articles_gateway = new ArticlesGateway($db);
7 $users_gateway = new UsersGateway($db);
8 $article_transactions = new ArticleTransactions(
9 $articles_gateway,
10 $users_gateway
11 );
12 $response = new \Mlaphp\Response('/path/to/app/views');
13 // return the new instance
14 return new \Controller\ArticlesPage(
15 $request,
16 $response,
17 $user,
18 $article_transactions
19 );
20 });
21 ?>

一旦我们将逻辑复制到容器中,我们就从pages/中删除原始页面脚本文件。

将 URL 路径路由到容器服务

现在我们已经删除了页面脚本,转而使用容器服务,我们需要确保Router指向容器服务,而不是现在缺失的页面脚本。我们通过向setRoutes()方法参数添加一个数组元素来实现这一点,其中键是 URL 路径,值是服务名称。

例如,如果 URL 路径是/articles.php,我们的新容器服务命名为Controller\ArticlesPage,我们将修改我们的router服务如下:

**includes/services.php**
1 <?php
2 // ...
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router($di->pages_dir);
5 $router->setRoutes(array(
6 // add a route that points to a container service name
7 '/articles.php' => 'Controller\ArticlesPage',
8 ));
9 return $router;
10 });
11 ?>

抽查和提交

最后,我们检查页面脚本转换为容器服务是否按我们的预期工作。我们通过浏览或以其他方式调用 URL 路径来抽查旧页面脚本。如果它起作用,那么我们知道容器服务已成功取代现在已删除的页面脚本。

如果不是,我们需要撤消并重做我们的更改,看看哪里出了问题。我在这里看到的最常见的错误是:

  • 未能将页面脚本中的$var变量替换为服务定义中的$di->var属性

  • 未能从服务定义中返回对象

  • 控制器服务名称与映射的路由值之间的不匹配

一旦我们确定应用程序将 URL 路由到新的容器服务,并且服务正常工作,我们就提交我们的更改。

做...直到

我们继续下一个页面脚本,并重新开始这个过程。当所有页面脚本都转换为容器服务然后被删除时,我们就完成了。

删除 pages/,提交,推送,通知 QA

在我们将所有页面脚本提取到 DI 容器之后,pages/目录应该是空的。我们现在可以安全地将其删除。

有了这个,我们提交我们的工作,推送到共同的存储库,并通知 QA 我们有新的更改需要他们审查。

常见问题

我们如何完善我们的服务定义?

当我们完成将对象创建逻辑提取到容器后,每个服务定义可能会变得相当长,而且可能重复。我们可以通过进一步将对象创建逻辑的每个部分提取到自己的服务中来减少重复并完善服务定义,使其变得简短而简洁。

例如,如果我们有几个服务使用Request对象,我们可以将对象创建逻辑提取到自己的服务中,然后在其他服务中引用该服务。我们可以命名它以显示我们的意图,即它可以被用作共享服务(request)或新实例(Mlaphp\Request)。其他服务可以使用get()newInstance()而不是在内部创建请求。

考虑到我们之前的Controller\ArticlesPage服务,我们可以将其拆分为几个可重用的服务,如下所示:

includes/services.php
1 <?php
2 // ...
3
4 $di->set('request', function () use ($di) {
5 return new \Mlaphp\Request($GLOBALS);
6 });
7
8 $di->set('response', function () use ($di) {
9 return new \Mlaphp\Response('/path/to/app/views');
10 });
11
12 $di->set('database', function () use ($di) {
13 return new \Database(
14 $di->db_host,
15 $di->db_user,
16 $di->db_pass
17 );
18 });
19
20 $di->set('Domain\Articles\ArticlesGateway', function () use ($di) {
21 return new \Domain\Articles\ArticlesGateway($di->get('database'));
22 });
23
24 $di->set('Domain\Users\UsersGateway', function () use ($di) {
25 return new \Domain\Users\UsersGateway($di->get('database'));
26 });
27
28 $di->set('Domain\Articles\ArticleTransactions', function () use ($di) {
29 return new \Domain\Articles\ArticleTransactions(
30 $di->newInstance('Domain\Articles\ArticlesGateway'),
31 $di->newInstance('Domain\Users\UsersGateway'),
32 );
33 });
34
35 $di->set('Controller\ArticlesPage', function () use ($di) {
36 return new \Controller\ArticlesPage(
37 $di->get('request'),
38 $di->get('response'),
39 $di->user,
40 $di->newInstance('Domain\Articles\ArticleTransactions')
41 );
42 });
43 ?>

注意Controller\ArticlesPage服务现在引用容器中的其他服务来构建自己的对象。当我们获得Controller\ArticlesPage服务对象的新实例时,它会访问$di容器以获取共享的请求和响应对象、$user属性以及ArticleTransactions服务对象的新实例。这反过来又会递归地访问$di容器以获取该服务对象的依赖关系,依此类推。

如果页面脚本中有包含文件怎么办?

尽管我们已经尽力删除它们,但我们的页面脚本中可能仍然存在一些包含文件。当我们将页面脚本逻辑复制到容器时,我们别无选择,只能一并复制它们。然而,一旦我们所有的页面脚本都转换为容器,我们就可以寻找共同点,并开始将包含逻辑提取到设置脚本或单独的类中(如果需要,这些类本身可以成为服务)。

我们能减小 services.php 文件的大小吗?

根据我们应用程序中页面脚本的数量,我们的 DI 容器可能会有数十个或数百个服务定义。这可能会使单个文件难以管理或浏览。

如果愿意,将容器拆分为多个文件,并使services.php成为包含各种定义的一系列调用也是完全合理的。

我们能减小 router 服务的大小吗?

作为 DI 容器文件长度的子集,router服务特别可能会变得非常长。这是因为我们将应用程序中的每个 URL 映射到一个服务;如果有数百个 URL,就会有数百行router

作为一种替代方案,我们可以创建一个单独的routes.php文件,并让它返回一个路由数组。然后我们可以在setRoutes()调用中包含该文件:

**includes/routes.php**
1 <?php return array(
2 '/articles.php' => 'Controller\ArticlesPage',
3 ); ?>
**includes/services.php**
1 <?php
2 // ...
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router($di->pages_dir);
5 $router->setRoutes(include '/path/to/includes/routes.php');
6 return $router;
7 });
8 ?>

至少这将减小services.php文件的大小,尽管它并不会减小路由数组的大小。

如果我们无法升级到 PHP 5.3 怎么办?

本章的示例显示了一个使用闭包封装对象创建逻辑的 DI 容器。闭包只在 PHP 5.3 中才可用,因此如果我们卡在较早版本的 PHP 上,使用 DI 容器似乎根本不是一个选择。

事实证明这并不正确。通过一些额外的努力和更大的容忍度,我们仍然可以为 PHP 5.2 及更早版本构建 DI 容器。

首先,我们需要扩展 DI 容器,以便我们可以向其添加方法。然后,我们不再将服务定义为闭包,而是将它们创建为我们扩展容器上的方法:

**classes/Di.php**
1 <?php
2 class Di extends \Mlaphp\Di
3 {
4 public function database()
5 {
6 return new \Database(
7 $this->db_host,
8 $this->db_user,
9 $this->db_pass
10 );
11 }
12 }
13 ?>

(注意我们在方法中使用$this而不是$di。)

然后在我们的services.php文件中,可调用的内容变成了对这个方法的引用,而不是内联闭包。

**includes/services.php**
1 <?php
2 $di->set('database', array($di, 'database'));
3 ?>

这有些混乱但可行。它也可能变得非常冗长。我们之前将Controller\ArticlesPage拆分的示例最终看起来更像这样:

**includes/services.php**
1 <?php
2 // ...
3 $di->set('request', array($di, 'request'));
4 $di->set('response', array($di, 'response'));
5 $di->set('database', array($di, 'database'));
6 $di->set('Domain\Articles\ArticlesGateway', array($di, 'ArticlesGateway'));
7 $di->set('Domain\Users\UsersGateway', array($di, 'UsersGateway'));
8 $di->set(
9 'Domain\Articles\ArticleTransactions',
10 array($di, 'ArticleTransactions')
11 );
12 $di->set('Controller\ArticlesPage', array($di, 'ArticlesPage'));
13 ?>
**classes/Di.php**
1 <?php
2 class Di extends \Mlaphp\Di
3 {
4 public function request()
5 {
6 return new \Mlaphp\Request($GLOBALS);
7 }
8
9 public function response()
10 {
11 return new \Mlaphp\Response('/path/to/app/views');
12 }
13
14 public function database()
15 {
16 return new \Database(
17 $this->db_host,
18 $this->db_user,
19 $this->db_pass
20 );
21 }
22
23 public function ArticlesGateway()
24 {
25 return new \Domain\Articles\ArticlesGateway($this->get('database'));
26 }
27
28 public function UsersGateway()
29 {
30 return new \Domain\Users\UsersGateway($this->get('database'));
31 }
32
33 public function ArticleTransactions()
34 {
35 return new \Domain\Articles\ArticleTransactions(
36 $this->newInstance('ArticlesGateway'),
37 $this->newInstance('UsersGateway'),
38 );
39 }
40
41 public function ArticlesPage()
42 {
43 return new \Controller\ArticlesPage(
44 $this->get('request'),
45 $this->get('response'),
46 $this->user,
47 $this->newInstance('ArticleTransactions')
48 );
49 }
50 }
51 ?>

不幸的是,为了使服务名称看起来像它们相关的方法名称,我们可能不得不打破一些我们的风格约定。我们还必须将用于新实例的服务方法名称缩短为它们的结束类名,而不是它们的完全限定名称。否则,我们会发现自己有着过长和令人困惑的方法名称。

这可能会很快让人困惑,但它确实有效。总的来说,如果我们能升级到 PHP 5.3 或更高版本,那真的会更好。

回顾和下一步

我们终于完成了现代化的过程。我们不再有任何页面脚本。我们所有的应用逻辑都已转换为类,剩下的唯一包含文件是引导和设置过程的一部分。我们所有的对象创建逻辑都存在于一个容器中,我们可以直接修改它,而不必干扰我们对象的内部。

在这之后可能的下一步是什么呢?答案是持续改进,这将持续到你的职业生涯的最后。

第十七章:结论

让我们回顾一下我们的进展。

我们开始时是一个混乱的遗留应用程序。整个应用程序都基于文档根目录,并且用户直接浏览到页面脚本。它使用了一个包含导向的架构,仅仅包含一个文件就会导致逻辑被执行。全局变量随处可见,这使得调试变得困难甚至不可能。没有任何测试,更不用说单元测试,所以每次更改都可能导致其他地方出现问题。模型、视图和控制器层没有明确的分离。SQL 语句嵌入在我们的代码中,领域逻辑与展示和行为逻辑混在一起。

现在,在经过大量的努力、奉献和承诺之后,我们已经现代化了我们的遗留应用程序。文档根目录只包含公共资源和一个前端控制器。所有页面脚本都被分解成单独的模型、视图和控制器层。这些层由一系列良好结构的类表示,每个类都有自己的一套单元测试。应用程序对象是在依赖注入容器内构建的,使它们的操作与它们的创建分开。

还有什么可以做的吗?

改进机会

即使我们已经现代化了我们的应用程序,它仍然不完美。坦率地说,它将永远不会完美(无论这意味着什么)。总会有一些机会来改进它。事实上,现代化过程本身已经为我们揭示了许多机会。

  • 数据源层由一系列网关组成。虽然它们现在很好地满足了我们的目的,但将这些重组为与我们的领域对象更清晰地交互的数据映射器可能更好。

  • 领域层建立在事务脚本之上。这些也都有它们自己的好处,但在使用它们时,我们可能会意识到它们对我们的需求来说是不够的。它们将我们的领域逻辑的太多方面结合到了单块的类和方法中。我们可能会希望开始将我们的领域逻辑的不同方面分离出来,形成一个领域模型,并用一系列服务层来包装它。

  • 展示层仍然相对来说是单块的。我们可能希望将我们的视图文件转换为一个两步视图系统。这将为我们提供一个统一的布局,提供一系列可重用的“部分”模板,并帮助我们将每个视图文件减少到其核心部分。

  • 我们的控制器可能作为遗留架构的产物处理了几个不相关的操作。我们可能希望重新组织它们,以便更快地理解。事实上,每个控制器可能做的工作太多了(即一个臃肿的控制器而不是一个精简的控制器),这些工作可能更好地由辅助类或服务层来处理。

  • 响应系统将内容构建与 HTTP 响应构建的问题结合在一起。我们可能希望重构整个响应发送过程为两个或更多个单独的层:一个处理响应主体,一个处理响应头。事实上,我们可能希望将响应表示为数据传输对象,描述我们的意图,但将实际的响应构建和发送交给一个单独的处理程序。

  • 路由系统肯定是过渡性的。我们可能仍然依赖 URL 中的查询参数将客户端请求信息传递到应用程序中,而不是使用“美化的 URL”,其中参数被表示为路径信息的一部分。路由本身仅描述要调用的类,并没有携带应用程序应执行的动作的太多信息。我们将希望用更强大的路由器替换这个基本的路由器。

  • 前端控制器充当我们的调度器,而不是将路由分发交给一个单独的对象。我们可能希望将发现路由信息的任务与分发该路由的任务分开。

  • 最后,我们的依赖注入容器在本质上是非常“手动”的。我们可能希望找到一个能自动化一些对象创建的基本方面的容器系统,这样我们就可以集中精力解决服务定义的更复杂方面。

换句话说,我们面临的是现代代码库的问题,而不是遗留代码库的问题。我们已经将一个混乱的低质量问题换成了一个自动加载、依赖注入、单元测试、层分离、前端控制的高质量问题。

因为我们已经现代化了我们的代码库,我们可以以完全不同的方式解决这些问题,而不是在遗留体系下所做的。我们可以利用重构工具来改进代码。现在我们有了更好的关注点分离,我们可以在代码的有限部分进行小的改变,以提高代码的质量。每个改变都可以通过我们的单元测试套件进行回归测试。

我们添加的每个新功能都可以使用我们在现代化过程中获得的技术插入到我们的新应用程序架构中。我们不再随意地从以前的页面脚本中复制和修改一个新页面。相反,我们添加了一个经过单元测试的控制器类,并通过前端控制器路由到它。我们领域逻辑中的新功能是作为领域层中的经过单元测试的类或方法添加的。对呈现的更改和添加可以通过我们的视图层与我们的模型和控制器分开进行测试。

转换到框架

还有将我们的应用程序转换到最新、最热门的框架的可能性。虽然切换到一个公共框架有点像应用程序重写,但现在应该更容易,因为我们有一系列良好分离的应用程序层。我们应该能够确定哪些部分将被移植到公共框架,哪些只是我们特定架构运作的附属部分。

我不建议支持或反对这种方法。我只会指出,在现代化我们的遗留应用程序的过程中,我们实际上建立了我们自己定制的框架。我们所做的可能比 PHP 领域中大多数公共框架更加纪律严明和严格。虽然我们获得了与公共框架一起的社区,但我们也获得了框架开发者自己的包袱。这些以及其他权衡是我无法代表你来判断的;你必须自己决定利益是否超过成本。

回顾和下一步

无论我们从这里如何继续,毫无疑问,应用程序的改进已经导致了我们生活质量和专业方法的改进。我们在代码上投入的时间不仅在我们的就业方面得到了回报,我们现在花费更少的时间感到沮丧和泄气,更多的时间感到能干和富有成效。它在我们的技能、知识和应用架构、模式和实践方面也得到了回报。

我们现在的目标是继续改进我们的代码,继续改进自己,并帮助其他人也改进。我们需要分享我们的知识。通过这样做,我们将减少世界上因不得不处理遗留应用程序而产生的痛苦。当更多的人学会应用我们在这里学到的东西时,我们自己也可以继续处理更大、更好、更有趣的专业问题。

所以继续向你的同事、同胞和同事们传播这个好消息,他们不必因为不想要遗留应用程序而受苦。他们也可以现代化他们的代码库,并在这样做的过程中改善自己的生活。

附录 A. 典型的传统页面脚本

<?php
2 include("common/db_include.php");
3 include("common/functions.inc");
4 include("theme/leftnav.php");
5 include("theme/header.php");
6
7 define("SEARCHNUM", 10);
8
9 function letter_links()
10 {
11 global $p, $letter;
12 $lettersArray = array(
13 '0-9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I',
14 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
15 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
16 );
17 foreach ($lettersArray as $let) {
18 if ($letter == $let)
19 echo $let.' ';
20 else
21 echo '<a class="letters" '
22 . 'href="letter.php?p='
23 . $p
24 . '&letter='
25 . $let
26 . '">'
27 . $let
28 . '</a> ';
29 }
30 }
31
32 $page = ($page) ? $page : 0;
33
34 if (!empty($p) && $p!="all" && $p!="none") {
35 $where = "`foo` LIKE '%$p%'";
36 } else {
37 $where = "1";
38 }
39
40 if ($p=="hand") {
41 $where = "`foo` LIKE '%type1%'"
42 . " OR `foo` LIKE '%type2%'"
43 . " OR `foo` LIKE '%type3%'";
44 }
45
46 $where .= " AND `bar`='1'";
47 if ($s) {
48 $s = str_replace(" ", "%", $s);
49 $s = str_replace("'", "", $s);
50 $s = str_replace(";", "", $s);
51 $where .= " AND (`baz` LIKE '%$s%')";
52 $orderby = "ORDER BY `baz` ASC";
53 } elseif ($letter!="none" && $letter) {
54 $where .= " AND (`baz` LIKE '$letter%'"
55 . " OR `baz` LIKE 'The $letter%')";
56 $orderby = "ORDER BY `baz` ASC";
57 } else {
58 $orderby = "ORDER BY `item_date` DESC";
59 }
60 $query = mysql_query(
61 "SELECT * FROM `items` WHERE $where $orderby
62 LIMIT $page,".SEARCHNUM;
63 );
64 $count = db_count("items", $where);
65 ?>
66
67 <td align="middle" width="480" valign="top">
68 <img border="0" width="480" height="30"
69 src="http://example.com/images/example1.gif">
70 <table border="0" cellspacing="0" width="480"
71 cellpadding="0" bgcolor="#000000">
72 <tr>
73 <td colspan="2" width="480" height="50">
74 <img border="0"
75 src="http://example.com/images/example2.gif">
76 </td>
77 </tr>
78 <tr>
79 <td width="120" align="right" nowrap>
80 <img border="0"
81 src="http://example.com/images/example3.gif">
82 </td>
83 <td width="360" align="right" nowrap>
84 <div class="letter"><?php letter_links(); ?></div>
85 </td>
86 </tr>
87 </table>
88
89 <form name="search" enctype="multipart/form-data"
90 action="search.php" method="POST" margin="0"
91 style="margin: 0px;">
92 <table border="0" style="border-collapse: collapse"
93 width="480" cellpadding="0">
94 <tr>
95 <td align="center" width="140">
96 <input type="text" name="s" size="22"
97 class="user_search" title="enter your search..."
98 value="<?php
99 echo $s
100 ? $s
101 : "enter your search..."
102 ;
103 ?>" onFocus=" enable(this); "
104 onBlur=" disable(this); ">
105 </td>
106 <td align="center" width="70">
107 <input type="image" name="submit"
108 src="http://example.com/images/user_search.gif"
109 width="66" height="17">
110 </td>
111 <td align="right" width="135">
112 <img border="0"
113 src="http://example.com/images/list_foo.gif"
114 width="120" height="26">
115 </td>
116 <td align="center" width="135">
117 <select size="1" name="p" onChange="submit();">
118 <?php
119 if ($p) {
120 ${$p} = 'selected="selected"';
121 }
122 foreach ($foos as $key => $value) {
123 echo '<option value="'
124 . $key
125 . '" '
126 . ${$key}
127 . '>'
128 . $value
129 . '</option>';
130 }
131 ?>
132 </select>
133 </td>
134 </tr>
135 </table>
136 <?php if ($letter) {
137 echo '<input type="hidden" name="letter" '
138 . 'value="' . $letter . '">';
139 } ?>
140 </form>
141
142 <table border="0" cellspacing="0" width="480"
143 cellpadding="0" style="border-style: solid; border-color:
144 #606875; border-width: 1px 1px 0px 1px;">
145 <tr>
146 <td>
147 <div class="nav"><?php
148 $pagecount = ceil(($count / SEARCHNUM));
149 $currpage = ($page / SEARCHNUM) + 1;
150 if ($pagecount)
151 echo ($page + 1)
152 . " to "
153 . min(($page + SEARCHNUM), $count)
154 . " of $count";
155 ?></div>
156 </td>
157 <td align="right">
158 <div class="nav"><?php
159 unset($getstring);
160 if ($_POST) {
161 foreach ($_POST as $key => $val) {
162 if ($key != "page") {
163 $getstring .= "&$key=$val";
164 }
165 }
166 }
167 if ($_GET) {
168 foreach ($_GET as $key => $val) {
169 if ($key != "page") {
170 $getstring .= "&$key=$val";
171 }
172 }
173 }
174
175 if (!$pagecount) {
176 echo "No results found!";
177 } else {
178 if ($page >= (3*SEARCHNUM)) {
179 $firstlink = " | <a class=\"searchresults\"
180 href=\"?page=0$getstring\">1</a>";
181 if ($page >= (4*SEARCHNUM)) {
182 $firstlink .= " ... ";
183 }
184 }
185
186 if ($page >= (2*SEARCHNUM)) {
187 $prevpages = " | <a class=\"searchresults\""
188 . " href=\"?page="
189 . ($page - (2*SEARCHNUM))
190 . "$getstring\">"
191 . ($currpage - 2)
192 ."</a>";
193 }
194
195 if ($page >= SEARCHNUM) {
196 $prevpages .= " | <a class=\"searchresults\""
197 . " href=\"?page="
198 . ($page - SEARCHNUM)
199 . "$getstring\">"
200 . ($currpage - 1)
201 . "</a>";
202 }
203
204 if ($page==0) {
205 $prevlink = "&laquo; Previous";
206 } else {
207 $prevnum = $page - SEARCHNUM;
208 $prevlink = "<a class=\"searchresults\""
209 . " href=\"?page=$prevnum$getstring\">"
210 . "&laquo; Previous</a>";
211 }
212
213 if ($currpage==$pagecount) {
214 $nextlink = "Next &raquo;";
215 } else {
216 $nextnum = $page + SEARCHNUM;
217 $nextlink = "<a class=\"searchresults\""
218 . " href=\"?page=$nextnum$getstring\">"
219 . "Next &raquo;</a>";
220 }
221
222 if ($page < (($pagecount - 1) * SEARCHNUM))
223 $nextpages = " | <a class=\"searchresults\""
224 . " href=\"?page="
225 . ($page + SEARCHNUM)
226 . "$getstring\">"
227 . ($currpage + 1)
228 . "</a>";
229
230 if ($page < (($pagecount - 2)*SEARCHNUM)) {
231 $nextpages .= " | <a class=\"searchresults\""
232 . " href=\"?page="
233 . ($page + (2*SEARCHNUM))
234 . "$getstring\">"
235 . ($currpage + 2)
236 . "</a>";
237 }
238
239 if ($page < (($pagecount - 3)*SEARCHNUM)) {
240 if ($page < (($pagecount - 4)*SEARCHNUM))
241 $lastlink = " ... of ";
242 else
243 $lastlink = " | ";
244 $lastlink .= "<a class=\"searchresults\""
245 . href=\"?page="
246 . (($pagecount - 1)*SEARCHNUM)
247 . "$getstring\">"
248 . $pagecount
249 . "</a>";
250 }
251
252 $pagenums = " | <b>$currpage</b>";
253 echo $prevlink
254 . $firstlink
255 . $prevpages
256 . $pagenums
257 . $nextpages
258 . $lastlink
259 . ' | '
260 . $nextlink;
261 }
262 ?></div>
263 </td>
264 </tr>
265 </table>
266
267 <table border="0" cellspacing="0" width="100%"
268 cellpadding="0" style="border-style: solid; border-color:
269 #606875; border-width: 0px 1px 0px 1px;">
270
271 <?php while($item = mysql_fetch_array($query)) {
272
273 $links = get_links(
274 $item[id],
275 $item[filename],
276 $item[fileinfotext]
277 );
278
279 $dls = get_dls($item['id']);
280
281 echo '
282 <tr>
283 <td class="bg'.(($ii % 2) ? 1 : 2).'" align="center">
284
285 <div style="margin:10px">
286 <table border="0" style="border-collapse:
287 collapse" width="458" id="table5" cellpadding="0">
288 <tr>
289 <td rowspan="3" width="188">
290 <table border="0" cellpadding="0"
291 cellspacing="0" width="174">
292 <tr>
293 <td colspan="4">
294 <img border="0"
295 src="http://www.example.com/common/'
296 .$item[thumbnail].'"
297 width="178" height="74"
298 class="media_img">
299 </td>
300 </tr>
301 <tr>
302 <td style="border-color: #565656;
303 border-style: solid; border-width: 0px
304 0px 1px 1px;" width="18">
305 <a target="_blank"
306 href="'.$links[0][link].'"
307 '.$links[0][addlink].'>
308 <img border="0"
309 src="http://example.com/images/'
310 .$links[0][type].'.gif"
311 width="14" height="14"
312 hspace="3" vspace="3">
313 </a>
314 </td>
315 <td style="border-color: #565656;
316 border-style: solid; border-width: 0px
317 0px 1px 0px;" align="left" width="71">
318 <a target="_blank"
319 href="'.$links[0][link].'"
320 class="media_download_link"
321 '.$links[0][addlink].'>'
322 .(round($links[0][filesize]
323 / 104858) / 10).' MB</a>
324 </td>
325 <td style="border-color: #565656;
326 border-style: solid; border-width: 0px
327 0px 1px 0px;" width="18">
328 '.(($links[1][type]) ? '<a
329 target="_blank"
330 href="'.$links[1][link].'"
331 '.$links[1][addlink].'><img
332 border="0"
333 src="http://example.com/images/'
334 .$links[1][type].'.gif"
335 width="14" height="14" hspace="3"
336 vspace="3">
337 </td>
338 <td style="border-color: #565656;
r339 border-style: solid; border-width: 0px
340 1px 1px 0px;" align="left" width="71">
341 <a target="_blank"
342 href="'.$links[1][link].'"
343 class="media_download_link"
344 '.$links[1][addlink].'>'
345 .(round($links[1][filesize]
346 / 104858) / 10).' MB</a>' :
347 '&nbsp;</td><td>&nbsp;').'
348 </td>
349 </tr>
350 </table>
351 </td>
352 <td width="270" valign="bottom">
353 <div class="list_title">
354 <a
355 href="page.php?id='.$item[rel_id].'"
356 class="list_title_link">'.$item[baz].'</a>
357 </div>
358 </td>
359 </tr>
360 <tr>
361 <td align="left" width="270">
362 <div class="media_text">
363 '.$item[description].'
364 </div>
365 </td>
366 </tr>
367 <tr>
368 <td align="left" width="270">
369 <div class="media_downloads">'
370 .number_format($dls)
371 .' Downloads
372 </div>
373 </td>
374 </tr>
375 </table>
376 </div>
377 </td>
378 </tr>';
379 $ii++;
380 } ?>
381 </table>
382
383 <table border="0" cellspacing="0" width="480"
384 cellpadding="0" style="border-style: solid; border-color:
385 #606875; border-width: 0px 1px 1px 1px;">
386 <tr>
387 <td>
388 <div class="nav"><?php
389 if ($pagecount)
390 echo ($page + 1)
391 . " to "
392 . min(($page + SEARCHNUM), $count)
393 . " of $count";
394 ?></div>
395 </td>
396 <td align="right">
397 <div class="nav"><?php
398 if (!$pagecount) {
399 echo "No search results found!";
400 } else {
401 echo $prevlink
402 . $firstlink
403 . $prevpages
404 . $pagenums
405 . $nextpages
406 . $lastlink
407 . ' | '
408 . $nextlink;
409 }
410 ?></div>
411 </td>
412 </tr>
413 </table>
414 </td>
415
416 <?php include("theme/footer.php"); ?>

附录 B. 门户网站之前的代码

本附录显示了一个遗留应用程序的部分页面脚本。它已经经过清理和匿名化处理,不是来自实际应用程序的。

这个脚本是系统的一部分,允许新闻学生撰写文章进行审查,并为其他学生的文章提供反馈。学生们可以为其他学生提供“积分”来审查他们的作品,并通过审查其他学生的文章来获得积分。由于积分是按审查支付的,学生们会限制最大审查次数,以确保他们不会用完积分。最后,他们被允许提供注释,指出审阅者应该注意的事项。

这是页面脚本在转换为使用网关类之前的版本。它只包含领域逻辑和数据源交互,而不包括初步设置或任何显示代码。

1 <?php
2 // ...
3 require 'includes/setup.php';
4 // ...
5
6 $article_types = array(1, 2, 3, 4, 5);
7 $failure = array();
8 $now = time();
9
10 // sanitize and escape the user input
11 $input = $_POST;
12 $input['body'] = strip_tags($input['body']);
13 $input['notes'] = strip_tags($input['notes']);
14 foreach ($input as $key => $val) {
15 $input[$key] = mysql_real_escape_string($val);
16 }
17
18 if (isset($input['ready']) && $input['ready'] == 'on') {
19 $input['ready'] = 1;
20 } else {
21 $input['ready'] = 0;
22 }
23
24 // nothing less than 0.01 credits per rating
25 $input['credits_per_rating'] = round(
26 $input['credits_per_rating'],
27 2
28 );
29
30 $credits = round(
31 $input['credits_per_rating'] * $input['max_ratings'],
32 2
33 );
34
35 // updating an existing article?
36 if ($input['id']) {
37
38 // make sure this article belongs to the user
39 $stm = "SELECT *
40 FROM articles
41 WHERE user_id = '{$user_id}'
42 AND id = '{$input['id']}'
43 LIMIT 1";
44 $result = mysql_query($stm);
45
46 if (mysql_num_rows($result)) {
47
48 // get the existing article from the database
49 $row = mysql_fetch_assoc($result);
50
51 // don't charge unless the article is ready
52 $decrement = false;
53
54 // is the article marked as ready?
55 if ($input['ready'] == 1) {
56
57 // did they offer at least the minimum?
58 if (
59 $credits > 0
60 && $input['credits_per_rating'] >= 0.01
61 && is_numeric($credits)
62 ) {
63
64 // was the article previously ready for review?
65 // (note 'row' not 'input')
66 if ($row['ready'] == 1) {
67
68 // only subtract (or add back) the difference to their
69 // account, since they already paid something
70 if (
71 is_numeric($row['credits_per_rating'])
72 && is_numeric($row['max_ratings'])
73 ) {
74 // user owes $credits, minus whatever they paid already
75 $amount = $row['credits_per_rating']
76 * $row['max_ratings']
77 $credits = $credits - $amount;
78 }
79
80 $decrement = true;
81
82 } else {
83 // article not ready previously, so they hadn't
84 // had credits deducted. if this is less than their
85 // in their account now, they may proceed.
86 $residual = $user->get('credits') - $credits;
87 $decrement = true;
88 }
89
90 } else {
91 $residual = -1;
92 $failure[] = "Credit offering invalid.";
93 $decrement = false;
94 }
95
96 } else {
97
98 // arbitrary positive value; they can proceed
99 $residual = 1;
100
101 // if it was previously ready but is no longer, refund them
102 if (
103 is_numeric($row['credits_per_rating'])
104 && is_numeric($row['max_ratings'])
105 && ($row['ready'] == 1)
106 ) {
107 // subtract a negative value
108 $amount = $row['credits_per_rating']
109 * $row['max_ratings']
110 $credits = -($amount);
111 $decrement = true;
112 }
113 }
114
115 if ($residual >= 0) {
116
117 if (strlen($input['notes'])>0) {
118 $notes = "notes = '{$input['notes']}'";
119 } else {
120 $notes = "notes = NULL";
121 }
122
123 if (strlen($input['title'])>0) {
124 $title = "title = '{$input['title']}'";
125 } else {
126 $title = "title = NULL";
127 }
128
129 if (! in_array(
130 $input['article_type'],
131 $article_types
132 )) {
133 $input['article_type'] = 1;
134 }
135
136 $stm = "UPDATE articles
137 SET
138 body = '{$input['body']}',
139 $notes,
140 $title,
141 article_type = '{$input['article_type']}',
142 ready = '{$input['ready']}',
143 last_edited = '{$now}',
144 ip = '{$_SERVER['REMOTE_ADDR']}',
145 credits_per_rating = '{$input['credits_per_rating']}',
146 max_ratings = '{$input['max_ratings']}'
147 WHERE user_id = '{$user_id}'
148 AND id = '{$input['id']}'";
149
150 if (mysql_query($stm)) {
151 $article_id = $input['id'];
152
153 if ($decrement) {
154 // Charge them
155 $stm = "UPDATE users
156 SET credits = credits - {$credits}
157 WHERE user_id = '{$user_id}'";
158 mysql_query($stm);
159 }
160 } else {
161 $failure[] = "Could not update article.";
162 }
163 } else {
164 $failure[] = "You do not have enough credits for ratings.";
165 }
166 }
167
168 } else {
169
170 // creating a new article. do not decrement until specified.
171 $decrement = false;
172
173 // if the article is ready, we need to subtract credits.
174 if ($input['ready'] == 1) {
175
176 // if this is greater than or equal to 0, they may proceed.
177 if (
178 $credits > 0
179 && $input['credits_per_rating']>=0.01
180 && is_numeric($credits)
181 ) {
182 // minimum offering is 0.01
183 $residual = $user->get('credits') - $credits;
184 $decrement = true;
185 } else {
186 $residual = -1;
187 $failure[] = "Credit offering invalid.";
188 }
189
190 } else {
191 // arbitrary positive value if they are not done with their article.
192 // no deduction made yet.
193 $residual = 1;
194 }
195
196 // can user afford ratings on the new article?
197 if ($residual >= 0) {
198
199 // yes, insert the article
200 $stm = "INSERT INTO articles (
201 user_id,
202 ip,
203 last_edited,
204 article_type
205 ) VALUES (
206 '{$user_id}',
207 '{$_SERVER['REMOTE_ADDR']}',
208 '$now',
209 '$input['article_type']'
210 )";
211
212 if (mysql_query($stm)) {
213 $article_id = mysql_insert_id();
214 if ($decrement) {
215 // Charge them
216 $stm = "UPDATE users
217 SET credits = credits - {$credits}
218 WHERE user_id='{$user_id}'";
219 mysql_query($stm);
220 }
221 } else {
222 $failure[] = "Could not update credits.";
223 }
224
225 $stm = "UPDATE articles
226 SET
227 body = '{$input['body']}',
228 $notes,
229 $title,
230 article_type = '{$input['article_type']}',
231 ready = '{$input['ready']}',
232 last_edited = '$now',
233 ip = '{$_SERVER['REMOTE_ADDR']}',
234 credits_per_rating = '{$input['credits_per_rating']}',
235 max_ratings = '{$input['max_ratings']}'
236 WHERE
237 user_id = '{$user_id}'
238 AND id = '$article_id'
239 ";
240
241 if (! mysql_query($stm)) {
242 $failure[] = "Could not update article.";
243 }
244
245 } else {
246
247 // cannot afford ratings on new article
248 $failure[] = "You do not have enough credits for ratings.";
249 }
250 }
251 ?>

附录 C. 网关后的代码

本附录显示了附录 B 中页面脚本在转换为使用网关类之后的版本。请注意,它几乎没有改变。尽管 SQL 语句已被移除,但领域业务逻辑仍嵌入在页面脚本中。

网关类在页面脚本下面提供,并显示了转换为 PDO 风格绑定参数。还要注意,页面脚本中的if()条件已经进行了微小的修改:以前它们检查查询是否成功,现在它们检查网关的返回值。

**page_script.php**
<?php
2 // ... $user_id value created earlier
3
4 $db = new Database($db_host, $db_user, $db_pass);
5 $articles_gateway = new ArticlesGateway($db);
6 $users_gateway = new UsersGateway($db);
7
8 $article_types = array(1, 2, 3, 4, 5);
9 $failure = array();
10 $now = time();
11
12 // sanitize and escape the user input
13 $input = $_POST;
14 $input['body'] = strip_tags($input['body']);
15 $input['notes'] = strip_tags($input['notes']);
16
17 if (isset($input['ready']) && $input['ready'] == 'on') {
18 $input['ready'] = 1;
19 } else {
20 $input['ready'] = 0;
21 }
22
23 // nothing less than 0.01 credits per rating
24 $input['credits_per_rating'] = round(
25 $input['credits_per_rating'],
26 2
27 );
28
29 $credits = round(
30 $input['credits_per_rating'] * $input['max_ratings'],
31 2
32 );
33
34 // updating an existing article?
35 if ($input['id']) {
36
37 $row = $articles_gateway->selectOneByIdAndUserId($input['id'], $user_id);
38
39 if ($row) {
40
41 // don't charge unless the article is ready
42 $decrement = false;
43
44 // is the article marked as ready?
45 if ($input['ready'] == 1) {
46
47 // did they offer at least the minimum?
48 if (
49 $credits > 0
50 && $input['credits_per_rating'] >= 0.01
51 && is_numeric($credits)
52 ) {
53
54 // was the article previously ready for review?
55 // (note 'row' not 'input')
56 if ($row['ready'] == 1) {
57
58 // only subtract (or add back) the difference to their
59 // account, since they already paid something
60 if (
61 is_numeric($row['credits_per_rating'])
62 && is_numeric($row['max_ratings'])
63 ) {
64 // user owes $credits, minus whatever they paid already
65 $amount = $row['credits_per_rating']
66 * $row['max_ratings']
67 $credits = $credits - $amount;
68 }
69
70 $decrement = true;
71
72 } else {
73 // article not ready previously, so they hadn't
74 // had credits deducted. if this is less than their
75 // in their account now, they may proceed.
76 $residual = $user->get('credits') - $credits;
77 $decrement = true;
78 }
79
80 } else {
81 $residual = -1;
82 $failure[] = "Credit offering invalid.";
83 $decrement = false;
84 }
85
86 } else {
87
88 // arbitrary positive value; they can proceed
89 $residual = 1;
90
91 // if it was previously ready but is no longer, refund them
92 if (
93 is_numeric($row['credits_per_rating'])
94 && is_numeric($row['max_ratings'])
95 && ($row['ready'] == 1)
96 ) {
97 // subtract a negative value
98 $amount = $row['credits_per_rating']
99 * $row['max_ratings']
100 $credits = -($amount);
101 $decrement = true;
102 }
103 }
104
105 if ($residual >= 0) {
106
107 $input['ip'] = $_SERVER['REMOTE_ADDR'];
108 $input['last_edited'] = $now;
109
110 if (! in_array(
111 $input['article_type'],
112 $article_types
113 )) {
114 $input['article_type'] = 1;
115 }
116
117 $result = $articles_gateway->updateByIdAndUserId(
118 $input['id'],
119 $user_id,
120 $input
121 );
122
123 if ($result) {
124 $article_id = $input['id'];
125
126 if ($decrement) {
127 $users_gateway->decrementCredits($user_id, $credits);
128 }
129 } else {
130 $failure[] = "Could not update article.";
131 }
132 } else {
133 $failure[] = "You do not have enough credits for ratings.";
134 }
135 }
136
137 } else {
138
139 // creating a new article. do not decrement until specified.
140 $decrement = false;
141
142 // if the article is ready, we need to subtract credits.
143 if ($input['ready'] == 1) {
144
145 // if this is greater than or equal to 0, they may proceed.
146 if (
147 $credits > 0
148 && $input['credits_per_rating']>=0.01
149 && is_numeric($credits)
150 ) {
151 // minimum offering is 0.01
152 $residual = $user->get('credits') - $credits;
153 $decrement = true;
154 } else {
155 $residual = -1;
156 $failure[] = "Credit offering invalid.";
157 }
158
159 } else {
160 // arbitrary positive value if they are not done with their article.
161 // no deduction made yet.
162 $residual = 1;
163 }
164
165 // can user afford ratings on the new article?
166 if ($residual >= 0) {
167
168 // yes, insert the article
169 $input['last_edited'] = $now;
170 $input['ip'] = $_SERVER['REMOTE_ADDR'];
171 $article_id = $articles_gateway->insert($input);
172
173 if ($article_id) {
174 if ($decrement) {
175 // Charge them
176 $users_gateway->decrementCredits($user_id, $credits);
177 }
178 } else {
179 $failure[] = "Could not update credits.";
180 }
181
182 $result = $articles_gateway->updateByIdAndUserId(
183 $article_id,
184 $user_id,
185 $input
186 );
187
188 if (! $result) {
189 $failure[] = "Could not update article.";
190 }
191
192 } else {
193
194 // cannot afford ratings on new article
195 $failure[] = "You do not have enough credits for ratings.";
196 }
197 }
198 ?>
**classes/Domain/Articles/ArticlesGateway.php**
1 <?php
2 namespace Domain\Articles;
3
4 class ArticlesGateway
5 {
6 protected $db;
7
8 public function __construct(Database $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectOneByIdAndUserId($id, $user_id)
14 {
15 $stm = "SELECT *
16 FROM articles
17 WHERE user_id = :user_id
18 AND id = :id
19 LIMIT 1";
20
21 return $this->db->query($stm, array(
22 'id' => $id,
23 'user_id' => $user_id,
24 ))
25 }
26
27 public function updateByIdAndUserId($id, $user_id, $input)
28 {
29 if (strlen($input['notes']) > 0) {
30 $notes = "notes = :notes";
31 } else {
32 $notes = "notes = NULL";
33 }
34
35 if (strlen($input['title']) > 0) {
36 $title = "title = :title";
37 } else {
38 $title = "title = NULL";
39 }
40
41 $input['id'] = $id;
42 $input['user_id'] = $user_id;
43
44 $stm = "UPDATE articles
45 SET
46 body = :body,
47 $notes,
48 $title,
49 article_type = :article_type,
50 ready = :ready,
51 last_edited = :last_edited,
52 ip = :ip,
53 credits_per_rating = :credits_per_rating,
54 max_ratings = :max_ratings
55 WHERE user_id = :user_id
56 AND id = :id";
57
58 return $this->query($stm, $input);
59 }
60
61 public function insert($input)
62 {
63 $stm = "INSERT INTO articles (
64 user_id,
65 ip,
66 last_edited,
67 article_type
68 ) VALUES (
69 :user_id,
70 :ip,
71 :last_edited,
72 :article_type
73 )";
74 $this->db->query($stm, $input);
75 return $this->db->lastInsertId();
76 }
77 }
78 ?>
**classes/Domain/Users/UsersGateway.php**
1 <?php
2 namespace Domain\Users;
3
4 class UsersGateway
5 {
6 protected $db;
7
8 public function __construct(Database $db)
9 {
10 $this->db = $db;
11 }
12
13 public function decrementCredits($user_id, $credits)
14 {
15 $stm = "UPDATE users
16 SET credits = credits - :credits
17 WHERE user_id = :user_id";
18 $this->db->query($stm, array(
19 'user_id' => $user_id,
20 'credits' => $credits,
21 ));
22 }
23 }
24 ?>

附录 D. 事务脚本后的代码

本附录展示了从附录 B 和 C 中提取领域逻辑到Transactions类的代码版本。请注意原始页面脚本现在被简化为对象创建和注入机制,并将大部分逻辑交给Transactions类处理。还请注意,现在$failure$credits$article_types变量现在是Transactions类的属性,而规范化/清理逻辑和信用计算逻辑是Transactions逻辑的一部分。

**page_script.php**
<?php
2
3 // ... $user_id value created earlier
4
5 $db = new Database($db_host, $db_user, $db_pass);
6 $articles_gateway = new ArticlesGateway($db);
7 $users_gateway = new UsersGateway($db);
8 $article_transactions = new ArticleTransactions(
9 $articles_gateway,
10 $users_gateway
11 );
12
13 if ($_POST['id']) {
14 $article_transactions->updateExistingArticle($user_id, $_POST);
15 } else {
16 $article_transactions->submitNewArticle($user_id, $_POST);
17 }
18
19 $failure = $article_transactions->getFailure();
20 ?>
**classes/Domain/Articles/ArticleTransactions.php**
1 <?php
2 namespace Domain\Articles;
3
4 use Domain\Users\UsersGateway;
5
6 class ArticleTransactions
7 {
8 protected $article_types = array(1, 2, 3, 4, 5);
9
10 protected $failure = array();
11
12 protected $input = array();
13
14 public function __construct(
15 ArticlesGateway $articles_gateway,
16 UsersGateway $users_gateway
17 ) {
18 $this->articles_gateway = $articles_gateway;
19 $this->users_gateway = $users_gateway;
20 }
21
22 public function getInput()
23 {
24 return $this->input;
25 }
26
27 public function getFailure()
28 {
29 return $this->failure;
30 }
31
32 public function getCredits()
33 {
34 return round(
35 $this->input['credits_per_rating'] * $this->input['max_ratings'],
36 2
37 );
38 }
39
40 public function filterInput($input)
41 {
42 $input['body'] = strip_tags($input['body']);
43 $input['notes'] = strip_tags($input['notes']);
44
45 if (isset($input['ready']) && $input['ready'] == 'on') {
46 $input['ready'] = 1;
47 } else {
48 $input['ready'] = 0;
49 }
50
51 // nothing less than 0.01 credits per rating
52 $input['credits_per_rating'] = round(
53 $input['credits_per_rating'],
54 2
55 );
56
57 // return the filtered input
58 return $input;
59 }
60
61 public function updateExistingArticle($user_id, $input)
62 {
63 $this->input = $this->filterInput($input);
64 $now = time();
65 $this->failure = array();
66 $credits = $this->getCredits();
67
68 $row = $this->articles_gateway->selectOneByIdAndUserId(
69 $this->input['id'],
70 $user_id
71 );
72
73 if ($row) {
74
75 // don't charge unless the article is ready
76 $decrement = false;
77
78 // is the article marked as ready?
79 if ($this->input['ready'] == 1) {
80
81 // did they offer at least the minimum?
82 if (
83 $credits > 0
84 && $this->input['credits_per_rating'] >= 0.01
85 && is_numeric($credits)
86 ) {
87
88 // was the article previously ready for review?
89 // (note 'row' not 'input')
90 if ($row['ready'] == 1) {
91
92 // only subtract (or add back) the difference to their
93 // account, since they already paid something
94 if (
95 is_numeric($row['credits_per_rating'])
96 && is_numeric($row['max_ratings'])
97 ) {
98 // user owes $credits, minus whatever they paid
99 // already
100 $amount = $row['credits_per_rating']
101 * $row['max_ratings']
102 $credits = $credits - $amount;
103 }
104
105 $decrement = true;
106
107 } else {
108 // article not ready previously, so they hadn't
109 // had credits deducted. if this is less than their
110 // in their account now, they may proceed.
111 $residual = $user->get('credits') - $credits;
112 $decrement = true;
113 }
114
115 } else {
116 $residual = -1;
117 $this->failure[] = "Credit offering invalid.";
118 $decrement = false;
119 }
120
121 } else {
122
123 // arbitrary positive value; they can proceed
124 $residual = 1;
125
126 // if it was previously ready but is no longer, refund them
127 if (
128 is_numeric($row['credits_per_rating'])
129 && is_numeric($row['max_ratings'])
130 && ($row['ready'] == 1)
131 ) {
132 // subtract a negative value
133 $amount = $row['credits_per_rating']
134 * $row['max_ratings']
135 $credits = -($amount);
136 $decrement = true;
137 }
138 }
139
140 if ($residual >= 0) {
141
142 $this->input['ip'] = $_SERVER['REMOTE_ADDR'];
143 $this->input['last_edited'] = $now;
144
145 if (! in_array(
146 $this->input['article_type'],
147 $this->article_types
148 )) {
149 $this->input['article_type'] = 1;
150 }
151
152 $result = $this->articles_gateway->updateByIdAndUserId(
153 $this->input['id'],
154 $user_id,
155 $this->input
156 );
157
158 if ($result) {
159 $article_id = $this->input['id'];
160
161 if ($decrement) {
162 $this->users_gateway->decrementCredits(
163 $user_id,
164 $credits
165 );
166 }
167 } else {
168 $this->failure[] = "Could not update article.";
169 }
170 } else {
171 $this->failure[] = "You do not have enough credits for ratings.";
172 }
173 }
174 }
175
176 public function submitNewArticle($user_id, $input)
177 {
178 $this->input = $this->filterInput($input);
179 $now = time();
180 $this->failure = array();
181 $credits = $this->getCredits();
182
183 $decrement = false;
184
185 // if the article is ready, we need to subtract credits.
186 if ($this->input['ready'] == 1) {
187
188 // if this is greater than or equal to 0, they may proceed.
189 if (
190 $credits > 0
191 && $this->input['credits_per_rating']>=0.01
192 && is_numeric($credits)
193 ) {
194 // minimum offering is 0.01
195 $residual = $user->get('credits') - $credits;
196 $decrement = true;
197 } else {
198 $residual = -1;
199 $this->failure[] = "Credit offering invalid.";
200 }
201
202 } else {
203 // arbitrary positive value if they are not done with their article.
204 // no deduction made yet.
205 $residual = 1;
206 }
207
208 // can user afford ratings on the new article?
209 if ($residual >= 0) {
210
211 // yes, insert the article
212 $this->input['last_edited'] = $now;
213 $this->input['ip'] = $_SERVER['REMOTE_ADDR'];
214 $article_id = $this->articles_gateway->insert($this->input);
215
216 if ($article_id) {
217 if ($decrement) {
218 // Charge them
219 $this->users_gateway->decrementCredits($user_id, $credits);
220 }
221 } else {
222 $this->failure[] = "Could not update credits.";
223 }
224
225 $result = $this->articles_gateway->updateByIdAndUserId(
226 $article_id,
227 $user_id,
228 $this->input
229 );
230
231 if (! $result) {
232 $this->failure[] = "Could not update article.";
233 }
234
235 } else {
236
237 // cannot afford ratings on new article
238 $this->failure[] = "You do not have enough credits for ratings.";
239 }
240 }
241 }
242 ?>

附录 E. 收集演示逻辑之前的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 $current_page = 'articles';
5
6 include "header.php";
7
8 $id = isset($_GET['id']) ? $_GET['id'] : 0;
9 if ($id) {
10 $page_title = "Edit An Article";
11 } else {
12 $page_title = "Submit An Article";
13 }
14
15 ?><h1><?php echo $page_title ?></h1><?php
16
17 $user_id = $user->getId();
18
19 $db = new Database($db_host, $db_user, $db_pass);
20 $articles_gateway = new ArticlesGateway($db);
21 $users_gateway = new UsersGateway($db);
22 $article_transactions = new ArticleTransactions(
23 $articles_gateway,
24 $users_gateway
25 );
26
27 if ($id) {
28 $article_transactions->updateExistingArticle($user_id, $_POST);
29 } else {
30 $article_transactions->submitNewArticle($user_id, $_POST);
31 }
32
33 $failure = $article_transactions->getFailure();
34 $input = $article_transactions->getInput();
35
36 ?>
37
38 <?php
39 if ($failure) {
40 $failure_text = implode("<br />\n", $failure);
41 echo "<h2>Failure</h2>";
42 echo "<p>We could not save the article.<br />";
43 echo $failure_text. "</p>";
44 } else {
45 echo "
46 <h2>Success</h2>
47 <p>We saved the article.</p>
48 ";
49 }
50 ?>
51
52 <form method="POST" action="<?php echo $_SERVER['PHP_SELF']?>">
53
54 <input type="hidden" name="id" value="<?php echo $id ?>" />
55
56 <h3>Title</h3>
57 <input type="text" name="title" value="<?php
58 echo $input['title']
59 ?>" size="100">
60
61
62 <h3>Article</h3>
63 <textarea name="body" cols="80" rows="30"><?php
64 echo stripslashes($input['body'])
65 ?></textarea>
66
67 <h3>Ratings</h3>
68 <p>How many rated reviews do you want?</p>
69 <select name='max_ratings'>
70 <?php for ($i = 1; $i <= 10; $i ++) {
71 echo "<option value='$i' ";
72 if ($input['max_ratings'] == $i) {
73 echo 'selected="selected"';
74 }
75 echo ">$i</option>\n";
76 } ?>
77 </select>
78
79 <p>How many credits will you give for each rating?</p>
80 <input type='text' name='credits_per_rating' value='<?php
81 echo $input['credits_per_rating'];
82 ?>' size='5' />
83
84 <h3>Notes for Reviewers</h3>
85 <input type="text" name="notes" value="<?php
86 echo $input['notes']
87 ?>" size="100">
88 <label><input type="checkbox" name="ready" <?php
89 echo $input['ready'] ? 'checked="checked"' : '';
90 ?> /> This article is ready to be rated.</label>
91
92 <p align="center">
93 <input type="submit" value="Save" name="submit">
94 </p>
95
96 </form>
97
98 <?php
99 include "footer.php";
100 ?>

附录 F. 收集演示逻辑后的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 $user_id = $user->getId();
5
6 $db = new Database($db_host, $db_user, $db_pass);
7 $articles_gateway = new ArticlesGateway($db);
8 $users_gateway = new UsersGateway($db);
9 $article_transactions = new ArticleTransactions(
10 $articles_gateway,
11 $users_gateway
12 );
13
14 $id = isset($_GET['id']) ? $_GET['id'] : 0;
15 if ($id) {
16 $article_transactions->updateExistingArticle($user_id, $_POST);
17 } else {
18 $article_transactions->submitNewArticle($user_id, $_POST);
19 }
20
21 $failure = $article_transactions->getFailure();
22 $input = $article_transactions->getInput();
23 $action = $_SERVER['PHP_SELF'];
24
25 /** PRESENTATION */
26
27 $current_page = 'articles';
28
29 include "header.php";
30
31 if ($id) {
32 $page_title = "Edit An Article";
33 } else {
34 $page_title = "Submit An Article";
35 }
36 ?>
37
38 <h1><?php echo $page_title ?></h1><?php
39
40 if ($failure) {
41 $failure_text = implode("<br />\n", $failure);
42 echo "<h2>Failure</h2>";
43 echo "<p>We could not save the article.<br />";
44 echo $failure_text. "</p>";
45 } else {
46 echo "
47 <h2>Success</h2>
48 <p>We saved the article.</p>
49 ";
50 }
51 ?>
52
53 <form method="POST" action="<?php echo $action ?>">
54
55 <input type="hidden" name="id" value="<?php echo $id ?>" />
56
57 <h3>Title</h3>
58 <input type="text" name="title" value="<?php
59 echo $input['title']
60 ?>" size="100">
61
62
63 <h3>Article</h3>
64 <textarea name="body" cols="80" rows="30"><?php
65 echo stripslashes($input['body'])
66 ?></textarea>
67
68 <h3>Ratings</h3>
69 <p>How many rated reviews do you want?</p>
70 <select name='max_ratings'>
71 <?php for ($i = 1; $i <= 10; $i ++) {
72 echo "<option value='$i' ";
73 if ($input['max_ratings'] == $i) {
74 echo 'selected="selected"';
75 }
76 echo ">$i</option>\n";
77 } ?>
78 </select>
79
80 <p>How many credits will you give for each rating?</p>
81 <input type='text' name='credits_per_rating' value='<?php
82 echo $input['credits_per_rating'];
83 ?>' size='5' />
84
85 <h3>Notes for Reviewers</h3>
86 <input type="text" name="notes" value="<?php
87 echo $input['notes']
88 ?>" size="100">
89 <label><input type="checkbox" name="ready" <?php
90 echo $input['ready'] ? 'checked="checked"' : '';
91 ?> /> This article is ready to be rated.</label>
92
93 <p align="center">
94 <input type="submit" value="Save" name="submit">
95 </p>
96
97 </form>
98
99 <?php
100 include "footer.php";
101 ?>

附录 G. 响应视图文件后的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 $user_id = $user->getId();
5
6 $db = new Database($db_host, $db_user, $db_pass);
7 $articles_gateway = new ArticlesGateway($db);
8 $users_gateway = new UsersGateway($db);
9 $article_transactions = new ArticleTransactions(
10 $articles_gateway,
11 $users_gateway
12 );
13
14 $id = isset($_GET['id']) ? $_GET['id'] : 0;
15 if ($id) {
16 $article_transactions->updateExistingArticle($user_id, $_POST);
17 } else {
18 $article_transactions->submitNewArticle($user_id, $_POST);
19 }
20
21 $response = new \Mlaphp\Response('/path/to/app/views');
22 $response->setView('articles.html.php');
23 $response->setVars(array(
24 'id' => $id,
25 'failure' => $article_transactions->getFailure(),
26 'input' => $article_transactions->getInput(),
27 'action' => $_SERVER['PHP_SELF'],
28 ));
29 $response->send();
30 ?>
**views/articles.html.php**
1 <?php
2 $current_page = 'articles';
3
4 include "header.php";
5
6 if ($id) {
7 $page_title = "Edit An Article";
8 } else {
9 $page_title = "Submit An Article";
10 }
11 ?>
12
13 <h1><?php echo $page_title ?></h1><?php
14
15 if ($failure) {
16 echo "<h2>Failure</h2>";
17 echo "<p>We could not save the article.<br />";
18 foreach ($failure as $failure_text) {
19 echo $this->esc($failure_text) . "<br />";
20 }
21 echo "</p>";
22 } else {
23 echo "
24 <h2>Success</h2>
25 <p>We saved the article.</p>
26 ";
27 }
28 ?>
29
30 <form method="POST" action="<?php echo $this->esc($action) ?>">
31
32 <input type="hidden" name="id" value="<?php echo $this->esc($id) ?>" />
33
34 <h3>Title</h3>
35 <input type="text" name="title" value="<?php
36 echo $this->esc($input['title'])
37 ?>" size="100">
38
39
40 <h3>Article</h3>
41 <textarea name="body" cols="80" rows="30"><?php
42 echo stripslashes($this->esc($input['body']))
43 ?></textarea>
44
45 <h3>Ratings</h3>
46 <p>How many rated reviews do you want?</p>
47 <select name='max_ratings'>
48 <?php for ($i = 1; $i <= 10; $i ++) {
49 $i = $this->esc($i);
50 echo "<option value='$i' ";
51 if ($input['max_ratings'] == $i) {
52 echo 'selected="selected"';
53 }
54 echo ">$i</option>\n";
55 } ?>
56 </select>
57
58 <p>How many credits will you give for each rating?</p>
59 <input type='text' name='credits_per_rating' value='<?php
60 echo $this->esc($input['credits_per_rating']);
61 ?>' size='5' />
62
63 <h3>Notes for Reviewers</h3>
64 <input type="text" name="notes" value="<?php
65 echo $this->esc($input['notes'])
66 ?>" size="100">
67 <label><input type="checkbox" name="ready" <?php
68 echo ($input['ready']) ? 'checked="checked"' : '';
69 ?> /> This article is ready to be rated.</label>
70
71 <p align="center">
72 <input type="submit" value="Save" name="submit">
73 </p>
74
75 </form>
76
77 <?php
78 include "footer.php";
79 ?>

附录 H. 控制器重新排列后的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 /* DEPENDENCY */
5
6 $db = new Database($db_host, $db_user, $db_pass);
7 $articles_gateway = new ArticlesGateway($db);
8 $users_gateway = new UsersGateway($db);
9 $article_transactions = new ArticleTransactions(
10 $articles_gateway,
11 $users_gateway
12 );
13 $response = new \Mlaphp\Response('/path/to/app/views');
14
15 /* CONTROLLER */
16
17 $user_id = $user->getId();
18
19 $id = isset($_GET['id']) ? $_GET['id'] : 0;
20 if ($id) {
21 $article_transactions->updateExistingArticle($user_id, $_POST);
22 } else {
23 $article_transactions->submitNewArticle($user_id, $_POST);
24 }
25
26 $response->setView('articles.html.php');
27 $response->setVars(array(
28 'id' => $id,
29 'failure' => $article_transactions->getFailure(),
30 'input' => $article_transactions->getInput(),
31 'action' => $_SERVER['PHP_SELF'],
32 ));
33
34 /* FINISHED */
35
36 $response->send();
37 ?>

附录 I. 控制器提取后的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 /* DEPENDENCY */
5
6 $db = new Database($db_host, $db_user, $db_pass);
7 $articles_gateway = new ArticlesGateway($db);
8 $users_gateway = new UsersGateway($db);
9 $article_transactions = new ArticleTransactions(
10 $articles_gateway,
11 $users_gateway
12 );
13 $response = new \Mlaphp\Response('/path/to/app/views');
14 $controller = new \Controller\ArticlesPage();
15
16 /* CONTROLLER */
17
18 $response = $controller->__invoke(
19 $request,
20 $response,
21 $user,
22 $article_transactions
23 );
24
25 /* FINISHED */
26
27 $response->send();
28 ?>
**classes/Controller/ArticlesPage.php**
1 <?php
2 namespace Controller;
3
4 use Domain\Articles\ArticleTransactions;
5 use Mlaphp\Request;
6 use Mlaphp\Response;
7 use User;
8
9 class ArticlesPage
10 {
11 public function __construct()
12 {
13 }
14
15 public function __invoke(
16 Request $request,
17 Response $response,
18 User $user,
19 ArticleTransactions $article_transactions
20 ) {
21 $user_id = $user->getId();
22
23 $id = isset($request->get['id'])
24 ? $request->get['id']
25 : 0;
26
27 if ($id) {
28 $article_transactions->updateExistingArticle(
29 $user_id,
30 $request->post
31 );
32 } else {
33 $article_transactions->submitNewArticle(
34 $user_id,
35 $request->post
36 );
37 }
38
39 $response->setView('articles.html.php');
40 $response->setVars(array(
41 'id' => $id,
42 'failure' => $article_transactions->getFailure(),
43 'input' => $article_transactions->getInput(),
44 'action' => $request->server['PHP_SELF'],
45 ));
46
47 return $response;
48 }
49 }
50 ?>

附录 J. 控制器依赖注入后的代码

**articles.php**
1 <?php
2 require "includes/setup.php";
3
4 /* DEPENDENCY */
5
6 $db = new Database($db_host, $db_user, $db_pass);
7 $articles_gateway = new ArticlesGateway($db);
8 $users_gateway = new UsersGateway($db);
9 $article_transactions = new ArticleTransactions(
10 $articles_gateway,
11 $users_gateway
12 );
13 $response = new \Mlaphp\Response('/path/to/app/views');
14 $controller = new \Controller\ArticlesPage(
15 $request,
16 $response,
17 $user,
18 $article_transactions
19 );
20
21 /* CONTROLLER */
22
23 $response = $controller->__invoke();
24
25 /* FINISHED */
26
27 $response->send();
28 ?>
**classes/Controller/ArticlesPage.php**
1 <?php
2 namespace Controller;
3
4 use Domain\Articles\ArticleTransactions;
5 use Mlaphp\Request;
6 use Mlaphp\Response;
7 use User;
8
9 class ArticlesPage
10 {
11 protected $user;
12
13 protected $article_transactions;
14
15 protected $request;
16
17 protected $response;
18
19 public function __construct(
20 Request $request,
21 Response $response,
22 User $user,
23 ArticleTransactions $article_transactions
24 ) {
25 $this->user = $user;
26 $this->article_transactions = $article_transactions;
27 $this->request = $request;
28 $this->response = $response;
29 }
30
31 public function __invoke()
32 {
33 $user_id = $this->user->getId();
34
35 $id = isset($this->request->get['id'])
36 ? $this->request->get['id']
37 : 0;
38
39 if ($id) {
40 $article_transactions->updateExistingArticle(
41 $user_id,
42 $this->request->post
43 );
44 } else {
Appendix J: Code After Controller Dependency Injection 217
45 $article_transactions->submitNewArticle(
46 $user_id,
47 $this->request->post
48 );
49 }
50
51 $this->response->setView('articles.html.php');
52 $this->response->setVars(array(
53 'id' => $id,
54 'failure' => $this->article_transactions->getFailure(),
55 'input' => $this->article_transactions->getInput(),
56 'action' => $this->request->server['PHP_SELF'],
57 ));
58
59 return $this->response;
60 }
61 }
62 ?>
posted @ 2024-05-05 12:12  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报