PHP8-对象-模式和实践-全-
PHP8 对象、模式和实践(全)
一、PHP:设计和管理
2004 年 7 月,PHP 5.0 发布。这个版本引入了一套激进的增强功能。也许其中第一个是对面向对象编程的彻底改进的支持。这激发了 PHP 社区对对象和设计的兴趣。事实上,这是一个过程的强化,这个过程始于第 4 版首次使 PHP 面向对象编程成为现实。
在这一章中,我看了用对象编码可以解决的一些需求。我非常简要地总结了模式演变和相关实践的一些方面。
我还概述了这本书涵盖的主题。我将查看以下内容:
-
灾难的演变:一个项目变坏
-
设计和 PHP:面向对象的设计技术如何在 PHP 社区扎根
-
本书:对象、模式、实践
问题
问题是 PHP 太简单了。它诱惑你去尝试你的想法,并且用好的结果取悦你。您可以将大部分代码直接写入网页,因为 PHP 就是为了支持这一点而设计的。您将实用函数(如数据库访问代码)添加到可以包含在页面之间的文件中,不知不觉中,您就拥有了一个工作的 web 应用。
你正在走向毁灭。当然,你没有意识到这一点,因为你的网站看起来棒极了。它表现很好,你的客户很高兴,你的用户在花钱。
当你回到代码开始一个新的阶段时,麻烦就来了。现在你有了一个更大的团队,更多的用户和更大的预算。然而,在没有警告的情况下,事情开始出错。就好像你的项目中毒了。
你的新程序员正在努力理解对你来说是第二天性的代码,尽管可能有些曲折复杂。作为团队成员,她需要的时间比你预期的要长。
一个简单的改变,估计需要一天,当你发现你必须更新 20 个或更多的网页时,需要三天。
您的一位编码人员保存了他的文件版本,覆盖了您之前对同一代码所做的主要更改。三天后才发现丢失,此时您已经修改了自己的本地副本。整理这些乱七八糟的东西花了一天时间,耽误了第三个也在处理这个文件的开发人员。
由于应用的流行,您需要将代码转移到新的服务器上。该项目必须手工安装,并且您发现文件路径、数据库名称和密码被硬编码到许多源文件中。您在移动过程中停止工作,因为您不想覆盖迁移所需的配置更改。估计的两个小时变成了八个小时,因为有人在 Apache 模块 ModRewrite 中做了一些聪明的事情,应用现在需要这样才能正常运行。
你终于开始了第二阶段。这一天半一切正常。当您即将离开办公室时,第一份错误报告出现了。客户几分钟后打电话投诉。她的报告与第一份报告相似,但是稍微仔细一点就会发现,这是一个不同的错误导致了类似的行为。您还记得在该阶段开始时的简单变更,这使得在项目的其余部分中需要进行大量的修改。
您意识到并非所有需要的修改都已到位。这要么是因为它们从一开始就被忽略了,要么是因为这些文件在合并冲突中被覆盖了。您匆忙地进行必要的修改来修复错误。您太急于测试更改了,但是它们只是简单的复制和粘贴,那么会有什么问题呢?
第二天早上,您到达办公室,发现一个购物篮模块已经关闭了一整夜。您在最后一刻所做的更改省略了一个前导引号,导致代码不可用。当然,当你在睡觉的时候,其他时区的潜在顾客完全醒着,准备在你的店里花钱。您解决了问题,安抚了客户,并召集团队准备下一天的救火工作。
这个编码人员的日常故事可能看起来有点夸张,但是我已经看到所有这些事情一次又一次地发生。许多 PHP 项目开始时都很小,后来演变成了怪物。
因为表示层还包含应用逻辑,所以当数据库查询、身份验证检查、表单处理等等从一页复制到另一页时,复制就开始了。每当需要对这些代码块中的一个进行更改时,必须在发现代码的任何地方进行更改,否则错误肯定会随之而来。
缺少文档会使代码难以阅读,缺少测试会使不明显的错误在部署前无法被发现。客户业务不断变化的性质通常意味着代码会偏离其最初的目的,直到它执行根本不适合它的任务。因为这样的代码经常演变成一个沸腾的、混杂的块,很难(如果不是不可能的话)切换并重写它的一部分来适应新的目的。
如果你是一名自由职业的 PHP 顾问,这些都不是坏消息。评估和修复这样一个系统可以资助昂贵的浓缩咖啡饮料和 DVD 盒六个月或更长时间。然而,更严重的是,这类问题可能意味着一家企业的成败。
PHP 和其他语言
PHP 惊人的受欢迎程度意味着它的边界很早就经过了严格的测试。正如你将在下一章看到的,PHP 最初是作为一组管理个人主页的宏而诞生的。随着 PHP 3 的出现,以及更大程度上 PHP 4 的出现,这种语言迅速成为大型企业网站背后的成功力量。然而,在许多方面,PHP 的早期继承一直延续到脚本设计和项目管理中。在某些方面,PHP 保留了业余语言的不公平名声,最适合于演示任务。
大约在这个时候(大约在世纪之交),新的想法在其他编码社区流行开来。对面向对象设计的兴趣刺激了 Java 社区。由于 Java 是面向对象的语言,你可能认为这是多余的。当然,Java 提供了一种更容易使用的粒度,但是使用类和对象本身并不能决定一种特定的设计方法。
作为一种描述问题的方式,设计模式的概念以及其解决方案的本质在 20 世纪 70 年代首次被讨论。也许恰当地说,这个想法起源于建筑领域,而不是计算机科学,是在 Christopher Alexander 的一部开创性著作中:**模式语言(牛津大学出版社,1977)。到 20 世纪 90 年代早期,面向对象的程序员使用同样的技术来命名和描述软件设计的问题。Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(在本书中以他们亲切的昵称四人组)撰写的关于设计模式的开创性著作Design Patterns:Elements of Reusable Object-Oriented Software(Addison-Wesley Professional,1995),今天仍然是不可或缺的。它所包含的模式是任何一个刚开始涉足这个领域的人所必需的第一步,这也是为什么本书中的大多数模式都是从它而来的。
*Java 语言本身在其 API 中部署了许多核心模式,但直到 20 世纪 90 年代末,设计模式才渗透到编码社区的意识中。模式很快感染了主要街道书店的电脑部门,第一次火焰战争开始于邮件列表和论坛。
无论你认为模式是交流工艺知识的一种强有力的方式,还是主要是空话(根据这本书的标题,你可能会猜到我在这个问题上的立场),很难否认他们鼓励的对软件设计的重视本身是有益的。
相关的话题也越来越突出。肯特·贝克倡导的极限编程(XP)就是其中之一。XP 是一种鼓励灵活的、面向设计的、高度集中的计划和执行的项目方法。
XP 原则中突出的一点是坚持测试对项目的成功至关重要。测试应该自动化,经常运行,最好在目标代码编写之前就设计好。
XP 还规定项目应该被分解成小的(非常小的)迭代。代码和需求都应该被仔细检查。架构和设计应该是一个共享和持续的问题,导致代码的频繁修改。
如果说 XP 是设计运动中好战的一翼,那么我读过的关于编程的最好的书之一就很好地代表了这种温和的倾向:安德鲁·亨特和戴维·托马斯写的《实用程序员:从熟练工到大师》(Addison-Wesley Professional,1999)。
XP 被一些人认为是有点狂热,但是它是二十年来最高水平的面向对象实践的产物,它的原则被广泛地移植。特别是,被称为重构的代码修改被认为是模式的一个强大的附属品。重构自 20 世纪 80 年代以来一直在发展,但它是在马丁·福勒的重构目录中编纂的,重构:改进现有代码的设计 (Addison-Wesley Professional),该目录于 1999 年出版并定义了该领域。
随着 XP 和模式的兴起,测试也成了一个热门话题。强大的 JUnit 测试平台的发布进一步强调了自动化测试的重要性,JUnit 测试平台成为 Java 程序员武器库中的关键武器。由肯特·贝克和埃里希·伽马( http://junit.sourceforge.net/doc/testinfected/testing.htm
)撰写的关于这个主题的一篇里程碑式的文章“测试感染:程序员喜欢编写测试”,对这个主题进行了出色的介绍,并且仍然具有巨大的影响力。
PHP 4 大约在这个时候发布,带来了效率的提高,更重要的是,增强了对对象的支持。这些增强使得完全面向对象的项目成为可能。程序员们欣然接受了这个特性,这让 Zend 的创始人 Zeev Suraski 和 Andi Gutmans 有些吃惊,他们加入拉斯马斯·勒德尔夫来管理 PHP 开发。正如你将在下一章看到的,PHP 的对象支持绝不是完美的。但是通过训练和小心使用语法,人们可以真正开始同时考虑对象和 PHP。
然而,像本章开头描述的设计灾难仍然很常见。设计文化还很遥远,在关于 PHP 的书籍中几乎不存在。然而,在网上,人们的兴趣很明显。Leon Atkinson 在 2001 年为 Zend 写了一篇关于 PHP 和模式的文章,Harry Fuecks 在 2002 年在 ??(现已不存在)创办了他的杂志。基于模式的框架项目,如 BinaryCloud,以及自动化测试和文档工具开始出现。
2003 年第一个 PHP 5 测试版的发布确保了 PHP 作为面向对象编程语言的未来。Zend Engine 2 提供了大大改进的对象支持。同样重要的是,它发出了一个信号,即对象和面向对象的设计现在是 PHP 项目的核心。
多年来,PHP 5 不断发展和改进,加入了一些重要的新特性,比如名称空间和闭包。在此期间,它获得了作为服务器端 web 编程最佳选择的声誉。
2015 年 12 月发布的 PHP 7 代表了这一趋势的延续。特别是,它提供了对参数和返回类型声明的支持——这两个特性是许多开发人员(以及本书以前的版本)多年来一直渴望的。还有许多其他特性和改进,包括匿名类、改进的内存使用和提高的速度。这些年来,从面向对象的程序员的角度来看,这种语言逐渐变得更健壮、更干净、更有趣。
在 2020 年 12 月,也就是 PHP 7 发布 5 年后,PHP 8 也将发布。虽然一些实现细节可能会发生变化(在撰写本书期间已经发生了一些变化),但在撰写本书时(2020 年 8 月),这些功能已经可用。我在这里详细介绍了其中的许多内容。它们包括对类型声明的改进、简化的属性赋值和许多其他新特性。标题添加的可能是对属性的支持(在其他语言中通常称为注释)。
关于这本书
这本书并不试图在面向对象设计领域开辟新的天地;在这方面,它岌岌可危地站在巨人的肩膀上。相反,我在 PHP 的上下文中研究了一些成熟的设计原则和一些关键模式(特别是那些在经典的四人组书籍设计模式中提到的)。最后,我超越了代码的严格限制,着眼于有助于确保项目成功的工具和技术。除了这个介绍和一个简短的结论,这本书分为三个主要部分:对象、模式和实践。
目标
我在第一部分开始时快速回顾了 PHP 和对象的历史,绘制了它们从 PHP 3 的事后想法到 PHP 5 的核心特性的转变。
即使你对对象知之甚少或者一无所知,你仍然可以成为一名有经验的成功的 PHP 程序员。出于这个原因,我从基本原则开始解释对象、类和继承。即使在这个早期阶段,我也看到了 PHP 5、PHP 7 和 PHP 8 引入的一些对象增强。
基础知识建立之后,我会更深入地研究我们的主题,研究 PHP 更高级的面向对象特性。我还专门用一章来介绍 PHP 提供的帮助您处理对象和类的工具。
然而,仅仅知道如何声明一个类并使用它来实例化一个对象是不够的。您必须首先为您的系统选择合适的参与者,并决定他们交互的最佳方式。这些选择比关于对象工具和语法的简单事实更难描述和学习。我以对 PHP 面向对象设计的介绍结束了第一部分。
模式
模式描述了软件设计中的一个问题,并提供了解决方案的核心。这里的“解决方案”不是指你可能在食谱中找到的那种剪切和粘贴代码(尽管食谱对程序员来说是很好的资源)。相反,设计模式描述了一种可以用来解决问题的方法。可能会给出一个示例实现,但它没有它用来说明的概念重要。
第二部分从定义设计模式和描述它们的结构开始。我也研究了它们流行背后的一些原因。
模式倾向于促进和遵循某些核心设计原则。理解这些可以帮助分析一个模式的动机,并且可以有效地应用于所有的编程。我讨论其中的一些原则。我还研究了统一建模语言(UML),这是一种独立于平台的描述类及其交互的方式。
虽然这本书不是一个模式目录,但是我研究了一些最著名和最有用的模式。我描述了每个模式解决的问题,分析了解决方案,并给出了一个 PHP 实现示例。
实践
如果管理不当,即使是完美平衡的架构也会失败。在第三部分中,我将介绍可用来帮助您创建确保项目成功的框架的工具。如果本书的其余部分是关于设计和编程的实践,那么第三部分是关于管理代码的实践。我所研究的工具可以形成一个项目的支持结构,有助于在错误发生时跟踪它们,促进程序员之间的协作,并提供安装的便利性和代码的清晰性。
我已经讨论了自动化测试的力量。我从介绍性的一章开始第三部分,概述了这个领域的问题和解决方案。
许多程序员对屈服于自己做所有事情的冲动感到内疚。Composer 和它的主存储库 Packagist 一起,提供了对数千个依赖项管理包的访问,这些包可以轻松地缝合到项目中。我研究了自己实现一个特性和部署一个 Composer 包之间的权衡。
当我在谈论 Composer 时,我会看看安装机制,它使包的部署像一个命令一样简单。
代码是关于协作的。这一事实可能是有益的。这也可能是一场彻底的噩梦。Git 是一个版本控制系统,它使许多程序员能够在同一个代码库上一起工作,而不会覆盖彼此的工作。它可以让您在开发的任何阶段抓取项目的快照,查看谁做了哪些更改,并将项目拆分为可合并的分支。Git 总有一天会拯救你的项目。
当人们和库合作时,他们通常会带来不同的惯例和风格。虽然这是健康的,但它也会破坏互操作性。像符合和符合这样的词让我不寒而栗,但不可否认的是,互联网的创造力是由标准支撑的。通过遵守某些惯例,我们可以自由地在一个难以想象的大沙盒中玩耍。因此,在新的一章中,我探索 PHP 标准,它们如何帮助我们,以及我们应该如何以及为什么,是的,遵从。
两个事实似乎是不可避免的。首先,bug 经常在代码的同一个区域重复出现,使得一些工作日就像是一场似曾相识的练习。第二,改进带来的破坏往往和它们修复的一样多,甚至更多。自动化测试可以解决这两个问题,为代码中的问题提供早期预警系统。我将介绍 PHPUnit,这是一个强大的 xUnit 测试平台实现,最初是为 Smalltalk 设计的,但现在已经移植到许多语言,特别是 Java。我特别关注 PHPUnit 的特性,以及测试的好处和一些成本。
申请很乱。他们可能需要将文件安装在非标准位置,或者想要设置数据库,或者需要修补服务器配置。简而言之,应用需要在安装过程中完成填充。Phing 是一个名为 Ant 的 Java 工具的忠实端口。Phing 和 Ant 解释一个构建文件,并按照您告诉他们的任何方式处理您的源文件。这通常意味着将它们从源目录复制到系统中的各个目标位置,但是,随着您的需求变得更加复杂,Phing 可以毫不费力地进行扩展来满足它们。
一些公司实施开发平台——但是在许多情况下,团队最终运行一系列不同的操作系统。承包商挥舞着笔记本电脑到达(你好,Paul Tregoing,第五版和当前版本的技术编辑),一些团队成员没完没了地宣传他们最喜欢的 Linux 发行版(那是我和我的 Fedora),许多人坚持要另一个性感的 PowerBook(在咖啡馆和会议室使用它根本不会让你看起来像是时髦的 Borg 大军中的另一个节点)。所有这些都可以不同程度地轻松运行灯堆栈。不过,理想情况下,开发人员应该在非常类似于最终产品系统的环境中运行他们的代码。我研究了一个应用,它使用了虚拟化技术,这样团队成员可以保留他们特有的开发平台,但在类似生产的系统上运行项目代码。
测试和构建都很好,但是您必须安装并运行您的测试,并且为了获得收益而继续这样做。如果您不自动化您的构建和测试,很容易变得自满和放任自流。我看到了一些被归入“持续集成”类别的工具和技术,它们将帮助您做到这一点。
第六版有什么新内容
PHP 是一门活的语言,正因为如此,它处于不断的回顾和发展之中。这个新版本,也已经过审查和彻底更新,以考虑到变化和新的机会。
我将介绍一些新特性,比如属性和对类型声明的许多增强。示例在适当的地方使用了 PHP 8 的特性,所以请注意,您经常需要针对 PHP 8 解释器运行代码——或者准备做一些降级工作。
摘要
这是一本关于面向对象设计和编程的书。它也是关于从协作到部署管理 PHP 代码库的工具。
这两个主题从不同但互补的角度论述了同一个问题。主要目标是构建实现其目标的系统,并很好地进行协作开发。
第二个目标在于软件系统的美观。作为程序员,我们建造有形状和动作的机器。我们在工作日投入了很多时间,在生活中投入了很多时间,写出了这些形状。我们希望我们构建的工具,无论是单独的类和对象,软件组件,还是最终产品,都能形成一个优雅的整体。版本控制、测试、文档和构建的过程不仅仅支持这个目标:它是我们想要实现的形状的一部分。就像我们想要干净和聪明的代码一样,我们想要一个为开发者和用户都设计好的代码库。共享、阅读和部署项目的机制应该和代码本身一样重要。*
二、PHP 和对象
对象并不总是 PHP 项目的关键部分。事实上,它们曾经被 PHP 的设计者描述为事后的想法。
事后想来,这一次已经被证明非常有弹性。在这一章中,我通过总结 PHP 面向对象特性的发展来介绍这本书对对象的覆盖。
我们将了解以下内容:
-
PHP/FI 2.0 : PHP,但不是我们所知道的。
-
PHP 3 :对象第一次出现。
-
PHP 4 :面向对象编程长大了。
-
PHP 5 :语言的核心对象。
-
PHP 7 :缩小差距。
-
PHP 8 :盘整继续。
PHP 对象的意外成功
有了 PHP 广泛的对象支持和如此多的面向对象的 PHP 库和应用,PHP 中对象的兴起似乎是一个自然而不可避免的过程的顶点。事实上,没有什么比这更偏离事实了。
一开始:PHP/FI
正如我们今天所知,PHP 的起源在于拉斯马斯·勒德尔夫使用 Perl 开发的两个工具。PHP 代表个人主页工具。FI 代表形式解释器。它们共同组成了向数据库发送 SQL 语句、处理表单和流程控制的宏。
这些工具用 C 语言重写,并以 PHP/FI 2.0 的名字组合在一起。这一阶段的语言看起来与我们今天认识的语法不同,但并没有那么不同。它支持变量、关联数组和函数。然而,天体甚至不在地平线上。
语法糖:PHP 3
事实上,甚至在 PHP 3 处于计划阶段时,对象就已经不在议程上了。PHP 3 的主要设计师是 Zeev Suraski 和 Andi Gutmans。PHP 3 完全重写了 PHP/FI 2.0,但是对象并没有被认为是新语法的必要部分。
根据 Zeev Suraski 的说法,对类的支持几乎是后来才添加的(准确地说是在 1997 年 8 月 27 日)。类和对象实际上只是定义和访问关联数组的另一种方式。
当然,方法和继承的增加使得类不仅仅是美化了的关联数组,但是对于如何处理类仍然有严格的限制。特别是,您不能访问父类的覆盖方法(如果您还不知道这意味着什么,请不要担心;后面我会解释)。我将在下一节中讨论的另一个缺点是 PHP 脚本中传递对象的方式不够理想。
这些物品在当时是一个边缘问题,这一点由于它们在官方文件中缺乏显著性而更加突出。手册中有一句话和一个代码示例是关于对象的。这个例子没有说明继承或属性。
PHP 4 和安静的革命
如果说 PHP 4 是这种语言的又一个突破性进展,那么大多数核心变化都发生在表面之下。Zend 引擎(它的名字来源于 Ze ev 和一个 nd i)是从零开始编写的,为语言提供动力。Zend 引擎是驱动 PHP 的主要组件之一。您可能想调用的任何 PHP 函数实际上都是高级扩展层的一部分。它们完成了它们被命名为的繁忙工作,比如与数据库 API 对话或为您处理字符串。在此之下,Zend 引擎管理内存,将控制权委托给其他组件,并将您每天使用的熟悉的 PHP 语法翻译成可运行的字节码。我们必须感谢 Zend 引擎提供了像类这样的核心语言特性。
从我们的 object ive 的角度来看,PHP 4 使得覆盖父方法并从子类访问它们成为可能的事实是一个主要的好处。
然而,一个主要的缺点仍然存在。将一个对象赋给一个变量,将它传递给一个函数,或者从一个方法返回它,都会产生一个副本。考虑这样一个任务:
$my_obj = new User('bob');
$other = $my_obj;
这导致存在两个用户对象,而不是对同一个用户对象的两个引用。在大多数面向对象的语言中,你会期望通过引用而不是通过值来赋值。这意味着您将传递并分配指向对象的句柄,而不是复制对象本身。默认的传值行为导致了许多不为人知的错误,因为程序员无意中修改了脚本中某个部分的对象,期望通过其他地方的引用看到这些更改。在本书中,你会看到很多例子,在这些例子中,我维护了对同一个对象的多个引用。
幸运的是,有一种方法可以强制按引用传递,但这意味着要记住使用笨拙的构造。
下面是如何通过引用进行分配:
$other =& $my_obj;
// $other and $my_obj point to same object
这将强制按引用传递:
function setSchool(& $school)
{
// $school is now a reference to not a copy of passed object
}
此处通过引用返回:
function & getSchool()
{
// returning a reference not a copy
return $this->school;
}
尽管这样做很好,但是很容易忘记添加&符号,这意味着 bug 很容易潜入面向对象的代码。这些特别难以追踪,因为它们很少导致任何报告的错误,只是看似合理但不完整的行为。
PHP 手册扩展了一般语法的覆盖范围,特别是对象,面向对象的编码开始成为主流。PHP 中的对象并不是没有争议的(毫无疑问,当时和现在一样),类似“我需要对象吗?”是邮件列表中常见的诱饵。事实上,Zend 网站上有很多鼓励面向对象编程的文章,还有一些警告性的文章。尽管存在传递引用问题和争议,但许多编码人员还是在代码中加入了与号字符。面向对象的 PHP 越来越受欢迎。齐夫·苏拉斯基在一篇为 DevX.com(www.devx.com/webdev/Article/10007/0/page/1
)写的文章中写道:
PHP 历史上最大的转折之一是,尽管功能非常有限,尽管有许多问题和限制,PHP 中的面向对象编程仍然蓬勃发展,并成为越来越多的现成 PHP 应用最流行的范例。这种趋势出乎意料,让 PHP 陷入了一种不太理想的境地。很明显,对象的行为不像其他面向对象语言中的对象,而是像[关联]数组。
正如前一章所提到的,在网站和在线文章中,对面向对象设计的兴趣变得很明显。PHP 的官方软件库 PEAR 本身就采用了面向对象编程。事后看来,很容易认为 PHP 采用面向对象的支持是对不可避免的力量的不情愿的投降。重要的是要记住,虽然面向对象编程从 20 世纪 60 年代就已经存在,但它真正普及是在 90 年代中期。Java,这个伟大的普及程序,直到 1995 年才发布。作为过程语言 C 的超集,C++从 1979 年就出现了。经过长期的发展,它可以说在 20 世纪 90 年代实现了飞跃。Perl 5 于 1994 年发布,这是以前的过程化语言中的又一次革命,它使用户能够用对象来思考(尽管有些人认为 Perl 的面向对象支持也像是一种事后的想法)。对于一种小型的过程语言来说,PHP 开发其对象支持非常快,显示了对用户需求的真正响应。
拥抱变化:PHP 5
PHP 5 代表了对对象和面向对象编程的明确认可。这并不是说对象是使用 PHP 的唯一方式(顺便说一下,这本书也没有这么说)。然而,对象被认为是开发企业系统的强大而重要的手段,PHP 在其核心设计中完全支持它们。
可以说,PHP 5 增强的一个显著效果是更大的互联网公司采用了这种语言。都是雅虎!例如,脸书开始在他们的平台上广泛使用 PHP。在版本 5 中,PHP 成为互联网上开发和企业的标准语言之一。
对象已经从事后思考变成了语言驱动。也许最重要的变化是新的明显的按引用传递行为,它取代了对象复制的弊端。然而,这仅仅是开始。在本书中,尤其是在这一部分,我们将会遇到更多的增强,包括私有和受保护的方法和属性、static 关键字、名称空间、类型提示(现在称为类型声明)和异常。PHP 5 已经存在了很长一段时间(大约 12 年),重要的新特性也在不断发布。
Note
值得注意的是,严格来说 PHP 并没有随着 PHP 5 的引入而转向按引用传递,这一点也没有改变。相反,默认情况下,当一个对象被赋值、传递给一个方法或从一个方法返回时,该对象的标识符被复制。因此,除非您确定问题并使用&字符强制按引用传递,否则您仍然在执行复制操作。然而,实际上,这种复制和通过引用传递之间通常没有什么区别,因为您使用复制的标识符引用了与原始标识符相同的目标对象。
例如,PHP 5.3 引入了名称空间。这些允许您为类和函数创建一个命名的作用域,这样当您包含库和扩展系统时,就不太可能遇到重复的名称。它们还会将您从丑陋但必要的命名惯例中解救出来,例如:
class megaquiz_util_Conf
{
}
诸如此类的类名是防止包之间冲突的一种方法,但是它们会导致代码变得复杂。
我们还看到了对闭包、生成器、特征和后期静态绑定的支持。
PHP 7:缩小差距
程序员要求很高。对于许多设计模式的爱好者来说,PHP 仍然缺少两个关键特性。这些是标量类型声明和强制返回类型。在 PHP 5 中,可以强制传递给函数或方法的参数的类型,只要您只需要一个对象、一个数组或者后来的可调用代码。标量值(如整数、字符串和浮点数)根本无法实施。此外,如果你想声明一个方法或者一个函数的返回类型,你就完全没有运气了。
正如您将看到的,面向对象设计经常使用方法声明作为一种契约。该方法需要特定的输入,反过来,它承诺返回特定类型的数据。在许多情况下,PHP 5 程序员被迫依靠注释、约定和手工类型检查来维护这种契约。开发人员和评论员经常抱怨这一点。这是本书第四版中的一段引文:
仍然没有承诺提供对提示返回类型的支持。这将允许您在方法或函数的声明中声明它返回的对象类型。这将由 PHP 引擎强制执行。暗示的返回类型将进一步改善 PHP 对模式原则的支持(如“代码到接口,而不是实现”)。我希望有一天修订这本书,以涵盖这一特点!
我很高兴地告诉大家,这一天终于到来了!PHP 7 引入了标量类型声明(以前称为类型提示)和返回类型声明。更重要的是,PHP 7.4 通过引入类型化属性将类型安全性推进了一步。当然,所有这些都包含在这个版本中。
PHP 7 还提供了其他一些好处,包括匿名类和一些名称空间增强。
PHP 8:整合仍在继续
PHP 一直是一只伟大的喜鹊,从其他语言中借用闪亮的成熟特性。PHP 8 引入了许多新特性,包括属性,在其他语言中通常被称为注释。这些方便的标签可以用来提供关于系统中的类、方法、属性和常量的附加上下文信息。此外,PHP 8 继续扩展对类型声明的支持。在这方面特别有趣的是联合类型声明。这允许您声明属性或参数的类型应被约束为几种指定类型中的一种。您可以在利用 PHP 的类型灵活性的同时锁定您的类型。拥有你的蛋糕并吃掉它的定义!
倡导和不可知论:对象辩论
对象和面向对象的设计似乎激起了热情分水岭两边的激情。许多优秀的程序员多年来在不使用对象的情况下编写了优秀的代码,PHP 仍然是过程化 web 编程的优秀平台。
这本书自始至终自然地展示了面向对象的偏见,这种偏见反映了我受对象感染的观点。因为这本书是对对象的颂扬,也是对面向对象设计的介绍,所以不可避免地强调面向对象。然而,本书并没有暗示对象是用 PHP 成功编码的唯一途径。
开发人员是否选择使用 PHP 作为面向对象语言曾经是一个偏好问题。在某种程度上,这仍然是正确的,人们可以使用函数和全局代码创建完全可以接受的工作系统。一些伟大的工具(比如 WordPress)在它们的底层架构中仍然是过程化的(尽管现在这些工具可能会大量使用对象)。然而,如果不使用和理解 PHP 对对象的支持,作为一名 PHP 程序员将变得越来越困难,尤其是因为您在项目中可能依赖的第三方库本身也可能是面向对象的。
尽管如此,当您阅读时,还是有必要记住著名的 Perl 格言,“有多种方法可以做到这一点。”对于较小的脚本来说尤其如此,在这种情况下,快速启动并运行一个工作示例比构建一个可以很好地扩展到更大系统的结构更重要(这种临时项目通常被称为“尖峰”)。
代码是一种灵活的媒介。诀窍是知道你的快速概念验证何时成为一个更大的开发的基础,并在你的代码的重量为你做出持久的设计决定之前停止。既然你已经决定在你的成长项目中采用面向设计的方法,我希望这本书能为你开始构建面向对象的架构提供帮助。
摘要
这个简短的章节将对象放在 PHP 语言的上下文中。PHP 的未来与面向对象设计紧密相关。在接下来的几章中,我将简要介绍 PHP 当前对对象特性的支持,并介绍一些设计问题。
三、对象基础
对象和类是本书的核心,自从十多年前引入 PHP 5 以来,它们也是 PHP 的核心。在这一章中,我通过研究 PHP 的核心面向对象特性,为更深入的对象和设计奠定了基础。如果你是面向对象编程的新手,你应该仔细阅读这一章。
本章将涵盖以下主题:
-
类和对象:声明类和实例化对象
-
构造器方法:自动设置你的对象
-
原始类型和类类型:为什么类型很重要
-
继承:为什么我们需要继承以及如何使用它
-
可见性:简化你的对象接口,保护你的方法和属性不受干扰
类和对象
理解面向对象编程的第一个障碍是类和对象之间奇怪而奇妙的关系。对于许多人来说,正是这种关系代表了第一个启示的时刻,第一次面向对象的兴奋的闪光。所以我们不要忽略基本原则。
一等舱
类通常用对象来描述。这很有趣,因为对象通常是用类来描述的。这种循环会使面向对象编程的第一步变得困难。因为是类塑造了对象,所以我们应该从定义一个类开始。
简而言之,类是用于生成一个或多个对象的代码模板。您用关键字class
和一个任意的类名来声明一个类。类名可以是数字和字母的任意组合,但不能以数字开头。它们也可以包含下划线字符。与类关联的代码必须用大括号括起来。在这里,我将这些元素结合起来构建一个类:
// listing 03.01
class ShopProduct
{
// class body
}
示例中的ShopProduct
类已经是一个合法的类,尽管它还不是非常有用。然而,我做了一件非常有意义的事情。我已经定义了一个类型;也就是说,我已经创建了一个可以在脚本中使用的数据类别。当你阅读这一章的时候,这种力量会变得更加清晰。
第一个(或两个)对象
如果类是生成对象的模板,那么对象就是根据类中定义的模板构造的数据。一个对象被称为它的类的一个实例。它是由类定义的类型。
我使用ShopProduct
类作为生成ShopProduct
对象的模型。为此,我需要new
操作符。new
运算符与类名结合使用,如下所示:
// listing 03.02
$product1 = new ShopProduct();
$product2 = new ShopProduct();
用类名作为唯一的操作数调用new
操作符,并返回该类的一个实例;在我们的例子中,它生成一个ShopProduct
对象。
我已经使用了ShopProduct
类作为模板来生成两个ShopProduct
对象。尽管它们在功能上是相同的(即,空的),$product1
和$product2
是从单个类生成的相同类型的不同对象。
如果你仍然困惑,试试这个类比。把一个类想象成制造塑料鸭子的机器中的一个铸件。我们的目标是这台机器生产的鸭子。产生的东西的类型是由压制它的模具决定的。这些鸭子在各方面看起来都一样,但它们是不同的实体。换句话说,它们是同一类型的不同实例。这些鸭子甚至可能有自己的序列号来证明它们的身份。在 PHP 脚本中创建的每个对象都有自己唯一的标识符。(注意,标识符对于对象的生命周期是唯一的;也就是说,PHP 重用标识符,即使是在一个进程中。)我可以通过打印出$product1
和$product2
对象来演示这一点:
// listing 03.03
var_dump($product1);
var_dump($product2);
执行这些函数会产生以下输出:
object(popp\ch03\batch01\ShopProduct)#235 (0) {
}
object(popp\ch03\batch01\ShopProduct)#234 (0) {
}
Note
在 PHP 的旧版本中(直到 5.1 版),可以直接打印一个对象。这将对象转换为包含对象 ID 的字符串。从 PHP 5.2 开始,这种语言不再支持这种魔力,任何将对象视为字符串的尝试都会导致错误,除非在对象的类中定义了名为__toString()
的方法。我将在本章后面介绍方法,我将在第四章中介绍__toString()
。
通过将对象传递给var_dump()
,我提取了有用的信息,包括在散列符号之后的每个对象的内部标识符。
为了让这些对象更有趣,我可以修改ShopProduct
类来支持称为属性的特殊数据字段。
在类中设置属性
类可以定义称为属性的特殊变量。属性也称为成员变量,它保存的数据因对象而异。例如,对于ShopProduct
对象,您可能希望操作标题和价格字段。
类中的属性看起来类似于标准变量,只是在声明属性时,必须在属性变量前面加上 visibility 关键字。这可以是public
、protected
或private
,它决定了代码中可以访问属性的位置。例如,公共属性可以在类外部访问,私有属性只能由类内部的代码访问。
我将在本章的后面回到这些关键词和可见性的问题。现在,我将使用关键字public
声明一些属性:
// listing 03.04
class ShopProduct
{
public $title = "default product";
public $producerMainName = "main name";
public $producerFirstName = "first name";
public $price = 0;
}
如您所见,我设置了四个属性,并为每个属性分配了一个默认值。我从ShopProduct
类实例化的任何对象现在将用默认数据预先填充。每个属性声明中的关键字public
确保我可以从对象上下文外部访问属性。
您可以使用字符'->'
(对象操作符)结合对象变量和属性名,逐个对象地访问属性变量,如下所示:
// listing 03.05
$product1 = new ShopProduct();
print $product1->title;
default product
因为属性被定义为public
,您可以像读取它们一样给它们赋值,替换类中设置的任何默认值:
// listing 03.06
$product1 = new ShopProduct();
$product2 = new ShopProduct();
$product1->title = "My Antonia";
$product2->title = "Catch 22";
通过在ShopProduct
类中声明和设置$title
属性,我确保所有的ShopProduct
对象在第一次创建时都有这个属性。这意味着基于这个假设,使用这个类的代码可以处理ShopProduct
对象。因为我可以重置它,所以$title
的值可能会因对象而异。
Note
使用类、函数或方法的代码通常被描述为类、函数或方法的客户端或客户端代码。在接下来的章节中,你会经常看到这个术语。
事实上,PHP 并没有强迫我们在类中声明所有的属性。您可以向对象动态添加属性,如下所示:
// listing 03.07
$product1->arbitraryAddition = "treehouse";
然而,这种给对象分配属性的方法在面向对象编程中并不被认为是好的实践。
为什么动态设置属性是不好的做法?当你创建一个类时,你定义了一个类型。您告诉世界,您的类(以及从它实例化的任何对象)由一组特定的字段和函数组成。如果你的ShopProduct
类定义了一个$title
属性,那么任何使用ShopProduct
对象的代码都可以在一个$title
属性可用的假设下继续运行。但是,不能保证属性是动态设置的。
我的对象在这个阶段仍然是笨重的。当我需要处理一个对象的属性时,我必须在对象的外部进行。我伸手进去设置并获取属性信息。在多个对象上设置多个属性将很快变成一件苦差事:
// listing 03.08
$product1 = new ShopProduct();
$product1->title = "My Antonia";
$product1->producerMainName = "Cather";
$product1->producerFirstName = "Willa";
$product1->price = 5.99;
我再次使用ShopProduct
类,一个接一个地覆盖所有的默认属性值,直到我设置了所有的产品细节。既然我已经设置了一些数据,我也可以访问它:
// listing 03.09
print "author: {$product1->producerFirstName} "
. "{$product1->producerMainName}\n";
这将输出以下内容:
author: Willa Cather
这种设置属性值的方法存在许多问题。因为 PHP 允许您动态设置属性,所以如果您拼错或忘记了属性名,您不会得到警告。例如,假设我想键入这一行:
// listing 03.10
$product1->producerFirstName = "Shirley";
$product1->producerMainName = "Jackson";
不幸的是,我错误地打成了这样:
// listing 03.11
$product1->producerFirstName = "Shirley";
$product1->producerSecondName = "Jackson";
就 PHP 引擎而言,这段代码完全合法,我不会被警告。但是,当我打印作者的名字时,我会得到意想不到的结果。
另一个问题是我的课堂太放松了。我没有被强迫去设定一个标题,一个价格,或者制片人的名字。客户端代码可以确保这些属性的存在,但是很可能经常会遇到默认值。理想情况下,我鼓励任何实例化ShopProduct
对象的人设置有意义的属性值。
最后,我不得不千方百计去做一些我可能经常想做的事情。正如我们已经看到的,打印完整的作者姓名是一个令人厌倦的过程。
让对象代表我处理这种苦差事会很好。
所有这些问题都可以通过赋予ShopProduct
对象自己的一组函数来解决,这些函数可以用来在对象上下文中操作属性数据。
使用方法
正如属性允许对象存储数据一样,方法允许对象执行任务。方法是在类中声明的特殊函数。正如您所料,方法声明类似于函数声明。function
关键字位于方法名之前,后面是括号中的可选参数变量列表。方法体用大括号括起来:
// listing 03.12
public function myMethod($argument, $another)
{
// ...
}
与函数不同,方法必须在类的主体中声明。它们还可以接受许多限定符,包括一个 visibility 关键字。像属性一样,方法可以被声明为public
、protected
或private
。通过声明一个方法public
,您可以确保它可以从当前对象的外部被调用。如果在方法声明中省略了 visibility 关键字,该方法将被隐式声明为public
。然而,为所有方法显式声明可见性被认为是一种好的做法(我将在本章后面回到方法修饰符)。
Note
在第十五章中,我将介绍代码中的最佳实践规则。编码风格标准 PSR-12 要求为所有方法声明可见性。
// listing 03.13
class ShopProduct
{
public $title = "default product";
public $producerMainName = "main name";
public $producerFirstName = "first name";
public $price = 0;
public function getProducer()
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
在大多数情况下,您将使用对象变量以及对象操作符->
和方法名来调用方法。在方法调用中必须使用括号,就像调用函数一样(即使不向方法传递任何参数):
// listing 03.14
$product1 = new ShopProduct();
$product1->title = "My Antonia";
$product1->producerMainName = "Cather";
$product1->producerFirstName = "Willa";
$product1->price = 5.99;
print "author: {$product1->getProducer()}\n";
这将输出以下内容:
author: Willa Cather
我将getProducer()
方法添加到ShopProduct
类中。注意,我声明getProducer()
是公共的,这意味着它可以从类外部调用。
我在这个方法的主体中引入了一个特性。$this
伪变量是一个类引用一个对象实例的机制。如果你觉得这个概念难以接受,试着用短语“当前实例”代替$this
请考虑以下陈述:
$this->producerFirstName
这转化为以下内容:
当前实例的 $producerFirstName
属性
因此,getProducer()
方法组合并返回$producerFirstName
和$producerMainName
属性,使我在每次需要引用完整的生产者名称时免于执行这项任务。
这让类提高了一点。尽管如此,我仍然受困于大量不必要的灵活性。我依靠客户端编码器来改变一个ShopProduct
对象的默认值。这在两个方面存在问题。首先,正确初始化一个ShopProduct
对象需要 5 行代码,没有一个编码人员会为此感谢你。第二,我无法确保在初始化ShopProduct
对象时设置了任何属性。
我需要的是一个当一个对象从一个类实例化时自动调用的方法。
创建构造函数方法
创建对象时会调用构造函数方法。您可以使用它来进行设置,确保为重要的属性赋值,并完成任何必要的准备工作。
Note
在 PHP 5 之前的版本中,构造函数方法采用了封装它的类名。所以ShopProduct
类将使用一个ShopProduct()
方法作为它的构造函数。这在 PHP 7 中被否决,在 PHP 8 中不再有效。将你的构造方法命名为__construct()
。
注意,方法名以两个下划线字符开头。对于 PHP 类中的许多其他特殊方法,您会看到这种命名约定。在这里,我为ShopProduct
类定义了一个构造函数:
Note
以这种方式开始的内置方法被称为魔法方法,因为它们在特定的环境中会被自动调用。你可以在 www.php.net/manual/en/language.oop5.magic.php
的 PHP 手册中读到更多关于它们的内容。虽然这样做并不违法,因为双下划线有如此特殊的含义,但在您自己的自定义方法中避免使用它们是一个好主意。
// listing 03.15
class ShopProduct
{
public $title;
public $producerMainName;
public $producerFirstName;
public $price = 0;
public function __construct(
$title,
$firstName,
$mainName,
$price
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
}
public function getProducer()
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
我再一次将功能收集到类中,节省了使用它的代码的工作量和重复。使用new
操作符创建对象时,调用__construct()
方法:
// listing 03.16
$product1 = new ShopProduct(
"My Antonia",
"Willa",
"Cather", 5.99
);
print "author: {$product1->getProducer()}\n";
这会产生以下结果:
author: Willa Cather
提供的任何参数都传递给构造函数。所以在我的例子中,我将标题、名字、主要名称和产品价格传递给构造函数。构造函数方法使用伪变量$this
给对象的每个属性赋值。
Note
一个ShopProduct
对象现在更容易实例化,使用起来也更安全。实例化和设置在一条语句中完成。任何使用ShopProduct
对象的代码都可以合理地确定它的所有属性都已初始化。
您可以不初始化属性而不出错。但是任何访问该属性的尝试都会导致致命错误。
构造函数属性提升
虽然我们已经使ShopProduct
类更加安全,并且从客户的角度来看更加方便,但是我们也引入了相当多的样板文件。回顾一下这个类的现状。为了用四个属性实例化一个对象,我们总共需要三组对数据的引用。首先,我们声明属性,然后我们提供构造函数参数来保存数据,然后当我们将方法参数分配给属性时,我们将所有数据放在一起。PHP 8 提供了一个名为构造器 属性提升的特性,它提供了一个受欢迎的快捷方式。通过为您的构造函数参数包含一个 visibility 关键字,您可以将它们与同时分配给它们的属性声明和结合起来。下面是ShopProduct
的新版本:
// listing 03.17
class ShopProduct
{
public function __construct(
public $title,
public $producerFirstName,
public $producerMainName,
public $price
) {
}
public function getProducer()
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
构造函数方法签名中属性的声明和赋值都是隐式处理的。通过减少重复,这也减少了错误潜入代码的机会。通过使类更加紧凑,阅读源代码的人更容易关注逻辑。
Note
PHP 8 中引入了构造函数属性提升。如果您的项目仍然运行 PHP 7,那么您应该避免利用新语法。
可预测性是面向对象编程的一个重要方面。您应该设计您的类,以便对象的用户可以确定它们的特性。使对象安全的一种方法是使其属性中保存的数据类型可预测。例如,可以确保一个$name
属性总是由字符数据组成。但是,如果属性数据是从类外部传入的,如何实现这一点呢?在下一节中,我将研究一种可以用来在方法声明中强制对象类型的机制。
默认参数和命名参数
随着时间的推移,方法参数列表会变得又长又笨拙。这使得处理一个类变得越来越困难,因为很难跟踪它的方法所需要的参数。通过在方法定义中提供默认值,我们可以让客户端编码人员的工作变得更容易。比方说,我们需要一个标题给我们的ShopProduct
对象,但是接受空字符串值作为生产者名称,零值作为价格。对于ShopProduct
,调用代码需要提供所有这些数据:
// listing 03.18
$product1 = new ShopProduct("Shop Catalogue", "", "", 0);
我们可以通过为参数提供默认值来简化这种实例化。在下一个例子中,我就是这样做的:
// listing 03.19
class ShopProduct
{
public function __construct(
public $title,
public $producerFirstName = "",
public $producerMainName = "",
public $price = 0
) {
}
// ...
}
只有当调用代码在其调用中不提供值时,这些赋值才会被激活。现在,对构造函数的调用只需要指定一个值:标题。
// listing 03.20
$product1 = new ShopProduct("Shop Catalogue");
默认的参数值可以使使用方法更加方便,但是,通常情况下,它们也会导致意想不到的复杂化。如果我想提供一个价格,但仍然希望生产者名称回到它们的默认值,那么我的紧凑构造函数调用会发生什么情况呢?在 PHP 8 之前,我会被卡住。为了指定价格,我必须提供空的生产商名称。这让我们兜了一圈。我还需要弄清楚构造函数期望空的生产者名称值是什么类型的值。我应该传递空字符串吗?还是空值?我对缺省值的支持非但没有节省工作,反而很可能造成了混乱。
幸运的是,PHP 8 提供了命名参数。在我的方法调用中,我现在可以在我希望传递的值前面指定每个参数名。然后,PHP 将该值与方法签名中的正确参数相关联,而不考虑调用代码中的顺序。
// listing 03.21
$product1 = new ShopProduct(
price: 0.7,
title: "Shop Catalogue"
);
注意这里的语法:我告诉 PHP 我想通过首先指定参数名price
,然后是冒号,然后是我想提供的值,将$price
参数设置为0.7
。因为我已经使用了命名参数,它们在调用中的顺序不再相关,我不再需要提供空的生产者名称值。
参数和类型
类型决定了在脚本中管理数据的方式。例如,您可以使用string
类型来显示字符数据,并使用字符串函数来操作这些数据。数学表达式中使用整数,测试表达式中使用布尔,等等。这些类别被称为基本类型。然而,在更高的层次上,类定义了类型。因此,ShopProduct
对象属于原始类型object
,但它也属于ShopProduct
类类型。在这一节中,我将研究这两种类型与类方法的关系。
方法和函数定义不一定要求参数应该是特定的类型。这既是祸也是福。参数可以是任何类型,这一事实为您提供了灵活性。您可以构建智能地响应不同数据类型的方法,根据不断变化的环境定制功能。当一个方法体期望一个参数保存一种类型,但却得到另一种类型时,这种灵活性也会导致代码中出现歧义。
原始类型
PHP 是一种松散类型的语言。这意味着没有必要声明变量来保存特定的数据类型。变量$number
可以在相同的范围内保存值2
和字符串"two"
。在强类型语言中,如 C 或 Java,在给变量赋值之前,必须声明变量的类型,当然,该值必须是指定的类型。
这并不意味着 PHP 没有类型的概念。每个可以赋给变量的值都有一个类型。您可以使用 PHP 的一个类型检查函数来确定变量值的类型。表 3-1 列出了 PHP 中识别的原语类型及其对应的测试函数。每个函数接受一个变量或值,如果这个参数是相关类型的,则返回true
。
表 3-1
PHP 中的基本类型和检查函数
|类型检查功能
|
类型
|
描述
|
| --- | --- | --- |
| is_bool()
| 布尔代数学体系的 | 两个特殊值之一 true 或 false |
| is_integer()
| 整数 | 一个整数。is_int()
和is_long()
的别名 |
| is_float()
| 浮动 | 浮点数(带小数点的数字)。is_double()
的别名 |
| is_string()
| 线 | 字符数据 |
| is_object()
| 目标 | 一个物体 |
| is_resource()
| 资源 | 用于识别和使用外部资源(如数据库或文件)的句柄 |
| is_array()
| 排列 | 阵列 |
| is_null()
| 空 | 未分配的价值 |
当您使用方法和函数参数时,检查变量的类型可能特别重要。
基本类型:一个示例
您需要密切关注代码中的类型。这是你可能遇到的许多类型相关问题中的一个例子。
假设您正在从 XML 文件中提取配置设置。XML 元素告诉您的应用是否应该尝试将 IP 地址解析为域名,这是一个有用但相对昂贵的过程。
以下是一些示例 XML:
// listing 03.22
<settings>
<resolvedomains>false</resolvedomains>
</settings>
字符串"false"
由您的应用提取,并作为一个标志传递给一个名为outputAddresses()
的方法,该方法显示 IP 地址数据。这里是outputAddresses()
:
// listing 03.23
class AddressManager
{
private $addresses = ["209.131.36.159", "216.58.213.174"];
public function outputAddresses($resolve)
{
foreach ($this->addresses as $address) {
print $address;
if ($resolve) {
print " (" . gethostbyaddr($address) . ")";
}
print "\n";
}
}
}
当然,AddressManager
类可以做一些改进。例如,将 IP 地址硬编码到一个类中不是很有用。然而,outputAddresses()
方法循环遍历$addresses
数组属性,打印每个元素。如果$resolve
参数变量本身解析为true
,该方法将输出域名和 IP 地址。
这里有一种结合使用settings
XML 配置元素和AddressManager
类的方法。看看你是否能发现它有什么缺陷:
// listing 03.24
$settings = simplexml_load_file(__DIR__ . "/resolve.xml");
$manager = new AddressManager();
$manager->outputAddresses((string)$settings->resolvedomains);
代码片段使用SimpleXML
API 来获取resolvedomains
元素的值。在这个例子中,我知道这个值是文本元素"false"
,我按照SimpleXML
文档的建议将它转换成一个字符串。
这段代码不会像您预期的那样运行。在将字符串"false"
传递给outputAddresses()
方法时,我误解了该方法对参数的隐含假设。该方法需要一个布尔值(即true
或false
)。事实上,字符串"false"
将在测试中解析为true
。这是因为 PHP 将在测试上下文中帮助您将一个非空字符串值转换为布尔值true
。考虑以下代码:
if ("false") {
// ...
}
它实际上相当于这样:
if (true) {
// ...
}
有许多方法可以解决这个问题。
您可以使outputAddresses()
方法更加宽容,这样它可以识别一个字符串,并应用一些基本规则将它转换成布尔等价形式:
// listing 03.25
public function outputAddresses($resolve)
{
if (is_string($resolve)) {
$resolve = (preg_match("/^(false|no|off)$/i", $resolve)) ? false : true;
}
// ...
}
然而,避免这种方法有很好的设计理由。一般来说,为一个方法或函数提供一个清晰严格的接口比提供一个模糊宽容的接口要好。模糊和宽容的函数和方法会导致混乱,从而滋生错误。
您可以采用另一种方法:让outputAddresses()
方法保持原样,并包含一个注释,该注释包含明确的指令,即$resolve
参数应该包含一个布尔值。这种方法本质上是告诉编码者阅读小字,否则后果自负:
// listing 03.26
/**
* Outputs the list of addresses.
* If $resolve is true then each address will be resolved
* @param $resolve boolean Resolve the address?
*/
public function outputAddresses($resolve)
{
// ...
}
这是一种合理的方法,假设您的客户端编码人员是文档的勤奋读者(或者使用能够识别这种注释的聪明编辑器)。
最后,您可以让outputAddresses()
严格控制它准备在$resolve
参数中找到的数据类型。对于像 Boolean 这样的基本类型,在 PHP 7 发布之前只有一种方法可以做到这一点。您必须编写代码来检查传入的数据,并在数据与所需类型不匹配时采取某种措施:
// listing 03.27
public function outputAddresses($resolve)
{
if (! is_bool($resolve)) {
// do something drastic
}
}
这种方法可以用来强制客户端代码在$resolve
参数中提供正确的数据类型,或者发出警告。
Note
在下一节“类型声明:对象类型”中,我将描述一种更好的方法来约束传递给方法和函数的参数类型。
代表客户端转换字符串参数是友好的,但可能会带来其他问题。在提供转换机制时,您要猜测客户端的上下文和意图。另一方面,通过强制布尔数据类型,您让客户端决定是否将字符串映射到布尔值,并决定哪个单词应该映射到true
或false
。与此同时,outputAddresses()
方法专注于它被设计来执行的任务。这种强调在故意忽略更广泛的上下文的情况下执行特定任务的做法是面向对象编程中的一个重要原则,我将在整本书中经常提到它。
事实上,您处理参数类型的策略一方面取决于任何潜在错误的严重性,另一方面取决于灵活性的好处。PHP 根据上下文为您转换大多数原始值。例如,当在数学表达式中使用时,字符串中的数字被转换为整数或浮点等效值。因此您的代码可能会自然地原谅类型错误。
然而,总的来说,当涉及到对象和基本类型时,最好是在严格性方面出错。幸运的是,PHP 8 提供了比以前更多的工具来加强类型安全。
一些其他类型检查函数
我们已经看到了检查原始类型的变量处理函数。当我们检查变量的内容时,值得一提的是几个函数,它们超出了检查基本类型的范围,提供了关于变量中保存的数据的使用方式的更一般的信息。我在表 3-2 中列出了这些。
表 3-2
伪类型检查函数
|功能
|
描述
|
| --- | --- |
| is_countable()
| 可以传递给count()
函数的数组或对象 |
| is_iterable()
| 可遍历的数据结构——也就是说,可以使用foreach
进行循环 |
| is_callable()
| 可以调用的代码—通常是匿名函数或函数名 |
| is_numeric()
| int、long 或可以解析为数字的字符串 |
表 3-2 中描述的函数并不检查具体的类型,而是检查处理测试值的方式。例如,如果is_callable()
为一个变量返回true
,您知道您可以像对待一个函数或方法一样对待它并调用它。类似地,您可以循环通过一个通过了is_iterable()
测试的值——即使它可能是一种特殊的对象而不是数组。
类型声明:对象类型
正如参数变量可以包含任何基本类型一样,默认情况下,它可以包含任何类型的对象。这种灵活性有其用途,但在方法定义的上下文中可能会出现问题。
想象一个设计用来处理ShopProduct
对象的方法:
// listing 03.28
class ShopProductWriter
{
public function write($shopProduct)
{
$str = $shopProduct->title . ": "
. $shopProduct->getProducer()
. " (" . $shopProduct->price . ")\n";
print $str;
}
}
您可以像这样测试这个类:
// listing 03.29
$product1 = new ShopProduct("My Antonia", "Willa", "Cather", 5.99);
$writer = new ShopProductWriter();
$writer->write($product1);
这将输出以下内容:
My Antonia: Willa Cather (5.99)
ShopProductWriter
类包含一个方法write()
。write()
方法接受一个ShopProduct
对象,并使用它的属性和方法来构造和打印一个摘要字符串。我使用了参数变量的名字$shopProduct
,作为该方法需要一个ShopProduct
对象的信号,但是我没有强制这样做。这意味着我可能会被传递一个意外的对象或原始类型,直到我开始尝试使用$shopProduct
参数时才知道。到那时,我的代码可能已经假定它已经被传递了一个真正的ShopProduct
对象。
Note
你可能想知道为什么我没有直接把write()
方法添加到ShopProduct
中。原因在于责任范围。ShopProduct
类负责管理产品数据;ShopProductWriter
负责写它。当你阅读本章时,你会开始明白为什么这种分工会有用。
为了解决这个问题,PHP 5 引入了类类型声明(当时称为类型提示)。要将类类型声明添加到方法参数中,只需在需要约束的方法参数前放置一个类名。所以我可以这样修改write()
方法:
// listing 03.30
public function write(ShopProduct $shopProduct)
{
// ...
}
现在,write()
方法将只接受包含类型为ShopProduct
的对象的$shopProduct
参数。
下面是一个基础类:
// listing 03.31
class Wrong
{
}
下面是一个试图用一个Wrong
对象调用write()
的片段:
// listing 03.32
$writer = new ShopProductWriter();
$writer->write(new Wrong());
因为write()
方法包含一个类类型声明,传递给它一个Wrong
对象会导致致命错误。
TypeError: popp\ch03\batch08\ShopProductWriter::write(): Argument #1 ($shopProduct) must be of type
popp\ch03\batch04\ShopProduct, popp\ch03\batch08\Wrong given, called in /var/popp/src/ch03/batch08/Runner.php on ...
Note
在 TypeError 示例输出中,您可能已经注意到引用的类包含了许多附加信息。例如,Wrong
类被引用为popp\ch03\batch08\ Wrong
。这些是名称空间的例子,你会在第四章中遇到它们的细节。
这使我不必在处理之前测试参数的类型。这也使得方法签名对于客户端编码者来说更加清晰。她一眼就能看出write()
方法的要求。她不必担心由类型错误引起的一些模糊错误,因为声明是严格执行的。
尽管这种自动类型检查是防止错误的好方法,但是理解类型声明是在运行时检查的还是很重要的。这意味着类声明只会在不需要的对象被传递给方法时报告错误。如果对write()
的调用隐藏在一个只在圣诞节早上运行的条件子句中,如果你没有仔细检查你的代码,你可能会发现自己在这个假期工作。
类型声明:基本类型
在 PHP 7 发布之前,只能约束对象和一些其他类型(callable 和 array)。PHP 7 最终引入了标量类型声明。这允许您在参数列表中强制使用布尔、字符串、整数和浮点类型。
有了标量类型声明,我可以向ShopProduct
类添加一些约束:
// listing 03.33
class ShopProduct
{
public $title;
public $producerMainName;
public $producerFirstName;
public $price = 0;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
}
// ...
}
有了这样的构造函数方法,我就可以确定$title
、$firstName
和$mainName
参数将总是包含字符串数据,而$price
将包含一个浮点数。我可以通过用错误的信息实例化ShopProduct
来证明这一点:
// listing 03.34
// will fail
$product = new ShopProduct("title", "first", "main", []);
我试图实例化一个ShopProduct
对象。我向构造函数传递了三个字符串,但是我在最后一关失败了,因为我传入了一个空数组而不是所需的 float。多亏了类型声明,PHP 不会让我得逞:
TypeError: popp\ch03\batch09\ShopProduct:: construct(): Argument #4 ($price) must be of type float, array given, called in...
默认情况下,在可能的情况下,PHP 会隐式地将参数转换为所需的类型。这是我们之前遇到的安全性和灵活性之间紧张关系的一个例子。例如,ShopProduct
类的新实现将悄悄地为我们把一个字符串转换成一个浮点数。因此,该实例化不会失败:
// listing 03.35
$product = new ShopProduct("title", "first", "main", "4.22");
在幕后,字符串"4.22"
变成了浮点4.22
。到目前为止,很有用。但是回想一下我们在使用AddressManager
类时遇到的问题。字符串"false"
被悄悄地解析成布尔型true
。默认情况下,如果我像这样在AddressManager::outputAddresses()
方法中使用bool
类型声明,这种情况仍然会发生:
// listing 03.36
public function outputAddresses(bool $resolve)
{
// ...
}
现在考虑这样一个调用,它传递一个字符串:
// listing 03.37
$manager->outputAddresses("false");
由于隐式转换,它在功能上等同于传递布尔值true
的函数。
您可以使标量类型声明变得严格,尽管只能在逐个文件的基础上进行。在这里,我打开严格类型声明并再次用字符串调用outputAddresses()
:
// listing 03.38
declare(strict_types=1);
$manager->outputAddresses("false");
因为我声明了严格类型,所以这个调用导致抛出一个TypeError
:
TypeError: popp\ch03\batch09\AddressManager::outputAddresses(): Argument #1 ($resolve) must be of type bool, string given, called in...
Note
strict_types
声明适用于发出调用的文件,而不适用于实现函数或方法的文件。所以由客户机代码来执行严格性。
您可能需要将参数设置为可选的,但是如果提供了参数的话,还是要对其类型进行约束。您可以通过提供默认值来做到这一点:
// listing 03.39
class ConfReader
{
public function getValues(array $default = [])
{
$values = [];
// do something to get values
// merge the provided defaults (it will always be an array)
$values = array_merge($default, $values);
return $values;
}
}
mixed
类型
PHP 8.0 中引入的mixed
类型声明可能被视为一个语法糖的例子——也就是说,它本身并没有做太多事情。这之间没有功能的区别:
// listing 03.40
class Storage
{
public function add(string $key, $value)
{
// do something with $key and $value
}
}
还有这个:
// listing 03.41
class Storage
{
public function add(string $key, mixed $value)
{
// do something with $key and $value
}
}
在第二个版本中,我声明add()
的$value
参数将接受mixed
——换句话说,来自array
、bool
、callable
、int
、float
、null
、object
、resource
或string
的任何类型。所以声明一个mixed $value
等同于让$value
在参数列表中没有类型声明。那么,为什么要为mixed
声明费心呢?本质上,您是在声明参数有意地接受任何值。一个空的参数可能会接受任何值,或者因为代码作者懒惰而没有类型声明。消除怀疑和不确定性,因此它是有用的。
综上所述,在表 3-3 中,我列出了 PHP 支持的类型声明。
表 3-3
类型声明
|类型声明
|
因为
|
描述
|
| --- | --- | --- |
| array
| Five point one | 一个数组。可以默认为null
或一个数组 |
| int
| Seven | 整数。可以默认为null
或整数 |
| float
| Seven | 浮点数(带小数点的数字)。即使启用了严格模式,也将接受整数。可以默认为null
,一个浮点数,或者一个整数 |
| callable
| Five point four | 可调用代码(如匿名函数)。可以默认为null
|
| bool
| Seven | 一个布尔值。可以默认为null
或布尔值 |
| string
| Five | 字符数据。可以默认为null
或一个字符串 |
| self
| Five | 对包含类的引用 |
| [a class type]
| Five | 类或接口的类型。可以默认为null
|
| iterable
| Seven point one | 可以用foreach
遍历(不一定是数组,可以实现Traversable
) |
| 目标 | Seven point two | 一个物体 |
| 混合的 | Eight | 值可以是任何类型的显式通知 |
联合类型
在包罗万象的mixed
声明和相对严格的类型声明之间有一条鸿沟。如果需要将一个参数约束到两个、三个或更多的命名类型,该怎么办?在 PHP 8 之前,实现这一点的唯一方法是在方法体中测试类型。让我们带着新的需求回到Storage
类。add()
应该只接受一个字符串或一个布尔值作为它的$value
方法。下面是一个在方法体中检查类型的实现:
// listing 03.42
class Storage
{
public function add(string $key, $value)
{
if (! is_bool($value) && ! is_string($value)) {
error_log("value must be string or Boolean - given: " . gettype($value));
return false;
}
// do something with $key and $value
}
}
Note
事实上,我们可能会抛出一个异常,而不是返回false
。你可以在第四章中读到更多关于异常的内容。
虽然这种手工检查完成了工作,但它很难操作,也很难阅读。幸运的是,PHP 8 引入了一个新特性:联合类型,它允许您组合两个或多个由管道符号分隔的类型,以进行复合类型声明。
下面是我对Storage
的重新实现:
// listing 03.43
class Storage
{
public function add(string $key, string|bool $value)
{
// do something with $key and $value
}
}
如果我现在试图将$value
设置为除了 float 或 Boolean 之外的任何值,我将触发一个现在已经很熟悉的TypeError
。
如果我想让add()
更宽容一点,我也可以使用联合类型来允许一个null
值。
// listing 03.44
class Storage
{
public function add(string $key, string|bool|null $value)
{
// do something with $key and $value
}
}
联合类型声明与对象类型声明一样有效。此示例将接受类型为ShopProduct
的对象或空值:
// listing 03.45
public function setShopProduct(ShopProduct|null $product)
{
// do something with $product
}
因为许多方法接受或返回false
作为可选值,PHP 8 在联合的上下文中支持false
伪类型。因此,在这个例子中,我将接受一个ShopProduct
对象或false
:
// listing 03.46
public function setShopProduct2(ShopProduct|false $product)
{
// do something with $product
}
}
这比 union ShopProduct|bool
更有用,因为在任何情况下我都不想接受true
。
Note
PHP 8 中增加了联合类型。
可空类型
当联合类型接受null
作为两个选项之一时,您可以使用一个等价的参数。可空类型由前面带问号的类型声明组成。所以这个版本的Storage
要么接受一个字符串,要么接受null
:
// listing 03.47
class Storage
{
public function add(string $key, ?string $value)
{
// do something with $key and $value
}
}
当我描述类类型声明时,我暗示类型和类是同义的。然而,这两者之间有一个关键的区别。当你定义一个类的时候,你也定义了一个类型,但是一个类型可以描述整个类家族。将不同的类组合在一个类型下的机制称为继承。我将在下一节讨论继承。
返回类型声明
正如我们可以声明参数的类型一样,我们也可以使用返回类型声明来约束方法返回的类型。返回类型声明直接放在方法或函数的右括号之后,采用冒号后跟类型的形式。声明返回类型和声明参数类型时支持相同的类型集。所以这里我约束了getPlayLength()
的返回类型:
// listing 03.48
public function getPlayLength(): int
{
return $this->playLength;
}
如果调用此方法时未能返回整数值,PHP 将生成一个错误:
TypeError: popp\ch03\batch15\CdProduct::getPlayLength(): Return value must be of type int, none returned
因为返回值是以这种方式强制的,所以任何调用此方法的代码都可以放心地将其返回值视为整数。
返回类型声明支持可空类型和联合类型。让我们强制一个联合类型:
// listing 03.49
public function getPrice(): int|float
{
return ($this->price - $this->discount);
}
从 PHP 8 开始,有一种类型是由返回类型声明而不是参数类型声明支持的。您可以声明一个方法永远不会返回一个带有void
伪类型的值。因此,例如,因为setDiscount()
方法被设计为设置而不是提供一个值,所以我在这里使用了一个void
返回类型声明:
// listing 03.50
public function setDiscount(int|float $num): void
{
$this->discount = $num;
}
继承
继承是从基类派生一个或多个类的方法。
从另一个类继承而来的类称为它的子类。这种关系经常用父母和孩子来描述。子类从父类派生并继承父类的特征。这些特征包括属性和方法。子类通常会向其父类(也称为超类)提供的功能添加新功能;由于这个原因,子类扩展了它的父类。
在深入研究继承的语法之前,我将检查它可以帮助您解决的问题。
继承问题
再看看ShopProduct
类。目前,它非常通用。它可以处理各种产品:
// listing 03.51
$product1 = new ShopProduct("My Antonia", "Willa", "Cather", 5.99);
$product2 = new ShopProduct(
"Exile on Coldharbour Lane", "The",
"Alabama 3",
10.99
);
print "author: " . $product1->getProducer() . "\n";
print "artist: " . $product2->getProducer() . "\n";
以下是输出结果:
author: Willa Cather
artist: The Alabama 3
将制作者的名字分成两部分对书籍和 CD 都很有效。我希望能够对“阿拉巴马 3”和“凯瑟”进行排序,而不是对“The”和“Willa”进行排序。懒惰是一种优秀的设计策略,所以现阶段不需要担心对不止一种产品使用ShopProduct
。
然而,如果我在我的例子中添加一些新的需求,事情会迅速变得更加复杂。例如,假设您需要表示特定于书籍和 CD 的数据。对于 CD,必须存储总播放时间;对于书籍,总页数。可能有许多其他的差异,但这将有助于说明这个问题。
我如何扩展我的例子来适应这些变化?两种选择立即呈现在眼前。首先,我可以将所有的数据放入ShopProduct
类。其次,我可以将ShopProduct
分成两个独立的类。
让我们检查第一种方法。这里,我将 CD 和书籍相关的数据合并到一个类中:
// listing 03.52
class ShopProduct
{
public $numPages;
public $playLength;
public $title;
public $producerMainName;
public $producerFirstName;
public $price;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $numPages = 0,
int $playLength = 0
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
$this->numPages = $numPages;
$this->playLength = $playLength;
}
public function getNumberOfPages(): int
{
return $this->numPages;
}
public function getPlayLength(): int
{
return $this->playLength;
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
我已经提供了对$numPages
和$playLength
属性的方法访问,以说明在这里起作用的不同力量。从此类实例化的对象将包含一个冗余方法,对于 CD,必须使用一个不必要的构造函数参数进行实例化:CD 将存储与书籍页面相关的信息和功能,而书籍将支持播放长度数据。这可能是你现在可以忍受的。但是,如果我添加更多的产品类型,每个类型都有自己的方法,然后为每个类型添加更多的方法,会发生什么呢?我们的类会变得越来越复杂,越来越难管理。
因此,将不属于同一个类的字段强制放入一个类会导致对象臃肿,具有冗余的属性和方法。
问题也不仅仅止于数据。我在功能性方面也遇到了困难。考虑一种总结产品的方法。销售部门要求在发票中使用清晰的汇总行。他们希望我包括 CD 的播放时间和书籍的页数,所以我将被迫为每种类型提供不同的实现。我可以尝试使用一个标志来跟踪对象的格式。
这里有一个例子:
// listing 03.53
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
if ($this->type == 'book') {
$base .= ": page count - {$this->numPages}";
} elseif ($this->type == 'cd') {
$base .= ": playing time - {$this->playLength}";
}
return $base;
}
为了设置$type
属性,我可以测试构造函数的$numPages
参数。然而,ShopProduct
类又一次变得比必要的更加复杂。随着我在我的格式中添加更多的差异,或者添加新的格式,这些功能上的差异将变得更加难以管理。也许我应该尝试用另一种方法来解决这个问题。
由于开始感觉像是两个类合二为一,我可以接受这一点,创建两种类型而不是一种。我可能会这样做:
// listing 03.54
class CdProduct
{
public $playLength;
public $title;
public $producerMainName;
public $producerFirstName;
public $price;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $playLength
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
$this->playLength = $playLength;
}
public function getPlayLength(): int
{
return $this->playLength;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": playing time - {$this->playLength}";
return $base;
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
// listing 03.55
class BookProduct
{
public $numPages;
public $title;
public $producerMainName;
public $producerFirstName;
public $price;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $numPages
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
$this->numPages = $numPages;
}
public function getNumberOfPages(): int
{
return $this->numPages;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": page count - {$this->numPages}";
return $base;
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
}
我已经解决了复杂性问题,但这是有代价的。我现在可以为每种格式创建一个getSummaryLine()
方法,而不必测试标志。这两个类都不维护与其无关的字段或方法。
代价在于重复。每个类中的getProducerName()
方法完全相同。每个构造函数都以相同的方式设置许多相同的属性。这是另一种你应该训练自己嗅出的难闻气味。
如果我需要getProducer()
方法对每个类都有相同的行为,那么我对一个实现所做的任何更改都需要对另一个实现进行。如果不小心,这些类很快就会失去同步。
即使我有信心我可以保持复制,我的担忧并没有结束。我现在有两种类型而不是一种。
还记得ShopProductWriter
课吗?它的write()
方法被设计为使用单一类型:ShopProduct
。我该如何修改它才能像以前一样工作?我可以从方法签名中删除类类型声明,但是我必须相信write()
被传递了一个正确类型的对象。我可以在方法体中添加我自己的类型检查代码:
// listing 03.56
class ShopProductWriter
{
public function write($shopProduct): void
{
if (
! ($shopProduct instanceof CdProduct) &&
! ($shopProduct instanceof BookProduct)
) {
die("wrong type supplied");
}
$str = "{$shopProduct->title}: "
. $shopProduct->getProducer()
. " ({$shopProduct->price})\n";
print $str;
}
}
注意例子中的instanceof
操作符;如果左边操作数中的对象属于右边操作数所代表的类型,则instanceof
解析为true
。
我又一次被迫增加了一层新的复杂性。我不仅要针对write()
方法中的两种类型测试$shopProduct
参数,还要相信每种类型都将继续支持与另一种类型相同的字段和方法。当我简单地要求单一类型时,一切都变得更加简洁,因为我可以使用类类型声明,因为我可以确信ShopProduct
类支持特定的接口。
CD 和书籍这两个方面似乎不能很好地结合在一起,但又不能分开。我想把书籍和 CD 作为一个单一的类型,同时为每种格式提供一个单独的实现。我想在一个地方提供通用的功能以避免重复,但允许每种格式以不同的方式处理一些方法调用。我需要使用继承。
使用继承
构建继承树的第一步是找到基类中不匹配或者需要不同处理的元素。
我知道getPlayLength()
和getNumberOfPages()
方法不属于一起。我也知道我需要为getSummaryLine()
方法创建不同的实现。
让我们将这些差异作为两个派生类的基础:
// listing 03.57
class ShopProduct
{
public $numPages;
public $playLength;
public $title;
public $producerMainName;
public $producerFirstName;
public $price;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $numPages = 0,
int $playLength = 0
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
$this->numPages = $numPages;
$this->playLength = $playLength;
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
return $base;
}
}
// listing 03.58
class CdProduct extends ShopProduct
{
public function getPlayLength(): int
{
return $this->playLength;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": playing time - {$this->playLength}";
return $base;
}
}
// listing 03.59
class BookProduct extends ShopProduct
{
public function getNumberOfPages(): int
{
return $this->numPages;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": page count - {$this->numPages}"; return $base;
}
}
要创建子类,必须在类声明中使用extends
关键字。在这个例子中,我创建了两个新的类,BookProduct
和CdProduct
。两者都扩展了ShopProduct
类。
因为派生类不定义构造函数,所以当它们被实例化时,父类的构造函数被自动调用。子类继承对所有父类的公共和受保护方法的访问(尽管不是私有方法或属性)。这意味着您可以在从CdProduct
类实例化的对象上调用getProducer()
方法,即使getProducer()
是在ShopProduct
类中定义的:
// listing 03.60
$product2 = new CdProduct(
"Exile on Coldharbour Lane",
"The",
"Alabama 3",
10.99,
0,
60.33
);
print "artist: {$product2->getProducer()}\n";
所以两个子类都继承了公共父类的行为。您可以将BookProduct
对象视为ShopProduct
对象。您可以将一个BookProduct
或CdProduct
对象传递给ShopProductWriter
类的write()
方法,所有这些都将按预期工作。
注意,CdProduct
和BookProduct
类都覆盖了getSummaryLine()
方法,提供了它们自己的实现。派生类可以扩展但也可以改变其父类的功能。
这个方法的超类实现可能看起来是多余的,因为它被它的两个子类覆盖了。然而,它提供了新子类可能使用的基本功能。该方法的存在也为客户端代码提供了保证,即所有的ShopProduct
对象都将提供一个getSummaryLine()
方法。稍后,您将看到如何在根本不提供任何实现的情况下,在一个基类中做出这个承诺。每个子ShopProduct
类继承其父类的属性。BookProduct
和CdProduct
都在各自版本的getSummaryLine()
中访问$title
属性。
起初,继承可能是一个很难理解的概念。通过定义一个扩展另一个类的类,可以确保从该类实例化的对象首先由子类的特征定义,然后由父类的特征定义。另一种思考方式是从搜索的角度。当我调用$product2->getProducer()
时,在CdProduct
类中找不到这样的方法,并且调用落入了ShopProduct
中的默认实现。另一方面,当我调用$product2->getSummaryLine()
时,在CdProduct
中找到并调用getSummaryLine()
方法。
属性访问也是如此。当我在BookProduct
类的getSummaryLine()
方法中访问$title
时,在BookProduct
类中找不到该属性。它是从父类ShopProduct
中获得的。属性同样适用于两个子类,因此它属于超类。
然而,快速浏览一下ShopProduct
构造函数,可以发现我仍然在管理基类中应该由其子类处理的数据。BookProduct
类应该处理$numPages
参数和属性,CdProduct
类应该处理$playLength
参数和属性。为了做到这一点,我将在每个子类中定义构造函数方法。
构造函数和继承
当你在子类中定义一个构造函数时,你就有责任将所有的参数传递给父类。如果您做不到这一点,您可能会得到一个部分构造的对象。
要调用父类中的方法,必须首先找到引用类本身的方法:句柄。PHP 为此提供了关键字parent
。
要在类而不是对象的上下文中引用一个方法,可以使用::
而不是->
:
parent::__construct()
Note
我将在第四章中更详细地介绍范围解析操作符(::
)。
前面的代码片段意味着“调用父类的__construct()
方法。”这里,我修改了我的例子,使每个类只处理适合它的数据:
// listing 03.61
class ShopProduct
{
public $title;
public $producerMainName;
public $producerFirstName;
public $price;
public function __construct(
$title,
$firstName,
$mainName,
$price
) {
$this->title = $title;
$this->producerFirstName = $firstName;
$this->producerMainName = $mainName;
$this->price = $price;
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )"; return $base;
}
}
// listing 03.62
class BookProduct extends ShopProduct
{
public $numPages;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $numPages
) {
parent:: __construct(
$title,
$firstName,
$mainName,
$price
);
$this->numPages = $numPages;
}
public function getNumberOfPages(): int
{
return $this->numPages;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( $this->producerMainName, ";
$base .= "$this->producerFirstName )";
$base .= ": page count - {$this->numPages}";
return $base;
}
}
// listing 03.63
class CdProduct extends ShopProduct
{
public $playLength;
public function __construct(
string $title,
string $firstName,
string $mainName,
float $price,
int $playLength
) {
parent:: __construct(
$title,
$firstName,
$mainName,
$price
);
$this->playLength = $playLength;
}
public function getPlayLength(): int
{
return $this->playLength;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": playing time - {$this->playLength}";
return $base;
}
}
每个子类在设置自己的属性之前都会调用其父类的构造函数。基类现在只知道自己的数据。子类通常是其父类的专门化。作为一个经验法则,你应该避免给家长类任何关于他们孩子的特殊知识。
Note
在 PHP 5 之前,构造函数采用封闭类的名字。新的统一构造函数使用名称__construct()
。使用旧的语法,调用父构造函数会将您绑定到那个特定的类:parent::ShopProduct();
。旧的构造函数语法在 PHP 7.0 中被弃用,在 PHP 8 中被完全删除。
调用被覆盖的方法
关键字parent
可以用于任何覆盖父类中对应方法的方法。当您重写一个方法时,您可能不希望删除父方法的功能,而是希望扩展它。您可以通过在当前对象的上下文中调用父类的方法来实现这一点。如果您再次查看getSummaryLine()
方法的实现,您会发现它们复制了大量代码。最好使用而不是复制已经在ShopProduct
类中开发的功能:
// listing 03.64
// ShopProduct
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
return $base;
}
// listing 03.65
// BookProduct
public function getSummaryLine(): string
{
$base = parent::getSummaryLine();
$base .= ": page count - $this->numPages";
return $base;
}
我在ShopProduct
基类中为getSummaryLine()
方法设置了核心功能。
我没有在CdProduct
和BookProduct
子类中重现这一点,而是在继续向摘要字符串添加更多数据之前简单地调用父方法。
既然您已经看到了继承的基础,我将根据完整的图片重新检查属性和方法的可见性。
公共、私有和受保护:管理对类的访问
至此,我已经声明了所有的属性public
。如果在属性声明中使用旧的var
关键字,那么公共访问是方法和属性的默认设置。
Note
var
在 PHP 5 中被弃用,将来可能会从语言中完全删除。
正如我们所见,类中的元素可以声明为public
、private
或protected
:
-
公共属性和方法可以从任何上下文中访问。
-
私有方法或属性只能从封闭类内部访问。甚至子类都没有权限。
-
受保护的方法或属性只能从封闭类或子类中访问。没有外部代码被授予访问权限。
那么这对我们有什么用呢?可见性关键字允许您仅公开客户端所需的类的那些方面。这为你的对象设置了一个清晰的界面。
通过防止客户端访问某些属性,访问控制还有助于防止代码中出现错误。例如,假设您希望允许ShopProduct
对象支持折扣。您可以添加一个$discount
属性和一个setDiscount()
方法:
// listing 03.66
// ShopProduct class
public $discount = 0;
//...
public function setDiscount(int $num): void
{
$this->discount = $num;
}
有了设置折扣的机制,您可以创建一个考虑已经应用的折扣的getPrice()
方法:
// listing 03.67
public function getPrice(): int|float
{
return ($this->price - $this->discount);
}
在这一点上,你有一个问题。您只想公开调整后的价格,但是客户端可以轻松地绕过getPrice()
方法并访问$price
属性:
print "The price is {$product1->price}\n";
这将打印原始价格,而不是您希望显示的折扣调整价格。您可以通过将$price
属性设为私有来立即停止这种情况。这将阻止直接访问,迫使客户端使用getPrice()
方法。任何从ShopProduct
类外部访问$price
属性的尝试都将失败。就更广阔的世界而言,这种财产已不复存在。
将属性设置为private
可能是一种过分热心的策略。子类不能访问private
属性。想象一下,我们的业务规则规定只有书没有资格享受折扣。您可以覆盖getPrice()
方法,使其返回$price
属性,不应用折扣:
// listing 03.68
// BookProduct
public function getPrice(): int|float
{
return $this->price;
}
由于私有的$price
属性是在ShopProduct
类中声明的,而不是在BookProduct
类中声明的,所以在这里访问它的尝试将会失败。这个问题的解决方案是将$price
变量声明为protected
,从而授予对子类的访问权限。请记住,受保护的属性或方法不能从声明它的类层次结构外部访问。它只能从其原始类或原始类的子类中访问。
一般来说,宁可失之于隐私。首先将属性设为私有或受保护,仅在需要时放松限制。您的类中的许多(如果不是大多数)方法将是公共的,但是如果有疑问,请再次锁定它。为类中的其他方法提供本地功能的方法与类的用户无关。使其成为private
或protected
。
存取方法
即使当客户端程序员需要使用您的类持有的值时,拒绝对属性的直接访问,而是提供传递所需值的方法,通常也是一个好主意。这样的方法被称为访问器或获取器和设置器。
您已经看到了访问器方法带来的一个好处。您可以使用访问器根据具体情况过滤属性值,如getPrice()
方法所示。
还可以使用 setter 方法来强制属性类型。类型声明可用于约束方法参数,但属性可以包含任何类型的数据。还记得使用ShopProduct
对象输出列表数据的ShopProductWriter
类吗?我可以进一步开发它,让它一次写任意数量的ShopProduct
对象:
// listing 03.69
class ShopProductWriter
{
public $products = [];
public function addProduct(ShopProduct $shopProduct): void
{
$this->products[] = $shopProduct;
}
public function write(): void
{
$str = "";
foreach ($this->products as $shopProduct) {
$str .= "{$shopProduct->title}: ";
$str .= $shopProduct->getProducer();
$str .= " ({$shopProduct->getPrice()})\n";
}
print $str;
}
}
ShopProductWriter
类现在更有用了。它可以保存许多ShopProduct
对象,并一次性为它们写入数据。不过,我必须相信我的客户编码人员会尊重这个类的意图。尽管我已经提供了一个addProduct()
方法,但我并没有阻止程序员直接操作$products
属性。有人不仅可以向$products
数组属性添加错误类型的对象,甚至可以覆盖整个数组并用原始值替换它。我可以通过将$products
属性私有来防止这种情况:
// listing 03.70
class ShopProductWriter
{
private $products = [];
//...
现在外部代码不可能破坏$products
属性。所有访问都必须通过addProduct()
方法,我在方法声明中使用的类类型声明确保只有ShopProduct
对象可以添加到数组属性中。
类型化属性
因此,通过将方法签名中的类型声明与属性可见性声明相结合,可以控制类中的属性类型。下面是另一个例子:一个Point
类,我在其中使用类型声明和属性可见性来管理属性类型:
// listing 03.71
class Point
{
private $x = 0;
private $y = 0;
public function setVals(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
public function getX(): int
{
return $this->x;
}
public function getY(): int
{
return $this->y;
}
}
因为$x
和$y
属性是私有的,它们只能通过setVals()
方法来设置——并且因为setVals()
只接受整数值,所以你可以确保$x
和$y
总是包含整数。
当然,因为这些属性被设置为private
,所以访问它们的唯一方式是通过 getter 或 accessor 方法。
在引入类型化属性的 PHP 7.4 版本之前,我们一直坚持使用这种固定属性类型的方法。这允许我们为我们的属性声明类型。下面是利用这一点的Point
版本:
// listing 03.72
class Point
{
public int $x = 0;
public int $y = 0;
}
我将属性$x
和$y
设为公共属性,并使用类型声明来约束它们的类型。正因为如此,如果我愿意,我可以选择在不牺牲控制的情况下摆脱setVals()
方法。我也不再需要getX()
和getY()
方法。Point
现在是一个非常简单的类,但是,即使它的两个属性都是公共的,它也向世界保证了它所保存的数据。
让我们尝试在其中一个属性上设置一个字符串:
// listing 03.73
$point = new Point();
$point->x = "a";
PHP 不会让我们得逞的:
TypeError: Cannot assign string to property popp\ch03\batch11\Point::$x of type int
Note
联合类型也可以在类型属性声明中使用。
商店产品类别
让我们通过修改ShopProduct
类及其子类来锁定访问控制,并加入一些我们已经介绍过的其他特性来结束本章:
// listing 03.74
class ShopProduct
{
private int|float $discount = 0;
public function __construct(
private string $title,
private string $producerFirstName,
private string $producerMainName,
protected int|float $price
) {
}
public function getProducerFirstName(): string
{
return $this->producerFirstName;
}
public function getProducerMainName(): string
{
return $this->producerMainName;
}
public function setDiscount(int|float $num): void
{
$this->discount = $num;
}
public function getDiscount(): int
{
return $this->discount;
}
public function getTitle(): string
{
return $this->title;
}
public function getPrice(): int|float
{
return ($this->price - $this->discount);
}
public function getProducer(): string
{
return $this->producerFirstName . " "
. $this->producerMainName;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
return $base;
}
}
除了通过将属性的可见性设置为private
(或者在$discount
的情况下设置为protected
来关闭对大多数属性的访问之外,我还重新引入了构造函数属性提升,这样我就可以将属性声明与构造函数签名结合起来。我还为$discount
使用了属性类型声明——同时展示了 PHP 8 的新类型联合特性。我已经约束了$discount
,这样它就可以被赋予一个int
或者float
值。这个约束可能看起来是多余的,因为$discount
被声明为private
,而setDiscount()
方法中的类型声明——另一个联合——将强制执行相同的条件。然而,为你的属性声明类型是一个很好的实践,一部分是因为这是一种强制的内联文档,另一部分是因为它可以防止我们在进一步开发ShopProduct
的过程中意外地反复无常。
// listing 03.75
class CdProduct extends ShopProduct
{
public function __construct(
string $title,
string $firstName,
string $mainName,
int|float $price,
private int $playLength
) {
parent:: __construct(
$title,
$firstName,
$mainName,
$price
);
}
public function getPlayLength(): int
{
return $this->playLength;
}
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
$base .= ": playing time - {$this->playLength}";
return $base;
}
}
同样,我在构造函数的签名中使用了属性提升。这一次,仅仅是为了一个理由:$playLength
。因为我将剩余的构造函数参数传递给父类,所以我没有为它们设置可见性。我在构造函数体中使用它们。
// listing 03.76
class BookProduct extends ShopProduct
{
public function __construct(
string $title,
string $firstName,
string $mainName,
int|float $price,
private int $numPages
) {
parent:: construct(
$title,
$firstName,
$mainName,
$price
);
}
public function getNumberOfPages(): int
{
return $this->numPages;
}
public function getSummaryLine(): string
{
$base = parent::getSummaryLine();
$base .= ": page count - $this->numPages";
return $base;
}
public function getPrice(): int|float
{
return $this->price;
}
}
因此,在这个版本的ShopProduct
家族中,所有属性要么是private
要么是protected
。我添加了一些访问器方法来使事情变得更完整。
摘要
这一章涵盖了很多内容,从一个空的实现到一个全功能的继承层次结构。你接受了一些设计问题,特别是关于类型和继承。您看到了 PHP 对可见性的支持,并探索了它的一些用途。在下一章,我将向你展示 PHP 更多的面向对象的特性。
四、高级功能
您已经看到了类类型提示和访问控制如何让您对类的接口有更多的控制。在这一章中,我将深入研究 PHP 的面向对象特性。
本章将涵盖几个主题:
-
静态方法和属性:通过类而不是对象来访问数据和功能
-
抽象类和接口:分离设计和实现
-
特征:在类层次结构之间共享实现
-
错误处理:引入异常
-
最终类和方法:限制继承
-
拦截器方法:自动化委托
-
析构函数方法:清理你的对象后
-
克隆对象:制作对象副本
-
将对象解析为字符串:创建汇总方法
-
回调:用匿名函数和类给组件添加功能
静态方法和属性
前一章中的所有例子都与对象有关。我将类描述为产生对象的模板,将对象描述为类的活动实例——调用其方法并访问其属性的事物。我暗示,在面向对象编程中,真正的工作是由类的实例来完成的。毕竟,类仅仅是对象的模板。
其实没那么简单。您可以在类的上下文中访问方法和属性,而不是在对象的上下文中。这些方法和属性是“静态”的,必须通过使用关键字static
来声明:
// listing 04.01
class StaticExample
{
public static int $aNum = 0;
public static function sayHello(): void
{
print "hello";
}
}
静态方法是具有类范围的函数。它们本身不能访问类中的任何普通属性,因为这些属性属于一个对象;但是,他们可以访问静态属性。如果更改静态属性,该类的所有实例都能够访问新值。
因为您通过类而不是实例来访问静态元素,所以您不需要引用对象的变量。相反,您可以将类名与::
结合使用,如下例所示:
// listing 04.02
print StaticExample::$aNum;
StaticExample::sayHello();
这种语法应该是上一章所熟悉的。我结合使用了::
和parent
来访问一个被覆盖的方法。现在,和那时一样,我访问的是类而不是对象数据。类代码可以使用parent
关键字来访问一个超类,而不使用它的类名。为了从同一个类中(而不是从子类中)访问一个静态方法或属性,我会使用self
关键字。self
对于类就像$this
伪变量对于对象一样。因此,在StaticExample
类之外,我使用类名来访问$aNum
属性:
StaticExample::$aNum;
在一个类中,我可以使用self
关键字:
// listing 04.03
class StaticExample2
{
public static int $aNum = 0;
public static function sayHello(): void
{
self::$aNum++;
print "hello (" . self::$aNum . ")\n";
}
}
Note
使用parent
进行方法调用是您应该使用对非静态方法的静态引用的唯一情况。
除非您正在访问一个被覆盖的方法,否则您应该只使用>::
来访问一个已经被显式声明为静态的方法或>属性。
然而,在文档中,您会经常看到用于引用方法或属性的静态语法。这并不意味着所讨论的项目一定是静态的,只是它属于某个类。例如,ShopProductWriter
类的write()
方法可能被称为ShopProductWriter::write()
,即使write()
方法不是静态的。当特定级别适当时,您会在这里看到这个语法。
根据定义,静态方法和属性是在类而不是对象上调用的。因此,它们通常被称为类变量和属性。这种面向类的结果是,您不能在静态方法中使用$this
伪变量。
那么,为什么要使用静态方法或属性呢?静态元素有许多有用的特征。首先,它们在脚本中的任何地方都是可用的(假设您可以访问该类)。这意味着您可以访问功能,而不需要在对象之间传递类的实例,或者更糟糕的是,将实例存储在全局变量中。第二,静态属性对类的每个实例都可用,因此您可以设置对类型的所有成员都可用的值。最后,您不需要实例来访问静态属性或方法,这一事实可以使您不必为了获得简单的函数而实例化一个对象。
为了说明这一点,我将为ShopProduct
类构建一个静态方法,该方法自动化了ShopProduct
对象的实例化。使用 SQLite,我可能会像这样定义一个products
表:
// listing 04.04
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT,
firstname TEXT,
mainname TEXT,
title TEXT,
price float,
numpages int,
playlength int,
discount int )
现在我想构建一个getInstance()
方法,它接受一个行 ID 和PDO
对象,用它们来获取一个数据库行,然后返回一个ShopProduct
对象。我可以将这些方法添加到我在前一章创建的ShopProduct
类中。你可能知道, PDO 代表 PHP 数据对象。PDO 类为不同的数据库应用提供了一个通用接口:
// listing 04.05
// ShopProduct class...
private int $id = 0;
// ...
public function setID(int $id): void
{
$this->id = $id;
}
// ...
public static function getInstance(int $id, \PDO $pdo): ShopProduct
{
$stmt = $pdo->prepare("select * from products where id=?");
$result = $stmt->execute([$id]);
$row = $stmt->fetch();
if (empty($row)) {
return null;
}
if ($row['type'] == "book") {
$product = new BookProduct(
$row['title'],
$row['firstname'],
$row['mainname'],
(float) $row['price'],
(int) $row['numpages']
);
} elseif ($row['type'] == "cd") {
$product = new CdProduct(
$row['title'],
$row['firstname'],
$row['mainname'],
(float) $row['price'],
(int) $row['playlength']
);
} else {
$firstname = (is_null($row['firstname'])) ? "" : $row['firstname'];
$product = new ShopProduct(
$row['title'],
$firstname,
$row['mainname'],
(float) $row['price']
);
}
$product->setId((int) $row['id']);
$product->setDiscount((int) $row['discount']);
return $product;
}
如您所见,getInstance()
方法返回一个ShopProduct
对象,并且基于一个类型标志,足够智能地计算出它应该实例化的精确专门化。为了保持示例简洁,我省略了任何错误处理。例如,在现实世界的版本中,我不会如此轻信假设所提供的PDO
对象被初始化为与正确的数据库对话。事实上,我可能用一个保证这种行为的类来包装PDO
。你可以在第十三章中读到更多关于面向对象编码和数据库的内容。
此方法在类上下文中比在对象上下文中更有用。它让您可以轻松地将数据库中的原始数据转换成一个对象,而不需要从一个ShopProduct
对象开始。该方法不使用任何实例属性或方法,因此没有理由不将其声明为static
。给定一个有效的PDO
对象,我可以从应用的任何地方调用该方法:
// listing 04.06
$dsn = "sqlite:/tmp/products.sqlite3";
$pdo = new \PDO($dsn, null, null);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$obj = ShopProduct::getInstance(1, $pdo);
像这样的方法就像“工厂”一样,它们获取原材料(比如行数据或配置信息)并使用它们来生产对象。术语工厂适用于设计用来生成对象实例的代码。在以后的章节中,你会再次遇到工厂的例子。
当然,在某些方面,这个例子带来的问题和它解决的一样多。虽然我让系统中的任何地方都可以访问ShopProduct::getInstance()
方法,而不需要ShopProduct
实例,但是我还要求客户端代码提供一个 PDO 对象。在哪里可以找到这个?对于一个父类来说,对它的孩子有如此亲密的了解真的是好的做法吗?(提示:不,不是。)这类问题——在哪里获取关键对象和值,以及类之间应该了解多少——在面向对象编程中非常常见。我在第九章中研究了对象生成的各种方法。
常量属性
有些属性不应更改。生命、宇宙和一切事物的答案都是 42,而且你希望它保持这个状态。错误和状态标志通常会硬编码到您的类中。尽管它们应该是公开和静态可用的,但是客户端代码应该不能更改它们。
PHP 允许你在一个类中定义常量属性。像全局常量一样,类常量一旦设置就不能更改。常量属性是用关键字const
声明的。常量不像常规属性那样以美元符号为前缀。按照惯例,它们通常只使用大写字符命名:
// listing 04.07
class ShopProduct
{
public const AVAILABLE = 0;
public const OUT_OF_STOCK = 1;
常量属性只能包含原始值。不能将对象赋给常数。像静态属性一样,常量属性是通过类而不是实例来访问的。正如您定义一个不带美元符号的常量一样,当您引用一个常量时,也不需要前导符号:
// listing 04.08
print ShopProduct::AVAILABLE;
Note
PHP 7.1 引入了对常量可见性修饰符的支持。它们的工作方式与属性的可见性修改器完全相同。
一旦常量被声明,试图对其设置值将导致分析错误。
当您的属性需要在类的所有实例中可用时,以及当属性值需要固定不变时,您应该使用常量。
抽象类
抽象类不能被实例化。相反,它为任何可能扩展它的类定义(并且,可选地,部分实现)接口。
您用关键字abstract
定义了一个抽象类。这里,我重新定义了我在前一章中创建的ShopProductWriter
类,这次是一个抽象类:
// listing 04.09
abstract class ShopProductWriter
{
protected array $products = [];
public function addProduct(ShopProduct $shopProduct): void
{
$this->products[] = $shopProduct;
}
}
您可以像往常一样创建方法和属性,但是任何以这种方式实例化抽象对象的尝试都会导致错误:
// listing 04.10
$writer = new ShopProductWriter();
您可以在以下输出中看到错误:
Error: Cannot instantiate abstract class
popp\ch04\batch03\ShopProductWriter
在大多数情况下,一个抽象类至少包含一个抽象方法。这些都是用关键字abstract
声明的。抽象方法不能有实现。您以正常方式声明它,但是用分号而不是方法体结束声明。在这里,我给ShopProductWriter
类添加了一个抽象的write()
方法:
// listing 04.11
abstract class ShopProductWriter
{
protected array $products = [];
public function addProduct(ShopProduct $shopProduct): void
{
$this->products[] = $shopProduct;
}
abstract public function write(): void;
}
在创建一个抽象方法时,你要确保一个实现在所有具体的子类中都是可用的,但是你没有定义这个实现的细节。
假设我要创建一个从ShopProductWriter
派生的类,它不实现write()
方法,如下例所示:
// listing 04.12
class ErroredWriter extends ShopProductWriter
{
}
我会面临以下错误:
Fatal error: Class popp\ch04\batch03\ErroredWriter contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (popp\ch04\batch03\ShopProductWriter::write) in...
因此,任何扩展抽象类的类都必须实现所有的抽象方法,或者将其本身声明为抽象的。一个扩展类不仅仅负责实现一个抽象方法。这样做时,它必须复制方法签名。这意味着实现方法的访问控制不能比抽象方法更严格。实现方法还应该需要与抽象方法相同数量的参数,以复制任何类类型声明。
下面是ShopProductWriter
的两个实现——首先是XmlProductWriter
:
// listing 04.13
class XmlProductWriter extends ShopProductWriter
{
public function write(): void
{
$writer = new \XMLWriter();
$writer->openMemory();
$writer->startDocument('1.0', 'UTF-8');
$writer->startElement("products");
foreach ($this->products as $shopProduct) {
$writer->startElement("product");
$writer->writeAttribute("title", $shopProduct->getTitle());
$writer->startElement("summary");
$writer->text($shopProduct->getSummaryLine());
$writer->endElement(); // summary
$writer->endElement(); // product
}
$writer->endElement(); // products
$writer->endDocument();
print $writer->flush();
}
}
这是更基本的TextProductWriter
:
// listing 04.14
class TextProductWriter extends ShopProductWriter
{
public function write(): void
{
$str = "PRODUCTS:\n";
foreach ($this->products as $shopProduct) {
$str .= $shopProduct->getSummaryLine() . "\n";
}
print $str;
}
}
因此,我创建了两个类,每个类都有自己的write()
方法实现。第一个输出 XML,第二个输出文本。一个需要一个ShopProductWriter
对象的方法不知道它接收的是这两个类中的哪一个,但是可以肯定的是一个write()
方法被实现了。请注意,在将$products
视为数组之前,我不会测试它的类型。这是因为这个属性既被声明为数组,又在ShopProductWriter
类中被初始化。
接口
虽然抽象类让你提供一些实现的方法,但是接口是纯粹的模板。接口只能定义功能;它永远无法实现它。用关键字interface
声明一个接口。它可以包含属性和方法声明,但不能包含方法体。
这是一个界面:
// listing 04.15
interface Chargeable
{
public function getPrice(): float;
}
如您所见,接口看起来非常像一个类。任何包含这个接口的类都必须实现它定义的所有方法,否则必须声明它是抽象的。
一个类可以在声明中使用关键字implements
来实现一个接口。一旦你完成了这些,实现一个接口的过程就和扩展一个只包含抽象方法的抽象类是一样的。现在我将使ShopProduct
类实现Chargeable
:
// listing 04.16
class ShopProduct implements Chargeable
{
// ...
protected float $price;
// ...
public function getPrice(): float
{
return $this->price;
}
// ...
}
ShopProduct
已经有了一个getPrice()
方法,那么为什么实现Chargeable
接口会有用呢?答案还是与类型有关。实现类采用它扩展的类的类型和它实现的接口。
这意味着CdProduct
类属于以下类别:
CdProduct
ShopProduct
Chargeable
客户端代码可以利用这一点。知道一个对象的类型就是知道它的能力。考虑这种方法:
// listing 04.17
public function cdInfo(CdProduct $prod): int
{
// we know we can call getPlayLength()
$length = $prod->getPlayLength();
// ...
}
该方法知道除了在ShopProduct
类和Chargeable
接口中定义的所有方法之外,$prod
对象还有一个getPlayLength()
方法。
传递了同一个对象,但是,具有更一般类型需求的方法——ShopProduct
而不是CdProduct
——只能知道提供的对象包含ShopProduct
方法。
// listing 04.18
public function addProduct(ShopProduct $prod)
{
// even if $prod is a CdProduct object
// we don't *know* this -- so we can't
// presume to use getPlayLength()
// ...
}
如果没有进一步的测试,该方法将对getPlayLength()
方法一无所知。
传递了同一个CdProduct
对象,一个需要Chargeable
对象的方法对ShopProduct
或CdProduct
类型一无所知:
// listing 04.19
public function addChargeableItem(Chargeable $item)
{
// all we know about $item is that it
// is a Chargeable object -- the fact that it
// is also a CdProduct object is irrelevant.
// We can only be sure of getPrice()
//
//...
}
这个方法只关心$item
参数是否包含一个getPrice()
方法。
因为任何类都可以实现一个接口(事实上,一个类可以实现任意数量的接口),所以接口有效地连接了原本不相关的类型。我可能会定义一个全新的类来实现Chargeable
:
// listing 04.20
class Shipping implements Chargeable
{
public function __construct(private float $price)
{
}
public function getPrice(): float
{
return $this->price;
}
}
我可以将一个Shipping
对象传递给addChargeableItem()
方法,就像我可以将一个ShopProduct
对象传递给它一样。
对于使用Chargeable
对象的客户端来说,重要的是它可以调用getPrice()
方法。任何其他可用的方法都与其他类型相关联,无论是通过对象自己的类、超类还是另一个接口。这些与客户无关。
一个类既可以扩展一个超类,也可以实现任意数量的接口。extends
子句应在implements
子句之前:
// listing 04.21
class Consultancy extends TimedService implements Bookable, Chargeable
{
// ...
}
注意,Consultancy
类实现了不止一个接口。多个接口在逗号分隔的列表中跟在implements
关键字后面。
PHP 只支持从单亲继承,所以extends
关键字只能放在一个类名的前面。
特征
正如我们所看到的,接口帮助你管理这样一个事实,即像 Java 一样,PHP 不支持多重继承。换句话说,PHP 中的一个类只能扩展一个父类。然而,你可以让一个类承诺实现尽可能多的接口;对于它实现的每个接口,该类都采用相应的类型。
所以接口提供了没有实现的类型。但是,如果您希望跨继承层次结构共享一个实现,该怎么办呢?PHP 5.4 引入了特性,这些特性让你可以做到这一点。
trait 是一个类结构,它本身不能被实例化,但是可以被合并到类中。特征中定义的任何方法都可以作为使用它的任何类的一部分。特征改变了类的结构,但没有改变它的类型。将特征视为类的包含。
让我们来看看为什么一种特质可能是有用的。
特质要解决的问题
下面是带有一个calculateTax()
方法的ShopProduct
类的一个版本:
// listing 04.22
class ShopProduct
{
private int $taxrate = 20;
// ...
public function calculateTax(float $price): float
{
return (($this->taxrate / 100) * $price);
}
}
calculateTax()
方法接受一个$price
参数,并根据私有的$taxrate
财产计算销售税金额。
当然,子类可以访问calculateTax()
。但是完全不同的类层次结构呢?想象一个名为UtilityService
的类,它继承自另一个类Service
。如果UtilityService
需要使用相同的例程,我可能会发现自己完全复制了calculateTax()
。服务如下:
// listing 04.23
abstract class Service
{
// service oriented stuff
}
这里是UtilityService
:
// listing 04.24
class UtilityService extends Service
{
private int $taxrate = 20;
public function calculateTax(float $price): float
{
return ( ( $this->taxrate / 100 ) * $price );
}
}
因为UtilityService
和ShopProduct
不共享任何公共基类,所以它们不能轻易共享calculateTax()
实现。因此,我们被迫将我们的实现从一个类复制粘贴到另一个类。
定义和使用特性
我将在本书中介绍的核心面向对象设计目标之一是消除重复。正如你将在第十一章看到的,这种重复的一个解决方案是将其分解成一个可重用的策略类。特质提供了另一种方法——也许不那么优雅,但肯定有效。
在这里,我声明了一个定义了calculateTax()
方法的特征,然后我将它包含在ShopProduct
和UtilityService
中:
// listing 04.25
trait PriceUtilities
{
private $taxrate = 20;
public function calculateTax(float $price): float
{
return (($this->taxrate / 100) * $price);
}
// other utilities
}
我用关键字trait
声明了PriceUtilities
特征。特征的主体看起来非常类似于类的主体。它只是收集在大括号中的一组方法和属性。一旦我声明了它,我就可以从我的类中访问PriceUtilities
特征。我用关键字use
加上我希望合并的特征的名称来完成这个操作。所以在一个地方声明并实现了calculateTax()
方法后,我继续将它合并到ShopProduct
类中。
// listing 04.26
use popp\ch04\batch06_1\PriceUtilities;
class ShopProduct
{
use PriceUtilities;
}
当然,我也将它添加到了UtilityService
类中:
// listing 04.27
class UtilityService extends Service
{
use PriceUtilities;
}
现在,当我调用这些类时,我知道它们共享PriceUtilities
实现而没有重复。如果我在PriceUtilities
中发现一个 bug,我可以在一个地方修复它。
// listing 04.28
$p = new ShopProduct();
print $p->calculateTax(100) . "\n";
$u = new UtilityService();
print $u->calculateTax(100) . "\n";
使用一个以上的特征
您可以在一个类中包含多个特征,方法是在关键字use
后列出每个特征,用逗号分隔。在这个例子中,我定义并应用了一个新的特征,IdentityTrait
,保留了我原来的PriceUtilities
特征:
// listing 04.29
trait IdentityTrait
{
public function generateId(): string
{
return uniqid();
}
}
通过应用带有use
关键字的PriceUtilities
和IdentityTrait
,我使calculateTax()
和generateId()
方法对ShopProduct
类可用。这意味着该类同时提供了calculateTax()
和generateId()
方法。
// listing 04.30
class ShopProduct
{
use PriceUtilities;
use IdentityTrait;
}
Note
IdentityTrait
特征提供了generateId()
方法。事实上,数据库经常为对象生成标识符,但是出于测试目的,您可能会切换到本地实现。你可以在第十三章中找到更多关于对象、数据库和唯一标识符的信息,这一章涵盖了身份映射模式。你可以在第十八章中了解更多关于测试和嘲讽的知识。
现在我可以在一个ShopProduct
类上同时调用generateId()
和calculateTax()
方法。
// listing 04.31
$p = new ShopProduct();
print $p->calculateTax(100) . "\n";
print $p->generateId() . "\n";
结合特征和界面
尽管特征很有用,但它们不会改变它们所应用的类的类型。因此,当您将IdentityTrait
特征应用于多个类时,它们不会共享一个可以在方法签名中暗示的类型。
幸运的是,特征与接口配合得很好。我可以定义一个需要generateId()
方法的接口,然后声明ShopProduct
实现它:
// listing 04.32
interface IdentityObject
{
public function generateId(): string;
}
如果我想让ShopProduct
实现IdentityObject
类型,我现在必须让它实现IdentityObject
接口。
// listing 04.33
class ShopProduct implements IdentityObject
{
use PriceUtilities;
use IdentityTrait;
}
和以前一样,ShopProduct
使用了IdentityTrait
特征。然而,这个导入的方法generateId()
,现在也实现了对IdentityObject
接口的承诺。这意味着我们可以将ShopProduct
对象传递给使用类型提示来要求IdentityObject
实例的方法和函数,就像这样:
// listing 04.34
public static function storeIdentityObject(IdentityObject $idobj)
{
// do something with the IdentityObject
}
管理方法名称与 insteadof 冲突
结合特质的能力是一个很好的特性,但是迟早会有冲突。例如,考虑一下如果我使用提供calculateTax()
方法的两个特征会发生什么:
// listing 04.35
trait TaxTools
{
public function calculateTax(float $price): float
{
return 222;
}
}
因为我已经包含了两个包含calculateTax()
方法的特征,PHP 无法确定哪个应该覆盖另一个。结果是一个致命的错误:
Fatal error: Trait method popp\ch04\batch06_3\TaxTools::calculateTax has not been applied as
popp\ch04\batch06_3\UtilityService::calculateTax, because of collision with
popp\ch04\batch06_3\PriceUtilities::calculateTax in...
为了解决这个问题,我可以使用insteadof
关键字。以下是如何:
// listing 04.36
class UtilityService extends Service
{
use PriceUtilities;
use TaxTools {
TaxTools::calculateTax insteadof PriceUtilities;
}
}
为了对一个use
语句应用进一步的指令,我必须首先添加一个主体。我用左大括号和右大括号来做这件事。在这个块中,我使用了insteadof
操作符。这需要在左侧有一个完全限定的方法引用(即,标识特征和方法名称的方法引用,由范围解析操作符分隔)。在右边,insteadof
需要特性的名称,它的等价方法应该被覆盖:
TaxTools::calculateTax insteadof PriceUtilities;
前面的代码片段意味着“使用TaxTools
的calculateTax()
方法,而不是PriceUtilities
中的同名方法。”
所以当我运行这段代码时:
// listing 04.37
$u = new UtilityService();
print $u->calculateTax(100) . "\n";
我得到了我在TaxTools::calculateTax()
中植入的虚拟输出:
222
别名覆盖特征方法
我们已经看到,您可以使用insteadof
来消除方法之间的歧义。但是,如果您想访问被覆盖的方法,该怎么做呢?as
操作符允许您给 trait 方法起别名。同样,as
操作符需要对其左侧的方法进行完整引用。在运算符的右边,您应该输入别名的名称。例如,在这里,我用新名字basicTax()
恢复了PriceUtilities
特征的calculateTax()
方法:
// listing 04.38
class UtilityService extends Service
{
use PriceUtilities;
use TaxTools {
TaxTools::calculateTax insteadof PriceUtilities;
PriceUtilities::calculateTax as basicTax;
}
}
现在UtilityService
类获得了两个方法:calculateTax()
的TaxTools
版本和别名为basicTax()
的PriceUtilities
版本。让我们运行这些方法:
// listing 04.39
$u = new UtilityService();
print $u->calculateTax(100) . "\n";
print $u->basicTax(100) . "\n";
这将产生以下输出:
222
20
所以PriceUtilities::calculateTax()
作为UtilityService
类的一部分以basicTax()
的名字复活了。
Note
当一个方法名与特征冲突时,在use
块中给其中一个方法名起别名是不够的。您必须首先使用insteadof
操作符确定哪种方法取代了另一种方法。然后,您可以使用as
操作符为被丢弃的方法重新指定一个新名称。
顺便提一下,在没有名称冲突的情况下,也可以使用方法名称别名。例如,您可能希望使用 trait 方法来实现在父类或接口中声明的抽象方法签名。
在 Traits 中使用静态方法
到目前为止,您看到的大多数示例都可以使用静态方法,因为它们不存储实例数据。将静态方法放在特征中并不复杂。这里,我更改了PriceUtilities::$taxrate
属性和PriceUtilities::calculateTax()
方法,使它们成为静态的:
// listing 04.40
trait PriceUtilities
{
private static int $taxrate = 20;
public static function calculateTax(float $price): float
{
return ((self::$taxrate / 100) * $price);
}
// other utilities
}
下面是UtilityService
回到它的最小形式:
// listing 04.41
class UtilityService extends Service
{
use PriceUtilities;
}
它所做的只是use
的PriceUtilities
特性。然而,在调用calculateTax()
方法时,有一个关键的区别:
// listing 04.42
print UtilityService::calculateTax(100) . "\n";
我现在必须在类上调用方法,而不是在对象上。如您所料,该脚本输出如下内容:
20
因此,静态方法在 traits 中声明,并通过主机类以正常方式访问。
访问主机类属性
您可能会认为静态方法真的是涉及特征的唯一方法。即使没有声明为静态的 trait 方法本质上也是静态的,对吗?嗯,错了,实际上你可以访问 host 类中的属性和方法:
// listing 04.43
trait PriceUtilities
{
public function calculateTax(float $price): float
{
// is this good design?
return (($this->taxrate / 100) * $price);
}
// other utilities
}
在前面的代码中,我修改了PriceUtilities
特征,以便它访问其主机类中的属性。下面是一个主机——PriceUtilities
——修改后声明属性:
// listing 04.44
class UtilityService extends Service
{
use PriceUtilities;
public $taxrate = 20;
}
如果你认为这是一个糟糕的设计,你是对的。这是一个极其糟糕的设计。虽然 trait 通过它的主机类访问数据集是有用的,但是并不要求UtilityService
类实际提供一个$taxrate
属性。请记住,特征应该可以跨许多不同的类使用。任何宿主类声明一个$taxrate
的保证甚至可能性是什么?
另一方面,如果能够建立一个契约,从本质上说,“如果你使用这种特性,那么你必须为它提供某些资源”,那就太好了。
其实你完全可以达到这种效果。特征支持抽象方法。
在特征中定义抽象方法
您可以像在类中一样在特征中定义抽象方法。当一个类使用一个特征时,它就承担了实现它声明的任何抽象方法的义务。
Note
在 PHP 8 之前,traits 中定义的抽象方法的方法签名并不总是完全强制的。这意味着在某些情况下,实现类中的参数和返回类型可能与抽象方法声明中的不同。这个漏洞现在已经被堵住了。
有了这些知识,我可以重新实现我前面的例子,这样特征就可以强制任何使用它的类提供税率信息:
// listing 04.45
trait PriceUtilities
{
public function calculateTax(float $price): float
{
// better design.. we know getTaxRate() is implemented
return (($this->getTaxRate() / 100) * $price);
}
abstract public function getTaxRate(): float;
// other utilities
}
通过在PriceUtilities
特征中声明一个抽象的getTaxRate()
方法,我强迫UtilityService
类提供一个实现。
// listing 04.46
class UtilityService extends Service
{
use PriceUtilities;
public function getTaxRate(): float
{
return 20;
}
}
由于 trait 中的抽象声明,如果我没有提供一个getTaxRate()
方法,我就会得到一个致命错误。
更改对 Trait 方法的访问权限
当然,你可以声明一个特征方法public
、private
或protected
。但是,您也可以从使用 trait 的类中更改这种访问。您已经看到了as
操作符可以用来给方法名起别名。如果在这个操作符的右边使用访问修饰符,它将改变方法的访问级别,而不是它的名称。
例如,想象一下,您想在UtilityService
中使用calculateTax()
,但是不能让它用于实现代码。你可以这样改变use
的陈述:
// listing 04.47
class UtilityService extends Service
{
use PriceUtilities {
PriceUtilities::calculateTax as private;
}
public function __construct(private float $price)
{
}
public function getTaxRate(): float
{
return 20;
}
public function getFinalPrice(): float
{
return ($this->price + $this->calculateTax($this->price));
}
}
我结合关键字private
部署了as
操作符,以便设置对calculateTax()
的私有访问。这意味着我可以从getFinalPrice()
访问该方法。这里有一个访问calculateTax()
的外部尝试:
// listing 04.48
$u = new UtilityService(100);
print $u->calculateTax() . "\n";
不幸的是,这段代码会产生一个错误:
Error: Call to private method popp\ch04\batch06_9\UtilityService::calculateTax() from context ...
后期静态绑定:静态关键字
既然已经看到了抽象类、特征和接口,是时候简单地回到静态方法了。您看到了静态方法可以用作工厂,一种生成包含类的实例的方式。如果你像我一样是个懒惰的程序员,你可能会对这样一个例子中的重复感到恼火:
// listing 04.49
abstract class DomainObject
{
}
// listing 04.50
class User extends DomainObject
{
public static function create(): User
{
return new User();
}
}
// listing 04.51
class Document extends DomainObject
{
public static function create(): Document
{
return new Document();
}
}
我创建了一个名为DomainObject
的超类。当然,在现实世界的项目中,这将包含其扩展类所共有的功能。然后我创建两个子类,User
和Document
。我希望我的具体类有静态的create()
方法。
Note
当构造函数已经执行了创建对象的工作时,我为什么还要使用静态工厂方法呢?在第十三章中,我将描述一种叫做身份图的模式。只有当具有相同区别特征的对象尚未被管理时,身份映射组件才生成和管理新对象。如果目标对象已经存在,则返回该对象。像create()
这样的工厂方法是这类组件的好客户。
这段代码运行良好,但是有令人讨厌的重复。我不想为我创建的每个DomainObject
子类创建这样的样板代码。相反,我将尝试将create()
方法提升到超类:
// listing 04.52
abstract class DomainObject
{
public static function create(): DomainObject
{
return new self();
}
}
嗯,那辆看起来很整洁。我现在在一个地方有了公共代码,并且我使用了self
作为对该类的引用。但是我对self
关键字做了一个假设。事实上,它对类的作用与$this
对对象的作用并不完全相同。self
不指调用上下文;它指的是解决问题的背景。所以如果我运行前面的例子,我得到这个:
Error: Cannot instantiate abstract class
popp\ch04\batch06\DomainObject
所以self
解析到DomainObject
,定义create()
的地方,而不是解析到Document
,调用它的类。在 PHP 5.3 之前,这是一个严重的限制,导致了许多笨拙的解决方法。PHP 5.3 引入了一个叫做后期静态绑定的概念。这个特点最明显的体现就是关键词:static
。static
类似于self
,只是它引用的是被调用的而不是包含类的。在这种情况下,这意味着调用Document::create()
会产生一个新的Document
对象,而不是注定要实例化一个DomainObject
对象。
所以现在我可以在静态上下文中利用我的继承关系:
// listing 04.53
abstract class DomainObject
{
public static function create(): DomainObject
{
return new static();
}
}
// listing 04.54
class User extends DomainObject
{
}
// listing 04.55
class Document extends DomainObject
{
}
现在,如果我们在其中一个子类上调用create()
,我们应该不会再导致错误——并获得一个与我们调用的相关的对象,而不是与包含create()
的类相关的对象。
// listing 04.56
print_r(Document::create());
这是输出。
popp\ch04\batch07\Document Object
(
)
关键字static
不仅可以用于实例化。与self
和parent
一样,static
可以用作静态方法调用的标识符,即使是在非静态上下文中。假设我想在我的DomainObject
课程中加入一个团队的概念。默认情况下,在我的新分类中,所有的类都属于“默认”类别,但是我希望能够为我的继承层次结构的一些分支覆盖这个类别:
// listing 04.57
abstract class DomainObject
{
private string $group;
public function __construct()
{
$this->group = static::getGroup();
}
public static function create(): DomainObject
{
return new static();
}
public static function getGroup(): string
{
return "default";
}
}
// listing 04.58
class User extends DomainObject
{
}
// listing 04.59
class Document extends DomainObject
{
public static function getGroup(): string
{
return "document";
}
}
// listing 04.60
class SpreadSheet extends Document
{
}
// listing 04.61
print_r(User::create());
print_r(SpreadSheet::create());
我向DomainObject
类引入了一个构造函数。它使用static
关键字来调用一个静态方法:getGroup()
。DomainObject
提供了默认的实现,但是Document
覆盖了它。我还创建了一个新类SpreadSheet
,它扩展了Document
。以下是输出结果:
popp\ch04\batch07\User Object (
[group:popp\ch04\batch07\DomainObject:private] => default
)
popp\ch04\batch07\SpreadSheet Object (
[group:popp\ch04\batch07\DomainObject:private] => document
)
对于User
类,不需要太多的聪明。DomainObject
构造函数调用getGroup()
并在本地找到它。不过,在SpreadSheet
的情况下,搜索从被调用的类SpreadSheet
本身开始。它没有提供实现,所以调用了Document
类中的getGroup()
方法。在 PHP 5.3 和后期静态绑定之前,我会一直使用self
关键字,它只会在DomainObject
类中寻找getGroup()
。
处理错误
事情出了差错。文件放错了位置,数据库服务器未初始化,URL 被更改,XML 文件被破坏,权限设置不当,以及超过了磁盘配额。这份名单越列越多。在预测每个问题的斗争中,一个简单的方法有时会被自己的错误处理代码压垮。
下面是一个简单的Conf
类,它在 XML 配置文件中存储、检索和设置数据:
// listing 04.62
class Conf
{
private \SimpleXMLElement $xml;
private \SimpleXMLElement $lastmatch;
public function __construct(private string $file)
{
$this->xml = simplexml_load_file($file);
}
public function write(): void
{
file_put_contents($this->file, $this->xml->asXML());
}
public function get(string $str): ?string
{
$matches = $this->xml->xpath("/conf/item[@name=\"$str\"]");
if (count($matches)) {
$this->lastmatch = $matches[0];
return (string)$matches[0];
}
return null;
}
public function set(string $key, string $value): void
{
if (! is_null($this->get($key))) {
$this->lastmatch[0] = $value;
return;
}
$conf = $this->xml->conf;
$this->xml->addChild('item', $value)->addAttribute('name', $key);
}
}
Conf
类使用SimpleXml
扩展来访问名称/值对。以下是它的设计工作格式:
<?xml version="1.0" ?>
<conf>
<item name="user">bob</item>
<item name="pass">newpass</item>
<item name="host">localhost</item>
</conf>
Conf
类的构造函数接受一个文件路径,并将其传递给simplexml_load_file()
。它将生成的SimpleXmlElement
对象存储在一个名为$xml
的属性中。get()
方法使用 XPath 定位具有给定name
属性的item
元素,并返回其值。set()
更改现有项目的值或创建一个新项目。最后,write()
方法将新的配置数据保存回文件。
像许多示例代码一样,Conf
类被高度简化了。特别是,它没有处理不存在或不可写文件的策略。它的前景也是乐观的。它假设 XML 文档是格式良好的,并且包含预期的元素。
测试这些错误条件相对来说是微不足道的,但是我仍然必须决定在它们出现时如何响应它们。一般有两种选择。
首先,我可以结束死刑。这很简单,但是很激烈。然后,我的卑微的类将负责带来一个围绕它崩溃的整个脚本。虽然像__construct()
和write()
这样的方法可以很好地检测错误,但是它们没有决定如何处理错误的信息。
我可以返回某种错误标志,而不是在我的类中处理错误。这可以是布尔值或整数值,如0
或-1
。有些类还会设置一个错误字符串或标志,这样客户端代码就可以在失败后请求更多信息。
许多 PEAR 包通过返回一个错误对象(一个PEAR_Error
的实例)将这两种方法结合起来,该对象作为一个错误发生的通知,并包含错误消息。这种方法现在被否决了,但是很多类还没有升级,尤其是因为客户端代码经常依赖于旧的行为。
这里的问题是你污染了你的返回值。每次调用容易出错的方法时,您都必须依靠客户端编码器来测试返回类型。这可能有风险。不要相信任何人!
当您向调用代码返回一个错误值时,不能保证客户端会比您的方法更好地决定如何处理错误。如果是这样,那么问题又重新开始了。客户端方法必须确定如何响应错误条件,甚至可能实现不同的错误报告策略。
例外
PHP 5 引入了 PHP 异常,这是一种完全不同的处理错误情况的方式。这对于 PHP 来说是不同的。如果你有 Java 或 C++的经验,你会发现它们非常熟悉。异常解决了我在本节中提出的所有问题。
例外是从内置Exception
类(或从派生类)实例化的特殊对象。
类型为Exception
的对象被设计用来保存和报告错误信息。
Exception
类构造函数接受两个可选参数,一个消息字符串和一个错误代码。该类提供了一些用于分析错误条件的有用方法。这些在表 4-1 中描述。
表 4-1
异常类的公共方法
|方法
|
描述
|
| --- | --- |
| getMessage()
| 获取传递给构造函数的消息字符串 |
| getCode()
| 获取传递给构造函数的代码整数 |
| getFile()
| 获取生成异常的文件 |
| getLine()
| 获取生成异常的行号 |
| getPrevious()
| 获取嵌套的异常对象 |
| getTrace()
| 获取跟踪导致异常的方法调用的多维数组,包括方法、类、文件和参数数据 |
| getTraceAsString()
| 获取由getTrace()
返回的数据的字符串版本 |
| __toString()
| 在字符串上下文中使用Exception
对象时自动调用。返回描述异常详细信息的字符串 |
Exception
类对于提供错误通知和调试信息非常有用(在这方面,getTrace()
和getTraceAsString()
方法特别有用)。其实和之前讨论的PEAR_Error
级几乎一模一样。但是,异常不仅仅包含信息。
引发异常
throw
关键字与Exception
对象结合使用。它停止当前方法的执行,并将处理错误的责任传递回调用代码。这里,我修改了__construct()
方法,使用了throw
语句:
// listing 04.63
public function __construct(private string $file)
{
if (! file_exists($file)) {
throw new \Exception("file '{$file}' does not exist");
}
$this->xml = simplexml_load_file($file);
}
write()
方法可以使用类似的构造:
// listing 04.64
public function write(): void
{
if (! is_writeable($this->file)) {
throw new \Exception("file '{$this->file}' is not writeable");
}
print "{$this->file} is apparently writeable\n";
file_put_contents($this->file, $this->xml->asXML());
}
// listing 04.65
try {
$conf = new Conf("/tmp/conf01.xml");
//$conf = new Conf( "/root/unwriteable.xml" );
//$conf = new Conf( "nonexistent/not_there.xml" );
print "user: " . $conf->get('user') . "\n";
print "host: " . $conf->get('host') . "\n";
$conf->set("pass", "newpass");
$conf->write();
} catch (\Exception $e) {
// handle error in some way
}
如您所见,catch
块表面上类似于一个方法声明。当抛出异常时,调用范围内的catch
块被调用。Exception
对象作为参数变量被自动传入。
正如在抛出异常时抛出方法中的执行被暂停一样,在try
块中也是如此——控制直接传递给catch
块。在那里,您可以执行任何可用的错误恢复任务。如果可以,避免依靠die
声明。通过调用die
,您使得测试更加困难,并且可能阻止系统中的其他代码执行必要的清理操作。如果无法从错误中恢复,您总是可以引发新的异常:
// listing 04.66
} catch (\Exception $e) {
// handle error in some way
// or
throw new \Exception("Conf error: " . $e->getMessage());
}
或者,您也可以重新抛出已经给出的异常:
// listing 04.67
try {
$conf = new Conf("nonexistent/not_there.xml");
} catch (\Exception $e) {
// handle error...
// or rethrow
throw $e;
}
如果您在错误处理中不需要Exception
对象本身,那么从 PHP 8 开始,您可以完全省略异常参数,只需指定类型:
// listing 04.68
try {
$conf = new Conf("nonexistent/not_there.xml");
} catch (\Exception) {
// handle error without using the Exception object
}
子类化异常
您可以创建扩展Exception
类的类,就像您创建任何用户定义的类一样。有两个原因可以解释你为什么想这么做。首先,您可以扩展该类的功能。其次,派生类定义了一个新的类类型这一事实本身可以帮助错误处理。
事实上,您可以为一个try
语句定义任意多的catch
块。被调用的特定的catch
块将取决于抛出的异常的类型和参数列表中的类类型提示。下面是一些扩展了Exception
的简单类:
// listing 04.69
class XmlException extends \Exception
{
public function construct(private \LibXmlError $error)
{
$shortfile = basename($error->file);
$msg = "[{$shortfile}, line {$error->line}, col {$error->column}] {$error->message}";
$this->error = $error;
parent:: __construct($msg, $error->code);
}
public function getLibXmlError(): \LibXmlError
{
return $this->error;
}
}
// listing 04.70
class FileException extends \Exception
{
}
// listing 04.71
class ConfException extends \Exception
{
}
当SimpleXml
遇到一个损坏的 XML 文件时,LibXmlError
类在后台生成。它有$message
和$code
属性,类似于Exception
类。我利用了这种相似性,在XmlException
类中使用了LibXmlError
对象。FileException
和ConfException
类只做子类Exception
的事情。我现在可以在我的代码中使用这些类并修改construct()
和write()
:
// listing 04.72
// Conf class...
public function __construct(private string $file)
{
if (! file_exists($file)) {
throw new FileException("file '$file' does not exist");
}
$this->xml = simplexml_load_file($file, null, LIBXML_NOERROR);
if (! is_object($this->xml)) {
throw new XmlException(libxml_get_last_error());
}
$matches = $this->xml->xpath("/conf");
if (! count($matches)) {
throw new ConfException("could not find root element: conf");
}
}
public function write(): void
{
if (! is_writeable($this->file)) {
throw new FileException("file '{$this->file}' is not writeable");
}
file_put_contents($this->file, $this->xml->asXML());
}
__construct()
抛出一个XmlException
、FileException
或ConfException
,这取决于它遇到的错误类型。注意,我将选项标志LIBXML_NOERROR
传递给simplexml_load_file()
。这抑制了警告,让我可以在事后用我的XmlException
类自由处理它们。如果我遇到一个格式错误的 XML 文件,我知道发生了错误,因为simplexml_load_file()
不会返回一个对象。然后,我可以使用libxml_get_last_error()
来访问错误。
如果$file
属性指向一个不可写的实体,write()
方法抛出一个FileException
。
因此,我已经确定__construct()
可能抛出三种可能的异常之一。我该如何利用这一点呢?下面是实例化一个Conf
对象的一些代码:
// listing 04.73
class Runner
{
public static function init()
{
try {
$conf = new Conf(__DIR__ . "/conf.broken.xml");
print "user: " . $conf->get('user') . "\n";
print "host: " . $conf->get('host') . "\n";
$conf->set("pass", "newpass");
$conf->write();
} catch (FileException $e) {
// permissions issue or non-existent file throw $e;
} catch (XmlException $e) {
// broken xml
} catch (ConfException $e) {
// wrong kind of XML file
} catch (\Exception $e) {
// backstop: should not be called
}
}
}
我为每个类类型提供了一个catch
块。调用的块取决于抛出的异常类型。将执行第一个匹配的类型,所以记住将最通用的类型放在最后,将最专用的类型放在开始。例如,如果您将Exception
的catch
块放在XmlException
和ConfException
的块之前,这两个块都不会被调用。这是因为这两个类都属于Exception
类型,因此会匹配第一个测试。
如果配置文件有问题(如果文件不存在或不可写),则调用第一个catch
块(FileException
)。如果在解析 XML 文件时出现错误(例如,如果一个元素没有关闭),那么调用第二个块(XmlException
)。如果一个有效的 XML 文件不包含预期的根conf
元素,则调用第三个块(ConfException
)。不应该到达最后一个块(Exception
),因为我的方法只生成三个异常,这三个异常是显式处理的。拥有这样的“backstop”块通常是个好主意,以防在开发过程中向代码添加新的异常。
Note
如果您确实提供了一个“逆止”catch 块,您应该确保在大多数情况下确实对异常做了一些事情——静默失败会导致难以诊断的错误。
这些细粒度的catch
块的好处是,它们允许您对不同的错误应用不同的恢复或失败机制。例如,您可能决定结束执行,记录错误并继续,或者显式地再次引发错误。
这里你可以玩的另一个技巧是抛出一个新的异常来包装当前的异常。这允许您声明错误并添加您自己的上下文信息,同时保留由您捕获的异常封装的数据。你可以在第十五章中读到更多关于这项技术的内容。
那么,如果客户端代码没有捕捉到异常,会发生什么呢?它被隐式地重新抛出,客户端自己的调用代码有机会捕获它。这个过程会一直继续,直到异常被捕获或者不再被抛出。此时,会出现致命错误。如果我没有捕捉到示例中的一个异常,将会发生以下情况:
PHP Fatal error: Uncaught exception 'FileException' with message
'file 'nonexistent/not_there.xml' does not exist' in ...
所以,当你抛出一个异常时,你就迫使客户端负责处理它。这不是放弃责任。当方法检测到错误,但没有上下文信息来智能地处理它时,应该抛出异常。我的例子中的write()
方法知道写操作何时会失败,它知道失败的原因,但是不知道该怎么办。这是应该的。如果我让Conf
类比现在更加知识化,它会失去焦点,变得不那么可重用。
用 finally 清除 try/catch 块后
异常影响代码流的方式可能会导致意想不到的问题。例如,在try
块中产生异常后,可能不会执行清理代码或其他必要的内务处理。正如您所看到的,如果一个异常是在一个try
块中生成的,那么流程会直接转移到相关的catch
块。关闭数据库连接或文件句柄的代码可能不会被调用,状态信息可能不会被更新。
例如,想象一下,Runner::init()
记录了它的行为。它记录初始化过程的开始、遇到的任何错误,然后记录初始化过程的结束。这里,我提供了这种日志记录的典型简化示例:
// listing 04.74
public static function init(): void
{
try {
$fh = fopen("/tmp/log.txt", "a"); fputs($fh, "start\n");
$conf = new Conf(dirname( FILE ) . "/conf.broken.xml");
print "user: " . $conf->get('user') . "\n";
print "host: " . $conf->get('host') . "\n";
$conf->set("pass", "newpass");
$conf->write();
fputs($fh, "end\n");
fclose($fh);
} catch (FileException $e) {
// permissions issue or non-existent file
fputs($fh, "file exception\n");
throw $e;
} catch (XmlException $e) {
fputs($fh, "xml exception\n");
// broken xml
} catch (ConfException $e) {
fputs($fh, "conf exception\n");
// wrong kind of XML file
} catch (\Exception $e) {
fputs($fh, "general exception\n");
// backstop: should not be called
}
}
我打开一个文件,log.txt
;我给它写信;然后我调用我的配置代码。如果在这个过程中遇到异常,我会将这个事实记录在相关的catch
块中。我通过写入日志并关闭其文件句柄来结束try
块。
当然,如果遇到异常,这最后一步将永远不会到达。流程直接传递到相关的catch
块,而try
块的其余部分从不运行。以下是生成 XML 异常时的日志输出:
start
xml exception
如您所见,日志记录开始了,并且记录了文件异常,但是记录日志记录结束的那部分代码从未到达,因此日志没有更新。
您可能认为解决方案是将最后的日志记录步骤完全放在try
/ catch
块之外。这不会可靠地工作。如果捕获到一个生成的异常,并且try
块允许继续执行,那么流程将移动到try
/ catch
构造之外。然而,catch
块可能会再次抛出异常,或者完全结束脚本执行。
为了帮助程序员处理类似这样的问题,PHP 5.5 引入了一个新的关键字:finally
。如果你熟悉 Java,你可能以前见过这个。尽管当抛出匹配异常时,catch
块只是有条件地运行,但是无论try
块中是否产生异常,finally
块总是运行的。
我可以通过将我的日志写和代码移动到靠近一个finally
块来解决这个问题:
// listing 04.75
public static function init2(): void
{
$fh = fopen("/tmp/log.txt", "a");
try {
fputs($fh, "start\n");
$conf = new Conf(dirname( FILE ) . "/conf.not-there.xml");
print "user: " . $conf->get('user') . "\n";
print "host: " . $conf->get('host') . "\n";
$conf->set("pass", "newpass");
$conf->write();
} catch (FileException $e) {
// permissions issue or non-existent file
fputs($fh, "file exception\n");
//throw $e;
} catch (XmlException $e) {
fputs($fh, "xml exception\n");
// broken xml
} catch (ConfException $e) {
fputs($fh, "conf exception\n");
// wrong kind of XML file
} catch (Exception $e) {
fputs($fh, "general exception\n");
// backstop: should not be called
} finally {
fputs($fh, "end\n");
fclose($fh);
}
}
因为日志写入和fclose()
调用被包装在一个finally
块中,所以即使这些语句被运行,就像在一个FileException
被捕获的情况下,异常被重新抛出。
以下是生成FileException
时的日志文本:
start
file exception
end
Note
如果被调用的 catch 块再次抛出一个异常或返回值,那么就会运行一个finally
块。但是,调用try
或catch
块中的die()
或exit()
将结束脚本执行,并且finally
块将不会运行。
最终类和方法
继承允许类层次结构中的巨大灵活性。您可以重写一个类或方法,以便客户端方法中的调用将获得完全不同的效果,这取决于它所传递的是哪个类实例。然而,有时一个类或方法应该保持固定不变。如果您已经为您的类或方法实现了明确的功能,并且您觉得覆盖它只会损害您工作的最终完美性,那么您可能需要final
关键字。
停止继承。final 类不能被子类化。不太明显的是,final 方法不能被重写。
这里有一个final
类:
// listing 04.76
final class Checkout
{
// ...
}
这里尝试对Checkout
类进行子类化:
// listing 04.77
class IllegalCheckout extends Checkout
{
// ...
}
这会产生一个错误:
Fatal error: Class popp\ch04\batch13\IllegalCheckout may not inherit
from final class (popp\ch04\batch13\Checkout) in ...
通过在Checkout
final 中声明一个方法,而不是在整个类中声明,我可以稍微放松一下。关键字final
应该放在任何其他修饰语如protected
或static
之前,就像这样:
// listing 04.78
class Checkout
{
final public function totalize(): void
{
// calculate bill
}
}
我现在可以子类化Checkout
,但是任何覆盖totalize()
的尝试都会导致致命错误:
// listing 04.79
class IllegalCheckout extends Checkout
{
final public function totalize(): void
{
// change bill calculation
}
}
Fatal error: Cannot override final method popp\ch04\batch14\Checkout::totalize() in /var/popp/src/ch04/batch14/IllegalCheckout.php on line 9
好的面向对象代码倾向于强调定义良好的接口。然而,在接口背后,实现往往会有所不同。不同的类或类的组合符合公共接口,但在不同的环境中表现不同。通过将类或方法声明为 final,您限制了这种灵活性。有时候这是可取的,在本书的后面你会看到一些。然而,在宣布某件事是最终决定之前,你应该仔细考虑。真的没有覆盖有用的情况吗?当然,你可以随时改变你的想法,但是如果你发布一个库给其他人使用,这就不那么容易了。小心使用final
。
内部错误类
当异常第一次被引入时,尝试和捕捉的世界主要应用于用 PHP 编写的代码,而不是核心引擎。内部产生的错误保持着自己的逻辑。如果您想以与代码生成异常相同的方式管理核心错误,这可能会变得很麻烦。PHP 7 已经开始用Error
类来解决这个问题。这实现了Throwable
——与Exception
类实现了相同的内置接口,因此可以用相同的方式对待它。这也意味着表 4-1 中描述的方法被采用。Error
是针对单个错误类型的子类。下面是如何捕捉由eval
语句生成的解析错误:
// listing 04.80
try {
eval("illegal code");
} catch (\Error $e) {
print get_class($e) . "\n";
print $e->getMessage();
} catch (\Exception $e) {
// do something with an Exception
}
以下是输出结果:
ParseError
syntax error, unexpected identifier "code"
因此,您可以通过指定Error
超类或指定更具体的子类来匹配catch
块中的某些类型的内部错误。表 4-2 显示了当前的Error
子类。
表 4-2
PHP 7 引入的内置错误类
|错误
|
描述
|
| --- | --- |
| ArgumentCountError
| 当传递给用户定义的方法或函数的参数太少时抛出 |
| ArithmeticError
| 引发与数学相关的错误,尤其是与位算术相关的错误 |
| AssertionError
| 当assert()
语言构造(用于调试)失败时抛出 |
| CompileError
| 当 PHP 代码格式错误,无法编译运行时抛出 |
| DivisionByZeroError
| 当试图将一个数除以零时抛出 |
| ParseError
| 当运行时尝试解析 PHP(例如,使用eval()
)失败时抛出 |
| TypeError
| 当将错误类型的参数传递给方法、方法返回错误类型的值或传递给方法的参数数量不正确时引发 |
使用拦截器
PHP 提供了内置的拦截器方法,可以拦截发送给未定义的方法和属性的消息。这也被称为重载,但是由于这个术语在 Java 和 C++中的意思完全不同,我认为更好的说法是拦截。
PHP 支持各种内置的拦截器或“神奇”的方法。像__construct()
一样,当合适的条件被满足时,它们会被调用。表 4-3 描述了其中的一些方法。
表 4-3
拦截器方法
|方法
|
描述
|
| --- | --- |
| __get($property)
| 当访问未定义的属性时调用 |
| __set($property, $value)
| 将值赋给未定义的属性时调用 |
| __isset($property)
| 在未定义的属性上调用isset()
时调用 |
| __unset($property)
| 在未定义的属性上调用unset()
时调用 |
| __call($method, $arg_array)
| 当调用未定义的非静态方法时调用 |
| __callStatic($method, $arg_array)
| 当调用未定义的静态方法时调用 |
Note
你可以在 PHP 手册页阅读更多关于拦截器或魔法方法的内容: www.php.net/manual/en/language.oop5.magic.php
。
__get()
和__set()
方法是为处理没有在类(或其父类)中声明的属性而设计的。
当客户端代码试图读取未声明的属性时,调用__get()
。它是用包含客户端试图访问的属性名称的单个字符串参数自动调用的。从__get()
方法返回的任何内容都将被发送回客户端,就好像目标属性以该值存在一样。这里有一个简单的例子:
// listing 04.81
class Person
{
public function __get(string $property): mixed
{
$method = "get{$property}";
if (method_exists($this, $method)) {
return $this->$method();
}
}
public function getName(): string
{
return "Bob";
}
public function getAge(): int
{
return 44;
}
}
当客户端试图访问一个未定义的属性时,就会调用__get()
方法。我已经实现了__get()
来获取属性名并构造一个新字符串,在前面加上单词“get”。我将这个字符串传递给一个名为method_exists()
的函数,它接受一个对象和一个方法名,并测试方法是否存在。如果这个方法确实存在,我就调用它并将它的返回值传递给客户机。假设客户请求一个$name
属性:
// listing 04.82
$p = new Person();
print $p->name;
在这种情况下,在后台调用getName()
方法:
Bob
如果方法不存在,我什么也不做。用户试图访问的属性将解析为 null。
__isset()
方法的工作方式与__get()
相似。它在客户端对未定义的属性调用isset()
后被调用。下面是我如何扩展Person
:
// listing 04.83
public function __isset(string $property): bool
{
$method = "get{$property}";
return (method_exists($this, $method));
}
现在,谨慎的用户可以在使用属性之前对其进行测试:
// listing 04.84
$p = new Person();
if (isset($p->name)) {
print $p->name;
}
当客户端代码试图给一个未定义的属性赋值时,调用__set()
方法。传递给它两个参数:属性的名称和客户端试图设置的值。然后,您可以决定如何处理这些参数。在这里,我进一步修改了Person
类:
// listing 04.85
class Person
{
private ?string $myname;
private ?int $myage;
public function __set(string $property, mixed $value): void
{
$method = "set{$property}";
if (method_exists($this, $method)) {
$this->$method($value);
}
}
public function setName(?string $name): void
{
$this->myname = $name;
if (! is_null($name)) {
$this->myname = strtoupper($this->myname);
}
}
public function setAge(?int $age): void
{
$this->myage = $age;
}
}
在这个例子中,我使用“setter”方法,而不是“getter”如果用户试图给一个未定义的属性赋值,那么就用属性名和赋值调用__set()
方法。我测试适当的方法是否存在,如果存在就调用它。这样,我就可以过滤赋值了。
Note
请记住,PHP 文档中的方法和属性经常以静态术语的形式出现,以便用它们的类来标识它们。所以你可能会谈到Person::$name
属性,即使该属性没有被声明为static
,并且实际上是通过一个对象来访问的。
因此,如果我创建一个Person
对象,然后试图设置一个名为Person::$name
的属性,就会调用__set()
方法,因为这个类没有定义$name
属性。向该方法传递字符串"name"
和客户端分配的值。然后如何使用该值取决于__set()
的实现。在这个例子中,我用属性参数结合字符串"set"
构造了一个方法名。找到并适时调用setName()
方法。这将转换传入的值并将其存储在不动产中:
// listing 04.86
$p = new Person();
$p->name = "bob";
// the $myname property becomes 'BOB'
如你所料,__unset()
镜像__set()
。当unset()
在一个未定义的属性上被调用时,__unset()
以属性的名称被调用。然后你可以用这些信息做你想做的事情。这个例子将null
传递给一个方法,该方法使用了与__set()
相同的技术进行解析:
// listing 04.87
public function __unset(string $property): void
{
$method = "set{$property}";
if (method_exists($this, $method)) {
$this->$method(null);
}
}
__call()
方法可能是所有拦截器方法中最有用的。当客户端代码调用未定义的方法时,它被调用。用方法名和一个保存客户端传递的所有参数的数组调用__call()
。从__call()
方法返回的任何值都被返回给客户端,就像是被调用的方法返回的一样。
__call()
方法对委托很有用。委托是一个对象将方法调用传递给第二个对象的机制。它类似于继承,因为子类将方法调用传递给它的父实现。通过继承,子对象和父对象之间的关系是固定的,因此在运行时切换接收对象的能力意味着委托比继承更灵活。一个例子可以稍微澄清一些事情。下面是一个简单的类,用于格式化来自Person
类的信息:
// listing 04.88
class PersonWriter
{
public function writeName(Person $p): void
{
print $p->getName() . "\n";
}
public function writeAge(Person $p): void
{
print $p->getAge() . "\n";
}
}
当然,我可以对其进行子类化,以各种方式输出Person
数据。下面是一个使用了PersonWriter
对象和__call()
方法的Person
类的实现:
// listing 04.89
class Person
{
public function __construct(private PersonWriter $writer)
{
}
public function __call(string $method, array $args): mixed
{
if (method_exists($this->writer, $method)) {
return $this->writer->$method($this);
}
}
public function getName(): string
{
return "Bob";
}
public function getAge(): int
{
return 44;
}
}
这里的Person
类需要一个PersonWriter
对象作为构造函数参数,并将其存储在一个属性变量中。在__call()
方法中,我使用提供的$method
参数,在我存储的PersonWriter
对象中测试同名的方法。如果我遇到这样的方法,我将方法调用委托给PersonWriter
对象,将我的当前实例传递给它(在$this
伪变量中)。考虑如果客户端调用Person
会发生什么:
// listing 04.90
$person = new Person(new PersonWriter());
$person->writeName();
在这种情况下,调用__call()
方法。我在我的PersonWriter
对象中找到一个名为writeName()
的方法并调用它。这样我就不用像这样手动调用委托方法了:
// listing 04.91
public function writeName(): void
{
$this->writer->writeName($this);
}
使用拦截器方法,Person
类神奇地获得了两个新方法。尽管自动委托可以节省大量的跑腿工作,但在清晰度方面可能会有成本。如果你过于依赖委托,那么你给世界呈现的是一个抵制反射(类方面的运行时检查)的动态接口,并且对客户端编码人员来说乍一看并不总是清晰的。这是因为控制委托类和它的目标之间的交互的逻辑可能是模糊的——隐藏在像__call()
这样的方法中,而不是像类似的关系那样,通过继承关系或方法类型提示提前发出信号。拦截器方法有它们的位置,但是应该小心使用,依赖它们的类应该非常清楚地记录这个事实。
我将在本书的后面回到委派和反思的主题。
__get()
和__set()
拦截器方法也可以用来管理复合属性。这对客户端程序员来说是一种便利。例如,想象一个管理门牌号和街道名称的Address
类。最终,该对象数据将被写入数据库字段,因此将号码和街道分开是明智的。但是,如果门牌号和街道名称通常是不加区分地获得的,那么您可能想要帮助该类的用户。下面是一个管理复合属性的类,Address::$streetaddress
:
// listing 04.92
class Address
{
private string $number;
private string $street;
public function __construct(string $maybenumber, string $maybestreet = null)
{
if (is_null($maybestreet)) {
$this->streetaddress = $maybenumber;
} else {
$this->number = $maybenumber;
$this->street = $maybestreet;
}
}
public function __set(string $property, mixed $value): void
{
if ($property === "streetaddress") {
if (preg_match("/^(\d+.*?)[\s,]+(.+)$/", $value, $matches)) {
$this->number = $matches[1];
$this->street = $matches[2];
} else {
throw new \Exception("unable to parse street address: '{$value}'");
}
}
}
public function __get(string $property): mixed
{
if ($property === "streetaddress") {
return $this->number . " " . $this->street;
}
}
}
当用户试图设置(不存在)Address::$streetaddress
属性(通过类构造函数)时,拦截器方法__set()
被调用。在那里,我测试了财产名称,streetaddress
。在设置$number
和$street
属性之前,我必须首先确保所提供的值可以被解析,然后继续提取字段。对于这个例子,我设置了简单的规则。如果地址以数字开头,并且在第二部分前面有空格或逗号,则可以解析该地址。多亏了反向引用,如果检查通过,我已经在$matches
数组中有了我要找的数据,并且我给$number
和$street
属性赋值。如果解析失败,我抛出一个异常。所以当一个字符串如441b Bakers Street
被分配给Address::$streetaddress
时,实际上是$number
和$street
属性被填充。我可以用print_r()
来证明这一点:
// listing 04.93
$address = new Address("441b Bakers Street"); print_r($address);
popp\ch04\batch16\Address Object
(
[number:popp\ch04\batch16\Address:private] => 441b
[street:popp\ch04\batch16\Address:private] => Bakers Street
)
当然,__get()
方法要简单得多。每当访问Address::$streetaddress
属性时,就会调用__get()
。在这个拦截器的实现中,我测试了streetaddress
,如果发现匹配,我将返回$number
和$street
属性的串联。
Note
当客户端试图访问不可访问的方法或属性(即设置为private
或protected
的方法或属性,因此对调用上下文隐藏)时,__get()
、__set()
和__call()
也会被自动调用。
定义析构函数方法
您已经看到了在实例化一个对象时会自动调用__construct()
方法。PHP 5 还引入了__destruct()
方法。这在对象被垃圾收集之前被调用;也就是说,在它被从记忆中抹去之前。您可以使用此方法执行任何可能需要的最终清理。
想象一下,例如,一个类在排序时将自己保存到数据库中。我可以使用__destruct()
方法来确保一个实例在被删除时保存它的数据:
// listing 04.94
class Person
{
private int $id;
public function __construct(protected string $name, private int $age)
{
$this->name = $name;
$this->age = $age;
}
public function setId(int $id): void
{
$this->id = $id;
}
public function __destruct()
{
if (! empty($this->id)) {
// save Person data
print "saving person\n";
}
}
}
每当您在一个对象上调用unset()
函数时,或者当流程中不再存在对该对象的引用时,就会调用__destruct()
方法。因此,如果我创建并销毁一个Person
对象,您可以看到__destruct()
方法开始发挥作用:
// listing 04.95
$person = new Person("bob", 44);
$person->setId(343);
unset($person);
以下是输出:
saving person
虽然像这样的把戏很有趣,但值得警惕。__call()
、__destruct()
和他们的同事有时被称为神功。如果你读过奇幻小说,你就会知道,魔法并不总是一件好事。魔法是任意的,意想不到的。魔法扭曲了规则。魔法会产生隐性成本。
例如,在__destruct()
的案例中,你可能最终会给客户带来不受欢迎的惊喜。考虑一下Person
类——它在其__destruct()
方法中执行数据库写操作。现在想象一个开发新手无所事事地测试Person
类。他没有发现__destruct()
方法,他开始实例化一组Person
对象。将值传递给构造函数,他将 CEO 的秘密且有点猥亵的昵称分配给属性$name
,然后将$age
设置为 150。他运行了几次测试脚本,尝试不同的姓名和年龄组合。
第二天早上,他的经理让他去会议室解释为什么数据库里有侮辱性的数据。寓意呢?不要相信魔法。
使用 __clone()复制对象
在 PHP 4 中,复制一个对象只是简单地将一个变量赋值给另一个变量:
// listing 04.96
class CopyMe
{
}
// listing 04.97
$first = new CopyMe();
$second = $first;
// PHP 4: $second and $first are 2 distinct objects
// PHP 5 plus: $second and $first refer to one object
这种“简单的事情”是许多错误的来源,因为当变量被赋值、方法被调用和对象被返回时,对象副本被意外地产生。更糟糕的是,没有办法测试两个变量来确定它们是否指向同一个对象。等价测试会告诉你是否所有的字段都是相同的(==
)或者两个变量都是对象(===
),但是不会告诉你它们是否指向同一个对象。
在 PHP 中,似乎包含一个对象的变量实际上包含一个引用底层数据结构的标识符。当这样的变量被赋值或传递给一个方法时,它包含的标识符被复制。但是,每个副本继续指向同一个对象。这意味着,在我之前的例子中,$first
和$second
包含指向同一个对象的标识符,而不是该对象的两个副本。虽然这通常是您在处理对象时想要的,但有时您需要获得对象的副本。
PHP 为此提供了关键字clone
。clone
对一个对象实例进行操作,产生一个按值复制:
// listing 04.98
$first = new CopyMe();
$second = clone $first;
// PHP 5 plus: $second and $first are 2 distinct objects
围绕对象复制的问题仅仅从这里开始。考虑我在上一节中实现的Person
类。一个Person
对象的默认副本将包含标识符($id
属性),在一个完整的实现中,我将使用它来定位数据库中的正确行。如果我允许复制这个属性,客户端编码人员可能会以两个不同的对象表示同一个数据实体(数据库行)而告终,这可能不是她在复制时想要的。
幸运的是,当在对象上调用clone
时,您可以控制复制什么。您可以通过实现一个名为__clone()
的特殊方法来实现这一点(注意前面的两个下划线是魔术方法的特征)。当在一个对象上调用clone
关键字时,会自动调用__clone()
。
当你实现__clone()
时,理解方法运行的环境是很重要的。__clone()
是在上运行的复制的对象而不是原来的。在这里,我将__clone()
添加到Person
类的另一个版本中:
// listing 04.99
class Person
{
private int $id = 0;
public function __construct(private string $name, private $age)
{
}
public function setId(int $id): void
{
$this->id = $id;
}
public function __clone(): void
{
$this->id = 0;
}
}
当在一个Person
对象上调用clone
时,产生一个新的浅拷贝,并且调用它的 __clone()
方法。这意味着我在__clone()
中做的任何事情都会覆盖我已经创建的默认副本。在这种情况下,我确保复制对象的$id
属性设置为零:
// listing 04.100
$person = new Person("bob", 44);
$person->setId(343);
$person2 = clone $person;
浅层复制确保原始属性从旧对象复制到新对象。对象属性复制了它们的标识符,但没有复制它们的底层数据,这可能不是您在克隆对象时想要或期望的。假设我给了Person
对象一个Account
对象属性。这个对象有一个余额,我想把它复制到克隆的对象中。但是,我不想让两个Person
对象都持有对同一个账户的引用:
// listing 04.101
class Account
{
public function __construct(public float $balance)
{
}
}
// listing 04.102
class Person
{
private int $id;
public function __construct(private string $name, private int $age, public Account $account)
{
}
public function setId(int $id): void
{
$this->id = $id;
}
public function __clone(): void
{
$this->id = 0;
}
}
// listing 04.103
$person = new Person("bob", 44, new Account(200));
$person->setId(343);
$person2 = clone $person;
// give $person some money
$person->account->balance += 10;
// $person2 sees the credit too
print $person2->account->balance;
这将产生以下输出:
210
$person
保存了对一个Account
对象的引用,为了简洁起见,我将该对象保持为可公开访问的(如您所知,我通常会限制对一个属性的访问,如有必要,提供一个访问器方法)。当克隆被创建时,它保存了一个对$person
引用的同一个Account
对象的引用。我通过添加到$person
对象的Account
并通过$person2
确认增加的平衡来演示这一点。
如果我不想在克隆操作后共享一个对象属性,那么由我来决定在__clone()
方法中显式克隆它:
// listing 04.104
public function __clone(): void
{
$this->id = 0;
$this->account = clone $this->account;
}
为对象定义字符串值
PHP 5 引入的另一个受 Java 启发的特性是__toString()
方法。在 PHP 5.2 之前,当您打印一个对象时,它会解析为如下所示的字符串:
// listing 04.105
class StringThing
{
}
// listing 04.106
$st = new StringThing();
print $st;
从 PHP 5.2 开始,这段代码会产生这样的错误:
Object of class popp\ch04\batch22\StringThing could not be converted to string ...
通过实现一个__toString()
方法,您可以控制对象在字符串上下文中被访问时如何表示它们自己(或者显式地转换为字符串)。__toString()
应该写成返回一个字符串值。当您的对象被传递给print
或echo
时,该方法被自动调用,其返回值被替换。在这里,我给一个最小的Person
类添加了一个__toString()
版本:
// listing 04.107
class Person
{
public function getName(): string
{
return "Bob";
}
public function getAge(): int
{
return 44;
}
public function __toString(): string
{
$desc = $this->getName() . " (age ";
$desc .= $this->getAge() . ")"; return $desc;
}
}
现在,当我打印一个Person
对象时,该对象将解析为:
// listing 04.108
$person = new Person();
print $person;
Bob (age 44)
对于日志记录和错误报告,以及主要任务是传递信息的类来说,__toString()
方法特别有用。例如,Exception
类在其__toString()
方法中汇总异常数据。
从 PHP 8 开始,任何实现了__toString()
方法的类都被隐式声明为实现了内置的Stringable
接口。这意味着您可以使用联合类型声明来约束参数和属性。这里有一个例子:
// listing 04.109
public static function printThing(string|\Stringable $str): void
{
print $str;
}
我们可以将一个字符串或我们的Person
对象传递给printThing()
方法,它会很高兴地接受任何一个,并确信它可以以任何它选择的类似字符串的方式处理我们传递的任何内容。
回调、匿名函数和闭包
虽然严格来说,匿名函数不是面向对象的特性,但在这里提到它还是很有用的,因为在利用回调的面向对象应用中,您可能会遇到它们。
Note
一个回调是一个可执行代码块,它可以存储在一个变量中,或者传递给方法和函数供以后调用。
首先,这里有几个类:
// listing 04.110
class Product
{
public function __construct(public string $name, public float $price)
{
}
}
// listing 04.111
class ProcessSale
{
private array $callbacks;
public function registerCallback(callable $callback): void
{
$this->callbacks[] = $callback;
}
public function sale(Product $product): void
{
print "{$product->name}: processing \n";
foreach ($this->callbacks as $callback) {
call_user_func($callback, $product);
}
}
}
这段代码是为运行我的各种回调而设计的。它由两类组成,Product
和ProcessSale
。Product
只是简单的存储$name
和$price
的属性。为了简洁起见,我公开了这些内容。请记住,在现实世界中,您可能希望使您的属性成为私有的或受保护的,并在必要时提供访问器方法。
ProcessSale
由两种方法组成。第一个函数registerCallback()
接受一个callable
类型,并将其添加到$callbacks
数组属性中。第二个方法,sale()
,接受一个Product
对象,输出一条关于它的消息,然后遍历$callbacks
数组属性。
它将每个元素传递给call_user_func()
,后者调用代码,传递给它一个对产品的引用。下面所有的例子都将适用于这个框架。
为什么回调有用?它们允许您在运行时将与组件核心任务不直接相关的功能插入到组件中。通过使组件具有回调感知能力,您可以让其他人在您还不知道的上下文中扩展您的代码。
例如,想象一下,ProcessSale
的未来用户想要创建一个销售日志。如果用户有权访问该类,她可能会将日志代码直接添加到sale()
方法中。然而,这并不总是一个好主意。如果她不是提供ProcessSale
的包的维护者,那么她的修改会在下次包升级时被覆盖。即使她是组件的维护者,给sale()
方法添加许多附带的任务将开始压倒它的核心职责,并潜在地使它在项目间不太可用。我将在下一节回到这些主题。
幸运的是,我让ProcessSale
知道回调。在这里,我创建了一个模拟日志记录的回调:
// listing 04.112
$logger = function ($product) {
print " logging ({$product->name})\n";
};
$processor = new ProcessSale();
$processor->registerCallback($logger);
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
在这里,我创建了一个匿名函数。也就是说,我内联使用了function
关键字,并且没有函数名。注意,因为这是一个内联语句,所以在代码块的末尾需要一个分号。我的匿名函数可以存储在变量中,并作为参数传递给函数和方法。这就是我所做的,将函数赋给$logger
变量,并将其传递给ProcessSale::registerCallback()
方法。最后,我创建了几个产品,并将它们传递给sale()
方法。然后处理销售(实际上,打印一条关于产品的简单消息),并执行任何回调。下面是实际运行的代码:
shoes: processing
logging (shoes)
coffee: processing
logging (coffee)
PHP 7.4 引入了一种声明匿名函数的新方法。箭头函数在功能上与您已经遇到的匿名函数非常相似。然而,语法要简洁得多。它们不是由关键字function
定义的,而是由fn
定义的,然后是一个参数列表的括号,最后是一个箭头操作符(=>
)后跟一个表达式,以此代替括号。这种紧凑的形式使得 arrow 函数在构建自定义排序等小回调时非常方便。在这里,我使用一个箭头函数将$logger
匿名函数替换为一个完全等价的函数:
// listing 04.113
$logger = fn($product) => print " logging ({$product->name})\n";
arrow 函数要简洁得多,但是,因为您只定义了一个表达式,所以它最适合用于相对简单的任务。
当然,回调不必匿名。您可以使用函数名,甚至对象引用和方法作为回调。在这里,我就是这样做的:
// listing 04.114
class Mailer
{
public function doMail(Product $product): void
{
print " mailing ({$product->name})\n";
}
}
// listing 04.115
$processor = new ProcessSale();
$processor->registerCallback([new Mailer(), "doMail"]);
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
我创建一个类:Mailer
。它的单个方法doMail()
接受一个Product
对象并输出关于它的消息。当我调用registerCallback()
时,我传递给它一个数组。第一个元素是一个Mailer
对象,第二个是一个字符串,它匹配我想要调用的方法的名称。
记住registerCallback()
使用类型声明来强制执行callable
参数。PHP 足够聪明,能够识别出这种类型的数组是可调用的。数组形式的有效回调应该将对象作为第一个元素,将方法名作为第二个元素。我通过了这里的测试,这是我的输出:
shoes: processing
mailing (shoes)
coffee: processing
mailing (coffee)
您可以让一个方法返回一个匿名函数,就像这样:
// listing 04.116
class Totalizer
{
public static function warnAmount(): callable
{
return function (Product $product) {
if ($product->price > 5) {
print " reached high price: {$product->price}\n";
}
};
}
}
// listing 04.117
$processor = new ProcessSale();
$processor->registerCallback(Totalizer::warnAmount());
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
除了使用warnAmount()
方法作为匿名函数的工厂的便利之外,我在这里没有增加太多的兴趣。但是这个结构允许我做的不仅仅是生成一个匿名函数。它允许我利用闭包。匿名函数可以引用在匿名函数的父作用域中声明的变量。这是一个很难理解的概念。这就好像匿名函数继续记住它被创建的上下文。想象一下,我想让Totalizer::warnAmount()
做两件事。首先,我希望它接受一个任意的目标金额。第二,我希望它能记录产品销售时的价格。当总数超过目标数量时,该函数将执行一个操作(在这种情况下,正如您可能已经猜到的,它将简单地编写一条消息)。
我可以用一个use
子句让我的匿名函数跟踪更大范围内的变量:
// listing 04.118
class Totalizer2
{
public static function warnAmount($amt): callable
{
$count = 0;
return function ($product) use ($amt, &$count) {
$count += $product->price;
print " count: $count\n";
if ($count > $amt) {
print " high price reached: {$count}\n";
}
};
}
}
// listing 04.119
$processor = new ProcessSale();
$processor->registerCallback(Totalizer2::warnAmount(8));
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
由Totalizer2::warnAmount()
返回的匿名函数在其use
子句中指定了两个变量。第一个是$amt
。这是warnAmount()
接受的论点。第二个闭包变量是$count
。$count
在warnAmount()
的主体中声明,初始设置为零。注意,我在use
子句中的$count
变量前加了一个&符号。这意味着在匿名函数中将通过引用而不是通过值来访问变量。在匿名函数的主体中,我将$count
增加产品的值,然后根据$amt
测试新的总数。如果达到了目标值,我会输出一个通知。
下面是实际运行的代码:
shoes: processing
count: 6
coffee: processing
count: 12
high price reached: 12
这表明回调在调用之间跟踪$count
。$count
和$amt
仍然与函数相关联,因为它们出现在声明的上下文中,并且在use
子句中被指定。
箭头函数也生成闭包(像匿名函数一样,它们解析为内置Closure
类的一个实例)。与需要与闭包变量显式关联的匿名函数不同,它们自动获得范围内所有变量的按值副本。这里有一个例子:
// listing 04.120
$markup = 3;
$counter = fn(Product $product) => print "($product->name) marked up price: " .
($product->price + $markup) . "\n";
$processor = new ProcessSale();
$processor->registerCallback($counter);
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
我能够在传递给ProcessSale::sale()
的匿名函数中访问$markup
。但是,因为函数只能通过值访问,所以我在函数中执行的任何操作都不会影响源变量。
PHP 7.1 引入了一种在对象上下文中管理闭包的新方法。Closure::fromCallable()
方法允许你生成一个闭包,这个闭包赋予调用代码对对象的类和属性的访问权。下面是Totalizer
系列的一个版本,它使用对象属性来实现与上一个示例相同的结果:
// listing 04.121
class Totalizer3
{
private float $count = 0;
private float $amt = 0;
public function warnAmount(int $amt): callable
{
$this->amt = $amt;
return \Closure::fromCallable([$this, "processPrice"]);
}
private function processPrice(Product $product): void
{
$this->count += $product->price;
print " count: {$this->count}\n";
if ($this->count > $this->amt) {
print " high price reached: {$this->count}\n";
}
}
}
在这个例子中,warnAmount()
方法不是静态的。这是因为,多亏了Closure::fromCallable()
,我返回了一个对processPrice()
方法的回调,该方法可以访问更大的对象。我设置了$amt
属性并返回一个可调用的方法引用。当被调用时,processPrice()
增加一个$count
属性,并在达到$amt
属性值时发出警告。如果processPrice()
是一个公共方法,我可以简单地返回[$this, "processPrice"]
。正如我们所看到的,PHP 足够聪明,可以计算出这样一个两元素数组应该解析为 callable。然而,我想使用Closure::fromCallable()
有两个很好的理由。首先,我可以对私有或受保护的方法进行受控访问,而不必向整个世界公开它们——在控制访问的同时提供增强的功能。其次,我获得了性能提升,因为在确定返回值是否真正可调用时会有开销。
这里,我将Totalizer3
与未更改的ProcessSale
类一起使用:
// listing 04.122
$totalizer3 = new Totalizer3();
$processor = new ProcessSale();
$processor->registerCallback($totalizer3->warnAmount(8));
$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));
匿名类
PHP 7 引入了匿名类。当您需要从一个小类创建和派生一个实例时,当所讨论的父类简单且特定于本地上下文时,这些是有用的。
让我们回到我们的PersonWriter
例子。这次我将从创建一个接口开始:
// listing 04.123
interface PersonWriter
{
public function write(Person $person): void;
}
现在,这里有一个可以使用PersonWriter
对象的Person
类的版本:
// listing 04.124
class Person
{
public function output(PersonWriter $writer): void
{
$writer->write($this);
}
public function getName(): string
{
return "Bob";
}
public function getAge(): int
{
return 44;
}
}
output()
方法接受一个PersonWriter
实例,然后将当前类的一个实例传递给它的write()
方法。通过这种方式,Person
类很好地与编写器的实现隔离开来。
转到客户端代码,如果我们需要一个编写器来打印一个Person
对象的名字和年龄值,我们可以继续用通常的方式创建一个类。但是这是一个如此简单的实现,我们同样可以创建一个类并同时将其传递给Person
:
// listing 04.125
$person = new Person();
$person->output(
new class implements PersonWriter {
public function write(Person $person): void
{
print $person->getName() . " " . $person->getAge() . "\n";
}
}
);
如您所见,您可以用关键字new class
声明一个匿名类。然后,在创建类块之前,您可以添加任何所需的extends
和implements
子句。
匿名类不支持闭包。换句话说,在更大范围内声明的变量不能在类内访问。然而,您可以将值传递给匿名类的构造函数。让我们创建一个稍微复杂一点的PersonWriter
:
// listing 04.126
$person = new Person();
$person->output(
new class ("/tmp/persondump") implements PersonWriter {
private $path;
public function __construct(string $path)
{
$this->path = $path;
}
public function write(Person $person): void
{
file_put_contents($this->path, $person->getName() . " " . $person->getAge() . "\n");
}
}
);
我向构造函数传递了一个路径参数。这个值存储在$path
属性中,最终由write()
方法使用。
当然,如果您的匿名类的规模和复杂性开始增长,那么在类文件中创建一个命名类就变得更加明智了。如果您发现自己在不止一个地方复制匿名类,这一点尤其正确。
摘要
在这一章中,我们开始掌握 PHP 的高级面向对象特性。当你阅读这本书时,其中的一些会变得熟悉。特别是,我将经常回到抽象类、异常和静态方法。
在下一章中,我将从内置的对象特性后退一步,看看设计用来帮助你处理对象的类和函数。
五、对象工具
正如我们所见,PHP 通过类和方法等语言结构支持面向对象编程。该语言还通过旨在帮助您处理对象的函数和类提供了更广泛的支持。
在这一章中,我们将会看到一些可以用来组织、测试和操作对象和类的工具和技术。
本章将涵盖以下工具和技术:
-
名称空间(Namespaces):将你的代码组织成独立的类似包的部分
-
包含路径:为您的库代码设置中央可访问位置
-
类和对象函数:测试对象、类、属性和方法的函数
-
反射 API:一套强大的内置类,在运行时提供对类信息前所未有的访问
-
属性 : PHP 对注释的实现——一种机制,通过这种机制,类、方法、属性和参数可以使用源代码中的标签用丰富的信息来增强
PHP 和包
包是一组相关的类和函数,通常以某种方式组合在一起。包可以用来将系统的各个部分相互分离。一些编程语言正式识别包,并为它们提供不同的名称空间。PHP 本身没有包的概念,但是从 PHP 5.3 开始,它引入了名称空间。我将在下一节讨论这个特性。我还将看看将类组织成类似包的结构的老方法。
PHP 包和名称空间
尽管 PHP 本质上并不支持包的概念,但开发人员传统上使用命名方案和文件系统将他们的代码组织成类似包的结构。
直到 PHP 5.3,开发人员被迫在共享的上下文中命名他们的文件。换句话说,如果您命名了一个类ShoppingBasket
,它将立即在您的系统中可用。这导致了两个主要问题。首先,也是最具破坏性的,是命名冲突的可能性。你可能认为这不太可能。毕竟,你所要做的就是记住给所有的类起一个唯一的名字,对吗?问题是,我们越来越依赖库代码。这当然是件好事,因为它促进了代码重用。但是假设您的项目是这样的:
// listing 05.01
require_once __DIR__ . "/../useful/Outputter.php";
class Outputter
{
// output data
}
现在假设您在useful/Outputter.php
合并了包含的文件:
// listing 05.02
class Outputter
{
//
}
你能猜到会发生什么,对吗?出现这种情况:
PHP Fatal error: Cannot declare class Outputter because the name is
already in use in /var/popp/src/ch05/batch01/useful/Outputter.php on
line 4
在引入名称空间之前,有一个解决这个问题的传统方法。答案是在类名前面加上包名,这样就保证了类名的唯一性:
// listing 05.03
// my/Outputer.php
require_once __DIR __ . "/../useful/Outputter.php";
class my_Outputter
{
// output data
}
// listing 05.04
// useful/Outputter.php
class useful_Outputter
{
//
}
这里的问题是,随着项目越来越复杂,类名变得越来越长。这不是一个很大的问题,但是它导致了代码可读性的问题,并且使得在工作时更难记住类名。许多累积的编码时间都浪费在了打字错误上。
如果您维护的是遗留代码,您可能仍然会看到遵循这种约定的代码。因此,在本章的后面,我将简单地回到处理包的老方法。
名称空间拯救世界
PHP 5.3 引入了名称空间。本质上,名称空间是一个桶,您可以在其中放置您的类、函数和变量。在命名空间中,您可以无限制地访问这些项。从外部,您必须导入或引用该命名空间,以便访问它包含的项。
迷茫?举个例子应该会有帮助。在这里,我使用名称空间重写了前面的示例:
// listing 05.05
namespace my;
require_once __DIR__ . "/../useful/Outputter.php";
class Outputter
{
// output data
}
// listing 05.06
namespace useful;
class Outputter
{
//
}
注意关键字namespace
。如您所料,这个关键字建立了一个名称空间。如果使用这个特性,那么命名空间声明必须是其文件中的第一条语句。我创建了两个名称空间:my
和useful
。不过,通常情况下,您会希望有更深的名称空间。您将从一个组织或项目标识符开始。然后,您需要通过产品包来进一步验证这一点。PHP 允许您声明嵌套的名称空间。为此,您只需使用反斜杠字符来划分每个级别:
// listing 05.07
namespace popp\ch05\batch04\util;
class Debug
{
public static function helloWorld(): void
{
print "hello from Debug\n";
}
}
您通常会使用与产品或组织相关的名称来定义存储库。我可能会使用我的一个域:getinstance.com
,例如。因为域名对其所有者来说是唯一的,所以这是 Java 开发人员通常用于他们的包名的一个技巧。它们颠倒域名,从最普通的到最特殊的。或者,我可以使用我为本书中的代码示例选择的名称空间:popp
,作为书名。一旦我确定了我的存储库,我可能会继续定义包。在这种情况下,我使用的是章节,然后是编号的批次。这允许我将示例组组织到离散的桶中。所以在这一章的这一点上,我在popp\ch05\batch04
。最后,我可以进一步按类别组织代码。我和util
一起去了。
那么我该如何调用这个方法呢?事实上,这取决于你从哪里打电话。如果从命名空间内调用方法,可以直接调用方法:
// listing 05.08
Debug::helloWorld();
这就是所谓的非限定名。因为我已经在popp\ch05\batch04\util
名称空间中,所以我不需要在类名前添加任何类型的路径。如果我从命名空间上下文之外访问该类,我可以这样做:
// listing 05.09
\popp\ch05\batch04\Debug::helloworld();
我会从下面的代码中得到什么输出?
// listing 05.10
namespace main;
popp\ch05\batch04\Debug::helloworld();
那是一个棘手的问题。事实上,这是我的输出:
PHP Fatal error: Class 'popp\ch05\batch04\Debug' not found in...
这是因为我在这里使用了相对名称空间。PHP 在名称空间main
下寻找popp\ ch05\batch04\util
,但没有找到。正如您可以通过使用分隔符创建绝对 URL 和文件路径一样,您也可以创建名称空间。此版本的示例修复了以前的错误:
// listing 05.11
namespace main;
\popp\ch05\batch04\Debug::helloworld();
这个反斜杠告诉 PHP 从根开始搜索,而不是从当前名称空间开始。
但是名称空间不是应该帮助您减少输入吗?当然,Debug
类声明更短,但是这些调用就像旧的命名约定一样冗长。您可以使用use
关键字来解决这个问题。这允许您在当前名称空间内为其他名称空间起别名。这里有一个例子:
// listing 05.12
namespace main;
use popp\ch05\batch04\util;
util\Debug::helloWorld();
popp\ch05\batch04\util
名称空间被导入并隐式别名化为util
。请注意,我没有以反斜杠字符开头。use
的参数是从根空间搜索的,而不是从当前的名称空间。如果我根本不想引用名称空间,我可以导入Debug
类本身:
// listing 05.13
namespace main;
use popp\ch05\batch04\util\Debug;
Debug::helloWorld();
这是最常用的约定。但是,如果在调用名称空间中已经有了一个Debug
类,会发生什么呢?这里有这样一个类:
// listing 05.14
namespace popp\ch05\batch04;
class Debug
{
public static function helloWorld(): void
{
print "hello from popp\\ch05\\batch04\\Debug\n";
}
}
这里有一些来自popp\ch05\batch04
名称空间的调用代码,它引用了两个Debug
类:
// listing 05.15
namespace popp\ch05\batch04;
use popp\ch05\batch04\util\Debug;
use popp\ch05\batch04\Debug;
Debug::helloWorld();
如您所料,这会导致致命错误:
PHP Fatal error: Cannot use popp\ch05\batch04\Debug as Debug because the name is already in use in...
因此,我似乎又回到了原点,回到了类名冲突。幸运的是,这个问题有一个答案。我可以明确我的别名:
// listing 05.16
namespace popp\ch05\batch04;
use popp\ch05\batch04\util\Debug;
use popp\ch05\batch04\Debug as CoreDebug;
CoreDebug::helloWorld();
通过将as
子句用于use
,我能够将Debug
别名改为coreDebug
。
如果你在一个命名空间中编写代码,并且你想访问一个驻留在根(非命名空间)空间中的类、特征或接口(例如,PHP 的核心类,如Exception
、Error
、Closure
),你可以简单地在名字前面加一个反斜杠。这里有一个在根空间中声明的类:
// listing 05.17
class TreeLister
{
public static function helloWorld(): void
{
print "hello from root namespace\n";
}
}
这里有一些命名空间代码:
// listing 05.18
namespace popp\ch05\batch04\util;
class TreeLister
{
public static function helloWorld(): void
{
print "hello from " . __NAMESPACE__ . "\n";
}
}
// listing 05.19
namespace popp\ch05\batch04;
use popp\ch05\batch04\util\TreeLister;
TreeLister::helloWorld(); // access local
\TreeLister::helloWorld(); // access from root
命名空间代码声明了它自己的TreeLister
类。客户端代码使用本地版本,用一个use
语句指定完整路径。用单个反斜杠限定的名称访问根名称空间中类似命名的类。
这是上一个片段的输出:
hello from popp\ch05\batch04\util
hello from root namespace
这个输出值得展示,因为它演示了__NAMESPACE__
常量的操作。这将输出当前的名称空间,这在调试中很有用。
您可以使用已经看到的语法在同一个文件中声明多个名称空间。您还可以使用一种将大括号与 namespace 关键字结合使用的替代语法:
// listing 05.20
namespace com\getinstance\util {
class Debug
{
public static function helloWorld(): void
{
print "hello from Debug\n";
}
}
}
namespace other {
\com\getinstance\util\Debug::helloWorld();
}
如果您必须在同一个文件中组合多个名称空间,那么这是推荐的做法。然而,通常认为在每个文件的基础上定义名称空间是最佳实践。
Note
不能在同一个文件中同时使用大括号和 line 命名空间语法。你必须选择一个并坚持到底。
使用文件系统模拟包
无论您使用哪个版本的 PHP,您都应该使用文件系统来组织类,文件系统提供了一种包结构。例如,您可以创建util
和business
目录,并包含带有require_once()
语句的类文件,如下所示:
// listing 05.21
require_once('business/Customer.php');
require_once('util/WebTools.php');
你也可以使用include_once()
达到同样的效果。include()
和require()
语句之间的唯一区别在于它们对错误的处理。当您遇到错误时,使用require()
调用的文件将使您的整个过程停止。通过调用include()
遇到的同样的错误只会产生一个警告并结束包含文件的执行,让调用代码继续。这使得require()
和require_once()
成为包含库文件的安全选择,而include()
和include_once()
对于模板化之类的操作非常有用。
Note
require()
和require_once()
其实是语句,不是函数。这意味着您可以在使用括号时省略它们。就我个人而言,我更喜欢使用括号,但如果你也这样做,请做好被急于解释你的错误的学究们烦透的准备。
图 5-1 从 Nautilus 文件管理器的角度显示了util
和business
包。
图 5-1
使用文件系统组织的 PHP 包
Note
require_once()
接受文件的路径,并将其包含在当前脚本中。只有在目标尚未包含在其他地方的情况下,该语句才会包含目标。这种一次性方法在访问库代码时特别有用,因为它可以防止意外重定义类和函数。当使用像require()
或include()
这样的语句,同一个文件被脚本的不同部分包含在一个进程中时,就会发生这种情况。
习惯上优先使用require()
和require_once()
而不是类似的include()
和include_once()
功能。这是因为在使用require()
函数访问的文件中遇到致命错误会导致整个脚本停止运行。在使用include()
函数访问的文件中遇到的相同错误将导致包含文件的执行停止,但只会在调用脚本中生成一个警告。前者,更激烈的行为,更安全。
与require()
相比,使用require_once()
会产生开销。如果你需要从你的系统中挤出最后一毫秒,你可以考虑使用require()
来代替。通常情况下,这是效率和便利之间的权衡。
就 PHP 而言,这种结构并没有什么特别之处。您只是将库脚本放在不同的目录中。它确实有助于组织的整洁,并且可以与名称空间或命名约定并行使用。
命名梨的方式
在引入名称空间之前,开发人员被迫求助于约定来避免类名冲突。正如我们所看到的,其中最常见的是 PEAR 开发人员维护的假命名空间。
Note
PEAR 代表 PHP 扩展和应用库。它是官方维护的增加 PHP 功能的包和工具的档案。核心 PEAR 包包含在 PHP 发行版中,其他包可以使用简单的命令行工具添加。您可以在 http://pear.php.net
浏览梨包。
PEAR 使用文件系统来定义它的包,正如我所描述的。在引入名称空间之前,每个类都是根据其包路径命名的,每个目录名用下划线字符分隔。
例如,PEAR 包含一个名为 XML 的包,其中有一个 RPC 子包。RPC 包包含一个名为Server.php
的文件。如你所料,Server.php
中定义的类并不叫做Server
。如果没有名称空间,迟早会与 PEAR 项目或用户代码中的另一个Server
类发生冲突。相反,这个类被命名为XML_RPC_Server
。这种方法产生了不吸引人的类名。然而,它确实使代码易于阅读,因为类名总是描述它自己的上下文。
包括路径
当你组织你的组件时,有两个观点你应该记住。我已经介绍了第一种,文件和目录放在文件系统中。但是您也应该考虑组件相互访问的方式。到目前为止,我在本节中已经谈到了包含路径的问题。
当包含一个文件时,可以使用当前工作目录的相对路径或文件系统上的绝对路径来引用它。
Note
尽管理解包含路径的工作方式和要求文件所涉及的问题很重要,但是记住许多现代系统不再依赖类级别的 require 语句也很重要。相反,它们使用自动加载和名称空间的组合。稍后我将介绍自动加载,然后在第 15 和 16 章中更详细地查看实用的自动加载建议和工具。
到目前为止,您所看到的示例偶尔会指定需求文件和必需文件之间的固定关系:
// listing 05.22
require_once __DIR__ . "/../useful/Outputter.php";
这工作得很好,除了它硬编码了文件之间的关系。在调用类的包含目录旁边必须总是有一个useful
目录。
也许最糟糕的方法是曲折的相对路径:
// listing 05.23
require_once('../../projectlib/business/User.php');
这是有问题的,因为这里指定的路径不是相对于包含这个require_once
语句的文件,而是相对于配置的调用上下文(通常,但不总是,当前工作目录)。像这样的路径会导致混乱(根据我的经验,这几乎总是一个迹象,表明一个系统在其他方面也需要相当大的改进)。
当然,您可以使用绝对路径:
// listing 05.24
require_once('/home/john/projectlib/business/User.php');
这将适用于单个实例——但它很脆弱。通过如此详细地指定路径,可以将库文件冻结在特定的上下文中。每当你在一个新的服务器上安装项目时,所有的require
语句都需要改变以适应一个新的文件路径。这使得库很难重新定位,如果不制作副本,在项目间共享库也不切实际。在这两种情况下,您都失去了在所有附加目录中打包的想法。是business
套餐,还是projectlib/business
套餐?
如果您必须在代码中手动包含文件,最简洁的方法是将调用代码从库中分离出来。您已经看到了这样的结构:
// listing 05.25
require_once('business/User.php');
在前面使用这种路径的例子中,我们隐含地假设了一个相对路径。换句话说,business/User.php
在功能上与./business/User.php
相同。但是,如果前面的 require 语句可以在系统上的任何目录下工作,那会怎么样呢?您可以使用包含路径来实现这一点。这是 PHP 在试图获取文件时搜索的目录列表。你可以通过修改include_path
指令来增加这个列表。include_path
通常在 PHP 的中央配置文件php.ini
中设置。它定义了一个目录列表,在类 Unix 系统中用冒号分隔,在 Windows 系统中用分号分隔:
include_path = ".:/usr/local/lib/php-libraries"
如果您正在使用 Apache,您也可以在服务器应用的配置文件(通常称为httpd.conf
)或每个目录的 Apache 配置文件(通常称为.htaccess
)中设置include_path
,语法如下:
php_value include_path value .:/usr/local/lib/php-libraries
Note
文件在一些托管公司提供的网络空间中特别有用,这些公司提供非常有限的对服务器环境的访问。
当您使用一个文件系统函数,如fopen()
或require()
时,其非绝对路径相对于当前工作目录不存在,包含路径中的目录会自动搜索,从列表中的第一个目录开始(对于fopen()
,您必须在其参数列表中包含一个标志来启用该特性)。当遇到目标文件时,搜索结束,文件函数完成它的任务。
因此,通过将包目录放在包含目录中,您只需要在您的require()
语句中引用包和文件。
您可能需要向include_path
添加一个目录,这样您就可以维护自己的库目录。要做到这一点,您可以编辑php.ini
文件(记住,对于 PHP 服务器模块,您需要重启服务器以使更改生效)。
如果您没有使用php.ini
文件所需的权限,您可以使用set_include_path()
函数在脚本中设置包含路径。set_include_path()
接受一个包含路径(正如它将出现在php.ini
中)并仅改变当前进程的include_path
设置。php.ini
文件可能已经为include_path
定义了一个有用的值,所以与其覆盖它,不如使用get_include_path()
函数访问它,并添加您自己的目录。以下是将目录添加到当前包含路径的方法:
set_include_path(get_include_path() . PATH_SEPARATOR . "/home/john/phplib/");
PATH_SEPARATOR
常量将在 Unix 系统上解析为冒号,在 Windows 平台上解析为分号。因此,出于可移植性的原因,使用它被认为是最佳实践。
自动加载
尽管将require_once
与 include 路径结合使用很简洁,但是许多开发人员在高级别上完全摒弃了 require 语句,转而依赖于 autoload。
Note
本书以前的版本讨论了一个名为__autoload()
的内置函数,它提供了本节讨论的功能的一个更粗糙的版本。从 PHP 7.2.0 开始,这个函数被弃用,并在 PHP 8 中被删除。
为此,您应该组织您的类,使每个类都位于自己的文件中。每个类文件应该与它包含的类名有固定的关系,所以您可以在名为ShopProduct.php
的文件中定义一个ShopProduct
类,其目录对应于该类的名称空间的元素。
PHP 5 引入了自动加载功能来帮助自动包含类文件。默认支持非常基本,但仍然有用。可以通过不带参数调用名为spl_autoload_register()
的函数来启用它。然后,如果以这种方式激活了自动加载功能,当您试图实例化一个未知的类时,PHP 将调用一个名为spl_autoload()
的内置函数。这将使用提供的类名(转换成小写)在您的包含路径中搜索名为<classname>.php
或<classname>.inc
(其中<classname>
是未知类名)的文件。
这里有一个简单的例子:
// listing 05.26
spl_autoload_register();
$writer = new Writer();
假设我还没有包含一个包含Writer
对象的文件,这个实例化看起来注定会失败。然而,因为我已经设置了自动加载,PHP 将试图包含一个名为writer.php
或writer.inc
的文件,然后将再次尝试实例化。如果这些文件中的一个存在,并且包含一个名为Writer
的类,那么一切都会好的。
此默认行为支持名称空间,用目录名替换每个包:
// listing 05.27
spl_autoload_register();
$writer = new util\Writer();
前面的代码将在名为util
的目录中找到名为writer.php
(注意小写名称)的文件。
如果我碰巧根据大小写来命名我的类文件怎么办?也就是说,如果我保留大写字母来命名它们呢?如果我将Writer
类放在一个名为Writer.php
的文件中,那么默认实现将无法找到它。
幸运的是,我可以注册自己的自定义函数来处理不同的约定集。为了利用这一点,我必须将一个自定义函数的引用传递给spl_autoload_register()
。我的自动加载函数需要一个参数。然后,如果 PHP 引擎试图实例化一个未知的类,它将调用这个函数,将未知的类名作为字符串传递给它。由 autoload 函数定义一个策略来定位并包含丢失的类文件。一旦调用了 autoload 函数,PHP 将再次尝试实例化该类。
下面是一个简单的自动加载函数,以及一个要加载的类:
// listing 05.28
class Blah
{
public function wave(): void
{
print "saying hi from root";
}
}
// listing 05.29
$basic = function (string $classname) {
$file = __DIR__ . "/" . "{$classname}.php";
if (file_exists($file)) {
require_once($file);
}
};
\spl_autoload_register($basic);
$blah = new Blah();
$blah->wave();
最初未能实例化Blah
,PHP 引擎将看到我已经用spl_autoload_register()
函数注册了一个自动加载函数,并向它传递字符串"Blah"
。我的实现只是试图包含文件Blah.php
。当然,只有当文件与声明 autoload 函数的文件在同一个目录中时,这才会起作用。在现实世界的例子中,我必须将包含路径配置与我的自动加载逻辑结合起来(这正是 Composer 的自动加载实现所做的)。
如果我想提供老学校的支持,我可能会自动化 PEAR 包包括:
// listing 05.30
class util_Blah
{
public function wave(): void
{
print "saying hi from underscore file";
}
}
// listing 05.31
$underscores = function (string $classname) {
$path = str_replace('_', DIRECTORY_SEPARATOR, $classname);
$path = __DIR__ . "/$path";
if (file_exists("{$path}.php")) {
require_once("{$path}.php");
}
};
\spl_autoload_register($underscores);
$blah = new util_Blah();
$blah->wave();
如您所见,autoload 函数匹配所提供的$classname
中的下划线,并用DIRECTORY_SEPARATOR
字符(Unix 系统上的/
)替换每一个下划线。我试图包含类文件(util/Blah.php
)。如果类文件存在,并且它包含的类已被正确命名,则对象应该被实例化而不会出现错误。当然,这确实需要程序员遵守一个命名约定,禁止在类名中使用下划线字符,除非是在它分割包的地方。
名称空间呢?我们已经看到默认的自动加载功能支持名称空间。但是如果我们覆盖默认设置,那么就由我们来提供名称空间支持。这只是匹配和替换反斜杠字符的问题:
// listing 05.32
namespace util;
class LocalPath
{
public function wave(): void
{
print "hello from " . get_class();
}
}
// listing 05.33
$namespaces = function (string $path) {
if (preg_match('/\\\\/', $path)) {
$path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
}
if (file_exists("{$path}.php")) {
require_once("{$path}.php");
}
};
\spl_autoload_register($namespaces);
$obj = new util\LocalPath();
$obj->wave();
传递给 autoload 函数的值总是被规范化为完全限定的名称,没有前导反斜杠,因此在实例化时不需要担心别名或相对名称空间。
请注意,这个解决方案绝不是完美的。file_exists()
函数没有考虑包含路径,所以它不能准确反映require_once
运行良好的所有情况。对此有各种解决方案。您可以使用自己的路径感知版本的file_exists()
,或者尝试在 try 子句中要求该文件(在本例中捕捉Error
,而不是Exception
)。然而幸运的是,PHP 提供了stream_resolve_include_path()
函数。这将返回一个表示给定路径的绝对文件名的字符串,或者,对于我们的目的很重要的是,如果在包含路径中找不到该文件,则返回false
。
// listing 05.34
$namespaces = function (string $path) {
if (preg_match('/\\\\/', $path)) {
$path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
}
if (\stream_resolve_include_path("{$path}.php") !== false) {
require_once("{$path}.php");
}
};
\spl_autoload_register($namespaces);
$obj = new util\LocalPath();
$obj->wave();
如果我想同时支持梨形类名和名称空间该怎么办?我可以将我的自动加载实现合并到一个单独的自定义函数中。或者,我可以利用spl_autoload_register()
堆栈其自动加载函数的事实:
// listing 05.35
$underscores = function (string $classname) {
$path = str_replace('_', DIRECTORY_SEPARATOR, $classname);
$path = __DIR__ . "/$path";
if (\stream_resolve_include_path("{$path}.php") !== false) {
require_once("{$path}.php");
}
};
$namespaces = function (string $path) {
if (preg_match('/\\\\/', $path)) {
$path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
}
if (\stream_resolve_include_path("{$path}.php") !== false) {
require_once("{$path}.php");
}
};
\spl_autoload_register($namespaces);
\spl_autoload_register($underscores);
$blah = new util_Blah();
$blah->wave();
$obj = new util\LocalPath();
$obj->wave();
当遇到未知的类时,PHP 引擎将依次调用 autoload 函数(根据它们注册的顺序),当可以实例化或所有选项都用尽时停止。
这种堆叠显然是有开销的,那么 PHP 为什么支持它呢?在实际项目中,您可能会将名称空间和下划线策略组合成一个函数。但是,大型系统和第三方库中的组件可能需要注册自己的自动加载机制。堆叠允许系统的多个部分独立注册自动加载策略,而不会相互覆盖。事实上,一个只需要一个自动加载机制的库可以将它的自定义自动加载函数(或者任何类型的可调用函数,比如匿名函数)的名称传递给spl_autoload_unregister()
来清理它自己!
类和对象函数
PHP 为测试类和对象提供了一组强大的函数。这为什么有用?毕竟,您可能编写了脚本中使用的大多数类。
事实上,您在运行时并不总是知道您正在使用的类。例如,您可能已经设计了一个透明地使用第三方附加类的系统。在这种情况下,通常会实例化一个只有类名的对象。PHP 允许你使用字符串动态地引用类,就像这样:
// listing 05.36
namespace tasks;
class Task
{
public function doSpeak()
{
print "hello\n";
}
}
// listing 05.37
$classname = "Task";
require_once("tasks/{$classname}.php");
$classname = "tasks\\$classname";
$myObj = new $classname();
$myObj->doSpeak();
这个脚本可能从一个配置文件或者通过比较一个 web 请求和一个目录的内容来获取我分配给$classname
的字符串。然后,可以使用该字符串加载一个类文件并实例化一个对象。注意,我在这个片段中构造了一个名称空间限定。
通常,当您希望系统能够运行用户创建的插件时,您会这样做。在实际项目中做任何有风险的事情之前,您必须检查该类是否存在,它是否有您期望的方法,等等。
Note
即使采取了安全措施,您也应该对动态安装第三方插件代码保持高度警惕。永远不要自动运行用户上传的代码。这样安装的任何插件通常会以与您的核心代码相同的权限执行,因此恶意插件作者可能会对您的系统造成严重破坏。
这并不是说插件不是一个好主意。允许第三方开发者增强核心系统可以提供很大的灵活性。为了确保更高的安全性,您可以支持插件目录,但是要求代码文件由系统管理员直接安装,或者从受密码保护的管理环境中安装。管理员要么在安装前亲自检查插件代码,要么从一个声誉良好的存储库中寻找插件。这是流行的博客平台 WordPress 处理插件的方式。
一些类函数已经被更强大的反射 API 所取代,我将在这一章的后面讨论。然而,它们的简单性和易用性使它们成为某些情况下的首选。
寻找课程
class_exists()
函数接受一个表示要检查的类的字符串,如果该类存在,则返回一个布尔值true
,否则返回false
。
使用这个函数,我可以使前面的片段更安全一点:
// listing 05.38
$base = __DIR__;
$classname = "Task";
$path = "{$base}/tasks/{$classname}.php";
if (! file_exists($path)) {
throw new \Exception("No such file as {$path}");
}
require_once($path);
$qclassname = "tasks\\$classname";
if (! class_exists($qclassname)) {
throw new Exception("No such class as $qclassname");
}
$myObj = new $qclassname();
$myObj->doSpeak();
当然,你不能确定这个类不需要构造函数参数。为了达到这种安全水平,你必须求助于反射 API,这将在本章后面介绍。然而,class_exists()
确实允许您在使用它之前检查该类是否存在。
Note
请记住,如前所述,您应该始终警惕外部来源提供的任何数据。在以任何方式使用它之前对它进行测试和处理。对于文件路径,您应该转义或删除点和目录分隔符,以防止不道德的用户更改目录和包含意外的文件。然而,当我描述构建易于扩展的系统的方法时,这些技术通常涵盖部署的所有者(具有隐含的写权限),而不是她的外部用户。
您还可以使用get_declared_classes()
函数获得脚本进程中定义的所有类的数组:
// listing 05.39
print_r(get_declared_classes());
这将列出用户定义的和内置的类。记住,它只返回函数调用时声明的类。您可以稍后运行require()
或require_once()
,从而增加脚本中的类数量。
了解一个对象或类
如您所知,您可以使用类类型提示来约束方法参数的对象类型。即使有了这个工具,你也不能总是确定一个对象的类型。
有许多基本工具可以用来检查对象的类型。首先可以用get_class()
函数检查一个对象的类。它接受任何对象作为参数,并以字符串形式返回其类名:
// listing 05.40
$product = self::getProduct();
if (get_class($product) === 'popp\ch05\batch05\CdProduct') {
print "\$product is a CdProduct object\n";
}
在这个片段中,我从getProduct()
函数中获取了某个东西。为了绝对确定它是一个CdProduct
对象,我使用了get_class()
方法。
Note
我在第三章中讲述了CdProduct
和BookProduct
类。
下面是getProduct()
函数:
// listing 05.41
public static function getProduct()
{
return new CdProduct(
"Exile on Coldharbour Lane",
"The",
"Alabama 3",
10.99,
60.33
);
}
getProduct()
只是实例化并返回一个CdProduct
对象。我将在本节中充分利用这个功能。
get_class()
函数是一个非常特殊的工具。你经常想要一个类类型的更一般的确认。您可能想知道一个对象属于ShopProduct
家族,但是您并不关心它的实际类是BookProduct
还是CdProduct
。为此,PHP 提供了instanceof
运算符。
Note
PHP 4 不支持instanceof
。相反,它提供了is_a()
函数,该函数在 PHP 5.0 中被弃用,但在 PHP 5.3 中被恢复了。
instanceof
操作符使用两个操作数,要测试的对象在关键字的左边,类或接口名在右边。如果对象是给定类型的实例,则解析为true
:
// listing 05.42
$product = self::getProduct();
if ($product instanceof \popp\ch05\batch05\CdProduct) {
print "\$product is an instance of CdProduct\n";
}
获取对类的完全限定字符串引用
名称空间已经清除了面向对象 PHP 的许多丑陋之处。我们不再需要忍受长得离谱的类名,或者冒着命名冲突的风险(遗留代码除外)。另一方面,对于别名和相对名称空间引用,解析一些类路径以使它们是完全限定的可能是一件麻烦的事情。
以下是一些难以解析的类名示例:
// listing 05.43
namespace mypackage;
use util as u;
use util\db\Querier as q;
class Local
{
}
// Resolve these:
// Aliased namespace
// u\Writer;
// Aliased class
// q;
// Class referenced in local context
// Local
弄清楚这些类引用是如何解析的并不太难,但是编写代码来捕捉每一种可能性会很痛苦。例如,给定u\Writer
,自动解析器需要知道u
是util
的别名,它本身不是一个名称空间。有益的是,PHP 5.5 引入了ClassName::class
语法。换句话说,给定一个类引用,您可以附加一个范围解析操作符和class
关键字来获得完全限定的类名:
// listing 05.44
print u\Writer::class . "\n";
print q::class . "\n";
print Local::class . "\n";
前面的代码片段输出如下:
util\Writer
util\db\Querier
mypackage\Local
从 PHP 8 开始,你也可以在一个对象上调用::class
。例如,给定一个ShopProduct
的实例,我可以得到完整的类名,如下所示:
// listing 05.45
$bookp = new BookProduct(
"Catch 22",
"Joseph",
"Heller",
11.99,
300
);
print $bookp::class;
运行此输出
popp\ch04\batch02\BookProduct
请注意,这种方便的语法并没有提供新的功能——您已经遇到了实现相同结果的get_class()
函数。
学习方法
您可以使用get_class_methods()
函数获得一个类中所有方法的列表。这需要一个类名,并返回一个包含该类中所有方法名称的数组:
// listing 05.46
print_r(get_class_methods('\\popp\\ch04\\batch02\\BookProduct'));
假设BookProduct
类存在,您可能会看到如下内容:
Array
(
[0] => __construct
[1] => getNumberOfPages
[2] => getSummaryLine
[3] => getPrice
[4] => setID
[5] => getProducerFirstName
[6] => getProducerMainName
[7] => setDiscount
[8] => getDiscount
[9] => getTitle
[10] => getProducer
[11] => getInstance
)
在示例中,我将包含类名的字符串传递给get_class_methods()
,并用print_r()
函数转储返回的数组。我也可以将一个对象传递给get_class_methods()
,得到同样的结果。只有公共方法的名称才会包含在返回的列表中。
如您所见,您可以将方法名存储在字符串变量中,并与对象一起动态调用它,如下所示:
// listing 05.47
$product = self::getProduct();
$method = "getTitle"; // define a method name
print $product->$method(); // invoke the method
当然,这可能是危险的。如果方法不存在会怎么样?正如您所料,您的脚本将会失败并出现错误。您已经遇到了一种测试方法是否存在的方式:
// listing 05.48
if (in_array($method, get_class_methods($product))) {
print $product->$method(); // invoke the method
}
在调用之前,我检查方法名是否存在于由get_class_methods()
返回的数组中。
PHP 为此提供了更专门的工具。您可以用两个函数在一定程度上检查方法名:is_callable()
和method_exists()
。is_callable()
是两种功能中较为复杂的一种。它接受一个表示函数名的字符串变量作为它的第一个参数,如果该函数存在并且可以被调用,则返回true
。要对一个方法应用相同的测试,您应该向它传递一个数组来代替函数名。数组必须包含一个对象或类名作为其第一个元素,要检查的方法名作为其第二个元素。如果该方法存在于类中,该函数将返回 true:
// listing 05.49
if (is_callable([$product, $method])) {
print $product->$method(); // invoke the method
}
可选地接受第二个参数,一个布尔值。如果将此设置为true
,函数将只检查给定方法或函数名的语法,而不检查其实际存在。它还接受可选的第三个参数,该参数应该是一个变量。如果提供,这将用您提供的可调用函数的字符串表示形式填充。
这里,我用可选第三个参数调用is_callable()
,然后输出:
// listing 05.50
if (is_callable([$product, $method], false, $callableName)) {
print $callableName;
}
这是我的输出:
popp\ch05\batch05\CdProduct::getTitle
这种功能对于文档或日志记录来说可能很方便。
method_exists()
函数需要一个对象(或类名)和一个方法名,如果给定的方法存在于对象的类中,则返回true
:
// listing 05.51
if (method_exists($product, $method)) {
print $product->$method(); // invoke the method
}
Caution
记住,一个方法的存在并不意味着它是可调用的。method_exists()
为private
和protected
方法以及public
方法返回true
。
了解属性
正如您可以查询类的方法一样,您也可以查询它的字段。get_class_vars()
函数需要一个类名,并返回一个关联数组。返回的数组包含字段名作为其键,包含字段值作为其值。让我们对CdProduct
对象进行测试。为了便于说明,我们向类添加了一个公共属性,CdProduct::$coverUrl
:
// listing 05.52
print_r(get_class_vars('\\popp\\ch05\\batch05\\CdProduct'));
仅显示公共属性:
Array (
[coverUrl] => cover url
)
了解继承
类函数也允许我们绘制继承关系。我们可以找到一个类的父类,比如用get_parent_class()
。这个函数需要一个对象或类名,如果有超类的话,它返回超类的名称。如果不存在这样的类——也就是说,如果我们测试的类没有父类——那么函数返回false
。
// listing 05.53
print get_parent_class('\\popp\\ch04\\batch02\\BookProduct');
如您所料,这会产生父类:ShopProduct
。
我们还可以使用is_subclass_of()
函数测试一个类是否是另一个类的后代。这需要一个子对象(或类名)和父类名。如果第二个参数是第一个参数的超类,函数返回true
:
// listing 05.54
$product = self::getBookProduct(); // acquire an object
if (is_subclass_of($product, '\\popp\\ch04\\batch02\\ShopProduct')) {
print "BookProduct is a subclass of ShopProduct\n";
}
将只告诉你类继承关系。它不会告诉你一个类实现了一个接口。为此,您应该使用instanceof
操作符。或者,您可以使用 SPL(标准 PHP 库)的一部分函数。class_implements()
接受类名或对象引用,并返回接口名数组:
// listing 05.55
if (in_array('someInterface', class_implements($product))) {
print "BookProduct is an interface of someInterface\n";
}
方法调用
您已经遇到了一个例子,在这个例子中,我使用了一个字符串来动态调用一个方法:
// listing 05.56
$product = self::getProduct();
$method = "getTitle"; // define a method name
print $product->$method(); // invoke the method
PHP 也提供了call_user_func()
方法来达到同样的目的。call_user_func()
可以调用任何种类的可调用函数(如函数名或匿名函数)。在这里,我通过传递字符串中的函数名来调用函数:
$returnVal = call_user_func("myFunction");
为了调用一个方法,我可以传递一个数组。此的第一个元素应该是对象,第二个元素应该是要调用的方法的名称:
$returnVal = call_user_func([$myObj, "methodName"]);
传递给call_user_func()
的任何进一步的参数将被视为目标函数或方法的参数,并以相同的顺序传递,如下所示:
// listing 05.57
$product = self::getBookProduct(); // Acquire a BookProduct object
call_user_func([$product, 'setDiscount'], 20);
当然,这个动态调用相当于:
$product->setDiscount(20);
call_user_func()
方法不会极大地改变你的生活,因为你同样可以直接用一个字符串代替方法名,就像这样:
// listing 05.58
$method = "setDiscount";
$product->$method(20);
然而,更令人印象深刻的是相关的call_user_func_array()
函数。就选择目标方法或功能而言,其操作方式与call_user_func()
相同。不过,最重要的是,它接受目标方法所需的任何参数作为数组。
Note
注意——使用call_user_func()
传递给函数或方法的参数不是通过引用传递的。
那么这为什么有用呢?有时,您会得到数组形式的参数。除非你事先知道你要处理的论点的数量,否则很难把它们传递下去。在第四章中,我看到了可以用来创建委托类的拦截器方法。下面是一个简单的__call()
方法的例子:
// listing 05.59
public function __call(string $method, array $args): mixed
{
if (method_exists($this->thirdpartyShop, $method)) {
return $this->thirdpartyShop->$method();
}
}
如您所见,当客户端代码调用未定义的方法时,会调用__call()
方法。在这个例子中,我在一个名为$thirdpartyShop
的属性中维护一个对象。如果我在存储对象中找到一个与$method
参数匹配的方法,我就调用它。我愉快地假设目标方法不需要任何参数,这就是我的问题的开始。当我编写__call()
方法时,我无法判断每次调用时$args
数组会有多大。如果我将$args
直接传递给委托方法,我将传递一个数组参数,而不是它可能期望的单独参数。call_user_func_array()
完美解决问题:
// listing 05.60
public function __call(string $method, array $args): mixed
{
if (method_exists($this->thirdpartyShop, $method)) {
return call_user_func_array(
[
$this->thirdpartyShop,
$method
],
$args
);
}
}
反射 API
PHP 的反射 API 对于 PHP 就像java.lang.reflect
包对于 Java 一样。它由用于分析属性、方法和类的内置类组成。它在某些方面类似于现有的对象函数,比如get_class_vars()
,但是更加灵活,并且提供了更多的细节。它还被设计成能与 PHP 的面向对象特性(如访问控制、接口和抽象类)一起工作,而更老、更有限的类函数则不能。
入门指南
反射 API 不仅仅可以用来检查类。例如,ReflectionFunction
类提供了关于给定函数的信息,而ReflectionExtension
提供了关于编译到语言中的扩展的信息。表 5-1 列出了 API 中的一些类。
表 5-1。
反射 API 中的关键类
|类
|
描述
|
| --- | --- |
| Reflection
| 提供一个静态的export()
方法来总结类信息 |
| ReflectionAttribute
| 关于类、属性、常数或参数的上下文信息 |
| ReflectionClass
| 课程信息和工具 |
| ReflectionClassConstant
| 关于常数的信息 |
| ReflectionException
| 错误类 |
| ReflectionExtension
| PHP 扩展信息 |
| ReflectionFunction
| 功能信息和工具 |
| ReflectionGenerator
| 关于发电机的信息 |
| ReflectionMethod
| 类方法信息和工具 |
| ReflectionNamedType
| 关于函数或方法的返回类型的信息(联合返回类型用ReflectionUnionType
描述) |
| ReflectionObject
| 对象信息和工具(继承自ReflectionClass
|
| ReflectionParameter
| 方法参数信息 |
| ReflectionProperty
| 类别属性信息 |
| ReflectionType
| 关于函数或方法的返回类型的信息 |
| ReflectionUnionType
| 联合类型声明的ReflectionType
对象集合 |
| ReflectionZendExtension
| PHP Zend 扩展信息 |
在它们之间,反射 API 中的类提供了对脚本中对象、函数和扩展信息的前所未有的运行时访问。
反射 API 的强大功能意味着你应该优先使用它而不是类和对象函数。你很快就会发现它作为测试类的工具是不可或缺的。例如,您可能想要生成类图或文档,或者您可能想要将对象信息保存到数据库,检查对象的访问器(getter 和 setter)方法以提取字段名。根据命名方案构建一个调用模块类中的方法的框架是反射的另一种用途。
是时候卷起袖子了
您已经遇到了一些用于检查类属性的函数。这些是有用的,但往往是有限的。这里有一个能够胜任这项工作的工具。ReflectionClass
提供揭示给定类的每个方面的信息的方法,无论它是用户定义的还是内部的。ReflectionClass
的构造函数接受一个类或接口名(或一个对象实例)作为它唯一的参数:
// listing 05.61
$prodclass = new \ReflectionClass(CdProduct::class);
print $prodclass;
一旦创建了一个ReflectionClass
对象,您就可以立即转储关于该类的各种信息,只需在字符串上下文中访问它。下面是我为ShopProduct
打印我的ReflectionClass
实例时生成的输出的节略摘录:
Class [ <user> class popp\ch04\batch02\CdProduct extends popp\ch04\batch02\
ShopProduct ] {
@@ /var/popp/src/ch04/batch02/CdProduct.php 6-37
- Constants [2] {
Constant [ public int AVAILABLE ] { 0 }
Constant [ public int OUT_OF_STOCK ] { 1 }
}
- Static properties [0] {
}
- Static methods [1] {
Method [ <user, inherits popp\ch04\batch02\ShopProduct> static public method getInstance ] {
@@ /var/popp/src/ch04/batch02/ShopProduct.php 93 - 130
- Parameters [2] {
Parameter #0 [ <required> int $id ]
Parameter #1 [ <required> PDO $pdo ]
}
- Return [ popp\ch04\batch02\ShopProduct ]
}
}
- Properties [3] {
Property [ private $playLength = 0 ]
Property [ public $status = NULL ]
Property [ protected int|float $price ]
}
...
Note
实用方法Reflection::export()
曾经是转储ReflectionClass
信息的标准方式。这在 PHP 7.4 中被否决,在 PHP 8.0 中被完全删除
如您所见,ReflectionClass
提供了对一个类信息的卓越访问。字符串输出提供了关于CdProduct
几乎每个方面的概要信息,包括属性和方法的访问控制状态、每个方法所需的参数以及每个方法在脚本文档中的位置。与更成熟的调试功能相比。var_dump()
函数是一个用于汇总数据的通用工具。在提取摘要之前,您必须实例化一个对象,即使这样,它也不能提供类似于ReflectionClass
所提供的细节:
// listing 05.62
$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
var_dump($cd);
以下是输出结果:
object(popp\ch04\batch02\CdProduct)#15 (8) {
["playLength":"popp\ch04\batch02\CdProduct":private]=>
int(50)
["status"]=>
NULL
["title":"popp\ch04\batch02\ShopProduct":private]=>
string(3) "cd1"
["producerMainName":"popp\ch04\batch02\ShopProduct":private]=>
string(9) "bobbleson"
["producerFirstName":"popp\ch04\batch02\ShopProduct":private]=>
string(3) "bob"
["price":protected]=>
float(4)
["discount":"popp\ch04\batch02\ShopProduct":private]=>
int(0)
["id":"popp\ch04\batch02\ShopProduct":private]=>
int(0)
}
var_dump()
和它的表亲print_r()
是在脚本中公开数据的非常方便的工具。对于类和函数,反射 API 将事情带到了一个全新的水平。
检查一节课
一个ReflectionClass
实例的原始转储可以为调试提供大量有用的信息,但是我们可以以更专业的方式使用 API。让我们直接使用Reflection
类。
您已经看到了如何实例化一个ReflectionClass
对象:
// listing 05.63
$prodclass = new \ReflectionClass(CdProduct::class);
接下来,我将使用ReflectionClass
对象来研究脚本中的CdProduct
。是什么样的课?可以创建实例吗?这里有一个函数来回答这些问题:
// listing 05.64
// class ClassInfo
public static function getData(\ReflectionClass $class): string
{
$details = "";
$name = $class->getName();
$details .= ($class->isUserDefined()) ? "$name is user defined\n" : "" ;
$details .= ($class->isInternal()) ? "$name is built-in\n" : "" ;
$details .= ($class->isInterface()) ? "$name is interface\n" : "" ;
$details .= ($class->isAbstract()) ? "$name is an abstract class\n" : "" ;
$details .= ($class->isFinal()) ? "$name is a final class\n" : "" ;
$details .= ($class->isInstantiable()) ? "$name can be instantiated\n" : "$name can not be instantiated\n" ;
$details .= ($class->isCloneable()) ? "$name can be cloned\n" : "$name can not be cloned\n" ;
return $details;
}
// listing 05.65
$prodclass = new \ReflectionClass(CdProduct::class);
print ClassInfo::getData($prodclass);
我创建了一个ReflectionClass
对象,通过将CdProduct
类名传递给ReflectionClass
的构造函数,将它赋给一个名为$prodclass
的变量。然后将$prodclass
传递给一个名为ClassInfo::classData()
的方法,该方法演示了一些可用于查询类的方法。
这些方法应该是不言自明的,但下面是对其中一些方法的简要描述:
-
ReflectionClass::getName()
返回被检查的类的名称。 -
如果该类已经在 PHP 代码中声明,则
ReflectionClass::isUserDefined()
方法返回true
,如果该类是内置的,则ReflectionClass::isInternal()
返回true
。 -
你可以用
ReflectionClass::isAbstract()
测试一个类是否抽象,用ReflectionClass::isInterface()
测试它是否是一个接口。 -
如果你想得到一个类的实例,你可以用
ReflectionClass::isInstantiable()
来测试它的可行性。 -
您可以用
ReflectionClass::isCloneable() method
来检查一个类是否是可克隆的。 -
您甚至可以检查用户定义的类的源代码。
ReflectionClass
对象提供对其类的文件名以及文件中该类的开始和结束行的访问。
这里有一个快速的方法,它使用ReflectionClass
来访问类的源代码:
// listing 05.66
class ReflectionUtil
{
public static function getClassSource(\ReflectionClass $class): string
{
$path = $class->getFileName();
$lines = @file($path);
$from = $class->getStartLine();
$to = $class->getEndLine();
$len = $to - $from + 1;
return implode(array_slice($lines, $from - 1, $len));
}
}
// listing 05.67
print ReflectionUtil::getClassSource(
new \ReflectionClass(CdProduct::class)
);
ReflectionUtil
是一个简单的类,只有一个静态方法ReflectionUtil::getClassSource()
。该方法将一个ReflectionClass
对象作为唯一的参数,并返回被引用类的源代码。ReflectionClass::getFileName()
提供类文件的路径作为绝对路径,所以代码应该能够直接打开它。file()
获取文件中所有行的数组。ReflectionClass::getStartLine()
提供该类的起始行;ReflectionClass::getEndLine()
找到最后一行。从那以后,只需使用array_slice()
提取感兴趣的行。
为了保持简洁,这段代码省略了错误处理(通过在对file()
的调用前放置字符@
)。在现实世界的应用中,您需要检查参数和结果代码。
检查方法
正如ReflectionClass
用于检查类一样,ReflectionMethod
对象检查方法。
你可以从ReflectionClass::getMethods()
中得到一个ReflectionMethod
对象的数组。或者,如果您需要使用一个特定的方法,ReflectionClass::getMethod()
接受一个方法名并返回相关的ReflectionMethod
对象。
您也可以直接实例化ReflectionMethod
,传递给它一个类/方法字符串、类名和方法名,或者一个对象和方法名。
这些变化看起来可能是这样的:
// listing 05.68
$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
$classname = CdProduct::class;
$rmethod1 = new \ReflectionMethod("{$classname}:: construct");// class/method string
$rmethod2 = new \ReflectionMethod($classname, " construct");// class name and method name
$rmethod3 = new \ReflectionMethod($cd, " construct");// object and method name
这里,我们使用ReflectionClass::getMethods()
来测试ReflectionMethod
类:
// listing 05.69
$prodclass = new \ReflectionClass(CdProduct::class);
$methods = $prodclass->getMethods();
foreach ($methods as $method) {
print ClassInfo::methodData($method);
print "\n----\n";
}
// listing 05.70
// class ClassInfo
public static function methodData(\ReflectionMethod $method): string
{
$details = "";
$name = $method->getName();
$details .= ($method->isUserDefined()) ? "$name is user defined\n" : "" ;
$details .= ($method->isInternal()) ? "$name is built-in\n" : "" ;
$details .= ($method->isAbstract()) ? "$name is an abstract class\n" : "" ;
$details .= ($method->isPublic()) ? "$name is public\n" : "" ;
$details .= ($method->isProtected()) ? "$name is protected\n" : "" ;
$details .= ($method->isPrivate()) ? "$name is private\n" : "" ;
$details .= ($method->isStatic()) ? "$name is static\n" : "" ;
$details .= ($method->isFinal()) ? "$name is final\n" : "" ;
$details .= ($method->isConstructor()) ? "$name is the constructor\n" : "" ;
$details .= ($method->returnsReference()) ? "$name returns a reference (as opposed to a value)\n" : "" ;
return $details;
}
代码使用ReflectionClass::getMethods()
来获得一个由ReflectionMethod
对象组成的数组,然后遍历该数组,将每个对象传递给methodData()
。
methodData()
中使用的方法名称反映了它们的意图:代码检查方法是用户定义的、内置的、抽象的、公共的、受保护的、静态的还是最终的。您还可以检查该方法是否是其类的构造函数,以及它是否返回引用。
有一个警告:如果被测试的方法只是返回一个对象,那么ReflectionMethod::returnsReference()
不会返回true
,即使在 PHP 5 中对象是通过引用传递和赋值的。相反,ReflectionMethod::returnsReference()
仅在所讨论的方法被显式声明为返回引用时才返回 true(通过在方法名前面放置一个&字符)。
如您所料,您可以使用类似于之前使用ReflectionClass
的技术来访问方法的源代码:
// listing 05.71
// class ReflectionUtil
public static function getMethodSource(\ReflectionMethod $method): string
{
$path = $method->getFileName();
$lines = @file($path);
$from = $method->getStartLine();
$to = $method->getEndLine();
$len = $to - $from + 1;
return implode(array_slice($lines, $from - 1, $len));
}
// listing 05.72
$class = new \ReflectionClass(CdProduct::class);
$method = $class->getMethod('getSummaryLine');
print ReflectionUtil::getMethodSource($method);
因为ReflectionMethod
为我们提供了getFileName()
、getStartLine()
和getEndLine()
方法,所以提取方法的源代码很简单。
检查方法参数
既然方法签名可以约束对象参数的类型,那么检查方法签名中声明的参数的能力就变得非常有用。反射 API 提供了ReflectionParameter
类就是为了这个目的。要得到一个ReflectionParameter
对象,你需要一个ReflectionMethod
对象的帮助。ReflectionMethod::getParameters()
方法返回一个ReflectionParameter
对象的数组。
也可以用通常的方法直接实例化一个ReflectionParameter
对象。ReflectionParameter 的构造函数需要一个callable
参数和一个表示参数编号的整数(索引为零)或一个表示参数名称的字符串。
所以,这四个实例化是等价的。每个都为CdProduct
类的构造函数的第二个参数建立了一个ReflectionParameter
对象。
// listing 05.73
$classname = CdProduct::class;
$rparam1 = new \ReflectionParameter([$classname, "__construct"], 1);
$rparam2 = new \ReflectionParameter([$classname, "__construct"], "firstName");
$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
$rparam3 = new \ReflectionParameter([$cd, "__construct"], 1);
$rparam4 = new \ReflectionParameter([$cd, "__construct"], "firstName");
ReflectionParameter
可以告诉您参数的名称以及变量是否通过引用传递(即,在方法声明中前面有一个&符号)。它还可以告诉您参数提示所需的类,以及该方法是否接受参数的空值。
下面是一些ReflectionParameter
的方法:
// listing 05.74
$class = new \ReflectionClass(CdProduct::class);
$method = $class->getMethod("__construct");
$params = $method->getParameters();
foreach ($params as $param) {
print ClassInfo::argData($param) . "\n";
}
// listing 05.75
// class ClassInfo
public static function argData(\ReflectionParameter $arg): string
{
$details = "";
$declaringclass = $arg->getDeclaringClass();
$name = $arg->getName();
$position = $arg->getPosition();
$details .= "\$$name has position $position\n";
if ($arg->hasType()) {
$type = $arg->getType();
$typenames = [];
if ($type instanceof \ReflectionUnionType) {
$types = $type->getTypes();
foreach ($types as $utype) {
$typenames[] = $utype->getName();
}
} else {
$typenames[] = $type->getName();
}
$typename = implode("|", $typenames);
$details .= "\$$name should be type {$typename}\n";
}
if ($arg->isPassedByReference()) {
$details .= "\${$name} is passed by reference\n";
}
if ($arg->isDefaultValueAvailable()) {
$def = $arg->getDefaultValue();
$details .= "\${$name} has default: $def\n";
}
if ($arg->allowsNull()) {
$details .= "\${$name} can be null\n";
}
return $details;
}
使用ReflectionClass::getMethod()
方法,代码获得一个ReflectionMethod
对象。然后它使用ReflectionMethod::getParameters()
来获得一个ReflectionParameter
对象的数组。argData()
函数使用传递给它的ReflectionParameter
对象来获取关于参数的信息。
首先,它用ReflectionParameter::getName()
获取参数的变量名。如果指定了类型,则ReflectionParameter::getType()
方法返回一个ReflectionType
对象,如果指定的类型是联合类型,则返回一个ReflectionUnionType
类。无论从哪一个返回,都将构造一个表示所需类型的字符串。然后,代码检查参数是否是对isPassedByReference();
的引用,最后,它查找默认值的可用性,然后将其添加到返回字符串中。
使用反射 API
有了反射 API 的基础知识,现在就可以让 API 工作了。
假设您正在创建一个动态调用Module
对象的类。也就是说,它可以接受由第三方编写的插件,这些插件可以嵌入到应用中,而不需要任何硬编码。为了实现这一点,您可以在Module
接口或抽象基类中定义一个execute()
方法,强制所有子类定义一个实现。您可以允许系统用户在外部 XML 配置文件中列出Module
类。在对每个对象调用execute()
之前,您的系统可以使用这些信息来聚集一些Module
对象。
然而,如果每个Module
需要不同的信息来完成它的工作,会发生什么呢?在这种情况下,XML 文件可以为每个Module
提供属性键和值,每个Module
的创建者可以为每个属性名提供 setter 方法。有了这个基础,就要靠代码来确保为正确的属性名调用正确的 setter 方法。
这里有一些关于Module
接口和几个实现类的基础:
// listing 05.76
class Person
{
public $name;
public function __construct(string $name)
{
$this->name = $name;
}
}
// listing 05.77
interface Module
{
public function execute(): void;
}
// listing 05.78
class FtpModule implements Module
{
public function setHost(string $host): void
{
print "FtpModule::setHost(): $host\n";
}
public function setUser(string|int $user): void
{
print "FtpModule::setUser(): $user\n";
}
public function execute(): void
{
// do things
}
}
// listing 05.79
class PersonModule implements Module
{
public function setPerson(Person $person): void
{
print "PersonModule::setPerson(): {$person->name}\n";
}
public function execute(): void
{
// do things
}
}
这里,PersonModule
和FtpModule
都提供了execute()
方法的空实现。每个类还实现 setter 方法,这些方法除了报告它们被调用之外什么也不做。系统规定了所有 setter 方法必须有一个参数的约定:或者是一个字符串,或者是一个可以用一个字符串参数实例化的对象。PersonModule::setPerson()
方法需要一个Person
对象,所以我在例子中包含了一个Person
类。
为了使用PersonModule
和FtpModule
,下一步是创建一个ModuleRunner
类。它将使用由模块名索引的多维数组来表示 XML 文件中提供的配置信息。下面是代码:
// listing 05.80
class ModuleRunner
{
private array $configData = [
PersonModule::class => ['person' => 'bob'],
FtpModule::class => [
'host' => 'example.com',
'user' => 'anon'
]
];
private array $modules = [];
// ...
}
ModuleRunner::$configData
属性包含对两个Module
类的引用。对于每个模块元素,代码维护一个包含一组属性的子数组。ModuleRunner
的init()
方法负责创建正确的Module
对象,如下所示:
// listing 05.81
// class ModuleRunner
public function init(): void
{
$interface = new \ReflectionClass(Module::class);
foreach ($this->configData as $modulename => $params) {
$module_class = new \ReflectionClass($modulename);
if (! $module_class->isSubclassOf($interface)) {
throw new Exception("unknown module type: $modulename");
}
$module = $module_class->newInstance();
foreach ($module_class->getMethods() as $method) {
$this->handleMethod($module, $method, $params);
// we cover handleMethod() in a future listing!
}
array_push($this->modules, $module);
}
}
// listing 05.82
$test = new ModuleRunner();
$test->init();
init()
方法遍历ModuleRunner::$configData
数组,对于每个模块元素,它试图创建一个ReflectionClass
对象。当用一个不存在的类名调用ReflectionClass
的构造函数时会产生一个异常,所以在现实环境中,我会在这里包含更多的错误处理。我使用ReflectionClass::isSubclassOf()
方法来确保模块类属于Module
类型。
在调用每个Module
的execute()
方法之前,必须创建一个实例。这就是ReflectionClass::newInstance()
的目的。该方法接受任意数量的参数,并将其传递给相关类的构造函数方法。如果一切正常,它将返回该类的一个实例(对于生产代码,一定要谨慎编码:在创建实例之前,检查每个Module
对象的构造函数方法是否不需要参数)。
ReflectionClass::getMethods()
返回该类可用的所有ReflectionMethod
对象的数组。对于数组中的每个元素,代码都会调用ModuleRunner::handleMethod()
方法。然后传递给它一个Module
实例、ReflectionMethod
对象和一个与Module
关联的属性数组。handleMethod()
验证并调用Module
对象的 setter 方法:
// listing 05.83
// class ModuleRunner
public function handleMethod(Module $module, \ReflectionMethod $method, array $params):
bool
{
$name = $method->getName();
$args = $method->getParameters();
if (count($args) != 1 || substr($name, 0, 3) != "set") {
return false;
}
$property = strtolower(substr($name, 3));
if (! isset($params[$property])) {
return false;
}
if (! $args[0]->hasType()) {
$method->invoke($module, $params[$property]);
return true;
}
$arg_type = $args[0]->getType();
if (! ($arg_type instanceof \ReflectionUnionType) && class_exists($arg_type->getName())) {
$method->invoke(
$module,
(new \ReflectionClass($arg_type->getName()))->newInstance($params[$property])
);
} else {
$method->invoke($module, $params[$property]);
}
return true;
}
handleMethod()
首先检查该方法是否是有效的 setter。在代码中,一个有效的 setter 方法必须被命名为setXXXX()
,并且必须声明一个——并且只能声明一个——参数。
假设参数检查通过,然后代码从方法名中提取一个属性名,方法是从方法名的开头删除set
,并将结果子串转换成小写字符。该字符串用于测试$params
数组参数。该数组包含用户提供的属性,这些属性将与Module
对象相关联。如果$params
数组不包含属性,代码放弃并返回false
。
如果从模块方法中提取的属性名与$params
数组中的一个元素匹配,我可以继续调用正确的 setter 方法。为此,代码必须检查 setter 方法的第一个(也是唯一一个)必需参数的类型。如果参数有一个类型声明(ReflectionParameter::hasType()
),并且指定的类型解析为一个类,那么我们知道该方法需要一个对象。否则,我们假设它需要一个原语。
为了调用 setter 方法,我需要新的反射 API 方法。ReflectionMethod::invoke()
需要一个对象(或静态方法的null
)和任意数量的方法参数来传递给它所代表的方法。ReflectionMethod::invoke()
如果提供的对象与其方法不匹配,抛出异常。我以两种方式之一调用这个方法。如果 setter 方法不需要对象参数,我用用户提供的属性字符串调用ReflectionMethod::invoke()
。如果这个方法需要一个对象(我可以通过使用类型名的class_exists
来测试),我使用属性字符串来实例化一个正确类型的对象。然后将它传递给 setter。
该示例假定所需的对象可以用其构造函数的单个字符串参数进行实例化。当然,最好在调用ReflectionClass::newInstance()
之前检查一下这个。
当ModuleRunner::init()
方法完成它的过程时,对象已经有了一个Module
对象的存储库,所有的都以数据为基础。现在可以给这个类一个方法来遍历Module
对象,对每个对象调用execute()
。
属性
许多语言都提供了一种机制,通过这种机制,代码可以使用源文件中的特殊标记。这些通常被称为注释。尽管在 PHP 8 之前,PHP 包中已经有了一些 userland 实现(特别是,例如,Doctrine 数据库库和 Symfony 路由组件),但是在语言层面上还没有对这个特性的支持。随着属性的引入,这种情况有所改变。
本质上,属性是一个特殊的标记,它允许您向类、方法、属性、参数或常数添加附加信息。通过反射,系统可以获得这些信息。
那么你能用注释做什么呢?通常,一个方法可能会提供更多关于其预期使用方式的信息。例如,客户端代码可能会扫描一个类来发现应该自动运行的方法。随着我们的进展,我将提到其他用例。
让我们声明并访问一个注释:
// listing 05.84
namespace popp\ch05\batch09;
#[info]
class Person
{
}
因此,用一个由#[
和]
包围的字符串标记来声明一个注释。在这种情况下,我选择了#[info]
。在许多代码示例中,我排除了名称空间声明,因为代码在声明的名称空间或main
中同样运行良好。不过,在这种情况下,值得注意的是名称空间。我将回到这一点。
现在要访问注释:
// listing 05.85
$rpers = new \ReflectionClass(Person::class);
$attrs = $rpers->getAttributes();
foreach ($attrs as $attr) {
print $attr->getName() . "\n";
}
我实例化了一个ReflectionClass
对象,这样我就可以检查Person
。然后我调用了getAttributes()
方法。这将返回一个由ReflectionAttribute
对象组成的数组。ReflectionAttribute::getName()
返回我声明的属性的名称。
以下是输出:
popp\ch05\batch09\info
因此,在我的输出中,注释是命名空间的。名称的popp\ch05\batch09
部分是隐含的。我可以根据引用类的规则和别名来引用注释。因此在popp\ch05\batch09
名称空间中声明[#info]
等同于在其他地方声明[#\popp\ch05\batch09\info]
。事实上,正如您将看到的,您甚至可以声明一个可以为您引用的任何属性实例化的类。
注释可以应用于 PHP 的各个方面。表 5-2 列出了可以注释的特性以及相应的反射类。
表 5-2。
适合注释的 PHP 特性
|特征
|
获得物ˌ获得
|
| --- | --- |
| 类 | ReflectionClass::getAttributes()
|
| 财产 | ReflectionProperty::getAttributes()
|
| 功能/方法 | ReflectionFunction::getAttributes()
|
| 常数 | ReflectionConstant::getAttributes()
|
下面是一个应用于方法的属性示例:
// listing 05.86
#[moreinfo]
public function setName(string $name): void
{
$this->name = $name;
}
现在访问它。您应该会发现这个过程非常熟悉:
// listing 05.87
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setName");
$attrs = $rmeth->getAttributes();
foreach ($attrs as $attr) {
print $attr->getName() . "\n";
}
输出现在应该也很熟悉了。我们显示了到moreinfo
的完全命名空间路径。
popp\ch05\batch09\moreinfo
到目前为止,您已经看到了一些有用的东西。我们可以包含一个属性作为某种标志。例如,Debug
属性可以与只在开发过程中调用的方法相关联。然而,属性还有更多。我们可以定义一个类型,并通过参数提供进一步的信息。这开启了新的可能性。在路由库中,我可能会断言一个方法应该映射到的 URL 端点。在事件系统中,属性可能表示一个类或方法应该与一个特定的事件相关联。
在这个例子中,我定义了一个包含两个参数的属性:
// listing 05.88
#[ApiInfo("The 3 digit company identifier", "A five character department tag")]
public function setInfo(int $companyid, string $department): void
{
$this->companyid = $companyid;
$this->department = $department;
}
一旦我获得了一个ReflectionAttribute
对象,我就可以使用getArguments()
方法访问参数。
// listing 05.89
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes();
foreach ($attrs as $attr) {
print $attr->getName() . "\n";
foreach ($attr->getArguments() as $arg) {
print " - $arg\n";
}
}
以下是输出:
popp\ch05\batch09\ApiInfo
- The 3 digit company identifier
- A five character department tag
正如我提到的,您可以显式地将一个属性映射到一个类。下面是一个简单的ApiInfo
类:
// listing 05.90
namespace popp\ch05\batch09;
use Attribute;
#[Attribute]
class ApiInfo
{
public function __construct(public string $compinfo, public string $depinfo)
{
}
}
为了正确地在属性和我的类之间建立关联,我必须记住use Attribute
并将内置的[#Attribute]
应用于类。
在实例化时,关联属性的任何参数都会自动传递给相应类的构造函数。在这种情况下,我只是将数据分配给相应的属性。在现实世界的应用中,我可能会执行一些额外的处理或提供相关的功能来证明类的声明是正确的。
理解属性类不会自动调用是很重要的。我们必须通过ReflectionAttribute::newInstance()
做到这一点。在这里,我修改了我的客户机代码,使之适用于新的类:
// listing 05.91
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes();
foreach ($attrs as $attr) {
print $attr->getName() . "\n";
$attrobj = $attr->newInstance();
print " - " . $attrobj->compinfo . "\n";
print " - " . $attrobj->depinfo . "\n";
}
虽然我是通过ApiInfo
对象访问属性数据,但是这里的效果是一样的。我调用ReflectionAttribute::newInstance()
,然后访问填充的属性。
等等,虽然!最后一个例子有一个深刻的、潜在的致命缺陷。一个方法可以添加多个属性。因此,我们不能确定分配给setInfo()
方法的每个属性都是ApiInfo
的实例。那些对ApiInfo::$compinfo
和ApiInfo::$depinfo
的属性访问对于任何不属于ApiInfo
类型的属性必定会失败。
幸运的是,我们可以对getAttributes()
应用过滤器:
// listing 05.92
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes(ApiInfo::class);
现在,将只返回与ApiInfo::class
完全匹配的内容——使得代码的其余部分变得安全。我们可以像这样进一步放松一下:
// listing 05.93
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes(ApiInfo::class, \ReflectionAttribute::IS_INSTANCEOF);
通过将第二个参数ReflectionAttribute::IS_INSTANCEOF
传递给ReflectionAttribute::getAttributes()
,我放松了过滤器以匹配指定的类以及任何扩展或实现的子类或接口。
表 5-3 列出了我们遇到的 ReflectionAttribute 的方法。
表 5-3。
一些反射属性方法
|方法
|
描述
|
| --- | --- |
| getName()
| 返回属性的完整命名空间类型 |
| getArguments()
| 返回与被引用属性相关联的所有参数的数组 |
| newInstance()
| 实例化并返回属性类的实例,将任何参数传递给构造函数 |
Note
在第九章中,我通过一个更加复杂的属性用法的例子来工作。
摘要
在这一章中,我介绍了一些你可以用来管理你的库和类的技术和工具。我研究了 PHP 的名称空间特性。您已经看到,我们可以将包含路径、名称空间、自动加载和文件系统结合起来,为类提供灵活的组织。
我们还研究了 PHP 的对象和类函数,然后用强大的反射 API 将事情推进到下一个级别。我们使用Reflection
类构建了一个简单的例子,展示了Reflection
必须提供的一个潜在用途。最后,我们将Reflection
类与属性结合起来:这是 PHP 8 的一个主要新特性。
六、对象和设计
既然我们已经详细了解了 PHP 对象支持的机制,我们将从细节上退一步,考虑如何最好地使用我们遇到的工具。在这一章中,我将向你介绍一些关于对象和设计的问题。我还将研究 UML,一种用于描述面向对象系统的强大的图形语言。
本章将涵盖以下主题:
-
设计基础知识:我所说的设计是什么,面向对象的设计与过程代码有什么不同
-
类范围:如何决定一个类包含什么
-
封装:将实现和数据隐藏在类的接口后面
-
多态性:使用一个公共超类型来允许在运行时透明地替换专门化的子类型
-
UML :使用图来描述面向对象的架构
定义代码设计
代码设计的一个意义涉及系统的定义:确定系统的需求、范围和目标。系统需要做什么?它需要为谁做这件事?系统的输出是什么?它们满足陈述的需求吗?在较低的层面上,设计可以被理解为定义系统参与者并组织他们之间关系的过程。本章关注的是第二种意义:类和对象的定义和配置。
那么什么是参与者呢?面向对象的系统是由类组成的。在你的系统中决定这些玩家的性质是很重要的。类部分是由方法组成的;所以在定义你的类时,你必须决定哪些方法属于同一个类。但是,正如您将看到的,类通常在继承关系中组合在一起,以符合公共接口。在设计系统时,这些接口或类型应该是您的第一选择。
您还可以为您的类定义其他关系。您可以创建由其他类型组成的类或管理其他类型实例列表的类。您可以设计简单使用其他对象的类。这种组合或使用关系的可能性是内置在您的类中的(例如,通过在方法签名中使用类型声明),但是实际的对象关系发生在运行时,这可以为您的设计增加灵活性。你将在本章中看到如何建立这些关系的模型,我们将在整本书中进一步探讨它们。
作为设计过程的一部分,您必须决定一个操作何时应该属于一个类型,何时应该属于该类型使用的另一个类。无论你走到哪里,你都会面临选择和决定,这些选择和决定可能会让你变得清晰和优雅,也可能会让你陷入妥协的泥沼。
在这一章中,我将探讨一些可能影响其中一些选择的问题。
面向对象和过程编程
面向对象的设计与更传统的过程化代码有什么不同?很容易说主要的区别是面向对象的代码中有对象。这既不真实也没用。在 PHP 中,你经常会发现使用对象的程序代码。您还可能遇到包含程序代码片段的类。类的存在并不能保证面向对象的设计,即使在 Java 这样的语言中也是如此,它迫使你在一个类中做大多数事情。
面向对象和过程化代码的一个核心区别在于责任的分配方式。过程代码采取一系列连续的命令和函数调用的形式。控制代码倾向于负责处理不同的情况。这种自上而下的控制会导致项目中重复和依赖的发展。面向对象的代码试图通过将处理任务的责任从客户端代码转移到系统中的对象来最小化这些依赖性。
在这一节中,我将设置一个简单的问题,然后从面向对象和过程代码两个方面来分析它,以说明这些要点。我的项目是建立一个快速的工具来读取和写入配置文件。为了保持对代码结构的关注,我将在这些例子中省略实现细节。
我将从解决这个问题的程序方法开始。首先,我将以这种格式读写文本:
key:value
为此,我只需要两个函数:
// listing 06.01
function readParams(string $source): array
{
$params = [];
// read text parameters from $source
return $params;
}
function writeParams(array $params, string $source): void
{
// write text parameters to $source
}
readParams
函数需要源文件的名称。它试图打开它并读取每一行,寻找键/值对。它构建了一个关联数组。最后,它将数组返回给控制代码。writeParams()
接受关联数组和源文件的路径。它遍历关联数组,将每个键/值对写入文件。下面是一些使用这些函数的客户端代码:
// listing 06.02
$file = "/tmp/params.txt";
$params = [
"key1" => "val1",
"key2" => "val2",
"key3" => "val3",
];
writeParams($params, $file);
$output = readParams($file);
print_r($output);
这段代码相对紧凑,应该易于维护。调用writeParams()
函数来创建param.txt
,并向其写入如下内容:
key1:val1
key2:val2
key3:val3
readParams()
函数解析相同的格式。
在许多项目中,范围不断扩大和发展。让我们通过引入一个新的需求来掩饰这一点。代码现在还必须处理如下所示的 XML 结构:
<params>
<param>
<key>my key</key>
<val>my val</val>
</param>
</params>
如果参数文件以.xml
结尾,则应以 XML 模式读取参数文件。尽管这并不难适应,但它可能会使我的代码更难维护。现阶段我真的有两个选择。我可以检查控制代码中的文件扩展名,或者在我的读写函数中进行测试。在这里,我倾向于后一种方法:
// listing 06.03
function readParams(string $source): array
{
$params = [];
if (preg_match("/\.xml$/i", $source)) {
// read XML parameters from $source
} else {
// read text parameters from $source
}
return $params;
}
function writeParams(array $params, string $source): void
{
if (preg_match("/\.xml$/i", $source)) {
// write XML parameters to $source
} else {
// write text parameters to $source
}
}
Note
说明性代码总是包含一个困难的平衡动作。它需要足够清楚地表明自己的观点,这通常意味着为了表面上的目的而牺牲错误检查和适用性。换句话说,这里的例子实际上是为了说明设计和复制的问题,而不是解析和写入文件数据的最佳方式。为此,我省略了与当前问题无关的实现。
如您所见,我不得不在每个函数中使用 XML 扩展测试。这种重复可能会给我们带来问题。如果我被要求包含另一种参数格式,我需要记住保持readParams()
和writeParams()
函数相互一致。
现在我将用一些简单的类来解决同样的问题。首先,我创建一个抽象基类,它将定义类型的接口:
// listing 06.04
abstract class ParamHandler
{
protected array $params = [];
public function __construct(protected string $source)
{
}
public function addParam(string $key, string $val): void
{
$this->params[$key] = $val;
}
public function getAllParams(): array
{
return $this->params;
}
public static function getInstance(string $filename): ParamHandler
{
if (preg_match("/\.xml$/i", $filename)) {
return new XmlParamHandler($filename);
}
return new TextParamHandler($filename);
}
abstract public function write(): void;
abstract public function read(): void;
}
我定义了addParam()
方法,允许用户向受保护的$params
属性和getAllParams()
添加参数,以提供对数组副本的访问。
我还创建了一个静态的getInstance()
方法来测试文件扩展名,并根据结果返回一个特定的子类。关键的是,我定义了两个抽象方法,read()
和write()
,确保任何子类都支持这个接口。
Note
将用于生成子对象的静态方法放在父类中很方便。然而,这样的设计决策有其自身的后果。ParamHandler
类型现在基本上仅限于处理这个中央条件语句中的具体类。如果需要处理另一种格式,会发生什么?当然,如果你是ParamHandler
的维护者,你可以随时修改getInstance()
的方法。然而,如果您是一名客户端编码人员,更改这个库类可能不那么容易(事实上,更改它并不难,但是您面临的前景是,每次重新安装提供它的包时,都必须重新应用您的补丁)。我将在第九章中讨论物体创建的问题。
现在,我将定义子类,再次省略实现的细节以保持示例的简洁:
// listing 06.05
class XmlParamHandler extends ParamHandler
{
public function write(): void
{
// write XML
// using $this->params
}
public function read(): void
{
// read XML
// and populate $this->params
}
}
// listing 06.06
class TextParamHandler extends ParamHandler
{
public function write(): void
{
// write text
// using $this->params
}
public function read(): void
{
// read text
// and populate $this->params
}
}
这些类只是提供了write()
和read()
方法的实现。每个类将根据适当的格式写作和阅读。
根据文件扩展名,客户端代码将完全透明地写入文本和 XML 格式:
// listing 06.07
$test = ParamHandler::getInstance(__DIR__ . "/params.xml");
$test->addParam("key1", "val1");
$test->addParam("key2", "val2");
$test->addParam("key3", "val3");
$test->write(); // writing in XML format
我们也可以从任一文件格式中读取:
// listing 06.08
$test = ParamHandler::getInstance(__DIR__ . "/params.txt");
$test->read(); // reading in text format
$params = $test->getAllParams();
print_r($params);
那么,我们能从这两种方法中学到什么呢?
责任
过程示例中的控制代码负责决定格式——不是一次,而是两次。当然,条件代码被整理成函数,但这只是掩盖了单个流程的事实,即在流程进行时做出决策。对readParams()
和writeParams()
的调用发生在不同的上下文中,所以我们被迫在每个函数中重复文件扩展名测试(或者对这个测试执行不同的操作)。
在面向对象版本中,关于文件格式的选择是在静态getInstance()
方法中进行的,该方法只测试一次文件扩展名,提供正确的子类。客户端代码不负责实现。它使用所提供的对象,而不知道或对它所属的特定子类不感兴趣。它只知道它正在处理一个ParamHandler
对象,并且它将支持write()
和read()
。过程代码忙于细节,而面向对象的代码只处理接口,不关心实现的细节。因为实现的责任在于对象,而不在于客户机代码,所以很容易透明地支持新格式。
内聚力
内聚性是最接近的过程相互关联的程度。理想情况下,您应该创建共享明确职责的组件。如果您的代码广泛传播相关的例程,您会发现它们更难维护,因为您必须四处搜寻以进行更改。
我们的ParamHandler
类将相关的过程收集到一个公共的上下文中。处理 XML 的方法共享一个上下文,在这个上下文中,它们可以共享数据,并且如果需要的话,对一个方法的更改可以很容易地反映在另一个方法中(例如,如果您需要更改 XML 元素名称)。因此可以说ParamHandler
类具有很高的内聚性。
另一方面,过程示例将相关的过程分开。使用 XML 的代码分布在不同的函数中。
耦合
当系统代码的离散部分彼此紧密结合在一起,以至于一个部分的变化必然导致其他部分的变化时,紧耦合就发生了。紧密耦合绝不是过程代码所独有的,尽管这种代码的顺序性质使它容易出现问题。
您可以在程序示例中看到这种耦合。writeParams()
和readParams()
函数对文件扩展名运行相同的测试,以确定它们应该如何处理数据。您对其中一个进行的任何逻辑更改都必须在另一个中实现。例如,如果您要添加一种新的格式,您必须使这些函数相互一致,以便它们都以相同的方式实现新的文件扩展名测试。随着您添加新的与参数相关的函数,这个问题只会变得更糟。
面向对象的示例将各个子类彼此分离,并与客户端代码分离。如果需要添加新的参数格式,可以简单地创建一个新的子类,修改静态getInstance()
方法中的一个测试。
正交性
具有严格定义的职责并且独立于更广泛的系统的组件的杀手级组合有时被称为正交性。安德鲁·亨特和戴维·托马斯在他们的书《实用主义程序员 20 周年纪念版》中讨论了这个问题。
有人认为,正交性促进了重用,因为组件可以插入新系统,而不需要任何特殊的配置。这些组成部分将有明确的输入和输出,独立于任何更广泛的背景。正交代码使更改更容易,因为更改实现的影响将局限于被更改的组件。最后,正交码更安全。bug 的影响范围应该是有限的。高度相互依赖的代码中的错误很容易在更广泛的系统中引起连锁反应。
在类上下文中,松散耦合和高内聚并不是自动的。毕竟,我们可以将整个过程性的例子嵌入到一个被误导的类中。那么,我们如何在代码中实现这种平衡呢?我通常从考虑应该存在于我的系统中的类开始。
选择你的课程
定义你的类的边界是非常困难的,特别是当它们随着你构建的任何系统而发展的时候。
当你对真实世界建模时,这看起来很简单。面向对象的系统通常以真实事物的软件表示为特色——有大量的Person
、Invoice
和Shop
类。这似乎表明定义一个类就是在你的系统中找到事物,然后通过方法给它们代理。这是一个不错的起点,但确实有其危险性。如果你把一个类看作一个名词,一个任意数量的动词的主语,那么你可能会发现它膨胀了,因为正在进行的开发和需求变化要求它做越来越多的事情。
让我们考虑一下我们在第三章中创建的ShopProduct
示例。我们的系统存在是为了向客户提供产品,所以定义一个ShopProduct
类是一个显而易见的选择。但这是我们需要做的唯一决定吗?我们提供了诸如getTitle()
和getPrice()
的方法来访问产品数据。当我们被要求提供一种输出发票和交货通知汇总信息的机制时,定义一个write()
方法似乎是有意义的。当客户要求我们提供不同格式的产品摘要时,我们再次查看我们的类。除了write()
方法之外,我们适时地创建了writeXML()
和writeHTML()
方法。或者我们给write()
添加条件代码,根据一个选项标志输出不同的格式。
不管怎样,这里的问题是ShopProduct
类现在试图做的太多了。它正在努力管理展示策略和产品数据。
你应该如何考虑定义类?最好的方法是认为一个类有一个主要的责任,并且尽可能地使这个责任单一和集中。把责任用语言表达出来。有人说过,你应该能够用 25 个或更少的单词来描述一个类的责任,很少使用“和”或“或”这样的词如果你的句子太长或陷入子句中,可能是时候考虑按照你描述的一些职责定义新的类了。
所以,ShopProduct
类负责管理产品数据。如果我们增加了不同格式的写作方法,我们就开始增加一个新的责任领域:产品展示。正如你在第三章中看到的,我们实际上根据这些不同的职责定义了两种类型。ShopProduct
类型仍然负责产品数据,而ShopProductWriter
类型负责显示产品信息。各个子类细化了这些职责。
Note
很少有设计规则是完全不灵活的。例如,您有时会看到在一个不相关的类中保存对象数据的代码。虽然这似乎违反了一个类应该有一个单独职责的规则,但它可能是功能存在的最方便的地方,因为一个方法必须拥有对实例字段的完全访问权。使用本地持久化方法还可以避免我们创建一个并行的持久化类层次结构来镜像我们的可保存类,从而引入不可避免的耦合。我们将在第十二章中讨论对象持久化的其他策略。避免宗教式的遵守设计规则;他们不能代替你分析面前的问题。努力保持对规则背后的推理的关注,并强调这一点胜过规则本身。
多态性
多态性,或者说类切换,是面向对象系统的一个常见特征。你已经在这本书里遇到过几次了。
多态是在一个公共接口后面维护多个实现。这听起来很复杂,但实际上你现在应该很熟悉了。代码中大量条件语句的出现通常表明了对多态性的需求。
当我在第三章中第一次创建ShopProduct
类时,我尝试了一个单独的类,它管理书籍和 CD 的功能,以及通用产品。为了提供汇总信息,我依赖一个条件语句:
// listing 06.09
public function getSummaryLine(): string
{
$base = "{$this->title} ( {$this->producerMainName}, ";
$base .= "{$this->producerFirstName} )";
if ($this->type == 'book') {
$base .= ": page count - {$this->numPages}";
} elseif ($this->type == 'cd') {
$base .= ": playing time - {$this->playLength}";
}
return $base;
}
这些陈述暗示了两个子类的形状:CdProduct
和BookProduct
。
出于同样的原因,我的过程参数示例中的条件语句包含了我最终实现的面向对象结构的种子。我在脚本的两个部分重复了相同的条件:
// listing 06.10
function readParams(string $source): array
{
$params = [];
if (preg_match("/\.xml$/i", $source)) {
// read XML parameters from $source
} else {
// read text parameters from $source
}
return $params;
}
function writeParams(array $params, string $source): void
{
if (preg_match("/\.xml$/i", $source)) {
// write XML parameters to $source
} else {
// write text parameters to $source
}
}
每个子句都暗示了我最终产生的一个子类:XmlParamHandler
和TextParamHandler
。这些扩展了抽象基类ParamHandler
的write()
和read()
的方法:
// listing 06.11
// could return XmlParamHandler or TextParamHandler
$test = ParamHandler::getInstance($file);
$test->read(); // could be XmlParamHandler::read() or TextParamHandler::read()
$test->addParam("newkey1", "newval1");
$test->write(); // could be XmlParamHandler::write() or TextParamHandler::write()
值得注意的是,多态并不排斥条件句。像ParamHandler::getInstance()
这样的方法通常会根据switch
或if
语句来决定返回哪些对象。但是,这些倾向于将条件代码集中到一个地方。
正如您所看到的,PHP 强制执行由抽象类定义的接口。这是有帮助的,因为我们可以确定一个具体的子类将支持与那些由抽象父类定义的方法签名完全相同的方法签名。这包括类型声明和访问控制。因此,客户端代码可以互换地对待一个公共超类的所有子类(只要它只依赖于父类中定义的功能)。
包装
封装仅仅意味着对客户端隐藏数据和功能。再说一次,它是一个关键的面向对象的概念。
在最简单的层面上,通过声明属性private
或protected
来封装数据。通过对客户端代码隐藏属性,可以强制实施接口并防止对象数据的意外损坏。
多态说明了另一种封装。通过将不同的实现放在一个公共接口后面,您可以对客户端隐藏这些底层策略。这意味着在这个接口后面所做的任何更改对更广泛的系统都是透明的。您可以添加新类或更改类中的代码,而不会导致错误。重要的是接口,而不是接口下的工作机制。这些机制保持得越独立,变更或修复在您的项目中产生连锁反应的机会就越小。
在某些方面,封装是面向对象编程的关键。你的目标应该是使每个部分尽可能独立于其他部分。类和方法应该接收尽可能多的信息来执行分配给它们的任务,这些任务应该限制在一定的范围内,并清楚地标识出来。
private
、protected
和public
关键字的引入使得封装变得更加容易。然而,封装也是一种精神状态。PHP 4 没有为隐藏数据提供正式的支持。隐私必须使用文档和命名约定来表示。例如,下划线是表示私有属性的常用方式:
var $_touchezpas;
当然,代码必须被仔细检查,因为隐私没有被严格执行。不过有趣的是,错误很少发生,因为代码的结构和风格非常清楚地表明了哪些属性不需要处理。
出于同样的原因,即使在 PHP 5 到来之后,我们也可以打破规则,通过使用instanceof
操作符来发现我们在类切换上下文中使用的对象的确切子类型:
// listing 06.12
public function workWithProducts(ShopProduct $prod)
{
if ($prod instanceof CdProduct) {
// do cd thing
} elseif ($prod instanceof BookProduct) {
// do book thing
}
}
你可能有一个很好的理由这样做,但是,一般来说,它带有一点不确定的气味。通过查询示例中的特定子类型,我建立了一个依赖关系。虽然子类型的细节被多态隐藏了,但是完全改变ShopProduct
继承层次结构而没有不良影响是可能的。这段代码结束了这一切。现在,如果我需要合理化CdProduct
和BookProduct
类,我可能会在workWithProducts()
方法中产生意想不到的副作用。
从这个例子中可以吸取两个教训。首先,封装有助于创建正交码。第二,封装的可实施程度无关紧要。封装是一种技术,应该被类和它们的客户同等地遵守。
忘记怎么做了
如果你像我一样,提到一个问题会让你的思维加速,寻找可能提供解决方案的机制。您可能会选择能够解决某个问题的函数,重新使用巧妙的正则表达式,并跟踪 Composer 包。您可能在一个旧项目中有一些可粘贴的代码,做一些类似的事情。在设计阶段,你可以把所有这些都放在一边一段时间。清空你头脑中的程序和机制。
只考虑系统的关键参与者:它需要的类型和它们的接口。当然,你对过程的了解会影响你的思维。打开文件的类需要路径,数据库代码需要管理表名和密码,等等。但是,让代码中的结构和关系引导你。您会发现,在定义良好的接口背后,实现很容易到位。然后,如果需要,您可以灵活地切换、改进或扩展实现,而不会影响更广泛的系统。
为了强调接口,考虑抽象基类或接口,而不是具体的孩子。例如,在我获取参数的代码中,接口是设计中最重要的方面。我想要一个读写名称/值对的类型。对于类型来说,重要的是这种责任,而不是实际的持久性介质或存储和检索数据的方式。我围绕抽象的ParamHandler
类来设计系统,并且只在稍后添加实际读写参数的具体策略。这样,我从一开始就将多态和封装构建到我的系统中。该结构有助于类别切换。
当然,话虽如此,我从一开始就知道会有文本和 XML 实现ParamHandler
,毫无疑问这影响了我的界面。在设计界面时,总会有一些心理杂耍要做。
在Design Patterns:Elements of Reusable Object-Oriented Software(Addison-Wesley Professional,1995 年)中,四人组用一句话总结了这个原则:“编程到一个接口,而不是一个实现。”这是一个很好的补充到你的编码手册。
四个路标
很少有人在设计阶段就完全正确。随着需求的变化,或者随着我们对正在解决的问题的本质有了更深的理解,我们大多数人都会修改我们的代码。
当你修改你的代码时,它很容易脱离你的控制。这里增加一个方法,那里增加一个新类,渐渐地你的系统开始衰退。正如您已经看到的,您的代码可以指出改进的方向。代码中的这些指针有时被称为代码味道——也就是说,代码中的特性可能会建议特定的修复,或者至少会让你重新审视你的设计。在这一节中,我将已经提出的一些要点提炼为四个迹象,您应该在编码时注意这些迹象。
代码复制
重复是代码中最大的弊端之一。如果你在编写一个例程时有一种奇怪的似曾相识的感觉,很可能你有问题。
看看你系统中重复的例子。也许他们属于彼此。复制通常意味着紧密耦合。如果你改变了某个套路的一些基本内容,类似的套路需要修改吗?如果是这样的话,他们很可能属于同一个类。
知道得太多的阶层
从一个方法到另一个方法传递参数可能很痛苦。为什么不简单地通过使用全局变量来减少痛苦呢?有了全球,每个人都可以得到数据。
全局变量有它们的位置,但是它们确实需要以某种程度的怀疑来看待。顺便说一句,这是相当高的怀疑程度。通过使用一个全局变量,或者通过给一个类任何种类的关于它的更广领域的知识,你把它锚定到它的上下文中,使它不那么可重用和依赖于超出它控制的代码。请记住,您希望解耦您的类和例程,而不是创建相互依赖。尝试限制一个类对其上下文的了解。我将在本书的后面部分探讨一些策略。
百事通
你的类是否试图同时做太多事情?如果是的话,看看你能否列出这个类的职责。你会发现其中的一个会成为一个好的课程的基础。
如果创建子类,保持一个过分热心的类不变会导致特殊的问题。您在子类中扩展了哪个职责?如果您需要一个子类来承担多个责任,您会怎么做?你可能会有太多的子类或者过度依赖条件代码。
条件语句
在你的项目中,你会有充分的理由使用if
和switch
语句。不过,有时这种结构可能是对多态性的一种呼唤。
如果您发现您在一个类中频繁地测试某些条件,特别是如果您发现这些测试在不止一个方法中被镜像,这可能是您的一个类应该是两个或更多的迹象。看看条件代码的结构是否暗示了可以在类中表达的职责。新的类应该实现一个共享的抽象基类。很有可能你必须解决如何将正确的类传递给客户端代码。我将在第九章介绍一些创建对象的模式。
UML
到目前为止,在本书中,我让代码自己说话,我用简短的例子来说明继承和多态等概念。这很有用,因为 PHP 在这里是一种通用的语言:如果你已经读到这里,它是我们共有的语言。然而,随着我们的例子越来越大,越来越复杂,仅仅使用代码来说明广泛的设计变得有些荒谬。很难在几行代码中看到一个概述。
UML 代表统一建模语言。首字母正确地用于定冠词。这不仅仅是一种统一建模语言,也是 ?? 的统一建模语言。
也许这种权威的语气来自于语言形成的环境。根据 Martin Fowler ( UML 精华,Addison-Wesley Professional,1999)的说法,UML 只是在面向对象设计社区的精英们之间经过多年的知识和官僚争论后才成为一个标准。
这场斗争的结果是一个强大的描述面向对象系统的图形语法。在这一节中,我们将仅仅触及表面,但是你将很快发现一点点 UML(对不起,一点点的 UML)就能走很长的路。
特别是类图可以描述结构和模式,这样它们的意义就显而易见了。这种明亮的清晰度在代码片段和要点中通常很难找到。
类图
虽然类图只是 UML 的一个方面,但是它们可能是最普遍的。因为它们对于描述面向对象的关系特别有用,所以我将在本书中主要使用它们。
代表类别
如你所料,类是类图的主要组成部分。一个类由一个命名的盒子表示(见图 6-1 )。
图 6-1
头等
该类分为三个部分,名称显示在第一个部分。当我们只给出类名的信息时,这些分割线是可选的。在设计类图时,我们可能会发现图 6-1 的详细程度对于某些类来说已经足够了。我们没有义务在类图中表示每个字段和方法,甚至每个类。
抽象类要么用斜体表示类名(见图 6-2 ),要么在类名前加上{abstract}
(见图 6-3 )。第一种方法是两种方法中比较常见的,但第二种方法在你做笔记时更有用。
图 6-3
使用约束定义的抽象类
图 6-2
抽象类
Note
{abstract}
语法是约束的一个例子。约束在类图中用来描述特定元素的使用方式。大括号之间的文本没有特殊的结构;它应该简单地提供适用于该元素的任何条件的简短说明。
接口的定义方式与类相同,只是它们必须包含一个原型(即 UML 的扩展),如图 6-4 所示。
图 6-4
一个界面
属性
概括地说,属性描述了一个类的特性。属性列在类名正下方的部分(见图 6-5 )。
图 6-5
一个属性
让我们仔细看看示例中的属性。初始符号表示属性的可见性或访问控制级别。表 6-1 显示了三种可用的符号。
表 6-1
可见度符号
|标志
|
能见度
|
说明
|
| --- | --- | --- |
| + | 公众 | 适用于所有代码 |
| - | 私人的 | 仅适用于当前类 |
| # | 保护 | 仅适用于当前类别及其子类 |
可见性符号后跟属性的名称。在这种情况下,我描述的是ShopProduct::$price
属性。冒号用于将属性名与其类型分开(或者,可以在末尾提供一个默认值,用等号分隔)。
同样,你只需要为了清晰起见,尽可能多地包括细节。
操作
操作描述方法;或者,更确切地说,它们描述了可以对一个类的实例进行的调用。图 6-6 显示了ShopProduct
类中的两个操作。
图 6-6
操作
如您所见,操作使用与属性相似的语法。可见性符号位于方法名之前。参数列表用括号括起来。该方法的返回类型(如果有)用冒号分隔。参数用逗号分隔,遵循属性语法,属性名称和类型用冒号分隔。
如您所料,这种语法相对灵活。您可以省略可见性标志和返回类型。参数通常只由它们的类型表示,因为参数名通常并不重要。
描述继承和实现
UML 将继承关系描述为一般化。这种关系用一条从子类别到其父类别的线来表示。这条线带有一个空的闭合箭头。
图 6-7 显示了ShopProduct
类与其子类之间的关系。
图 6-7
描述继承
UML 描述了接口和实现它的类之间的关系。因此,如果ShopProduct
类要实现Chargeable
接口,我们可以将其添加到我们的类图中,如图 6-8 所示。
图 6-8
描述接口实现
联合
继承只是面向对象系统中许多关系中的一种。当一个类属性被声明为包含对另一个类的一个(或多个)实例的引用时,就会发生关联。
在图 6-9 中,我们建模了两个类,并在它们之间创建了一个关联。
图 6-9
类协会
在这个阶段,我们对这种关系的性质是模糊的。我们只指定了一个Teacher
对象将引用一个或多个Pupil
对象,反之亦然。这种关系可能是也可能不是互惠的。
你可以用箭头来描述关联的方向。如果Teacher
类有一个Pupil
类的实例,而不是相反,那么你应该把你的关联做成一个从Teacher
指向Pupil
类的箭头。这种关联称为单向关联,如图 6-10 所示。
图 6-10
单向联系
如果每个类都有对另一个类的引用,你可以用一个双向箭头来描述双向关系,如图 6-11 所示。
图 6-11
双向联系
您还可以指定一个类被关联中的另一个类引用的实例的数量(这也称为关联的“基数”)。您可以通过在每个类别旁边放置一个数字或范围来实现这一点。您也可以使用星号(*)代表任何数字。在图 6-12 中,可以有一个Teacher
对象,也可以有零个或多个Pupil
对象。
图 6-12
定义关联的多重性
在图 6-13 中,关联中可以有一个Teacher
对象和五到十个Pupil
对象。
图 6-13
定义关联的多重性
聚集和组成
聚合和组合类似于关联。所有这些都描述了这样一种情况:一个类拥有对另一个类的一个或多个实例的永久引用。但是,通过聚合和组合,被引用的实例形成了引用对象的固有部分。
在聚合的情况下,包含的对象是容器的核心部分,但是它们也可以同时被其他对象包含。聚合关系由一条以空心菱形开始的线表示。
在图 6-14 中,我定义了两类:SchoolClass
和Pupil
。SchoolClass
班蕴Pupil
。
图 6-14
聚合
小学生组成一个类,但是同一个Pupil
对象可以同时被不同的SchoolClass
实例引用。如果我要解散一个学校类,我不一定会删除该学生,他可能会参加其他类。
组成代表了比这更强的关系。在合成中,被包含的对象只能由其容器引用。当容器被删除时,它应该被删除。组合关系的描述方式与聚合关系相同,只是菱形应被填充(见图 6-15 )。
图 6-15
作文
一个Person
类维护一个对SocialSecurityData
对象的引用。包含的实例只能属于包含的Person
对象。
描述用途
在 UML 中,使用关系被描述为依赖关系。它是本节讨论的关系中最短暂的,因为它没有描述类之间的永久链接。
使用的类可以作为参数传递,也可以作为方法调用的结果获取。
图 6-16 中的Report
类使用了一个ShopProductWriter
对象。使用关系由连接两者的虚线和空心箭头表示。然而,它并不像一个ShopProductWriter
对象维护一个ShopProduct
对象数组那样将这个引用作为一个属性来维护。
图 6-16
依赖关系
使用笔记
类图可以捕捉系统的结构,但是它们没有提供过程感。图 6-16 告诉我们系统中的类。从图 6-16 ,你知道一个Report
对象使用一个ShopProductWriter
,但是你不知道这个的机制。在图 6-17 中,我用一个注释来说明一些事情。
图 6-17
使用注释来阐明依赖关系
如你所见,一张纸币由一个带有折叠角的盒子组成。它通常包含伪代码的碎片。
这阐明了图 6-16;您现在可以看到,Report
对象使用了一个ShopProductWriter
来输出产品数据。这不是一个启示,但是使用关系并不总是那么明显。在某些情况下,即使是一张便条也不能提供足够的信息。幸运的是,您可以对系统中对象的交互以及类的结构进行建模。
序列图
序列图是基于对象的,而不是基于类的。它用于一步一步地对系统中的过程进行建模。
让我们构建一个简单的图表,对一个Report
对象写入产品数据的方式进行建模。序列图从左至右展示了系统的参与者(见图 6-18 )。
图 6-18
序列图中的对象
我已经用类名单独标记了我的对象。如果在我的图中,同一个类有多个独立工作的实例,我将使用格式label:class
(例如product1:ShopProduct
)包含一个对象名。
您从上到下展示了您正在建模的流程的生命周期,如图 6-19 所示。
图 6-19
序列图中的对象生命线
垂直虚线表示系统中对象的寿命。生命线后面的大方框代表过程的焦点。如果您从上到下阅读图 6-19 ,您可以看到该过程如何在系统中的对象之间移动。如果不显示对象之间传递的消息,这很难阅读。我在图 6-20 中添加了这些。
图 6-20
完整的序列图
箭头表示从一个对象发送到另一个对象的消息。返回值通常是隐式的(尽管它们可以用虚线表示,从被调用的对象传递到消息发起者)。每条消息都使用相关的方法调用进行标记。虽然有一些语法,但是你可以非常灵活地使用你的标签。方括号表示一种情况:
[okToPrint]
write()
这个代码片段意味着只有满足正确的条件时,才应该进行write()
调用。星号用于表示重复;可选地,进一步的澄清可以放在方括号中:
*[for each ShopProduct]
write()
可以从上到下解读图 6-20 。首先,Report
对象从一个ProductStore
对象获取一个ShopProduct
对象的列表。它将这些传递给一个ShopProductWriter
对象,该对象存储对它们的引用(尽管我们只能从图中推断出这一点)。ShopProductWriter
对象为它引用的每个ShopProduct
对象调用ShopProduct::getSummaryLine()
,将结果添加到它的输出中。
正如你所看到的,序列图可以模拟流程,冻结动态交互的片段,并以惊人的清晰度呈现出来。
Note
看图 6-16 和 6-20 。注意类图是如何说明多态性的,显示了从ShopProductWriter
和ShopProduct
派生的类。现在请注意,当我们对对象间的通信进行建模时,这个细节是如何变得透明的。在可能的情况下,我们希望对象使用最通用的类型,这样我们就可以隐藏实现的细节。
摘要
在这一章中,我超越了面向对象编程的具体细节,着眼于一些关键的设计问题。我研究了封装、松散耦合和内聚等特性,这些特性是灵活且可重用的面向对象系统的基本方面。我接着看了 UML,为本书后面的模式工作打下了基础。
七、什么是设计模式?为什么使用它们?
我们作为程序员遇到的大多数问题已经被我们社区中的其他人一次又一次地处理过了。设计模式可以为我们提供挖掘智慧的方法。一旦一个模式成为一种通用货币,它就丰富了我们的语言,使得分享设计思想及其结果变得容易。设计模式只是提取常见问题,定义经过测试的解决方案,并描述可能的结果。许多书籍和文章关注计算机语言的细节,比如可用的函数、类和方法等等。相反,模式目录关注的是如何从这些基础(“什么”)转移到对项目中的问题和潜在解决方案的理解(“为什么”和“如何”)。
在这一章中,我将向你介绍设计模式,并看看它们流行的一些原因。本章将涵盖以下内容:
-
模式基础:什么是设计模式?
-
模式结构:一个设计模式的关键元素是什么?
-
模式的好处:为什么模式值得你花时间?
什么是设计模式?
在软件世界中,模式是组织部落记忆的有形表现。
——格雷迪·布奇在核心 J2EE 模式
【模式是】在一个上下文中对一个问题的解决方案。
—四人帮,设计模式:可重用面向对象软件的要素
正如这些引文所暗示的,设计模式提供了对特定问题的分析,并描述了解决该问题的良好实践。
问题往往会反复出现,作为 web 程序员,我们必须一次又一次地解决它们。我们应该如何处理传入的请求?我们如何将这些数据转化为我们系统的指令?我们应该如何获取数据?呈现结果?随着时间的推移,我们以或多或少的优雅程度回答了这些问题,并发展出一套我们在项目中使用和重用的非正式技术。这些技术是设计的模式。
设计模式记录并形式化了这些问题和解决方案,使得来之不易的经验可以为更广泛的编程社区所用。模式本质上是(或者应该是)自底向上的,而不是自顶向下的。它们植根于实践,而不是理论。这并不是说设计模式没有很强的理论元素(我们将在下一章看到),但是模式是基于真正的程序员使用的真实技术的。著名的模式孵化者马丁·福勒说他发现了模式;他没有发明它们。由于这个原因,当你意识到你自己使用的技术时,许多模式会产生一种似曾相识的感觉。
模式目录不是食谱。菜谱可以照单全收;代码可以复制并插入到项目中,只需稍作修改。你甚至不总是需要理解食谱中使用的所有代码。设计模式记录了解决特定问题的方法。根据更广泛的背景,实现的细节可能有很大的不同。这种环境可能包括您正在使用的编程语言、应用的性质、项目的规模以及问题的具体情况。
比方说,你的项目要求你创建一个模板系统。给定模板文件的名称,您必须解析它并构建一个对象树来表示您遇到的标签。
首先使用默认解析器扫描文本中的触发器标记。当它找到一个匹配时,它把寻找的责任交给另一个解析器对象,这个解析器对象专门用于读取标签的内部信息。这将继续检查模板数据,直到失败、完成或找到另一个触发器。如果它找到了一个触发器,它也必须把责任交给一个专家——也许是一个参数解析器。总的来说,这些组件形成了所谓的递归下降解析器。
这些是你的参与者:一个MainParser
、一个TagParser
和一个ArgumentParser
。您创建一个ParserFactory
类来创建和返回这些对象。
当然,没有一件事是容易的,在游戏后期你会被告知你必须在你的模板中支持不止一种语法。现在,您需要根据语法创建一组并行的解析器:一个OtherTagParser
,一个OtherArgumentParser
,等等。
这是您的问题:您需要根据环境生成一组不同的对象,并且您希望这对系统中的其他组件或多或少是透明的。恰好四人组在他们的书《模式抽象工厂》的总结页中定义了以下问题,“提供一个接口来创建相关或依赖对象的系列,而不指定它们的具体类。”
那非常合适。正是我们问题的本质决定并塑造了我们对这种模式的使用。正如你在第九章中看到的,这个解决方案也没有任何剪切和粘贴的成分,在这一章中我讨论了抽象工厂。
命名一个模式的行为本身就是有价值的;它有助于在古老的工艺和职业中自然出现的那种通用词汇。这种简写极大地帮助了协作设计,因为替代方法和它们的各种结果被权衡和测试。例如,当您讨论备选的解析器系列时,您可以简单地告诉同事,系统使用抽象工厂模式创建每组对象。他们会明智地点头,要么立刻明白过来,要么记下来以后再查。关键是这一堆概念和结果有一个句柄,这是一个有用的简写,我将在本章后面说明。
最后,根据国际法,写关于模式的文章而不引用 Christopher Alexander 是非法的,他是一位建筑学者,他的工作对最初的面向对象模式倡导者产生了重大影响。他在中陈述了一种模式语言(牛津大学出版社,1977 年):
每个模式都描述了一个在我们的环境中反复出现的问题,然后描述了该问题解决方案的核心,这样你就可以使用这个解决方案一百万次,而不必以同样的方式做两次。
重要的是,这个定义(适用于架构问题和解决方案)从问题及其更广泛的背景开始,然后发展到解决方案。近年来有一些批评说设计模式被过度使用了,尤其是被没有经验的程序员使用。这通常是一个信号,表明在问题和背景不存在的地方已经应用了解决方案。模式不仅仅是以特定方式合作的类和对象的特定组织。模式的结构定义了解决方案应该应用的条件,并讨论了解决方案的效果。
在本书中,我将关注模式领域中一个特别有影响力的分支:四人组(Addison-Wesley Professional,1995)在Design Patterns:Elements of Reusable Object-Oriented Software中描述的形式。它专注于面向对象软件开发中的模式,并记录了大多数现代面向对象项目中出现的一些经典模式。
《四人帮》这本书很重要,因为它记录了关键模式,并且描述了指导和激励这些模式的设计原则。我们将在下一章探讨其中的一些原则。
Note
四人帮和本书中描述的模式实际上是模式语言的实例。模式语言是组织在一起的问题和解决方案的目录,因此它们相互补充,形成一个相互关联的整体。还有其他问题空间的模式语言,比如视觉设计和项目管理(当然还有架构)。当我在这里讨论设计模式时,我指的是面向对象软件开发中的问题和解决方案。
设计模式概述
本质上,设计模式由四部分组成:名称、问题、解决方案和后果。
名字
名字很重要。它们丰富了程序员的语言;几个简短的词可以代表相当复杂的问题和解决方案。他们必须平衡简洁和描述。四人组声称,“找到好名字是开发我们目录最困难的部分之一。”
Martin Fowler 同意:“模式名称至关重要,因为模式的部分目的是创建一个允许开发人员更有效地交流的词汇表”(企业应用架构的模式,Addison-Wesley Professional,2002)。
在企业应用架构的模式中,Martin Fowler 提炼了我在 Deepak Alur、Dan Malks 和 John Crupi (Prentice Hall,2001)的核心 J2EE 模式中首次遇到的数据库访问模式。Fowler 定义了两种模式来描述旧模式的专门化。他的方法的逻辑显然是正确的(一个新模式建模领域对象,而另一个建模数据库表,这种区别在早期的工作中是模糊的)。然而,很难训练自己用新的模式来思考。我在设计会议和文档中使用原作的名字已经很久了,它已经成为我语言的一部分。
问题
无论解决方案多么优雅(有些确实非常优雅),问题及其背景都是模式的基础。识别问题比应用模式目录中的任何一个解决方案都要困难。这是一些模式解决方案可能被误用或过度使用的一个原因。
模式非常小心地描述了一个问题空间。对问题进行简要描述,然后结合上下文,通常有一个典型的例子和一个或多个图表。它被分解成它的细节,它的各种表现。描述了可能有助于识别问题的任何警告信号。
解决方案
结合问题对解决方案进行初步总结。它也被详细描述,经常使用 UML 类图和交互图。该模式通常包括一个代码示例。
虽然可能会出现代码,但解决方案永远不会是剪切和粘贴。模式描述了解决问题的方法。它的实现可能有成百上千的细微差别。想想播种粮食作物的说明。如果你只是盲目地按照一套步骤去做,到了收获季节,你很可能会挨饿。更有用的是基于模式的方法,它涵盖了可能适用的各种条件。这个问题的基本解决方案(让你的作物生长)总是一样的(准备土壤、播种、灌溉、收获作物),但是你采取的实际步骤将取决于各种因素,例如你的土壤类型、你的位置、你的土地的方向、当地的害虫等等。
Martin Fowler 将模式中的解决方案称为“半成品”也就是说,编码者必须拿走概念,自己完成。
结果
你做出的每一个设计决策都会产生更广泛的影响。这当然应该包括令人满意地解决问题。一个解决方案一旦部署,可能非常适合与其他模式一起工作。也可能有危险需要注意。
“四人帮”的形式
当我写的时候,我面前的桌子上有五个图案目录。快速看一下每个中的模式,可以确认它们都没有使用相同的结构。有些是正式的;有些是细粒度的,有很多子节;还有一些是散漫的。
有许多定义良好的模式结构,包括由 Christopher Alexander 开发的原始形式(亚历山大形式)和波特兰模式库偏爱的叙述方法(波特兰形式)。因为“四人帮”的书影响如此之大,而且因为我们将涵盖他们描述的许多模式,所以让我们检查一下他们的模式中包括的一些部分:
-
意图:模式目的的简要陈述。你应该一眼就能看出图案的要点。
-
动机:描述的问题,通常是根据一个典型的情况。轶事方法有助于使模式易于掌握。
-
适用性:检查您可能应用模式的不同情况。虽然动机描述了一个典型的问题,但本节定义了具体的情况,并在每种情况下权衡了解决方案的优点。
-
结构/交互:这些部分可能包含描述解决方案中的类和对象之间关系的 UML 类和交互图。
-
实现:这一部分着眼于解决方案的细节。它分析了应用该技术时可能出现的任何问题,并提供了部署技巧。
-
样本代码:我总是跳到这一节。我发现一个简单的代码示例通常提供了一种进入模式的方法。为了暴露解决方案,这个例子经常被删减到最基本的部分。它可以是任何面向对象的语言。当然,在这本书里,永远是 PHP。
-
已知用途:这些描述模式(问题、上下文和解决方案)出现的真实系统。有人说,一个模式要成为真实的,它必须在至少三个公开可用的上下文中找到。这有时被称为“三法则”
-
相关模式:一些模式暗示着另一些模式。在应用一个解决方案时,您可以创建另一个解决方案变得有用的环境。本节研究这些协同作用。它还可能讨论与问题或解决方案有相似之处的模式,以及任何先例(即,在当前模式的基础上定义的模式)。
为什么要使用设计模式?
那么模式能带来什么好处呢?假设模式是定义的问题和描述的解决方案,答案应该是显而易见的。模式可以帮助你解决常见的问题。当然,模式还不止这些。
设计模式定义了一个问题
有多少次你在一个项目中到了一个阶段,发现已经没有前进的方向了?在重新开始之前,你可能必须原路返回。
通过定义常见问题,模式可以帮助您改进设计。有时候,解决问题的第一步是认识到你有问题。
设计模式定义了一个解决方案
在定义和识别了问题(并确定它是正确的问题)之后,模式给你提供了一个解决方案,以及对使用它的后果的分析。尽管模式并不能免除你考虑设计决策含义的责任,但你至少可以确定你使用的是一种久经考验的技术。
设计模式是独立于语言的
模式用面向对象的术语定义对象和解决方案。这意味着许多模式同样适用于不止一种语言。当我第一次开始使用模式时,我阅读 C++和 Smalltalk 中的代码示例,然后用 Java 部署我的解决方案。其他的随着模式的适用性或结果的修改而转移,但是仍然有效。无论哪种方式,当你在不同语言间转换时,模式都可以帮助你。同样,基于良好的面向对象设计原则构建的应用可以相对容易地在不同语言之间移植(尽管总有一些问题必须解决)。
模式定义了词汇表
通过为开发人员提供技术名称,模式使得交流更加丰富。想象一个设计会议。我已经描述了我的抽象工厂解决方案,现在我需要描述我管理系统编译的数据的策略。我向鲍勃描述了我的计划:
-
我:我在考虑使用复合材料。
-
鲍勃:我不认为你已经考虑清楚了。
好吧,鲍勃不同意我的观点。他从来没有。但他知道我在说什么,因此也知道为什么我的想法很糟糕。让我们在没有设计词汇的情况下再次播放那个场景。
-
我打算使用共享相同类型的对象树。该类型的接口将提供用于添加其自身类型的子对象的方法。这样,我们可以在运行时构建实现对象的复杂组合。
-
鲍勃:嗯?
模式,或者它们描述的技术,倾向于互操作。组合模式适合与访问者模式协作,例如:
-
我:然后我们可以用访客来总结数据。
-
鲍勃:你没抓住重点。
忽略鲍勃。我不会描述这个曲折的非模式版本;我将在第十章介绍复合材料,在第十一章介绍访客。
关键是,如果没有模式语言,我们仍然会使用这些技术。他们先于他们的命名和组织。如果模式不存在,它们会自己进化。任何被充分使用的工具最终都会获得一个名字。
模式是经过试验和测试的
因此,如果模式记录了良好的实践,那么命名是模式目录中唯一真正原创的东西吗?从某种意义上说,这似乎是真的。模式代表了面向对象环境中的最佳实践。对于一些经验丰富的程序员来说,这似乎是对显而易见的东西进行重新包装。对我们其余的人来说,模式提供了解决问题和解决方案的途径,否则我们将不得不艰难地去发现。
模式让设计变得容易理解。随着模式目录出现在越来越多的专业领域,即使是经验丰富的人也可以在进入他们领域的新方面时发现好处。例如,GUI 程序员可以快速访问企业编程中的常见问题和解决方案。一个网络程序员可以快速制定策略,避免平板电脑和智能手机项目中潜伏的陷阱。
模式是为协作而设计的
从本质上讲,模式应该是可生成和可组合的。这意味着您应该能够应用一种模式,从而为另一种模式的应用创造条件。换句话说,在使用一个模式时,你可能会发现其他的门为你打开了。
模式目录的设计通常考虑了这种协作,模式组合的潜力总是记录在模式本身中。
设计模式促进好的设计
设计模式展示并应用了面向对象设计的原则。因此,对设计模式的研究可以在一个环境中产生比特定解决方案更多的东西。您可以从一个新的角度来看待对象和类的组合方式,以实现一个目标。
流行的框架使用设计模式
这本书主要是关于从头开始的设计。这里介绍的模式和原则应该使您能够根据项目的需要设计自己的核心框架。然而,懒惰也是一种美德,你可能希望使用(或者继承已经使用的代码)Zend、Laravel 或 Symfony 等框架。当您使用这些框架 API 时,对核心设计模式的良好理解会对您有所帮助。
PHP 和设计模式
这一章中很少是专门针对 PHP 的,这在某种程度上是我们主题的特点。许多模式适用于许多支持对象的语言,很少或没有实现问题。
当然,情况并非总是如此。一些企业模式在应用流程在服务器请求之间继续运行的语言中工作得很好。PHP 不是这样工作的。对于每个请求,都会启动一个新的脚本执行。这意味着有些模式需要更加小心地对待。
例如,前端控制器通常需要很长的初始化时间。当初始化在应用启动时发生一次时,这没问题,但是当它必须为每个请求发生时,这就更成问题了。这并不是说我们不能使用模式;我过去部署过它,效果非常好。我们必须确保在讨论模式时考虑到 PHP 相关的问题。PHP 构成了本书所考察的所有模式的背景。
我在本节前面提到了支持对象的语言。你可以不用定义任何类就用 PHP 编码。然而,除了几个明显的例外,对象和面向对象的设计是大多数 PHP 项目和库的核心。
摘要
在这一章中,我介绍了设计模式,向您展示了它们的结构(使用四人组的形式),并提出了一些您可能希望在脚本中使用设计模式的原因。
重要的是要记住,设计模式不是可以像组件一样组合起来构建项目的嵌入式解决方案。它们是解决常见问题的建议方法。这些解决方案体现了一些关键的设计原则。这就是我们将在下一章探讨的问题。
八、一些模式原则
尽管设计模式简单地描述了问题的解决方案,但是它们倾向于强调提高可重用性和灵活性的解决方案。为了实现这一点,它们体现了一些关键的面向对象设计原则。我们将在本章中遇到其中的一些,并在本书的其余部分更详细地介绍。
本章将涵盖以下主题:
-
组合:如何使用对象聚合来获得比单独使用继承更大的灵活性
-
解耦:如何减少系统中元素之间的依赖
-
接口的力量:模式和多态性
-
图案类别:本书将涉及的图案类型
模式启示
我第一次开始用 Java 语言处理对象。正如你所料,一些概念的出现需要一段时间。然而,当它真的发生时,它发生得非常快,几乎具有启示的力量。继承和封装的优雅让我大吃一惊。我能感觉到这是一种不同的定义和构建系统的方式。我得到了多态,在运行时处理一个类型并切换实现。在我看来,这种理解将解决我的大部分设计问题,并帮助我设计出漂亮而优雅的系统。
当时我桌上所有的书都集中在语言特性和 Java 程序员可用的许多 API 上。除了多态性的简短定义,很少有人尝试去检查设计策略。
语言特性本身不会产生面向对象的设计。尽管我的项目满足了它们的功能需求,但继承、封装和多态提供的设计似乎继续困扰着我。
当我试图为每一个可能发生的事情建立一个新的类时,我的继承层次变得越来越宽,越来越深。我的系统的结构使得很难将消息从一个层传递到另一个层,而不会让中间类过多地意识到它们的环境,将它们绑定到应用中,并使它们在新的上下文中不可用。
直到我发现了设计模式:可重用面向对象软件的元素 (Addison-Wesley Professional,1995),或者被称为四人帮的书,我才意识到我错过了整个设计维度。到那时,我已经为自己发现了一些核心模式,但其他人贡献了一种新的思维方式。
我发现我的设计中有过多的特权继承,试图在我的类中构建太多的功能。但是在面向对象的系统中,功能还能去哪里呢?
我在作文里找到了答案。通过以灵活的关系组合对象,可以在运行时定义软件组件。“四人帮”把这归结为一条原则:“重创作,轻继承。”模式描述了在运行时组合对象的方式,以达到在继承树中不可能实现的灵活性。
构成和继承
继承是为不断变化的环境或上下文进行设计的一种强有力的方式。然而,这会限制灵活性,尤其是当类承担多重责任时。
问题
众所周知,子类继承了其父类的方法和属性(只要它们是受保护的或公共的元素)。您可以利用这一事实来设计提供专门功能的子类。
图 8-1 给出了一个使用 UML 的简单例子。
图 8-1
一个父类和两个子类
图 8-1 中的抽象Lesson
类模拟了大学中的一堂课。它定义了抽象的cost()
和chargeType()
方法。该图显示了两个实现类,FixedPriceLesson
和TimedPriceLesson
,它们为课程提供了不同的收费机制。
使用这个继承方案,我可以在课程实现之间切换。客户端代码将只知道它正在处理一个Lesson
对象,因此成本的细节将是透明的。
但是,如果我引入一组新的专门化,会发生什么呢?我需要处理讲座和研讨会。因为它们以不同的方式组织注册和课程笔记,所以需要单独的课程。现在我有两种力量在影响我的设计。我需要处理定价策略和单独的讲座和研讨会。
图 8-2 显示了一个强力解决方案。
图 8-2
糟糕的继承结构
图 8-2 显示了一个明显有缺陷的层级。我不能再使用继承树来管理我的定价机制而不复制大量的功能。定价策略反映在Lecture
和Seminar
级系列中。
在这个阶段,我可能会考虑在Lesson
超类中使用条件语句,删除那些不幸的重复。本质上,我将定价逻辑从继承树中完全移除,将其移到超类中。这与通常的重构相反,用多态替换条件。下面是一个修改过的Lesson
类:
// listing 08.01
abstract class Lesson
{
public const FIXED = 1;
public const TIMED = 2;
public function __construct(protected int $duration, private int $costtype = 1)
{
}
public function cost(): int
{
switch ($this->costtype) {
case self::TIMED:
return (5 * $this->duration);
break;
case self::FIXED:
return 30;
break;
default:
$this->costtype = self::FIXED;
return 30;
}
}
public function chargeType(): string
{
switch ($this->costtype) {
case self::TIMED:
return "hourly rate";
break;
case self::FIXED:
return "fixed rate";
break;
default:
$this->costtype = self::FIXED;
return "fixed rate";
}
}
// more lesson methods...
}
// listing 08.02
class Lecture extends Lesson
{
// Lecture-specific implementations ...
}
// listing 08.03
class Seminar extends Lesson
{
// Seminar-specific implementations ...
}
下面是我如何使用这些类:
// listing 08.04
$lecture = new Lecture(5, Lesson::FIXED);
print "{$lecture->cost()} ({$lecture->chargeType()})\n";
$seminar = new Seminar(3, Lesson::TIMED);
print "{$seminar->cost()} ({$seminar->chargeType()})\n";
这是输出结果:
30 (fixed rate)
15 (hourly rate)
你可以在图 8-3 中看到新的类图。
图 8-3
通过从子类中移除成本计算改进了继承层次结构
我已经使职业结构变得更容易管理,但这是有代价的。在这段代码中使用条件句是一种倒退。通常,您会尝试用多态来替换条件语句。在这里,我做了相反的事情。如您所见,这迫使我在chargeType()
和cost()
方法中重复条件语句。
我似乎注定要复制代码。
使用合成
我可以用策略模式来构建我的脱困之路。策略用于将一组算法转移到一个单独的类型中。通过移动成本计算,我可以简化Lesson
类型。你可以在图 8-4 中看到这一点。
图 8-4
将算法转移到单独的类型中
我创建了一个抽象类CostStrategy
,它定义了抽象方法cost()
和chargeType()
。cost()
方法需要一个Lesson
的实例,它将使用这个实例来生成成本数据。我为CostStrategy
提供了两个具体的子类。Lesson
对象只适用于CostStrategy
类型,而不是特定的实现,所以我可以通过子类化CostStrategy
随时添加新的成本算法。这不需要对任何Lesson
类做任何改变。
下面是新Lesson
类的简化版本,如图 8-4 所示:
// listing 08.05
abstract class Lesson
{
public function __construct(private int $duration, private CostStrategy $costStrategy)
{
}
public function cost(): int
{
return $this->costStrategy->cost($this);
}
public function chargeType(): string
{
return $this->costStrategy->chargeType();
}
public function getDuration(): int
{
return $this->duration;
}
// more lesson methods...
}
// listing 08.06
class Lecture extends Lesson
{
// Lecture-specific implementations ...
}
// listing 08.07
class Seminar extends Lesson
{
// Seminar-specific implementations ...
}
Lesson
类需要一个CostStrategy
对象,它将该对象存储为一个属性。Lesson::cost()
方法简单地调用CostStrategy::cost()
。同样,Lesson::chargeType()
调用CostStrategy::chargeType()
。这种为了满足请求而显式调用另一个对象的方法的行为称为委托。在我的例子中,CostStrategy
对象是Lesson
的委托。Lesson
类不再负责成本计算,并将任务交给CostStrategy
实现。在这里,它被夹在授权的行为中:
// listing 08.08
public function cost(): int
{
return $this->costStrategy->cost($this);
}
下面是CostStrategy
类及其实现子类:
// listing 08.09
abstract class CostStrategy
{
abstract public function cost(Lesson $lesson): int;
abstract public function chargeType(): string;
}
// listing 08.10
class TimedCostStrategy extends CostStrategy
{
public function cost(Lesson $lesson): int
{
return ($lesson->getDuration() * 5);
}
public function chargeType(): string
{
return "hourly rate";
}
}
// listing 08.11
class FixedCostStrategy extends CostStrategy
{
public function cost(Lesson $lesson): int
{
return 30;
}
public function chargeType(): string
{
return "fixed rate";
}
}
我可以通过在运行时传递不同的CostStrategy
对象来改变任何Lesson
对象计算成本的方式。这种方法可以产生高度灵活的代码。我可以动态地组合和重组对象,而不是静态地在我的代码结构中构建功能:
// listing 08.12
$lessons[] = new Seminar(4, new TimedCostStrategy());
$lessons[] = new Lecture(4, new FixedCostStrategy());
foreach ($lessons as $lesson) {
print "lesson charge {$lesson->cost()}. ";
print "Charge type: {$lesson->chargeType()}\n";
}
lesson charge 20\. Charge type: hourly rate
lesson charge 30\. Charge type: fixed rate
正如你所看到的,这种结构的一个效果是我集中了我的类的职责。CostStrategy
对象单独负责计算成本,Lesson
对象管理课程数据。
因此,组合可以使您的代码更加灵活,因为对象可以组合在一起,以比您单独在继承层次结构中预期的更多的方式来动态处理任务。不过,可读性可能会受到影响。因为组合往往会产生更多的类型,并且关系不像继承关系那样具有固定的可预测性,所以在系统中消化这些关系会稍微困难一些。
退耦
你在第六章中看到,构建独立的组件是有意义的。具有高度相互依赖的类的系统可能很难维护。一个地点的变化可能需要整个系统的一系列相关变化。
问题
可重用性是面向对象设计的关键目标之一,紧耦合是它的敌人。当您看到对系统的一个组件的更改必然导致其他地方的许多更改时,您可以诊断出紧耦合。您应该渴望创建独立的组件,这样您就可以在没有意外后果的多米诺骨牌效应的情况下进行更改。当您更改一个组件时,它的独立程度与您的更改导致系统其他部分失败的可能性有关。
你可以在图 8-2 中看到一个紧密耦合的例子。因为成本逻辑是跨Lecture
和Seminar
类型镜像的,所以对TimedPriceLecture
的更改将需要对TimedPriceSeminar
中的相同逻辑进行并行更改。通过更新一个类而不更新另一个类,我会破坏我的系统——没有任何来自 PHP 引擎的警告。我的第一个解决方案使用条件语句,在cost()
和chargeType()
方法之间产生了类似的依赖关系。
通过应用策略模式,我将我的成本算法提炼为CostStrategy
类型,将它们放在一个公共接口后面,并且每个算法只实现一次。
当系统中的许多类被显式嵌入到平台或环境中时,会出现另一种类型的耦合。例如,假设您正在构建一个使用 MySQL 数据库的系统。您可以使用诸如mysqli::query()
这样的方法与数据库服务器对话。
如果您被要求在不支持 MySQL 的服务器上部署系统,您可以将整个项目转换成使用 SQLite。但是,您将被迫在整个代码中进行更改,并且面临维护应用的两个并行版本的前景。
这里的问题不是系统对外部平台的依赖。这种依赖是不可避免的。您需要处理与数据库对话的代码。当这样的代码分散在整个项目中时,问题就来了。与数据库对话并不是系统中大多数类的主要职责,所以最好的策略是提取这样的代码,并将其组合在一个公共接口后面。这样,你促进了你的类的独立性。同时,通过将您的网关代码集中在一个地方,您可以更容易地切换到一个新的平台,而不会干扰您更广泛的系统。这个过程,将实现隐藏在干净的接口后面,被称为封装。主义数据库库用DBAL
(数据库抽象层)项目解决了这个问题。这为多个数据库提供了单点访问。
DriverManager
类提供了一个名为getConnection()
的静态方法,它接受一个参数数组。根据这个数组的组成,它返回一个名为Doctrine\DBAL\Driver
的接口的特定实现。你可以在图 8-5 中看到阶级结构。
图 8-5
DBAL 包将客户机代码从数据库对象中分离出来
Note
静态属性和操作应该在 UML 中加下划线。
然后,DBAL
包让您将应用代码从数据库平台的细节中分离出来。您应该能够用 MySQL、SQLite、MSSQL 和其他工具运行一个系统,而不需要修改一行代码(当然,除了配置参数之外)。
松开你的联轴器
为了灵活地处理数据库代码,您应该将应用逻辑从它所使用的数据库平台的细节中分离出来。在你自己的项目中,你会看到很多这种组件分离的机会。
例如,想象一下,Lesson
系统必须包含一个注册组件来为系统添加新的课程。作为注册过程的一部分,添加课时应通知管理员。该系统的用户无法就该通知是通过邮件还是短信发送达成一致。事实上,他们太爱争论了,以至于你怀疑他们可能想在未来换一种新的交流方式。更重要的是,他们希望得到各种事情的通知,因此一个地方的通知模式的改变将意味着许多其他地方的类似改变。
如果您硬编码了对一个Mailer
类或一个Texter
类的调用,那么您的系统将紧密耦合到一个特定的通知模式,就像它将通过使用一个专门的数据库 API 紧密耦合到一个数据库平台一样。
下面是一些代码,它们向使用通知程序的系统隐藏了通知程序的实现细节:
// listing 08.13
class RegistrationMgr
{
public function register(Lesson $lesson): void
{
// do something with this Lesson
// now tell someone
$notifier = Notifier::getNotifier();
$notifier->inform("new lesson: cost ({$lesson->cost()})");
}
}
// listing 08.14
abstract class Notifier
{
public static function getNotifier(): Notifier
{
// acquire concrete class according to
// configuration or other logic
if (rand(1, 2) === 1) {
return new MailNotifier();
} else {
return new TextNotifier();
}
}
abstract public function inform($message): void;
}
// listing 08.15
class MailNotifier extends Notifier
{
public function inform($message): void
{
print "MAIL notification: {$message}\n";
}
}
// listing 08.16
class TextNotifier extends Notifier
{
public function inform($message): void
{
print "TEXT notification: {$message}\n";
}
}
我为我的通知程序类创建了一个样本客户端RegistrationMgr
。Notifier
类是抽象的,但是它实现了一个静态方法getNotifier()
,该方法获取一个具体的Notifier
对象(TextNotifier
或MailNotifier
)。在一个真实的项目中,Notifier
的选择将由一个灵活的机制决定,比如一个配置文件。在这里,我作弊,随机选择。MailNotifier
和TextNotifier
只不过是打印出它们被传递的消息,以及一个标识符来显示哪个被调用了。
注意应该使用哪种混凝土的知识是如何在Notifier::getNotifier()
方法中集中的。我可以从我的系统的上百个不同部分发送通知消息,并且只需要在这一个方法中改变通知消息。
下面是一些调用RegistrationMgr
的代码:
// listing 08.17
$lessons1 = new Seminar(4, new TimedCostStrategy());
$lessons2 = new Lecture(4, new FixedCostStrategy());
$mgr = new RegistrationMgr();
$mgr->register($lessons1);
$mgr->register($lessons2);
下面是典型运行的输出:
TEXT notification: new lesson: cost (20)
MAIL notification: new lesson: cost (30)
图 8-6 显示了这些类别。
图 8-6
通告程序类将客户端代码与通告程序实现分开
请注意图 8-6 中的结构与图 8-5 中所示的原则组件形成的结构是多么相似。
代码指向接口,而不是实现
这个原则是这本书的主题之一。你在第六章(以及最后一节)中看到,你可以在一个超类中定义的公共接口后面隐藏不同的实现。然后,客户端代码可能需要超类类型的对象,而不是实现类的对象,而不关心它实际获得的具体实现。
并行条件语句,就像我从Lesson::cost()
和Lesson::chargeType()
中找到的,是需要多态性的一个常见标志。它们使得代码难以维护,因为一个条件表达式的变化必然导致其兄弟表达式的变化。条件语句有时被称为实现了“模拟继承”
通过将成本算法放在实现CostStrategy
的单独的类中,我消除了重复。如果我将来需要添加新的成本策略,我也会让它变得更加容易。
从客户端代码的角度来看,在方法的参数中要求抽象或通用类型通常是个好主意。通过要求更具体的类型,您可能会限制代码在运行时的灵活性。
当然,话虽如此,你在论点暗示中选择的概括性程度是一个判断问题。让你的选择过于笼统,你的方法可能会变得不那么安全。如果您需要子类型的特定功能,那么在一个方法中接受一个装备不同的兄弟可能会有风险。
尽管如此,如果对参数提示的选择过于严格,就会失去多态性的好处。看看这个来自Lesson
类的修改过的摘录:
// listing 08.18
public function __construct(private int $duration, private FixedCostStrategy $costStrategy)
{
}
在这个例子中,设计决策产生了两个问题。首先,Lesson
对象现在被绑定到一个特定的成本策略,这限制了我编写动态组件的能力。其次,对FixedPriceStrategy
类的显式引用迫使我维护这个特定的实现。
通过要求一个公共接口,我可以将一个Lesson
对象与任何CostStrategy
实现结合起来:
// listing 08.19
public function __construct(private int $duration, private CostStrategy $costStrategy)
{
}
换句话说,我已经将我的Lesson
类从成本计算的细节中分离出来。重要的是接口和保证被提供的对象会遵守它。
当然,编写接口代码通常可以简单地推迟如何实例化对象的问题。当我说一个Lesson
对象可以在运行时与任何一个CostStrategy
接口结合时,我提出了一个问题,“但是这个CostStrategy
对象是从哪里来的呢?”
当你创建一个抽象超类时,总会有一个问题,那就是它的子类应该如何被实例化。你选择哪个孩子,根据哪个条件?这个主题在“四人帮”的模式目录中自成一类,我将在下一章进一步探讨这个问题。
变化的概念
设计决策一旦做出,很容易解释,但是如何决定从哪里开始呢?
“四人帮”建议你“把变化的概念封装起来。”根据我的例子,变化的概念是成本算法。在这个例子中,成本计算不仅是两种可能的策略之一,而且显然是一种扩展的候选策略:特别优惠、海外学生费率、入门折扣,各种各样的可能性都出现了。
我很快发现为这种变化划分子类是不合适的,于是我求助于条件语句。通过将我的变体放在同一个类中,我强调了它适合封装。
“四人帮”建议您积极地在类中寻找不同的元素,并评估它们是否适合封装成新的类型。可疑条件中的每一个选择都可以被提取以形成扩展公共抽象父类的类。然后,这个新类型可以由提取它的一个或多个类使用。这具有以下效果:
-
聚焦责任
-
通过构图提高灵活性
-
使继承层次更加紧凑和集中
-
减少重复
那么,你如何发现变异呢?一个迹象是对继承的滥用。这可能包括同时根据多种力量部署的继承(例如,讲座/研讨会和固定/定时成本)。它还可能包括算法的子类化,其中算法是该类型的核心职责所附带的。正如您所看到的,适合封装的变体的另一个标志是条件表达式。
图案炎
没有模式的一个问题是模式的不必要或不恰当的使用。这使得模式在某些领域名声不佳。因为模式解决方案很简洁,所以无论它们是否真正满足需求,都很容易将它们应用到您认为合适的地方。
极限编程(XP)方法提供了一些可能适用于这里的原则。第一个是,“你不需要它”(通常缩写为 YAGNI)。这通常适用于应用特性,但对模式也有意义。
当我用 PHP 构建大型环境时,我倾向于将我的应用分层,将应用逻辑从表示层和持久层中分离出来。我将各种核心和企业模式相互结合使用。
然而,当我被要求为一个小型商业网站构建一个反馈表单时,我可能只是在一个单页脚本中使用程序代码。我不需要大量的灵活性;我不会在最初版本的基础上进行构建。我不需要在更大的系统中使用解决问题的模式。相反,我应用第二个 XP 原则:“做最简单的工作。”
当您使用一个模式目录时,解决方案的结构和过程是牢记在心的,并通过代码示例得到巩固。但是,在应用一个模式之前,请密切关注问题,或者“何时使用它”一节,然后仔细阅读该模式的结果。在某些情况下,治疗可能比疾病更糟糕。
模式
这本书不是一个模式目录。然而,在接下来的章节中,我将介绍一些目前正在使用的关键模式,提供 PHP 实现并在 PHP 编程的大背景下讨论它们。
所描述的模式将来自关键目录,包括马丁·福勒(Addison-Wesley Professional,2002)的设计模式:可重用面向对象软件的元素 (Addison-Wesley Professional,1995);企业应用架构的模式,以及 Alur 等人的核心 J2EE 模式:最佳实践和设计策略 (Prentice Hall,2001)。
用于生成对象的模式
这些模式与对象的实例化有关。这是一个重要的类别,因为原则是“接口代码”如果您在设计中使用抽象父类,那么您必须开发从具体子类实例化对象的策略。正是这些对象将在您的系统中传递。
组织对象和类的模式
这些模式帮助您组织对象的组合关系。更简单地说,这些模式展示了如何组合对象和类。
面向任务的模式
这些模式描述了类和对象合作实现目标的机制。
企业模式
我看到了一些描述典型互联网编程问题和解决方案的模式。主要从 企业应用 架构和核心 J2EE 模式:最佳实践和设计策略的模式中提取,模式处理表示和应用逻辑。
数据库模式
本节分析了有助于存储和检索数据以及将对象映射到数据库和从数据库映射对象的模式。
摘要
在这一章中,我研究了支撑许多设计模式的一些原则。我研究了如何使用组合来实现运行时的对象组合和重组,从而得到比单独使用继承更灵活的结构。我还向您介绍了解耦,即从上下文中提取软件组件以使它们更普遍适用的实践。最后,我回顾了接口作为将客户端从实现细节中分离出来的手段的重要性。
在接下来的章节中,我将详细研究一些设计模式。
九、创建对象
创建对象是一件麻烦的事情。因此,许多面向对象的设计处理漂亮、干净的抽象类,利用多态提供的令人印象深刻的灵活性(运行时具体实现的切换)。但是,为了实现这种灵活性,我必须为对象生成设计策略。这是我将在本章探讨的主题。
本章将涵盖以下模式:
-
单例模式(Singleton pattern):一个特殊的类,它生成一个——且只有一个——对象实例
-
工厂方法模式:构建创建者类的继承层次
-
抽象工厂模式:对功能相关产品的创建进行分组
-
原型模式:使用
clone
生成对象 -
服务定位器模式:向系统请求对象
-
依赖注入模式:让你的系统给你对象
生成对象中的问题及解决方案
对象创建可能是面向对象设计中的一个弱点。在前一章中,你看到了这样一个原则,“编码到一个接口,而不是一个实现。”为此,鼓励你在类中使用抽象超类型。这使得代码更加灵活,允许您在运行时使用从不同具体子类实例化的对象。这带来了延迟对象实例化的副作用。
下面是一个抽象类,它接受一个名称字符串并实例化一个特定的对象:
// listing 09.01
abstract class Employee
{
public function __construct(protected string $name)
{
}
abstract public function fire(): void;
}
这是一个具体的类,它扩展了Employee
:
// listing 09.02
class Minion extends Employee
{
public function fire(): void
{
print "{$this->name}: I'll clear my desk\n";
}
}
现在,这里有一个处理Minion
对象的客户端类:
// listing 09.03
class NastyBoss
{
private array $employees = [];
public function addEmployee(string $employeeName): void
{
$this->employees[] = new Minion($employeeName);
}
public function projectFails(): void
{
if (count($this->employees) > 0) {
$emp = array_pop($this->employees);
$emp->fire();
}
}
}
是时候测试代码了:
// listing 09.04
$boss = new NastyBoss();
$boss->addEmployee("harry");
$boss->addEmployee("bob");
$boss->addEmployee("mary");
$boss->projectFails();
下面是输出:mary: I'll clear my desk
如您所见,我定义了一个抽象基类Employee
,以及一个被践踏的实现Minion
。给定一个名称字符串,NastyBoss::addEmployee()
方法实例化一个新的Minion
对象。每当一个NastyBoss
对象遇到麻烦时(通过NastyBoss::projectFails()
方法),它会寻找一个Minion
来触发。
通过在NastyBoss
类中直接实例化一个Minion
对象,我们限制了灵活性。如果一个NastyBoss
对象可以和Employee
类型的任何实例一起工作,我们可以让我们的代码在运行时随着我们添加更多的Employee
专门化而服从变化。你应该会发现图 9-1 中的多态性很熟悉。
图 9-1
使用抽象类型可以实现多态性
如果NastyBoss
类没有实例化一个Minion
对象,那么它从何而来?作者经常通过在方法声明中约束参数类型来回避这个问题,然后除了测试上下文之外,方便地忽略显示实例化:
// listing 09.05
class NastyBoss
{
private array $employees = [];
public function addEmployee(Employee $employee): void
{
$this->employees[] = $employee;
}
public function projectFails(): void
{
if (count($this->employees)) {
$emp = array_pop($this->employees);
$emp->fire();
}
}
}
// listing 09.06
class CluedUp extends Employee
{
public function fire(): void
{
print "{$this->name}: I'll call my lawyer\n";
}
}
// listing 09.07
$boss = new NastyBoss();
$boss->addEmployee(new Minion("harry"));
$boss->addEmployee(new CluedUp("bob"));
$boss->addEmployee(new Minion("mary"));
$boss->projectFails();
$boss->projectFails();
$boss->projectFails();
mary: I'll clear my desk
bob: I'll call my lawyer
harry: I'll clear my desk
虽然这个版本的NastyBoss
类与Employee
类型一起工作,因此受益于多态,但是我仍然没有定义对象创建的策略。实例化对象是一件肮脏的事情,但是必须要做。这一章是关于使用具体类的类和对象,所以你的其他类就不必这样做了。
如果在这里可以找到一个原则,那就是“委托对象实例化”在前一个例子中,我通过要求将一个Employee
对象传递给NastyBoss::addEmployee()
方法来隐式地做到这一点。然而,我同样可以委托给一个单独的类或方法,负责生成Employee
对象。在这里,我向Employee
类添加了一个静态方法,该方法实现了一个对象创建策略:
// listing 09.08
abstract class Employee
{
private static $types = ['Minion', 'CluedUp', 'WellConnected'];
public static function recruit(string $name): Employee
{
$num = rand(1, count(self::$types)) - 1;
$class = __NAMESPACE __ . "\\" . self::$types[$num];
return new $class($name);
}
public function __construct(protected string $name)
{
}
abstract public function fire(): void;
}
// listing 09.09
class WellConnected extends Employee
{
public function fire(): void
{
print "{$this->name}: I'll call my dad\n";
}
}
如您所见,这采用了一个名称字符串,并使用它随机实例化一个特定的Employee
子类型。我现在可以将实例化的细节委托给Employee
类的recruit()
方法:
// listing 09.10
$boss = new NastyBoss();
$boss->addEmployee(Employee::recruit("harry"));
$boss->addEmployee(Employee::recruit("bob"));
$boss->addEmployee(Employee::recruit("mary"));
你在第四章中看到了这样一个类的简单例子。我在名为getInstance()
的ShopProduct
类中放置了一个静态方法。
Note
在这一章中,我经常使用“工厂”这个术语。工厂是负责生成对象的类或方法。
getInstance()
负责根据数据库查询生成正确的ShopProduct
子类。因此,ShopProduct
级有着双重角色。它定义了ShopProduct
类型,但也充当了具体ShopProduct
对象的工厂:
// listing 09.11
public static function getInstance(int $id, \PDO $pdo): ShopProduct
{
$stmt = $pdo->prepare("select * from products where id=?");
$result = $stmt->execute([$id]);
$row = $stmt->fetch();
if (empty($row)) {
return null;
}
if ($row['type'] == "book") {
// instantiate a BookProduct object
} elseif ($row['type'] == "cd") {
// instantiate a CdProduct object
} else {
// instantiate a ShopProduct object
}
$product->setId((int) $row['id']);
$product->setDiscount((int) $row['discount']);
return $product;
}
getInstance()
方法使用一个大的if/else
语句来决定实例化哪个子类。像这样的条件在工厂代码中很常见。虽然您应该尝试从项目中删除大量的条件语句,但是这样做通常会将条件语句推回到对象生成的时刻。这通常不是一个严重的问题,因为在将决策推回到这一点时,您从代码中删除了并行条件。
然后,在这一章中,我将研究一些生成对象的关键的四人组模式。
单一模式
全局变量是面向对象程序员最大的烦恼之一。原因你现在应该很熟悉了。全局变量将类绑定到它们的上下文中,破坏了封装(参见第六章和第八章了解更多)。如果不首先确保新应用本身定义了相同的全局变量,那么依赖于全局变量的类就不可能从一个应用中取出并在另一个应用中使用。
虽然这是不可取的,但全局变量不受保护的特性可能是一个更大的问题。一旦你开始依赖全局变量,你的一个库声明一个全局变量与另一个在别处声明的相冲突可能只是时间问题。您已经看到,如果不使用名称空间,PHP 很容易发生类名冲突。但这更糟糕。当全局冲突时 PHP 不会警告你。当您的脚本开始表现异常时,您首先会知道这一点。更糟糕的是,您可能根本没有注意到开发环境中的任何问题。但是,通过使用全局变量,当用户试图将您的库和其他库一起部署时,您可能会将他们暴露在新的有趣的冲突中。
然而,全球化仍然是一种诱惑。这是因为有时候为了让所有的类都可以访问一个对象,全局访问中固有的罪恶似乎是值得付出的代价。
正如我所暗示的,名称空间提供了一些保护。您至少可以将变量限定在一个包中,这意味着第三方库不太可能与您自己的系统冲突。即便如此,名称空间本身也存在冲突的风险。
Note
除了变量,常量和函数也在命名空间范围内。当一个变量、常量或函数在没有显式名称空间的情况下被调用时,PHP 首先在本地查找,然后在全局名称空间中查找。
问题
设计良好的系统通常通过方法调用传递对象实例。每个类都保持独立于更广泛的上下文,通过清晰的通信线路与系统的其他部分协作。但是,有时您会发现这迫使您使用一些类作为与它们无关的对象的管道,以良好设计的名义引入了依赖性。
想象一个保存应用级信息的Preferences
类。我们可以使用一个Preferences
对象来存储数据,比如 DSN 字符串(数据源名称是保存连接到数据库所需信息的字符串)、URL 根、文件路径等等。这是一种因安装而异的信息。该对象还可以用作公告板,即系统中不相关的对象可以设置或检索的消息的中心位置。
从一个对象到另一个对象传递一个Preferences
对象可能并不总是一个好主意。许多不使用该对象的类可能被迫接受它,这样它们就可以将它传递给它们所处理的对象。这只是另一种耦合。
你还需要确保你系统中的所有对象都与同一个 Preferences
对象一起工作。您不希望对象在一个对象上设置值,而其他对象从完全不同的对象上读取值。
让我们提取这个问题中的力量:
-
一个
Preferences
对象应该对你系统中的任何对象都可用。 -
一个
Preferences
对象不应该存储在一个全局变量中,因为它可能会被覆盖。 -
系统中不能有超过一个
Preferences
物体在游戏中。这意味着对象 Y 可以在Preferences
对象中设置一个属性,而对象 Z 可以检索相同的属性,而不需要任何一方直接与另一方对话(假设双方都可以访问Preferences
对象)。
履行
为了解决这个问题,我可以从断言对对象实例化的控制开始。这里,我创建了一个不能从自身外部实例化的类。这听起来可能很难,但这只是定义一个私有构造函数的问题:
// listing 09.12
class Preferences
{
private array $props = [];
private function __construct()
{
}
public function setProperty(string $key, string $val): void
{
$this->props[$key] = $val;
}
public function getProperty(string $key): string
{
return $this->props[$key];
}
}
当然,在这一点上,Preferences
类是完全不可用的。我已经把访问限制提高到了荒谬的程度。因为构造函数被声明为private
,所以没有客户端代码可以从中实例化一个对象。因此,setProperty()
和getProperty()
方法是多余的。
这里,我使用一个静态方法和一个静态属性来协调对象实例化:
// listing 09.13
class Preferences
{
private array $props = [];
private static Preferences $instance;
private function __construct()
{
}
public static function getInstance(): Preferences
{
if (empty(self::$instance)) {
self::$instance = new Preferences();
}
return self::$instance;
}
public function setProperty(string $key, string $val): void
{
$this->props[$key] = $val;
}
public function getProperty(string $key): string
{
return $this->props[$key];
}
}
属性是私有的和静态的,所以它不能从类外部访问。然而,getInstance()
方法可以访问。因为getInstance()
是公共的和静态的,它可以在脚本中的任何地方通过类被调用:
// listing 09.14
$pref = Preferences::getInstance();
$pref->setProperty("name", "matt");
unset($pref); // remove the reference
$pref2 = Preferences::getInstance();
print $pref2->getProperty("name") . "\n"; // demonstrate value is not lost
输出是我们最初添加到Preferences
对象的单个值,可通过单独的访问获得:
matt
静态方法不能访问对象属性,因为根据定义,它是在类而不是对象上下文中调用的。但是,它可以访问静态属性。当调用getInstance()
时,我检查Preferences::$instance
属性。如果它是空的,那么我创建一个Preferences
类的实例,并将其存储在属性中。然后我将实例返回给调用代码。因为静态的getInstance()
方法是Preferences
类的一部分,我对实例化一个Preferences
对象没有问题,即使构造函数是私有的。
图 9-2 显示了单例模式。
图 9-2
单一模式的一个例子
结果
那么,单例方法与使用全局变量相比如何呢?首先,坏消息。单例变量和全局变量都容易被误用。因为可以从系统中的任何地方访问单例,所以它们会产生难以调试的依赖关系。更改单例,使用它的类可能会受到影响。依赖本身不是问题。毕竟,每当我们声明一个方法需要一个特定类型的参数时,我们就创建了一个依赖。问题是单例的全局性质让程序员绕过了由类接口定义的通信线路。当使用 Singleton 时,依赖关系隐藏在方法内部,不在其签名中声明。这使得追踪系统内部的关系变得更加困难。因此,应该谨慎小心地部署单例类。
尽管如此,我认为适度使用单例模式可以改进系统的设计,避免在系统中传递不必要的对象时出现可怕的扭曲。
在面向对象的上下文中,单例表示对全局变量的改进。不能用错误类型的数据覆盖单例。此外,您可以将操作和数据束组合在一个单独的类中,这比关联数组或一组标量变量更好。
工厂方法模式
面向对象的设计强调抽象类而不是实现。也就是说,它的工作原理是一般化,而不是特殊化。工厂方法模式解决了当您的代码关注抽象类型时如何创建对象实例的问题。答案?让专家类来处理实例化。
问题
想象一个管理Appointment
对象以及其他对象类型的个人管理器项目。您的业务组与另一家公司建立了关系,您必须使用一种称为 BloggsCal 的格式与它交流约会数据。不过,商业团体提醒你,随着时间的推移,你可能会面临更多的格式。
单停留在接口层面,你马上就能识别出两个参与者。您需要一个数据编码器,将您的Appointment
对象转换成专有格式。让我们称那个类为ApptEncoder
。您需要一个管理器类来检索编码器,并可能使用它与第三方进行通信。你可以称之为CommsManager
。用模式的术语来说,CommsManager
是创造者,ApptEncoder
是产品。你可以在图 9-3 中看到这个结构。
图 9-3
抽象创建者和产品类
但是,你如何得到真正的混凝土呢?
您可以要求将一个ApptEncoder
传递给CommsManager
,但这只是推迟了您的问题,并且您希望推卸到此为止。这里,我直接在CommsManager
类中实例化了一个BloggsApptEncoder
对象:
// listing 09.15
abstract class ApptEncoder
{
abstract public function encode(): string;
}
// listing 09.16
class BloggsApptEncoder extends ApptEncoder
{
public function encode(): string
{
return "Appointment data encoded in BloggsCal format\n";
}
}
// listing 09.17
class CommsManager
{
public function getApptEncoder(): ApptEncoder
{
return new BloggsApptEncoder();
}
}
CommsManager
类负责生成BloggsApptEncoder
对象。当企业忠诚度不可避免地发生变化时,我们被要求转换我们的系统,以使用一种叫做 MegaCal 的新格式,我们可以简单地在CommsManager::getApptEncoder()
方法中添加一个条件。毕竟,这是我们过去用过的策略。让我们构建一个处理 BloggsCal 和 MegaCal 格式的CommsManager
的新实现:
// listing 09.18
class CommsManager
{
public const BLOGGS = 1;
public const MEGA = 2;
public function __construct(private int $mode)
{
}
public function getApptEncoder(): ApptEncoder
{
switch ($this->mode) {
case (self::MEGA):
return new MegaApptEncoder();
default:
return new BloggsApptEncoder();
}
}
}
// listing 09.19
class MegaApptEncoder extends ApptEncoder
{
public function encode(): string
{
return "Appointment data encoded in MegaCal format\n";
}
}
// listing 09.20
$man = new CommsManager(CommsManager::MEGA);
print (get_class($man->getApptEncoder())) . "\n";
$man = new CommsManager(CommsManager::BLOGGS);
print (get_class($man->getApptEncoder())) . "\n";
我使用常量标志来定义脚本可能运行的两种模式:MEGA
和BLOGGS
。我在getApptEncoder()
方法中使用一个switch
语句来测试$mode
属性,并实例化ApptEncoder
的适当实现。
这种方法没什么问题。条件有时被认为是不好的“代码味道”的例子,但是对象创建在某些时候经常需要条件。如果你看到重复的条件悄悄进入你的代码,你不应该那么乐观。CommsManager
类提供了传递日历数据的功能。假设您使用的协议要求您提供页眉和页脚数据来描述每个约会。我可以扩展前面的例子来支持一个getHeaderText()
方法:
// listing 09.21
class CommsManager
{
public const BLOGGS = 1;
public const MEGA = 2;
public function __construct(private int $mode)
{
}
public function getApptEncoder(): ApptEncoder
{
switch ($this->mode) {
case (self::MEGA):
return new MegaApptEncoder();
default:
return new BloggsApptEncoder();
}
}
public function getHeaderText(): string
{
switch ($this->mode) {
case (self::MEGA):
return "MegaCal header\n";
default:
return "BloggsCal header\n";
}
}
}
如您所见,支持头输出的需求迫使我重复协议条件测试。随着我添加新的协议,这将变得难以处理,特别是如果我还添加了一个getFooterText()
方法。
所以,我们总结一下目前为止的问题:
-
直到运行时我才知道我需要生成哪种对象(
BloggsApptEncoder
或MegaApptEncoder
)。 -
我需要能够相对容易地添加新产品类型(SyncML 支持只是一项新的业务交易!).
-
每个产品类型都与需要其他定制操作的上下文相关联(例如,
getHeaderText()
、getFooterText()
)。
此外,我使用了条件语句,您已经看到这些语句可以被多态自然地替换。工厂方法模式使您能够使用继承和多态来封装具体产品的创建。换句话说,您为每个协议创建一个CommsManager
子类,每个子类实现getApptEncoder()
方法。
履行
工厂方法模式将创建者类从它们被设计来生成的产品中分离出来。creator 是一个工厂类,它定义了生成产品对象的方法。如果没有提供默认的实现,那么就由创建者子类来执行实例化。通常,每个 creator 子类实例化一个并行的 product 子类。
我可以将CommsManager
重新指定为一个抽象类。这样,我就保留了一个灵活的超类,并将所有特定于协议的代码放在具体的子类中。你可以在图 9-4 中看到这种变化。
图 9-4
具体的创建者和产品类
下面是一些简化的代码:
// listing 09.22
abstract class ApptEncoder
{
abstract public function encode(): string;
}
// listing 09.23
class BloggsApptEncoder extends ApptEncoder
{
public function encode(): string
{
return "Appointment data encoded in BloggsCal format\n";
}
}
// listing 09.24
abstract class CommsManager
{
abstract public function getHeaderText(): string;
abstract public function getApptEncoder(): ApptEncoder;
abstract public function getFooterText(): string;
}
// listing 09.25
class BloggsCommsManager extends CommsManager
{
public function getHeaderText(): string
{
return "BloggsCal header\n";
}
public function getApptEncoder(): ApptEncoder
{
return new BloggsApptEncoder();
}
public function getFooterText(): string
{
return "BloggsCal footer\n";
}
}
// listing 09.26
$mgr = new BloggsCommsManager();
print $mgr->getHeaderText();
print $mgr->getApptEncoder()->encode();
print $mgr->getFooterText();
下面是输出:BloggsCal header
Appointment data encoded in BloggsCal format
BloggsCal footer
因此,当我需要实现 MegaCal 时,支持它只是为我的抽象类编写一个新的实现。图 9-5 显示了兆卡等级。
图 9-5
扩展设计以支持新协议
结果
请注意,creator 类反映了产品层次结构。这是工厂方法模式的常见结果,有些人不喜欢这种特殊的代码重复。另一个问题是这种模式可能会鼓励不必要的子类化。如果子类化 creator 的唯一原因是部署工厂方法模式,那么您可能需要重新考虑(这就是为什么我在这里的例子中引入了 header 和 footer 约束)。
在我的例子中,我只关注了约会。如果我将它稍微扩展到包括待办事项和联系人,我将面临一个新的问题。我需要一个结构,将处理一次相关的实现集。
工厂方法模式通常与抽象工厂模式一起使用,您将在下一节看到这一点。
抽象工厂模式
在大型应用中,您可能需要产生相关类集的工厂。抽象工厂模式解决了这个问题。
问题
让我们再次看看组织者的例子。我管理两种格式的编码,BloggsCal 和 MegaCal。我可以通过添加更多的编码格式在水平方向上扩展这个结构,但是我如何在垂直方向上扩展,为不同类型的 PIM 对象添加编码器呢?事实上,我已经朝着这个模式努力了。
在图 9-6 中,你可以看到我想与之合作的平行家庭。这些是约会(Appt
)、要做的事情(Ttd
)和联系人(Contact
)。
图 9-6
三个产品系列
BloggsCal 类通过继承彼此无关(尽管它们可以实现一个公共接口),但是它们在功能上是并行的。如果系统当前正在与BloggsTtdEncoder
一起工作,它也应该与BloggsContactEncoder
一起工作。
为了了解我是如何实施的,你可以从接口开始,就像我对工厂方法模式所做的那样(见图 9-7 )。
图 9-7
抽象创造者及其抽象产品
履行
抽象的CommsManager
类定义了生成三个产品(ApptEncoder
、TtdEncoder
和ContactEncoder
)的接口。您需要实现一个具体的创建器,以便为特定的系列实际生成具体的产品。我在图 9-8 中举例说明了 BloggsCal 格式。
图 9-8
添加混凝土创建器和一些混凝土产品
下面是CommsManager
和BloggsCommsManager
的代码版本:
// listing 09.27
abstract class CommsManager
{
abstract public function getHeaderText(): string;
abstract public function getApptEncoder(): ApptEncoder;
abstract public function getTtdEncoder(): TtdEncoder;
abstract public function getContactEncoder(): ContactEncoder;
abstract public function getFooterText(): string;
}
// listing 09.28
class BloggsCommsManager extends CommsManager
{
public function getHeaderText(): string
{
return "BloggsCal header\n";
}
public function getApptEncoder(): ApptEncoder
{
return new BloggsApptEncoder();
}
public function getTtdEncoder(): TtdEncoder
{
return new BloggsTtdEncoder();
}
public function getContactEncoder(): ContactEncoder
{
return new BloggsContactEncoder();
}
public function getFooterText(): string
{
return "BloggsCal footer\n";
}
}
注意,我在这个例子中使用了工厂方法模式。getContactEncoder()
在CommsManager
中是抽象的,在BloggsCommsManager
中实现。设计模式往往以这种方式协同工作,一种模式创建适合另一种模式的上下文。在图 9-9 中,我添加了对兆卡格式的支持。
图 9-9
添加具体的创作者和一些具体的产品
结果
那么,让我们看看这种模式买了什么:
-
首先,我将我的系统从实现的细节中分离出来。在我的例子中,我可以添加或删除任意数量的编码格式,而不会引起连锁反应。
-
我对系统中功能相关的元素进行分组。因此,通过使用
BloggsCommsManager
,我保证我将只处理与 BloggsCal 相关的类。 -
添加新产品可能是一件痛苦的事情。我不仅要创建新产品的具体实现,还要修改抽象的创建者和每一个具体的实现者来支持它。
抽象工厂模式的许多实现都使用工厂方法模式。这可能是因为大多数例子都是用 Java 或 C++编写的。然而,PHP 不必强制方法的返回类型(尽管现在可以了),这为我们提供了一些可以利用的灵活性。
与其为每个工厂方法创建单独的方法,不如创建一个单独的make()
方法,它使用一个标志参数来确定返回哪个对象:
// listing 09.29
interface Encoder
{
public function encode(): string;
}
// listing 09.30
abstract class CommsManager
{
public const APPT = 1;
public const TTD = 2;
public const CONTACT = 3;
abstract public function getHeaderText(): string;
abstract public function make(int $flag_int): Encoder;
abstract public function getFooterText(): string;
}
// listing 09.31
class BloggsCommsManager extends CommsManager
{
public function getHeaderText(): string
{
return "BloggsCal header\n";
}
public function make(int $flag_int): Encoder
{
switch ($flag_int) {
case self::APPT:
return new BloggsApptEncoder();
case self::CONTACT:
return new BloggsContactEncoder();
case self::TTD:
return new BloggsTtdEncoder();
}
}
public function getFooterText(): string
{
return "BloggsCal footer\n";
}
}
正如你所看到的,我已经把类接口做得更紧凑了。不过,我为此付出了相当大的代价。在使用工厂方法时,我定义了一个清晰的接口,并强制所有具体的工厂对象遵守它。在使用单个make()
方法时,我必须记住支持所有具体创建者中的所有产品对象。我还介绍了并行条件,因为每个具体的创建者必须实现相同的标志测试。客户类不能确定具体的创建者生成所有的产品,因为在每种情况下make()
的内部是一个选择的问题。
另一方面,我可以建立更灵活的创作者。基本 creator 类可以提供一个make()
方法,保证每个产品系列的默认实现。具体的孩子可以有选择地修改这种行为。在提供自己的实现后,由实现 creator 类来调用默认的make()
方法。
在下一节中,您将看到抽象工厂模式的另一种变体。
原型
并行继承层次的出现可能是工厂方法模式的一个问题。这是一种让一些程序员不舒服的耦合。每当您添加一个产品系列时,您都必须创建一个相关的具体创建者(例如,BloggsCal 编码器由BloggsCommsManager
匹配)。在一个发展速度足够快的系统中,维持这种关系很快就会变得令人厌倦。
避免这种依赖性的一种方法是使用 PHP 的clone
关键字来复制现有的具体产品。具体的产品类本身成为它们自己生成的基础。这是原型模式。它使您能够用合成代替继承。这反过来提高了运行时的灵活性,减少了必须创建的类的数量。
问题
想象一个文明风格的网页游戏,游戏中的单位在一个格子上操作。每块瓷砖可以代表海洋、平原或森林。地形类型限制了占据该区域的单位的移动和战斗能力。您可能有一个为Sea
、Forest
和Plains
对象提供服务的TerrainFactory
对象。您决定允许用户在完全不同的环境中进行选择,因此Sea
对象是由MarsSea
和EarthSea
实现的抽象超类。Forest
和Plains
对象的实现方式类似。这里的力量适合抽象的工厂模式。您有不同的产品层次结构(Sea
、Plains
、Forests
),有跨越继承的强大家族关系(Earth
、Mars
)。图 9-10 展示了一个类图,展示了如何部署抽象工厂和工厂方法模式来处理这些产品。
图 9-10
用抽象工厂方法处理地形
如您所见,我依靠继承来为工厂将生成的产品分组 terrain 族。这是一个可行的解决方案,但是它需要一个大的继承层次,并且相对不灵活。当您不想要并行继承层次结构,并且需要最大化运行时灵活性时,可以在抽象工厂模式的强大变体中使用原型模式。
履行
当您使用抽象工厂/工厂方法模式时,您必须在某个时候决定您希望使用哪个具体的创建者,可能是通过检查某种偏好标志。既然您无论如何都必须这样做,为什么不简单地创建一个存储具体产品的工厂类,然后在初始化时填充它呢?你可以通过这种方式减少一些课程,并且,正如你将看到的,利用其他的好处。下面是一些在工厂中使用原型模式的简单代码:
// listing 09.32
class Plains
{
}
// listing 09.33
class Forest
{
}
// listing 09.34
class Sea
{
}
// listing 09.35
class EarthPlains extends Plains
{
}
// listing 09.36
class EarthSea extends Sea
{
}
// listing 09.37
class EarthForest extends Forest
{
}
// listing 09.38
class MarsSea extends Sea
{
}
// listing 09.39
class MarsForest extends Forest
{
}
// listing 09.40
class MarsPlains extends Plains
{
}
// listing 09.41
class TerrainFactory
{
public function __construct(private Sea $sea, private Plains $plains, private Forest $forest)
{
}
public function getSea(): Sea
{
return clone $this->sea;
}
public function getPlains(): Plains
{
return clone $this->plains;
}
public function getForest(): Forest
{
return clone $this->forest;
}
}
// listing 09.42
$factory = new TerrainFactory(
new EarthSea(),
new EarthPlains(),
new EarthForest()
);
print_r($factory->getSea());
print_r($factory->getPlains());
print_r($factory->getForest());
以下是输出:
popp\ch09\batch11\EarthSea Object
(
)
popp\ch09\batch11\EarthPlains Object
(
)
popp\ch09\batch11\EarthForest Object
(
)
如您所见,我用产品对象的实例加载了一个具体的TerrainFactory
。当客户端调用getSea()
时,我返回一个在初始化时缓存的Sea
对象的克隆。这种结构给我带来了额外的灵活性。想在一个新的星球上玩游戏,那里有像地球一样的海洋和森林,但有像火星一样的平原吗?无需编写新的 creator 类——您可以简单地更改添加到TerrainFactory
中的类的组合:
// listing 09.43
$factory = new TerrainFactory(
new EarthSea(),
new MarsPlains(),
new EarthForest()
);
因此原型模式允许您利用组合提供的灵活性。不过,我们得到的不止这些。因为您在运行时存储和克隆对象,所以当您生成新产品时,您会复制对象状态。假设Sea
对象有一个$navigability
属性。该属性影响海瓷砖从船上吸取的移动能量的数量,并且可以设置来调整游戏的难度等级:
// listing 09.44
class Sea
{
public function __construct(private int $navigability)
{
}
}
现在,当我初始化TerrainFactory
对象时,我可以添加一个带有可导航性修饰符的Sea
对象。这将适用于由TerrainFactory
服务的所有Sea
对象:
// listing 09.45
$factory = new TerrainFactory(
new EarthSea(-1),
new EarthPlains(),
new EarthForest()
);
当您希望生成的对象由其他对象组成时,这种灵活性也很明显。
Note
我在第四章中讲述了对象克隆。关键字clone
生成应用它的任何对象的浅层副本。这意味着产品对象将具有与源相同的属性。如果源的任何属性是对象,则这些属性不会被复制到产品中。相反,产品将引用与相同的对象属性。您可以通过实现一个__clone()
方法来改变这个缺省值并以任何其他方式定制对象复制。当使用clone
关键字时,这个函数被自动调用。
也许所有的Sea
对象都可以包含Resource
对象(FishResource
、OilResource
等)。).根据偏好标志,我们可能会默认给所有的Sea
对象一个FishResource
。请记住,如果您的产品引用了其他对象,您应该实现一个__clone()
方法来确保您制作了一个深层副本:
// listing 09.46
class Contained
{
}
// listing 09.47
class Container
{
public Contained $contained;
public function __construct()
{
$this->contained = new Contained();
}
public function __clone()
{
// Ensure that cloned object holds a
// clone of self::$contained and not
// a reference to it
$this->contained = clone $this->contained;
}
}
推到边缘:服务定位器
我保证这一章将处理对象创建的逻辑,消除许多面向对象例子中偷偷摸摸的推诿责任。然而,这里的一些模式狡猾地避开了对象创建的决策部分,如果不是创建本身的话。
单例模式无罪。对象创建的逻辑是内置的,没有歧义。抽象工厂模式将产品系列的创建分组到不同的具体创建者中。但是,我们如何决定使用哪个具体的创建者呢?原型模式向我们提出了一个类似的问题。这两种模式都处理对象的创建,但是它们推迟了应该创建哪个对象或对象组的决定。
系统选择的特定具体创建者通常是根据某种配置开关的值决定的。这可以位于数据库、配置文件或服务器文件中(比如 Apache 的目录级配置文件,通常称为.htaccess
),或者甚至可以硬编码为 PHP 变量或属性。因为 PHP 应用必须为每个请求或 CLI 调用重新配置,所以您需要脚本初始化尽可能地简单。出于这个原因,我经常选择在 PHP 代码中硬编码配置标志。这可以手工完成,也可以通过编写自动生成类文件的脚本来完成。下面是一个包含日历协议类型标志的简单类:
// listing 09.48
class Settings
{
public static string $COMMSTYPE = 'Mega';
}
现在我有了一个标志(不管多么不雅),我可以创建一个类,用它来决定根据请求服务哪个CommsManager
。将单例模式与抽象工厂模式结合使用是很常见的,所以让我们这样做:
// listing 09.49
class AppConfig
{
private static ?AppConfig $instance = null;
private CommsManager $commsManager;
private function __construct()
{
// will run once only
$this->init();
}
private function init(): void
{
switch (Settings::$COMMSTYPE) {
case 'Mega':
$this->commsManager = new MegaCommsManager();
break;
default:
$this->commsManager = new BloggsCommsManager();
}
}
public static function getInstance(): AppConfig
{
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
public function getCommsManager(): CommsManager
{
return $this->commsManager;
}
}
AppConfig
类是一个标准的单例。出于这个原因,我可以在系统的任何地方获得一个AppConfig
实例,并且我将总是获得同一个实例。init()
方法由类的构造函数调用,因此在一个进程中只运行一次。它测试Settings::$COMMSTYPE
属性,根据它的值实例化一个具体的CommsManager
对象。现在,我的脚本可以获得一个CommsManager
对象并使用它,而无需知道它的具体实现或它生成的具体类:
$commsMgr = AppConfig::getInstance()->getCommsManager();
$commsMgr->getApptEncoder()->encode();
因为AppConfig
为我们管理查找和创建组件的工作,所以它是服务定位器模式的一个实例。这很简洁,但它确实引入了比直接实例化更良性的依赖关系。任何使用其服务的类都必须显式调用这个整体,将它们绑定到更广泛的系统。出于这个原因,有些人更喜欢另一种方法。
出色的隔离:依赖注入
在上一节中,我在工厂中使用了一个标志和一个条件语句来决定提供两个CommsManager
类中的哪一个。这个解决方案没有想象中的那么灵活。提供的类被硬编码在一个定位器中,有两个组件内置在一个条件中。不过,这种不灵活性是我的演示代码的一个方面,而不是服务定位器本身的问题。我可以使用任意数量的策略来代表客户端代码定位、实例化和返回对象。然而,服务定位器经常受到怀疑的真正原因是组件必须显式调用定位器。这感觉有点,嗯,全球化。面向对象的开发人员有理由怀疑所有的全局事物。
问题
每当您使用new
操作符时,您就关闭了该范围内多态性的可能性。想象一个部署硬编码的BloggsApptEncoder
对象的方法,例如:
// listing 09.50
class AppointmentMaker
{
public function makeAppointment(): string
{
$encoder = new BloggsApptEncoder();
return $encoder->encode();
}
}
这可能满足我们最初的需求,但是它不允许在运行时切换任何其他的ApptEncoder
实现。这限制了该类的使用方式,并且使得该类更难测试。
Note
单元测试通常被设计成关注与更广泛的系统隔离的特定类和方法。如果被测试的类包含一个直接实例化的对象,那么所有与测试无关的代码都可能被执行——这可能会导致错误和意想不到的副作用。另一方面,如果一个被测试的类以某种方式而不是直接实例化的方式获得了它所使用的对象,那么为了测试的目的,可以向它提供 fake— mock 或stub—对象。我在第十八章中讲述了测试细节。
直接实例化使得代码难以测试。这一章的大部分内容正是针对这种不灵活性。但是,正如我在上一节中指出的,我忽略了一个事实,即使我们使用原型或抽象工厂模式,实例化也必须在某个地方发生。下面是创建原型对象的一段代码:
// listing 09.51
$factory = new TerrainFactory(
new EarthSea(),
new EarthPlains(),
new EarthForest()
);
这里调用的原型TerrainFactory
类是朝着正确方向迈出的一步——它需要泛型类型:Sea
、Plains
和Forest
。该类让客户端代码来决定应该提供哪些实现。但是这是怎么做到的呢?
履行
我们的大部分代码调用工厂。正如我们已经看到的,这个模型被称为服务定位器模式。方法将责任委托给它信任的提供者,让其找到并提供所需类型的实例。原型例子颠倒了这一点;它只是希望实例化代码在调用时提供实现。这里没有魔法——只是需要在构造函数的签名中包含类型,而不是直接在方法中创建它们。这方面的一个变化是提供 setter 方法,这样客户端可以在调用使用对象的方法之前传入对象。
因此,让我们以这种方式解决AppointmentMaker
:
// listing 09.52
class AppointmentMaker2
{
public function __construct(private ApptEncoder $encoder)
{
}
public function makeAppointment(): string
{
return $this->encoder->encode();
}
}
AppointmentMaker2
已经放弃了控制——它不再创造BloggsApptEncoder
,我们获得了灵活性。然而,实际创建ApptEncoder
对象的逻辑呢?可怕的new
语句存在于何处?我们需要一个装配组件来承担这项工作。这里的一个常见策略是使用配置文件来确定应该实例化哪些实现。有工具可以帮助我们做到这一点,但这本书都是关于我们自己做,所以让我们建立一个非常幼稚的实现。我将从一个简单的 XML 格式开始,它描述了抽象类和它们的首选实现之间的关系。
// listing 09.53
<objects>
<class name="popp\ch09\batch06\ApptEncoder">
<instance inst="popp\ch09\batch06\BloggsApptEncoder" />
</class>
</objects>
这表明当我们请求一个ApptEncoder
时,我们的工具应该生成一个BloggsApptEncoder
。当然,我们必须创建汇编程序。
// listing 09.54
class ObjectAssembler
{
private array $components = [];
public function __construct(string $conf)
{
$this->configure($conf);
}
private function configure(string $conf): void
{
$data = simplexml_load_file($conf);
foreach ($data->class as $class) {
$name = (string)$class['name'];
$resolvedname = $name;
if (isset($class->instance)) {
if (isset($class->instance[0]['inst'])) {
$resolvedname = (string)$class->instance[0]['inst'];
}
}
$this->components[$name] = function () use ($resolvedname) {
$rclass = new \ReflectionClass($resolvedname);
return $rclass->newInstance();
};
}
}
public function getComponent(string $class): object
{
if (isset($this->components[$class])) {
$inst = $this->components[$class]();
} else {
$rclass = new \ReflectionClass($class);
$inst = $rclass->newInstance();
}
return $inst;
}
}
乍一看这有点晦涩,所以让我们简单地看一下。大多数真实的行动发生在configure()
。方法接受从构造函数传递的路径。它使用simplexml
扩展来解析配置 XML。当然,在一个真实的项目中,我们会在这里和各处添加更多的错误处理。目前,我非常信任我正在解析的 XML。
对于每个<class>
元素,我提取完全限定的类名,并将其存储在$name
变量中。我还创建了一个$resolvedname
变量,它将保存我们将要生成的具体类的名称。假设找到了一个<instance>
元素(在后面的例子中,您会看到它并不总是存在),我将正确的值赋给了$resolvedname
。
除非需要,否则我不想创建一个对象,所以我创建了一个匿名函数,当被调用时它会创建对象并将其添加到$components
属性中。
getComponent()
方法接受一个给定的类名,并将其解析为一个实例。它以两种方式之一做到这一点。如果提供的类名是$components
数组中的一个键,那么我提取并运行相应的匿名函数。另一方面,如果我找不到所提供的类的记录,我仍然可以勇敢地尝试创建一个实例。最后,我返回结果。
让我们测试一下这段代码:
// listing 09.55
$assembler = new ObjectAssembler("src/ch09/batch14_1/objects.xml");
$encoder = $assembler->getComponent(ApptEncoder::class);
$apptmaker = new AppointmentMaker2($encoder);
$out = $apptmaker->makeAppointment();
print $out;
因为ApptEncoder
::class
解析为popp\ch09\batch06\ApptEncoder
——objects.xml
文件中建立的键——BloggsApptEncoder
对象被实例化并返回。您可以从这个片段的输出中看到这一点:
Appointment data encoded in BloggsCal format
正如您所看到的,代码足够聪明,可以创建一个具体的对象,即使它不在配置文件中。
// listing 09.56
$assembler = new ObjectAssembler("src/ch09/batch14_1/objects.xml");
$encoder = $assembler->getComponent(MegaApptEncoder::class);
$apptmaker = new AppointmentMaker2($encoder);
$out = $apptmaker->makeAppointment();
print $out;
配置文件中没有MegaApptEncoder
键,但是,因为MegaApptEncoder
类存在并且是可实例化的,所以ObjectAssembler
类能够创建并返回一个实例。
但是带有需要参数的构造函数的对象呢?我们不需要做太多的工作就能做到。还记得最近的TerrainFactory
课吗?它需要一个Sea
、一个Plains
和一个Forest
对象。在这里,我修改了我的 XML 格式以适应这个需求。
// listing 09.57
<objects>
<class name="popp\ch09\batch11\TerrainFactory">
<arg num="0" inst="popp\ch09\batch11\EarthSea" />
<arg num="1" inst="popp\ch09\batch11\MarsPlains" />
<arg num="2" inst="popp\ch09\batch11\Forest" />
</class>
<class name="popp\ch09\batch11\Forest">
<instance inst="popp\ch09\batch11\EarthForest" />
</class>
<class name="popp\ch09\batch14\AppointmentMaker2">
<arg num="0" inst="popp\ch09\batch06\BloggsApptEncoder" />
</class>
</objects>
本章中我描述了两个类:TerrainFactory
和AppointmentMaker2
。我希望用一个EarthSea
对象、一个MarsPlains
对象和一个EarthForest
对象实例化TerrainFactory
。我也希望给AppointmentMaker2
传递一个BloggsApptEncoder
对象。因为TerrainFactory
和AppointmentMaker2
已经是具体的类,所以在这两种情况下我都不需要提供<instance>
元素。
虽然EarthSea
和MarsPlains
是具体的类,但是请注意Forest
是抽象的。这是一个简洁的逻辑递归。虽然Forest
本身不能被实例化,但是有一个对应的<class>
元素定义了一个具体的实例。你认为新版本的ObjectAssembler
能够满足这些要求吗?
// listing 09.58
class ObjectAssembler
{
private array $components = [];
public function __construct(string $conf)
{
$this->configure($conf);
}
private function configure(string $conf): void
{
$data = simplexml_load_file($conf);
foreach ($data->class as $class) {
$args = [];
$name = (string)$class['name'];
$resolvedname = $name;
foreach ($class->arg as $arg) {
$argclass = (string)$arg['inst'];
$args[(int)$arg['num']] = $argclass;
}
if (isset($class->instance)) {
if (isset($class->instance[0]['inst'])) {
$resolvedname = (string)$class->instance[0]['inst'];
}
}
ksort($args);
$this->components[$name] = function () use ($resolvedname, $args) {
$expandedargs = [];
foreach ($args as $arg) {
$expandedargs[] = $this->getComponent($arg);
}
$rclass = new \ReflectionClass($resolvedname);
return $rclass->newInstanceArgs($expandedargs);
};
}
}
public function getComponent(string $class): object
{
if (isset($this->components[$class])) {
$inst = $this->components[$class]();
} else {
$rclass = new \ReflectionClass($class);
$inst = $rclass->newInstance();
}
return $inst;
}
}
让我们仔细看看这里有什么新内容。
首先,在configure()
方法中,我现在遍历每个<class>
元素中的任何<arg>
元素,并构建一个类名列表。
// listing 09.59
foreach ($class->arg as $arg) {
$argclass = (string)$arg['inst'];
$args[(int)$arg['num']] = $argclass;
}
然后,在匿名构建器函数中,我真的不需要做太多的工作来将这些元素扩展成对象实例,以便传递给我的类的构造函数。毕竟,我已经为此创建了getComponent()
方法。
// listing 09.60
ksort($args);
$this->components[$name] = function () use ($resolvedname, $args) {
$expandedargs = [];
foreach ($args as $arg) {
$expandedargs[] = $this->getComponent($arg);
}
$rclass = new \ReflectionClass($resolvedname);
return $rclass->newInstanceArgs($expandedargs);
};
Note
如果你正在考虑构建一个依赖注入组装器/容器,你应该考虑几个选项:Pimple(尽管它的名字不好听)和 Symfony DI。你可以在 http://pimple.sensiolabs.org/
找到更多关于青春痘的信息;您可以在 http://symfony.com/doc/current/components/dependency_injection/introduction.html
了解更多关于 Symfony DI 组件的信息。
因此,我们现在可以保持组件的灵活性,并动态处理实例化。让我们试试ObjectAssembler
类:
// listing 09.61
$assembler = new ObjectAssembler("src/ch09/batch14/objects.xml");
$apptmaker = $assembler->getComponent(AppointmentMaker2::class);
$out = $apptmaker->makeAppointment();
print $out;
一旦我们有了一个ObjectAssembler
,对象获取就占用了一条语句。AppointmentMaker2
类摆脱了之前对ApptEncoder
实例的硬编码依赖。开发人员现在可以使用配置文件来控制在运行时使用什么类,以及从更广泛的系统中独立测试AppointmentMaker2
。
具有属性的依赖注入
我们还可以使用 PHP 8 引入的属性特性将一些逻辑从配置文件转移到类本身,我们可以在不牺牲已经定义的功能的情况下做到这一点。
Note
我在第五章中讨论了属性。
这是另一个 XML 文件。我在这里不介绍任何新功能。事实上,配置文件负责少于的逻辑。
// listing 09.62
<objects>
<class name="popp\ch09\batch06\ApptEncoder">
<instance inst="popp\ch09\batch06\BloggsApptEncoder" />
</class>
<class name="popp\ch09\batch11\Sea">
<instance inst="popp\ch09\batch11\EarthSea" />
</class>
<class name="popp\ch09\batch11\Plains">
<instance inst="popp\ch09\batch11\MarsPlains" />
</class>
<class name="popp\ch09\batch11\Forest">
<instance inst="popp\ch09\batch11\EarthForest" />
</class>
</objects>
我想生成新版本的TerrainFactory
。如果这个定义在配置文件中不明显,那么在哪里可以找到它呢?答案就在TerrainFactory
类本身:
// listing 09.63
class TerrainFactory
{
#[InjectConstructor(Sea::class, Plains::class, Forest::class)]
public function __construct(private Sea $sea, private Plains $plains, private Forest $forest)
{
}
public function getSea(): Sea
{
return clone $this->sea;
}
public function getPlains(): Plains
{
return clone $this->plains;
}
public function getForest(): Forest
{
return clone $this->forest;
}
}
这只是你已经看到的原型TerrainConstructor
类,但是增加了重要的InjectConstructor
属性。这需要一个样板类定义:
// listing 09.64
use Attribute;
#[Attribute]
public class InjectConstructor
{
function __construct()
{
}
}
因此,InjectConstructor
属性定义了我需要的行为。我希望我的依赖注入示例提供抽象类Sea
、Plains
和Forest
的具体实例。又到了勤劳的ObjectAssembler
阶层挺身而出的时候了。
// listing 09.65
class ObjectAssembler
{
private array $components = [];
public function __construct(string $conf)
{
$this->configure($conf);
}
private function configure(string $conf): void
{
$data = simplexml_load_file($conf);
foreach ($data->class as $class) {
$args = [];
$name = (string)$class['name'];
$resolvedname = $name;
foreach ($class->arg as $arg) {
$argclass = (string)$arg['inst'];
$args[(int)$arg['num']] = $argclass;
}
if (isset($class->instance)) {
if (isset($class->instance[0]['inst'])) {
$resolvedname = (string)$class->instance[0]['inst'];
}
}
ksort($args);
$this->components[$name] = function () use ($resolvedname, $args) {
$expandedargs = [];
foreach ($args as $arg) {
$expandedargs[] = $this->getComponent($arg);
}
$rclass = new \ReflectionClass($resolvedname);
return $rclass->newInstanceArgs($expandedargs);
};
}
}
public function getComponent(string $class): object
{
// create $inst -- our object instance
// and a list of \ReflectionMethod objects
if (isset($this->components[$class])) {
// instance found in config
$inst = $this->components[$class]();
$rclass = new \ReflectionClass($inst::class);
$methods = $rclass->getMethods();
} else {
$rclass = new \ReflectionClass($class);
$methods = $rclass->getMethods();
$injectconstructor = null;
foreach ($methods as $method) {
foreach ($method->getAttributes(InjectConstructor::class) as $attribute) {
$injectconstructor = $attribute;
break;
}
}
if (is_null($injectconstructor)) {
$inst = $rclass->newInstance();
} else {
$constructorargs = [];
foreach ($injectconstructor->getArguments() as $arg) {
$constructorargs[] = $this->getComponent($arg);
}
$inst = $rclass->newInstanceArgs($constructorargs);
}
}
return $inst;
}
}
也许现在这看起来更加令人生畏。不过,我还是没有加那么多。让我们把它分解。新增内容均可在getComponent()
中找到。如果我在$components
数组属性中找到了提供的类键——$class
参数变量,我只需依靠相应的匿名函数来处理实例化。如果不是,那么逻辑可以在属性中找到。为了检查这一点,我遍历目标类中的所有方法,寻找一个InjectConstructor
属性。如果我找到一个,那么我就把相关的方法当作一个构造函数。我将每个属性参数展开成一个对象实例,然后将完成的列表传递给ReflectionClass::newInstanceArgs()
。另一方面,如果我没有找到InjectConstructor
属性,我只是使用ReflectionClass::newInstance()
不带参数地实例化。
请注意,在整个示例中,我创建了一个名为$methods
的数组,其中包含该类的ReflectionMethod
对象。这个数组在这里是多余的,但是我们很快就会找到它的用处!
这是从ObjectAssembler::getComponent()
方法中提取的逻辑:
// listing 09.66
$rclass = new \ReflectionClass($class);
$methods = $rclass->getMethods();
$injectconstructor = null;
foreach ($methods as $method) {
foreach ($method->getAttributes(InjectConstructor::class) as $attribute) {
$injectconstructor = $attribute;
break;
}
}
if (is_null($injectconstructor)) {
$inst = $rclass->newInstance();
} else {
$constructorargs = [];
foreach ($injectconstructor->getArguments() as $arg) {
$constructorargs[] = $this->getComponent($arg);
}
$inst = $rclass->newInstanceArgs($constructorargs);
}
注意这里递归的使用。为了将属性参数扩展到一个对象,我将类名传递回getComponent()
。
现在,理论上,我可以生成一个神奇填充的TerrainFactory
对象。
// listing 09.67
$assembler = new ObjectAssembler("src/ch09/batch15/objects.xml");
$terrainfactory = $assembler->getComponent(TerrainFactory::class);
$plains = $terrainfactory->getPlains(); // MarsPlains
当用TerrainFactory
名称调用ObjectAssembler
对象时,方法ObjectAssembler::getcomponent()
首先在它的$components
数组中寻找匹配的配置元素。在这种情况下,它没有找到。然后它遍历TerrainFactory
中的方法,并打开InjectConstructor
属性。这有三个论点。对于其中的每一个,它递归地调用getComponent()
。在每一种情况下,it 确实找到了一个配置元素,该元素提供了一个类,从该类中可以实例化一个参数。
Note
此示例代码不检查循环递归。至少,这样的产品版本应该可以防止对getComponent()
的递归调用运行到过多的级别。
最后,让我们用一个新的属性来完善一下。Inject
与InjectConstructor
相似,除了它应该应用于标准方法。这些将在目标对象实例化后调用。下面是正在使用的属性:
// listing 09.68
class AppointmentMaker
{
private ApptEncoder $encoder;
#[Inject(ApptEncoder::class)]
public function setApptEncoder(ApptEncoder $encoder)
{
$this->encoder = $encoder;
}
public function makeAppointment(): string
{
return $this->encoder->encode();
}
}
这里的指令是在实例化后应该为AppointmentMaker
类提供一个ApptEncoder
对象。
下面是对应于属性的样板文件Inject
类:
// listing 09.69
use Attribute;
#[Attribute]
class Inject
{
public function __construct()
{
}
}
与InjectConstructor
一样,除了填充名称空间,它实际上没有做任何有用的事情。是时候给ObjectAssembler
添加对Inject
的支持了:
// listing 09.70
public function getComponent(string $class): object
{
// create $inst -- our object instance
// and a list of \ReflectionMethod objects
$this->injectMethods($inst, $methods);
return $inst;
}
public function injectMethods(object $inst, array $methods)
{
foreach ($methods as $method) {
foreach ($method->getAttributes(Inject::class) as $attribute) {
$args = [];
foreach ($attribute->getArguments() as $argstring) {
$args[] = $this->getComponent($argstring);
}
$method->invokeArgs($inst, $args);
}
}
}
我省略了大部分的getComponent()
,因为它在这里没有变化。唯一增加的是对一个新方法的调用:injectMethods()
。它接受新实例化的对象和一个ReflectionMethod
对象数组。然后,它执行一个熟悉的舞蹈,遍历所有具有Inject
属性的方法,获取属性参数,并将每个参数传递回getComponent()
。一旦编译了参数列表,就在实例上调用该方法。
下面是一些客户端代码:
// listing 09.71
$assembler = new ObjectAssembler("src/ch09/batch15/objects.xml");
$apptmaker = $assembler->getComponent(AppointmentMaker::class);
$output = $apptmaker->makeAppointment();
print $output;
所以,当我调用getComponent()
时,它会根据我们已经探索过的流程创建一个AppointmentMaker
实例。然后它调用injectMethods()
,后者在AppointmentMaker
类中找到一个带有Inject
属性的方法。属性的参数指定了ApptEncoder
。这个类密钥在递归调用中被传递给getComponent()
。因为我们的配置文件指定BloggsApptEncoder
作为ApptEncoder
的解析,所以这个对象被实例化并传递给 setter 方法。
输出再次证明了这一点
Appointment data encoded in BloggsCal format
这里是ObjectAssembler
的全部。它包含了一个有限的概念证明依赖注入类,不超过 80 行!
// listing 09.72
class ObjectAssembler
{
private array $components = [];
public function __construct(string $conf)
{
$this->configure($conf);
}
private function configure(string $conf): void
{
$data = simplexml_load_file($conf);
foreach ($data->class as $class) {
$args = [];
$name = (string)$class['name'];
$resolvedname = $name;
foreach ($class->arg as $arg) {
$argclass = (string)$arg['inst'];
$args[(int)$arg['num']] = $argclass;
}
if (isset($class->instance)) {
if (isset($class->instance[0]['inst'])) {
$resolvedname = (string)$class->instance[0]['inst'];
}
}
ksort($args);
$this->components[$name] = function () use ($resolvedname, $args) {
$expandedargs = [];
foreach ($args as $arg) {
$expandedargs[] = $this->getComponent($arg);
}
$rclass = new \ReflectionClass($resolvedname);
return $rclass->newInstanceArgs($expandedargs);
};
}
}
public function getComponent(string $class): object
{
// create $inst -- our object instance
// and a list of \ReflectionMethod objects
if (isset($this->components[$class])) {
// instance found in config
$inst = $this->components[$class]();
$rclass = new \ReflectionClass($inst::class);
$methods = $rclass->getMethods();
} else {
$rclass = new \ReflectionClass($class);
$methods = $rclass->getMethods();
$injectconstructor = null;
foreach ($methods as $method) {
foreach ($method->getAttributes(InjectConstructor::class) as $attribute) {
$injectconstructor = $attribute;
break;
}
}
if (is_null($injectconstructor)) {
$inst = $rclass->newInstance();
} else {
$constructorargs = [];
foreach ($injectconstructor->getArguments() as $arg) {
$constructorargs[] = $this->getComponent($arg);
}
$inst = $rclass->newInstanceArgs($constructorargs);
}
}
$this->injectMethods($inst, $methods);
return $inst;
}
public function injectMethods(object $inst, array $methods)
{
foreach ($methods as $method) {
foreach ($method->getAttributes(Inject::class) as $attribute) {
$args = [];
foreach ($attribute->getArguments() as $argstring) {
$args[] = $this->getComponent($argstring);
}
$method->invokeArgs($inst, $args);
}
}
}
}
结果
现在,我们已经看到了创建对象的两个选项。AppConfig
类是服务定位器的一个实例(也就是说,一个能够代表其客户找到组件或服务的类)。使用依赖注入当然会产生更优雅的客户端代码。AppointmentMaker2
类幸运地不知道对象创建的策略。它只是做它的工作。这当然是一个类的理想状态。我们希望设计的类能够专注于它们的职责,尽可能远离更广泛的系统。然而,这种纯粹是有代价的。对象组装器组件隐藏了许多魔力。我们必须把它当作一个黑匣子,相信它能代表我们召唤出物体。这很好,只要魔法有效。意外的行为很难调试。
另一方面,服务定位器模式更简单,尽管它将您的组件嵌入到一个更大的系统中。如果使用得当,服务定位器并不会使测试变得更加困难。它也不会使系统变得不灵活。服务定位器可以被配置为提供任意组件用于测试或根据配置。但是对服务定位器的硬编码调用会使组件依赖于它。因为调用是在方法体内进行的,所以客户端和目标组件(由服务定位器提供)之间的关系也有些模糊。这种关系在依赖注入示例中是显式的,因为它是在构造函数方法的签名中声明的。
那么,我们应该选择哪种方法呢?在某种程度上,这是一个偏好的问题。就我自己而言,我倾向于从最简单的解决方案开始,然后根据需要重构到更复杂的程度。因此,我通常选择服务定位器。我可以用几行代码创建一个注册表类,并根据需求增加它的灵活性。我的组件知道的比我希望的多一点,但是因为我很少将类从一个系统转移到另一个系统,所以我没有受到嵌入效应的太大影响。当我将一个基于系统的类转移到一个独立的库中时,我并没有发现重构服务定位器依赖性有多难。
依赖注入提供了纯度,但是它需要另一种嵌入。你必须相信汇编程序的魔力。如果您已经在一个提供这种功能的框架中工作,那么没有理由不利用它。例如,Symfony 依赖注入组件提供了服务定位器(称为“服务容器”)和依赖注入的混合解决方案。服务容器根据配置(或者代码,如果您愿意的话)管理对象的实例化,并为客户端提供一个简单的接口来获取这些对象。服务容器甚至允许使用工厂来创建对象。另一方面,如果您正在开发自己的组件,或者使用来自各种框架的组件,您可能希望以牺牲一些优雅为代价来保持简单。
摘要
本章讲述了一些可以用来生成对象的技巧。我首先研究了 Singleton 模式,它提供了对单个实例的全局访问。接下来,我看了工厂方法模式,它将多态原理应用于对象生成。我将工厂方法与抽象工厂模式结合起来,生成实例化相关对象集的 creator 类。我还查看了原型模式,了解了对象克隆如何允许组合用于对象生成。最后,我研究了对象创建的两种策略:服务定位器和依赖注入。
十、对象编程的灵活模式
在介绍了生成对象的策略之后,我们现在可以自由地看一些构造类和对象的策略了。我将特别关注组合比继承提供更大灵活性的原则。我在这一章中考察的模式再次取自四人帮的目录。
本章将介绍三种模式:
-
组合模式(Composite pattern):组合结构,其中对象组可以像单独的对象一样使用
-
装饰模式:一种在运行时组合对象以扩展功能的灵活机制
-
门面模式:创建复杂或可变系统的简单接口
构造类以允许灵活的对象
在第三章中,我说过初学者经常混淆对象和类。这话只对了一半。事实上,我们大多数人偶尔会对 UML 类图感到困惑,试图将它们显示的静态继承结构与它们的对象将要进入的动态对象关系协调起来。
还记得模式原则“重组合轻继承”吗?这个原则提炼了类和对象的组织之间的紧张关系。为了在我们的项目中建立灵活性,我们构建我们的类,以便它们的对象可以在运行时组成有用的结构。
这是贯穿本章前两个模式的共同主题。继承在两者中都是一个重要的特性,但是它的部分重要性在于提供了一种机制,通过这种机制可以使用组合来表示结构和扩展功能。
组合模式
组合模式可能是组合服务中部署的继承的最极端的例子。这是一个简单而优雅的设计。它也非常有用。不过,要小心;它如此简洁,你可能会忍不住过度使用这个策略。
组合模式是一种简单的聚集和管理相似对象组的方法,这样客户端就无法将单个对象与对象集合区分开来。事实上,这种模式非常简单,但也经常令人困惑。其中一个原因是模式中的类的结构与其对象组织的相似性。继承层次是树,从根的超类开始,分支到专门的子类。由组合模式建立的类的继承树被设计成允许简单地生成和遍历对象的树。
如果您还不熟悉这种模式,那么在这一点上您完全有理由感到困惑。让我们用一个类比来说明单个实体可以像事物的集合一样被对待。给定谷物和肉类(或者大豆,如果你喜欢的话)等基本不可减少的成分,我们可以制作一种食品——比如香肠。然后,我们作为一个单一的实体对结果采取行动。正如我们吃、煮、买或卖肉一样,我们也可以吃、煮、买或卖部分由肉组成的香肠。我们可以把香肠和其他复合材料成分混合在一起做成馅饼,从而把一种复合材料卷成一种更大的复合材料。我们对集合的行为方式与对零件的行为方式相同。组合模式帮助我们在代码中对集合和组件之间的关系进行建模。
问题
管理对象组可能是一项相当复杂的任务,尤其是当所讨论的对象可能还包含它们自己的对象时。这种问题在编码中很常见。想想发票,带有总结附加产品或服务的行项目,或者带有项目的待办事项列表,这些项目本身包含多个子任务。在内容管理中,我们不能为章节、页面、文章或媒体组件的树而移动。从外部管理这些结构会很快变得令人生畏。
让我们回到之前的场景。我正在设计一个基于游戏《文明》的系统。玩家可以在构成地图的数百个方块周围移动单位。单个柜台可以组合在一起,作为一个整体进行移动、战斗和防御。在这里,我定义了几个单元类型:
// listing 10.01
abstract class Unit
{
abstract public function bombardStrength(): int;
}
class Archer extends Unit
{
public function bombardStrength(): int
{
return 4;
}
}
class LaserCannonUnit extends Unit
{
public function bombardStrength(): int
{
return 44;
}
}
Unit
类定义了一个抽象的bombardStrength()
方法,该方法设置一个单位轰击相邻磁贴的攻击强度。我在Archer
和LaserCannonUnit
类中都实现了这一点。这些类也将包含关于移动和防御能力的信息,但是我将保持事情简单。我可以定义一个单独的类来将单元组合在一起,就像这样:
// listing 10.02
class Army
{
private array $units = [];
public function addUnit(Unit $unit): void
{
array_push($this->units, $unit);
}
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
return $ret;
}
}
// listing 10.03
$unit1 = new Archer();
$unit2 = new LaserCannonUnit();
$army = new Army();
$army->addUnit($unit1);
$army->addUnit($unit2);
print $army->bombardStrength();
Army
类有一个接受Unit
对象的addUnit()
方法。Unit
对象存储在一个名为$units
的数组属性中。我用bombardStrength()
方法计算我的军队的综合实力。这只是遍历聚合的Unit
对象,调用每个对象的bombardStrength()
方法。以下是输出:
48
只要问题仍然像这样简单,这个模型是完全可以接受的。但是,如果我添加一些新的需求,会发生什么呢?这么说吧,一支军队要能和其他军队联合起来。每支军队都应该保留自己的身份,以便日后能够脱离整体。大公爵的勇敢的部队今天可能会和索姆斯将军一起攻击敌人暴露的侧翼,但是国内的叛乱可能会让他的军队随时逃回老家。由于这个原因,我不能把每个军队的单位都编入一个新的部队。
我可以修改Army
类来接受Army
对象和Unit
对象:
// listing 10.04
public function addArmy(Army $army): void
{
array_push($this->armies, $army);
}
然后我需要修改bombardStrength()
方法来遍历所有的军队和单位:
// listing 10.05
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
foreach ($this->armies as $army) {
$ret += $army->bombardStrength();
}
return $ret;
}
这种额外的复杂性目前还不是太大的问题。但是,请记住,我需要在类似于defensiveStrength()
、movementRange()
等方法中做一些类似的事情。我的游戏将会有丰富的特色。这个商业组织已经开始呼吁能够装载十个单位的运兵船来提高他们在特定地形上的移动范围。很明显,运兵船和军队相似,都是由单位组成的。它也有自己的特点。我可以进一步修改Army
类来处理TroopCarrier
对象,但是我知道还需要更多的单位分组。很明显,我需要一个更灵活的模型。
让我们再来看看我一直在构建的模型。我创建的所有类都需要一个bombardStrength()
方法。实际上,客户端不需要区分军队、单位或运兵船。它们功能相同。他们需要移动、进攻和防守。那些包含其他对象的对象需要提供添加和移除它们的方法。这些相似之处让我们得出一个必然的结论。因为容器对象与其包含的对象共享一个接口,所以它们自然适合共享一个类型族。
履行
组合模式定义了一个单一的继承层次结构,它划分了两组不同的职责。在我们的示例中,我们已经看到了这两种情况。模式中的类必须支持一组通用的操作,这是它们的主要职责。对我们来说,这意味着bombardStrength()
方法。类还必须支持添加和移除子对象的方法。
图 10-1 显示了一个类图,展示了应用于我们问题的组合模式。
图 10-1
组合模式
如你所见,这个模型中的所有单元都扩展了Unit
类。那么,客户可以确信任何一个Unit
对象都将支持bombardStrength()
方法。所以,一只Army
可以和一只Archer
完全一样的方式被对待。
Army
和TroopCarrier
类是复合:它们被设计用来保存Unit
对象。Archer
和LaserCannon
类是叶,用于支持单位操作,但不支持其他Unit
物体。实际上存在一个问题,即叶子是否应该遵循与复合物相同的接口,如图 10-1 所示。该图显示了聚合其他单元的TroopCarrier
和Army
,即使叶类也被绑定来实现addUnit()
。我将很快回到这个问题。下面是抽象的Unit
类:
// listing 10.06
abstract class Unit
{
abstract public function addUnit(Unit $unit): void;
abstract public function removeUnit(Unit $unit): void;
abstract public function bombardStrength(): int;
}
如您所见,我在这里为所有的Unit
对象提供了基本的功能。现在,让我们看看复合对象如何实现这些抽象方法:
// listing 10.07
class Army extends Unit
{
private array $units = [];
public function addUnit(Unit $unit): void
{
if (in_array($unit, $this->units, true)) {
return;
}
$this->units[] = $unit;
}
public function removeUnit(Unit $unit): void
{
$idx = array_search($unit, $this->units, true);
if (is_int($idx)) {
array_splice($this->units, $idx, 1, []);
}
}
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
return $ret;
}
}
在将同一个Unit
对象存储到私有的$units
数组属性之前,addUnit()
方法检查我是否已经添加了它。removeUnit()
使用类似的检查从属性中删除给定的Unit
对象。
Note
在检查我是否已经向addUnit()
方法添加了一个特定的对象时,我使用了带有第三个布尔true
参数的in_array()
。这加强了in_array()
的严格性,使得它只匹配对同一对象的引用。array_search()
的第三个参数以同样的方式工作,仅当提供的搜索值是在数组中找到的等价对象引用时,才返回数组索引。
然后,Army
对象可以存储任何种类的Units
,包括其他的Army
对象,或者像Archer
或LaserCannonUnit
这样的树叶。因为所有单元都保证支持bombardStrength()
,所以我们的Army::bombardStrength()
方法简单地遍历存储在$units
属性中的所有子Unit
对象,对每个对象调用相同的方法。
组合模式的一个问题是添加和删除功能的实现。经典模式将add()
和remove()
方法放在抽象超类中。这确保了模式中的所有类共享一个公共接口。正如您在这里看到的,这也意味着叶类必须提供一个实现:
// listing 10.08
class UnitException extends \Exception
{
}
// listing 10.09
class Archer extends Unit
{
public function addUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function removeUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function bombardStrength(): int
{
return 4;
}
}
我不想让添加一个Unit
对象到一个Archer
对象成为可能,所以如果调用了addUnit()
或removeUnit()
,我会抛出异常。我需要对所有的叶子对象都这样做,所以我也许可以通过用默认实现替换Unit
中的抽象addUnit()/removeUnit()
方法来改进我的设计:
// listing 10.10
abstract class Unit
{
public function addUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function removeUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
abstract public function bombardStrength(): int;
}
// listing 10.11
class Archer extends Unit
{
public function bombardStrength(): int
{
return 4;
}
}
这消除了叶类中的重复,但缺点是在编译时没有强制复合来提供addUnit()
和removeUnit()
的实现,这可能会导致问题。
在下一节中,我将更详细地讨论组合模式带来的一些问题。让我们通过研究它的一些好处来结束这一部分:
-
灵活性:因为组合模式中的所有东西都共享一个公共的超类型,所以在不改变程序更广泛的上下文的情况下,向设计中添加新的复合或叶对象是非常容易的。
-
简单性:使用复合结构的客户端有一个简单明了的界面。客户端不需要区分由其他组件组成的对象和叶对象(除非添加新组件)。对
Army::bombardStrength()
的调用可能会在幕后导致委托调用的级联;但是对于客户端来说,这个过程和结果与调用Archer::bombardStrength()
是完全等价的。 -
隐式到达:复合图案中的对象被组织成一棵树。每个组合都包含对其子组合的引用。因此,对树的特定部分的操作会产生广泛的影响。我们可以从它的
Army
父对象中移除一个Army
对象,并将其添加到另一个对象中。这个简单的动作是在一个对象上完成的,但是它具有改变Army
对象的被引用的Unit
对象和它们自己的子对象的状态的效果。 -
显式到达:树形结构容易遍历。为了获取信息或执行转换,可以对它们进行迭代。在下一章处理访问者模式时,我们将会看到一个特别强大的技术。
通常,你只能从客户的角度看到一个模式的好处,所以这里有几支军队:
// listing 10.12
// create an army
$main_army = new Army();
// add some units
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCannonUnit());
// create a new army
$sub_army = new Army();
// add some units
$sub_army->addUnit(new Archer());
$sub_army->addUnit(new Archer());
$sub_army->addUnit(new Archer());
// add the second army to the first
$main_army->addUnit($sub_army);
// all the calculations handled behind the scenes
print "attacking with strength: {$main_army->bombardStrength()}\n";
我创建了一个新的Army
对象,并添加了一些原始的Unit
对象。我为第二个Army
对象重复这个过程,然后添加到第一个。当我在第一个Army
对象上调用Unit::bombardStrength()
时,我构建的结构的所有复杂性都被完全隐藏了。以下是我的输出:
attacking with strength: 60
结果
如果你和我一样,那么当你看到Archer
类的代码摘录时,你会听到警钟响起。为什么我们在不需要支持它们的叶子类中忍受这些多余的addUnit()
和removeUnit()
方法?一个答案在于Unit
类型的透明性。
如果一个客户端被传递了一个Unit
对象,它知道addUnit()
方法将会出现。坚持组合模式原则,即基本(叶)类与复合类具有相同的接口。这实际上对你没有多大帮助,因为你仍然不知道在你可能遇到的任何Unit
对象上调用addUnit()
有多安全。
如果我将这些添加/移除方法下移,使它们只对复合类可用,那么将一个Unit
对象传递给一个方法会给我带来一个问题,我不知道默认情况下它是否支持addUnit()
。然而,在 leaf 类中留下陷阱方法让我很不舒服。它没有增加任何价值,并且混淆了系统的设计,因为界面实际上依赖于它自己的功能。
您可以很容易地将复合类分解成它们自己的CompositeUnit
子类型。首先,我删除了Unit
中的添加/删除行为:
// listing 10.13
abstract class Unit
{
public function getComposite(): ?CompositeUnit
{
return null;
}
abstract public function bombardStrength(): int;
}
注意新的getComposite()
方法。过一会儿我会回到这个话题。现在,我需要一个新的抽象类来保存addUnit()
和removeUnit()
。我甚至可以提供默认实现:
// listing 10.14
abstract class CompositeUnit extends Unit
{
private array $units = [];
public function getComposite(): ?CompositeUnit
{
return $this;
}
public function addUnit(Unit $unit): void
{
if (in_array($unit, $this->units, true)) {
return;
}
$this->units[] = $unit;
}
public function removeUnit(Unit $unit): void
{
$idx = array_search($unit, $this->units, true);
if (is_int($idx)) {
array_splice($this->units, $idx, 1, []);
}
}
public function getUnits(): array
{
return $this->units;
}
}
CompositeUnit
类被声明为抽象的,即使它本身没有声明抽象方法。然而,它扩展了Unit
,并且没有实现抽象的bombardStrength()
方法。Army
(以及任何其他复合类)现在可以扩展CompositeUnit
。我的例子中的类现在如图 10-2 所示。
图 10-2
将添加/移除方法移出基类
叶子类中恼人的、无用的 add/remove 方法的实现已经消失了,但是客户机仍然必须在使用addUnit()
之前检查它是否有一个CompositeUnit
。
这就是getComposite()
方法发挥作用的地方。默认情况下,此方法返回空值。只有在一个CompositeUnit
类中,它才返回CompositeUnit
。因此,如果对这个方法的调用返回一个对象,我们应该能够对它调用addUnit()
。这里有一个使用这种技术的客户:
// listing 10.15
class UnitScript
{
public static function joinExisting(
Unit $newUnit,
Unit $occupyingUnit
): CompositeUnit {
$comp = $occupyingUnit->getComposite();
if (! is_null($comp)) {
$comp->addUnit($newUnit);
} else {
$comp = new Army();
$comp->addUnit($occupyingUnit);
$comp->addUnit($newUnit);
}
return $comp;
}
}
joinExisting()
方法接受两个Unit
对象。第一个是瓷砖的新用户,第二个是先前的用户。如果第二只Unit
是一只CompositeUnit
,那么第一只将试图加入它。如果没有,那么将创建一个新的Army
来涵盖这两个部门。我一开始无法知道$occupyingUnit
参数是否包含一个CompositeUnit
。然而,调用getComposite()
解决了这个问题。如果getComposite()
返回一个对象,我可以直接将新的Unit
对象添加到其中。如果没有,我创建新的Army
对象并添加两者。
我可以通过让Unit::getComposite()
方法返回一个预先填充了当前Unit
的Army
对象来进一步简化这个模型。或者我可以回到以前的模型(它没有在结构上区分复合对象和叶对象),让Unit::addUnit()
做同样的事情:创建一个Army
对象,并向其中添加两个Unit
对象。这很简洁,但是它预先假定您知道您想要用来聚合您的单元的复合类型。当您设计像getComposite()
和addUnit()
这样的方法时,您的业务逻辑将决定您可以做出的假设的种类。
这些扭曲是合成图案缺陷的症状。简单性是通过确保所有的类都是从一个公共库派生出来的来实现的。简单的好处有时是以牺牲类型安全为代价的。你的模型变得越复杂,你需要做的手工类型检查就越多。假设我有一个Cavalry
对象。如果游戏规则规定你不能把马放在运兵船上,我没有自动的方法用组合模式来强制执行:
// listing 10.16
class TroopCarrier extends CompositeUnit
{
public function addUnit(Unit $unit): void
{
if ($unit instanceof Cavalry) {
throw new UnitException("Can't get a horse on the vehicle");
}
parent::addUnit($unit);
}
public function bombardStrength(): int
{
return 0;
}
}
我被迫使用instanceof
操作符来测试传递给addUnit()
的对象的类型。如果有太多这种特殊情况,这种模式的缺点就会开始超过它的好处。当大多数组件可以互换时,复合材料的效果最好。
要记住的另一个问题是一些复合操作的成本。Army::bombardStrength()
方法的典型之处在于它引发了对同一方法的级联调用。对于一棵有很多子树的大树来说,一个简单的调用就能在幕后引发雪崩。bombardStrength()
本身并不昂贵,但是如果一些树叶执行复杂的计算来得出它们的返回值,会发生什么呢?解决这个问题的一种方法是在父对象中缓存这种方法调用的结果,这样后续调用的开销就不会太大。但是,您需要小心,以确保缓存的值不会过时。您应该制定策略,以便在树上发生任何操作时清除任何缓存。这可能需要您为子对象提供对其父对象的引用。
最后,关于持久性的说明。组合模式很优雅,但是它不适合存储在关系数据库中。这是因为,默认情况下,您只能通过级联引用来访问整个结构。要以自然的方式从数据库构建一个复合结构,您必须进行多次昂贵的查询。您可以通过为整个树分配一个 ID 来解决这个问题,这样就可以一次从数据库中提取所有组件。然而,获得所有对象后,您仍然需要重新创建父/子引用,这些引用本身必须存储在数据库中。这个不难,但是有些乱。
尽管复合数据库与关系数据库不太兼容,但它们确实非常适合 XML 或 JSON 存储,因此也适合各种 NoSQL 商店,如 MongoDB、CouchDB 和 Elasticsearch。这是因为在这两种情况下,元素本身通常由子元素树组成。
综合总结
因此,当您需要以对待个人的方式对待事物集合时,组合模式非常有用,因为集合本质上就像一个组件(军队和弓箭手),或者因为上下文赋予了集合与组件相同的特征(发票中的行项目)。组合以树的形式排列,因此整体上的操作可以影响部分,而来自部分的数据可以通过整体透明地获得。组合模式使得这样的操作和查询对客户端是透明的。树很容易穿过(我们将在下一章看到)。向复合结构中添加新的组件类型很容易。
不利的一面是,复合材料依赖于其部件的相似性。一旦我们引入了复杂的规则,比如哪个复合对象可以容纳哪组组件,我们的代码就会变得难以管理。复合不太适合存储在关系数据库中。
装饰图案
组合模式帮助我们创建聚合组件的灵活表示,装饰模式使用类似的结构来帮助我们修改具体组件的功能。同样,这种模式的关键在于运行时合成的重要性。继承是一种在父类特性的基础上构建的简洁方式。这种整洁会导致您将变体硬编码到继承层次结构中,这通常会导致不灵活。
问题
将所有功能构建到一个继承结构中会导致系统中类的激增。更糟糕的是,当您试图对继承树的不同分支应用类似的修改时,您很可能会看到重复出现。
让我们回到我们的游戏。在这里,我定义了一个Tile
类和一个派生类型:
// listing 10.17
abstract class Tile
{
abstract public function getWealthFactor(): int;
}
// listing 10.18
class Plains extends Tile
{
private int $wealthfactor = 2;
public function getWealthFactor(): int
{
return $this->wealthfactor;
}
}
一个方块代表一个方块,我的单位可能会出现在这个方块上。每个瓷砖都有一定的特点。在这个例子中,我定义了一个getWealthFactor()
方法,这个方法影响一个特定方块如果被玩家拥有可能产生的收入。如你所见,Plains
物体的财富因子为 2。显然,tiles 管理其他数据。它们可能还包含对图像信息的引用,这样就可以绘制板子了。再说一次,我会保持事情简单。
我需要修改Plains
对象的行为来处理自然资源和人类滥用的影响。我希望模拟钻石在景观中的出现和污染造成的破坏。一种方法可能是从Plains
对象继承:
// listing 10.19
class DiamondPlains extends Plains
{
public function getWealthFactor(): int
{
return parent::getWealthFactor() + 2;
}
}
// listing 10.20
class PollutedPlains extends Plains
{
public function getWealthFactor(): int
{
return parent::getWealthFactor() - 4;
}
}
我现在可以轻而易举地获得一块被污染的瓷砖:
// listing 10.21
$tile = new PollutedPlains();
print $tile->getWealthFactor();
以下是输出:
-2
你可以在图 10-3 中看到这个例子的类图。
图 10-3
将变异构建到继承树中
这个结构显然是不灵活的。我可以得到有钻石的平原。我可以得到被污染的平原。但是我能两个都要吗?显然不会,除非我愿意犯下那种令人恐惧的罪行。这种情况在我引入Forest
级的时候只能变得更糟,它也可以有钻石和污染。
当然,这是一个极端的例子,但这一点是明确的。完全依赖继承来定义您的功能会导致类的多样性和重复的趋势。
在这一点上,让我们举一个更普通的例子。严肃的 web 应用通常必须在启动任务形成响应之前对请求执行一系列操作。例如,您可能需要对用户进行身份验证,并记录请求。也许您应该处理从原始输入构建数据结构的请求。最后,您必须执行您的核心处理。你面临着同样的问题。
您可以通过在派生的LogRequest
类、StructureRequest
类和AuthenticateRequest
类中进行额外的处理来扩展基本ProcessRequest
类的功能。你可以在图 10-4 中看到这个阶级等级。
图 10-4
更多硬编码变体
但是,当您需要执行日志记录和身份验证,而不是数据准备时,会发生什么呢?你创建了一个LogAndAuthenticateProcessor
类吗?显然,现在是找到更灵活的解决方案的时候了。
履行
Decorator
模式使用组合和委托,而不是仅仅使用继承来解决功能变化的问题。本质上,Decorator
类管理对它们自己类型的另一个类的实例的引用。一个Decorator
将实现一个操作,这样它在执行自己的动作之前(或之后)对它所引用的对象调用相同的操作。这样,就有可能在运行时构建一个Decorator
对象的管道。
让我们重写我们的游戏示例来说明这一点:
// listing 10.22
abstract class Tile
{
abstract public function getWealthFactor(): int;
}
// listing 10.23
class Plains extends Tile
{
private int $wealthfactor = 2;
public function getWealthFactor(): int
{
return $this->wealthfactor;
}
}
// listing 10.24
abstract class TileDecorator extends Tile
{
protected Tile $tile;
public function construct(Tile $tile)
{
$this->tile = $tile;
}
}
这里,我像以前一样声明了Tile
和Plains
类,但是我也引入了一个新类:TileDecorator
。这个没有实现getWealthFactor()
,所以必须声明抽象。我定义了一个需要一个Tile
对象的构造函数,它存储在一个名为$tile
的属性中。我创建这个属性protected
,以便子类可以访问它。现在我将重新定义Pollution
和Diamond
类:
// listing 10.25
class DiamondDecorator extends TileDecorator
{
public function getWealthFactor(): int
{
return $this->tile->getWealthFactor() + 2;
}
}
// listing 10.26
class PollutionDecorator extends TileDecorator
{
public function getWealthFactor(): int
{
return $this->tile->getWealthFactor() - 4;
}
}
每个类都扩展了TileDecorator
。这意味着它们引用了一个Tile
对象。当getWealthFactor()
被调用时,这些类中的每一个在做出自己的调整之前都会调用其Tile
引用上的相同方法。
通过像这样使用组合和委托,可以很容易地在运行时组合对象。因为模式中的所有对象都扩展了Tile
,所以客户端不需要知道它正在使用哪个组合。可以肯定的是,getWealthFactor()
方法可用于任何Tile
对象,无论它是否在幕后装饰另一个对象:
// listing 10.27
$tile = new Plains();
print $tile->getWealthFactor(); // 2
Plains
是组件。它只是返回 2:
// listing 10.28
$tile = new DiamondDecorator(new Plains());
print $tile->getWealthFactor(); // 4
DiamondDecorator
引用了一个Plains
对象。它在添加自己的权重 2 之前调用getWealthFactor()
:
// listing 10.29
$tile = new PollutionDecorator(new DiamondDecorator(new Plains()));
print $tile->getWealthFactor(); // 0
PollutionDecorator
引用了一个DiamondDecorator
对象,该对象有自己的Tile
引用。
你可以在图 10-5 中看到这个例子的类图。
图 10-5
装饰图案
这个模型是非常可扩展的。您可以非常容易地添加新的装饰器和组件。有了许多装饰器,你可以在运行时构建非常灵活的结构。在这种情况下,组件类Plains
可以以多种方式进行显著修改,而无需将所有修改构建到类层次结构中。简单地说,这意味着你可以拥有一个带有钻石的被污染的Plains
物体,而不必创建一个PollutedDiamondPlains
物体。
Decorator
模式构建了对创建过滤器非常有用的管道。java.io
包充分利用了装饰类。客户端编码器可以将装饰对象与核心组件结合起来,为核心方法如read()
添加过滤、缓冲、压缩等功能。我的 web 请求示例也可以开发成可配置的管道。下面是一个使用Decorator
模式的简单实现:
// listing 10.30
class RequestHelper
{
}
// listing 10.31
abstract class ProcessRequest
{
abstract public function process(RequestHelper $req): void;
}
// listing 10.32
class MainProcess extends ProcessRequest
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": doing something useful with request\n";
}
}
// listing 10.33
abstract class DecorateProcess extends ProcessRequest
{
public function __construct(protected ProcessRequest $processrequest)
{
}
}
和以前一样,我们定义了一个抽象超类(ProcessRequest
)、一个具体组件(MainProcess
)和一个抽象装饰器(DecorateProcess
)。MainProcess::process()
什么也不做,只是报告它已经被调用。DecorateProcess
代表其子节点存储一个ProcessRequest
对象。下面是一些简单的具体装饰类:
// listing 10.34
class LogRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": logging request\n";
$this->processrequest->process($req);
}
}
// listing 10.35
class AuthenticateRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": authenticating request\n";
$this->processrequest->process($req);
}
}
// listing 10.36
class StructureRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": structuring request data\n";
$this->processrequest->process($req);
}
}
每个process()
方法在调用被引用的ProcessRequest
对象自己的process()
方法之前输出一条消息。现在,您可以在运行时组合从这些类实例化的对象来构建过滤器,以不同的顺序对请求执行不同的操作。下面是将所有这些具体类中的对象合并到一个过滤器中的一些代码:
// listing 10.37
$process = new AuthenticateRequest(
new StructureRequest(
new LogRequest(
new MainProcess()
)
)
);
$process->process(new RequestHelper());
该代码给出以下输出:
popp\ch10\batch07\AuthenticateRequest: authenticating request
popp\ch10\batch07\StructureRequest: structuring request data
popp\ch10\batch07\LogRequest: logging request
popp\ch10\batch07\MainProcess: doing something useful with request
Note
事实上,这个例子也是一个名为拦截过滤器的企业模式的实例。拦截过滤器在 Alur 等人的核心 J2EE 模式:最佳实践和设计策略(Prentice Hall,2001) 中有所描述。
结果
像Composite
模式一样,Decorator
可能会令人困惑。重要的是要记住,组合和继承是同时起作用的。所以LogRequest
从ProcessRequest
继承了它的接口,但是它是另一个ProcessRequest
对象的包装器。
因为 decorator 对象在子对象周围形成了一个包装器,所以它有助于保持接口尽可能稀疏。如果您构建了一个功能强大的基类,那么 decorators 将被迫委托给它们所包含的对象中的所有公共方法。这可以在抽象装饰类中完成,但是它仍然引入了会导致错误的耦合。
一些程序员创建的装饰器与他们修改的对象不共享一个公共类型。只要它们实现与这些对象相同的接口,这种策略就能很好地工作。您得到的好处是能够使用内置的拦截器方法来自动化委托(实现call()
来捕捉对不存在的方法的调用,并在子对象上自动调用相同的方法)。然而,这样做,您也失去了类类型检查所提供的安全性。在我们到目前为止的例子中,客户端代码可以在其参数列表中要求一个Tile
或一个ProcessRequest
对象,并确定它的接口,不管所讨论的对象是否被大量修饰。
立面图案
在过去,您可能有机会将第三方系统整合到您自己的项目中。无论代码是否面向对象,它通常都是令人望而生畏的、庞大的和复杂的。您自己的代码也可能对只需要访问少数几个特性的客户端程序员构成挑战。Facade
模式是一种为复杂系统提供简单、清晰界面的方式。
问题
系统倾向于进化出大量的代码,这些代码实际上只在系统内部有用。正如类定义了清晰的公共接口并对外界隐藏了它们的本质一样,设计良好的系统也应该如此。然而,并不总是清楚系统的哪些部分被设计为供客户端代码使用,哪些部分最好隐藏起来。
当您使用子系统(如 web 论坛或图库应用)时,您可能会发现自己在深入代码逻辑中进行调用。如果子系统代码随着时间的推移会发生变化,并且您的代码在许多不同的点上与之交互,那么随着子系统的发展,您可能会发现自己面临着严重的维护问题。
类似地,当您构建自己的系统时,将不同的部分组织到不同的层中是一个好主意。通常,您可能有一个层负责应用逻辑,另一个层负责数据库交互,另一个层负责表示,等等。您应该努力保持这些层尽可能地相互独立,这样项目中一个区域的变化对其他区域的影响就很小。如果一层的代码紧密集成到另一层的代码中,那么这个目标就很难实现。
下面是一些故意混淆的程序代码,它们将从文件中获取日志信息并将其转换为对象数据的简单过程变成了歌舞升平的例行程序:
// listing 10.38
function getProductFileLines(string $file): array
{
return file($file);
}
function getProductObjectFromId(string $id, string $productname): Product
{
// some kind of database lookup
return new Product($id, $productname);
}
function getNameFromLine(string $line): string
{
if (preg_match("/.*-(.*)\s\d+/", $line, $array)) {
return str_replace('_', ' ', $array[1]);
}
return '';
}
function getIDFromLine($line): int|string
{
if (preg_match("/^(\d{1,3})-/", $line, $array)) {
return $array[1];
}
return -1;
}
class Product
{
public string $id;
public string $name;
public function __construct(string $id, string $name)
{
$this->id = $id;
$this->name = $name;
}
}
让我们假设这段代码的内部比实际更复杂,我坚持使用它,而不是从头开始重写。例如,假设我必须将包含如下行的文件转换成一个对象数组:
234-ladies_jumper 55
532-gents_hat 44
为此,我必须调用所有这些函数(注意,为了简洁起见,我没有提取代表价格的最终数字):
// listing 10.39
$lines = getProductFileLines(__DIR__ . '/test2.txt');
$objects = [];
foreach ($lines as $line) {
$id = getIDFromLine($line);
$name = getNameFromLine($line);
$objects[$id] = getProductObjectFromID($id, $name);
}
print_r($objects);
以下是输出:
Array
(
[234] => Product Object
(
[id] => 234
[name] => ladies jumper
)
[532] => Product Object
(
[id] => 532
[name] => gents hat
)
)
如果我在整个项目中像这样直接调用这些函数,我的代码将与它所使用的子系统紧密相连。如果子系统发生变化,或者如果我决定将其完全切换出去,这可能会导致问题。我真的需要在系统和其余代码之间引入一个网关。
履行
下面是一个简单的类,它为您在上一节中遇到的过程代码提供了一个接口:
// listing 10.40
class ProductFacade
{
private array $products = [];
public function __construct(private string $file)
{
$this->compile();
}
private function compile(): void
{
$lines = getProductFileLines($this->file);
foreach ($lines as $line) {
$id = getIDFromLine($line);
$name = getNameFromLine($line);
$this->products[$id] = getProductObjectFromID($id, $name);
}
}
public function getProducts(): array
{
return $this->products;
}
public function getProduct(string $id): ?\Product
{
if (isset($this->products[$id])) {
return $this->products[$id];
}
return null;
}
}
从客户端代码的角度来看,从日志文件访问Product
对象要简单得多:
// listing 10.41
$facade = new ProductFacade(__DIR__ . '/test2.txt');
$object = $facade->getProduct("234");
结果
A Facade
真的是一个很简单的概念。这只是为层或子系统创建一个单一入口点的问题。这有许多好处。它有助于将项目中不同的区域相互分离。对于客户端编码人员来说,使用简单的方法来实现清晰的目标是非常有用和方便的。它通过将子系统的使用集中在一个地方来减少错误;对子系统的更改应该会导致可预测位置的故障。复杂子系统中的Facade
类也可以最小化错误,否则客户端代码可能会错误地使用内部函数。
尽管Facade
模式很简单,但是很容易忘记使用它,尤其是如果您熟悉您正在使用的子系统。当然,需要找到一个平衡点。一方面,为复杂系统创建简单接口的好处应该是显而易见的。另一方面,人们可以不顾后果地对系统进行抽象,然后再进行抽象。如果您为了客户代码的明显好处而进行了显著的简化,并且/或者将它从可能改变的系统中屏蔽掉,那么您实现Facade
模式可能是正确的。
摘要
在这一章中,我看了一些在系统中组织类和对象的方法。特别是,我关注的原则是,在继承失败的情况下,组合可以用来产生灵活性。在Composite
和Decorator
模式中,继承被用来促进组合,并定义一个为客户端代码提供保证的公共接口。
您还看到了在这些模式中有效使用的委托。最后,我看了看简单但功能强大的Facade
模式。Facade
是许多人已经使用多年却没有名字的模式之一。Facade
允许您为层或子系统提供一个干净的入口点。在 PHP 中,Facade
模式也用于创建封装程序代码块的对象包装器。
十一、执行和表示任务
在这一章中,我们开始行动。我着眼于帮助你完成事情的模式,无论是解释一种迷你语言还是封装一种算法。
本章将带您了解几种模式:
-
解释器模式:构建一个小型语言解释器,可以用来创建可脚本化的应用
-
策略模式:识别系统中的算法,并将它们封装成自己的类型
-
观察者模式:创建钩子来提醒不同的对象关于系统事件
-
访问者模式:对对象树中的所有节点应用操作
-
命令模式:创建可以保存和传递的命令对象
-
空对象模式:使用非操作对象代替空值
解释器模式
语言是用其他语言写的(至少一开始是这样)。例如,PHP 本身就是用 c 语言编写的。同样,尽管听起来有些奇怪,但你可以使用 PHP 定义和运行你自己的语言。当然,你可能创造的任何语言都将是缓慢的,而且有些局限。尽管如此,小语种还是非常有用的,你会在本章中看到。
问题
当您在 PHP 中创建 web(或命令行)界面时,您给了用户访问功能的权限。界面设计的取舍是在功能和易用性之间。通常,你给用户的权力越多,你的界面就变得越混乱。当然,好的界面设计在这里会有很大帮助。但是如果 90%的用户都在使用你的 30%的功能,增加功能的成本可能会超过收益。您可能希望为大多数用户简化您的系统。但是那 10%使用你的系统的高级功能的超级用户呢?也许你可以用不同的方式来适应它们。通过为这些用户提供一种领域语言(通常称为 DSL——特定于领域的语言),您实际上可以扩展应用的功能。
当然,你马上就有了一门编程语言。它叫 PHP。以下是允许用户编写系统脚本的方法:
// listing 11.01
$form_input = $_REQUEST['form_input'];
// contains: "print file_get_contents('/etc/passwd');"
eval($form_input);
这种使应用可脚本化的方法显然是疯狂的。以防原因不明显,它们归结为两个问题:安全性和复杂性。这个例子很好地解决了安全问题。通过允许用户通过您的脚本执行 PHP,您实际上是给了他们访问运行脚本的服务器的权限。复杂性问题也是一大缺点。无论你的代码有多清晰,普通用户都不太可能轻易扩展它,当然更不可能从浏览器窗口扩展。
然而,迷你语言可以解决这两个问题。您可以在语言中设计灵活性,减少用户造成损害的可能性,并保持事情的重点。
设想一个用于编写测验的应用。制作人设计问题,并制定规则,对参赛者提交的答案进行评分。要求测验必须在没有人工干预的情况下进行标记,即使用户可以将一些答案键入文本字段中。
这里有个问题:
How many members in the Design Patterns gang?
你可以接受“四”或“4”作为正确答案。您可以创建一个 web 界面,允许生成器使用正则表达式来标记响应:
⁴|four$
然而,大多数生产者并不是因为他们对正则表达式的了解而被雇佣的。为了使每个人的生活更容易,您可以实现一个更用户友好的机制来标记响应:
$input equals "4" or $input equals "four"
你提出了一种支持变量的语言,一个叫做equals
的操作符,以及布尔逻辑(or
和and
)。程序员喜欢给东西命名,我们就叫它 MarkLogic 吧。它应该很容易扩展,因为你可以想象对更丰富功能的大量请求。让我们暂时把解析输入的问题放在一边,专注于一种在运行时将这些元素组合在一起以产生答案的机制。如您所料,这就是解释器模式的用武之地。
履行
语言包含表达式(即解析为值的事物)。正如你在表 11-1 中看到的,即使是像 MarkLogic 这样微小的语言也需要跟踪大量的元素。
表 11-1
标记逻辑语法的要素
|描述
|
EBNF 元标识符
|
类别名
|
例子
|
| --- | --- | --- | --- |
| 可变的 | variable
| VariableExpression
| $input
|
| 字符串文字 | stringLiteral
| LiteralExpression
| "four"
|
| 布尔与 | andExpr
| BooleanAndExpression
| $input equals '4' and $other equals '6'
|
| 布尔或 | orExpr
| BooleanOrExpression
| $input equals '4' or $other equals '6'
|
| 平等测试 | eqExpr
| BooleanEqualsExpression
| $input equals '4'
|
表 11-1 列出了 EBNF 的名字。那么 EBNF 到底是什么呢?EBNF 是一种句法元语言,你可以用它来描述语言语法。EBNF 代表扩展的巴克斯-诺尔形式。它由一系列行(称为产品)组成,每一行都由一个名称和一个描述组成,描述的形式是对其他产品和终端的引用(即元素本身不是由对其他产品的引用组成的)。下面是用 EBNF 描述我的语法的一种方式:
Expr = operand { orExpr | andExpr }
Operand = ( '(' expr ')' | ? string literal ? | variable ) { eqExpr } orExpr = 'or' operand
andExpr = 'and' operand
eqExpr = 'equals' operand
variable = '$' , ? word ?
一些符号有特殊的含义(从正则表达式符号中应该很熟悉):|
(更确切地说是一个定义分隔符)可以粗略地认为是或,例如。您可以使用括号对标识符进行分组。所以在这个例子中,一个表达式(expr
)由一个operand
后跟零个或多个orExpr
或andExpr
组成。一个operand
可以是一个带括号的expr
(即,一个用文字“(”和“)”字符包装的expr
)、一个带引号的字符串(我已经省略了它的产生),或者一个后面跟有零个或多个eqExpr
实例的variable
。一旦你掌握了从一部作品提到另一部作品的诀窍,《EBNF》就变得很容易读懂了。
在图 11-1 中,我将我的语法元素表示为类。
图 11-1
组成 MarkLogic 语言的解释器类
如您所见,BooleanAndExpression
和它的兄弟从OperatorExpression
继承而来。这是因为这些类都在其他Expression
对象上执行它们的操作。VariableExpression
和LiteralExpression
直接和价值观打交道。
所有的Expression
对象都实现了一个在抽象基类Expression
中定义的interpret()
方法。interpret()
方法需要一个用作共享数据存储的InterpreterContext
对象。每个Expression
对象可以在InterpreterContext
对象中存储数据。然后InterpreterContext
将被传递给其他Expression
对象。为了方便从InterpreterContext
中检索数据,Expression
基类实现了一个返回唯一句柄的getKey()
方法。让我们通过Expression
的实现来看看这在实践中是如何工作的:
// listing 11.02
abstract class Expression
{
private static int $keycount = 0;
private string $key;
abstract public function interpret(InterpreterContext $context);
public function getKey(): string
{
if (! isset($this->key)) {
self::$keycount++;
$this->key = (string)self::$keycount;
}
return $this->key;
}
}
// listing 11.03
class LiteralExpression extends Expression
{
private mixed $value;
public function __construct(mixed $value)
{
$this->value = $value;
}
public function interpret(InterpreterContext $context): void
{
$context->replace($this, $this->value);
}
}
// listing 11.04
class InterpreterContext
{
private array $expressionstore = [];
public function replace(Expression $exp, mixed $value): void
{
$this->expressionstore[$exp->getKey()] = $value;
}
public function lookup(Expression $exp): mixed
{
return $this->expressionstore[$exp->getKey()];
}
}
// listing 11.05
$context = new InterpreterContext();
$literal = new LiteralExpression('four');
$literal->interpret($context);
print $context->lookup($literal) . "\n";
以下是输出结果:
four
我将从第InterpreterContext
课开始。如您所见,它实际上只是一个关联数组$expressionstore
的前端,我用它来保存数据。replace()
方法接受一个Expression
对象作为键和一个任意类型的值,然后将这一对添加到$expressionstore
。它还提供了一个用于检索数据的lookup()
方法。
Expression
类定义了抽象的interpret()
方法和具体的getKey()
方法,后者使用静态计数器值来生成、存储和返回字符串标识符。
这个方法被InterpreterContext::lookup()
和InterpreterContext::replace()
用来索引数据。
LiteralExpression
类定义一个接受值参数的构造函数。interpret()
方法需要一个InterpreterContext
对象。我简单地使用getKey()
调用replace()
来定义检索的键和$value
属性。在您研究其他Expression
类时,这将成为一种熟悉的模式。interpret()
方法总是将其结果写在InterpreterContext
对象上。
我还包含了一些客户端代码,实例化了一个InterpreterContext
对象和一个LiteralExpression
对象(值为"four"
)。我把InterpreterContext
对象递给LiteralExpression::interpret()
。interpret()
方法将键/值对存储在InterpreterContext
中,我通过调用lookup()
从那里检索值。
这是剩下的终端类。VariableExpression
稍微复杂一点:
// listing 11.06
class VariableExpression extends Expression
{
public function __construct(private string $name, private mixed $val = null)
{
}
public function interpret(InterpreterContext $context): void
{
if (! is_null($this->val)) {
$context->replace($this, $this->val);
$this->val = null;
}
}
public function setValue(mixed $value): void
{
$this->val = $value;
}
public function getKey(): string
{
return $this->name;
}
}
// listing 11.07
$context = new InterpreterContext();
$myvar = new VariableExpression('input', 'four');
$myvar->interpret($context);
print $context->lookup($myvar) . "\n";
// output: four
$newvar = new VariableExpression('input');
$newvar->interpret($context);
print $context->lookup($newvar) . "\n";
// output: four
$myvar->setValue("five");
$myvar->interpret($context);
print $context->lookup($myvar) . "\n";
// output: five
print $context->lookup($newvar) . "\n";
// output: five
VariableExpression
类接受存储在属性变量中的名称和值参数。我提供了setValue()
方法,这样客户端代码可以随时更改值。
interpret()
方法检查$val
属性是否具有非空值。如果$val
属性有一个值,它就在InterpreterContext
上设置它。然后我将$val
属性设置为null
。这是在VariableExpression
的另一个同名实例更改了InterpreterContext
对象中的值后再次调用interpret()
的情况下。这是一个非常有限的变量,只接受字符串值。如果你打算扩展你的语言,你应该考虑让它与其他Expression
对象一起工作,这样它就可以包含测试和操作的结果。不过现在,VariableExpression
将完成我需要它做的工作。注意,我已经覆盖了getKey()
方法,因此变量值链接到变量名,而不是任意的静态 ID。
语言中的运算符表达式都与另外两个Expression
对象一起工作,以完成它们的工作。因此,让它们扩展一个公共超类是有意义的。下面是OperatorExpression
类:
// listing 11.08
abstract class OperatorExpression extends Expression
{
public function __construct(protected Expression $l_op, protected Expression $r_op)
{
}
public function interpret(InterpreterContext $context): void
{
$this->l_op->interpret($context);
$this->r_op->interpret($context);
$result_l = $context->lookup($this->l_op);
$result_r = $context->lookup($this->r_op);
$this->doInterpret($context, $result_l, $result_r);
}
abstract protected function doInterpret(
InterpreterContext $context,
$result_l,
$result_r
): void;
}
OperatorExpression
是一个抽象类。它实现了interpret()
,但是它也定义了抽象的dointerpret()
方法。
构造函数需要两个Expression
对象,$l_op
和$r_op
,并将其存储在属性中。
interpret()
方法首先在其两个操作数属性上调用interpret()
(如果您已经阅读了前一章,您可能会注意到我在这里创建了组合模式的一个实例)。一旦操作数已经运行,interpret()
仍然需要获取它产生的值。它通过为每个属性调用InterpreterContext::lookup()
来做到这一点。然后它调用dointerpret()
,让子类来决定如何处理这些操作的结果。
Note
dointerpret()
是模板方法模式的一个实例。在这种模式中,父类既定义又调用抽象方法,让子类来提供实现。这可以简化具体类的开发,因为共享功能是由超类处理的,让孩子专注于干净、狭窄的目标。
下面是BooleanEqualsExpression
类,它测试两个Expression
对象是否相等:
// listing 11.09
class BooleanEqualsExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l == $result_r);
}
}
BooleanEqualsExpression
只实现了dointerpret()
方法,该方法测试由interpret()
方法传递的操作数结果的相等性,并将结果放在InterpreterContext
对象中。
总结一下Expression
类,下面是BooleanOrExpression
和BooleanAndExpression
:
// listing 11.10
class BooleanOrExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l || $result_r);
}
}
// listing 11.11
class BooleanAndExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l && $result_r);
}
}
BooleanOrExpression
类应用逻辑or
运算并通过InterpreterContext::replace()
方法存储运算结果,而不是测试相等性。BooleanAndExpression
当然应用了逻辑and
运算。
我现在有足够的代码来执行我前面引用的迷你语言片段。又来了:
$input equals "4" or $input equals "four"
下面是我如何用我的Expression
类建立这个语句:
// listing 11.12
$context = new InterpreterContext();
$input = new VariableExpression('input');
$statement = new BooleanOrExpression(
new BooleanEqualsExpression($input, new LiteralExpression('four')),
new BooleanEqualsExpression($input, new LiteralExpression('4'))
);
我实例化了一个名为"input"
的变量,但是没有为它提供值。然后我创建一个BooleanOrExpression
对象来比较两个BooleanEqualsExpression
对象的结果。这些对象中的第一个将存储在$input
中的VariableExpression
对象与包含字符串"four"
的LiteralExpression
进行比较;第二个将$input
与包含字符串"4"
的LiteralExpression
对象进行比较。
现在,准备好语句后,我准备为输入变量提供一个值并运行代码:
// listing 11.13
foreach ([ "four", "4", "52" ] as $val) {
$input->setValue($val);
print "$val:\n";
$statement->interpret($context);
if ($context->lookup($statement)) {
print "top marks\n\n";
} else {
print "dunce hat on\n\n";
}
}
事实上,我用三个不同的值运行了代码三次。第一次,我将临时变量$val
设置为"four"
,使用其setValue()
方法将它赋给输入VariableExpression
对象。然后我调用最顶层的Expression
对象上的interpret()
(BooleanOrExpression
对象包含对语句中所有其他表达式的引用)。下面是这个调用的内部过程,一步一步来:
-
$statement
在其$l_op
属性上调用interpret()
(第一个BooleanEqualsExpression
对象)。 -
第一个
BooleanEqualsExpression
对象调用上的interpret()
及其$l_op
属性(对输入VariableExpression
对象的引用,该对象当前设置为"four"
)。 -
输入
VariableExpression
对象通过调用InterpreterContext::replace()
将其当前值写入提供的InterpreterContext
对象。 -
第一个
BooleanEqualsExpression
对象在其$r_op
属性上调用interpret()
(一个值为"four"
的LiteralExpression
对象)。 -
LiteralExpression
对象向InterpreterContext
注册它的键和值。 -
第一个
BooleanEqualsExpression
对象从InterpreterContext
对象中检索$l_op
("four"
)和$r_op
("four"
)的值。 -
第一个
BooleanEqualsExpression
对象比较这两个值是否相等,然后向InterpreterContext
对象注册结果(true
)及其键。 -
回到树的顶端,
$statement
对象(BooleanOrExpression
)在其$r_op
属性上调用interpret()
。这以与$l_op
属性相同的方式解析为一个值(在本例中为false
)。 -
$statement
对象从InterpreterContext
对象中检索每个操作数的值,并使用||
进行比较。它在比较true
和false
,所以结果是true
。这个最终结果存储在InterpreterContext
对象中。
所有这些只是针对循环的第一次迭代。以下是最终输出:
four:
top marks 4:
top marks 52:
dunce hat on
您可能需要通读几遍这一部分,然后才能点击程序。在这里,对象与类树的老问题可能会让你困惑。Expression
类被安排在一个继承层次中,就像Expression
对象在运行时被组成一棵树。当您通读代码时,请记住这一区别。
图 11-2 显示了该示例的完整类图。
图 11-2
部署的解释器模式
翻译问题
一旦为解释器模式实现设置了核心类,扩展就变得容易了。您所付出的代价在于您最终可能创建的类的数量。由于这个原因,解释器最好应用于相对较小的语言。如果你需要一种通用编程语言,你最好找一个第三方工具来使用。
因为解释器类经常执行非常相似的任务,所以关注您创建的类以消除重复是值得的。
许多第一次接触解释器模式的人,在最初的兴奋之后,失望地发现它没有解决解析问题。这意味着你还不能为你的用户提供一种友好的语言。附录 B 包含一些粗略的代码来说明一种解析小型语言的策略。
战略模式
类经常试图做太多。这是可以理解的:你创建了一个执行一些相关动作的类;而且,当您编码时,这些动作中的一些需要根据环境而变化。同时,你的类需要拆分成子类。在你意识到之前,你的设计已经被竞争的力量撕裂了。
问题
因为我最近构建了一种标记语言,所以我坚持使用测验示例。测验需要问题,所以你构建了一个Question
类,给它一个mark()
方法。一切都很好,直到您需要支持不同的标记机制。
假设要求你支持简单的 MarkLogic 语言,通过直接匹配和正则表达式进行标记。你的第一个想法可能是对这些差异进行子类化,如图 11-3 所示。
图 11-3
根据标记策略定义子类
这将很好地为你服务,只要分数仍然是这门课唯一不同的方面。想象一下,你被要求支持不同类型的问题:基于文本的问题和支持富媒体的问题。这就给你带来了一个问题,如何将这些力量整合到一个继承树中,如图 11-4 所示。
图 11-4
根据两种力量定义子类
不仅层次结构中的类数量激增,而且还必然会引入重复。您的标记逻辑会在继承层次结构的每个分支中复制。
每当您发现自己在一个继承树中跨兄弟重复一个算法时(无论是通过子类化还是重复的条件语句),考虑将这些行为抽象成它们自己的类型。
履行
和所有最好的模式一样,策略简单而强大。当类必须支持一个接口的多种实现(例如,多种标记机制)时,最好的方法通常是提取这些实现并将它们放在自己的类型中,而不是扩展原始类来处理它们。
所以,在这个例子中,你的标记方法可能被放在一个Marker
类型中。图 11-5 显示了新的结构。
图 11-5
将算法提取到它们自己的类型中
还记得“四人帮”的“重作文轻继承”的原则吗?这是一个极好的例子。通过定义和封装标记算法,可以减少子类化并增加灵活性。您可以随时添加新的标记策略,而完全不需要更改Question
类。所有的Question
类都知道它们拥有一个Marker
的实例,并且它的接口保证它支持一个mark()
方法。实施的细节完全是别人的问题。
下面是呈现为代码的Question
类:
// listing 11.14
abstract class Question
{
public function __construct(protected string $prompt, protected Marker $marker)
{
}
public function mark(string $response): bool
{
return $this->marker->mark($response);
}
}
// listing 11.15
class TextQuestion extends Question
{
// do text question specific things
}
// listing 11.16
class AVQuestion extends Question
{
// do audiovisual question specific things
}
如你所见,我把TextQuestion
和AVQuestion
之间的差异的确切本质留给了想象。Question
基类提供所有真正的功能,存储一个提示属性和一个Marker
对象。当终端用户响应调用Question::mark()
时,该方法简单地将问题解决委托给它的Marker
对象。
现在是时候定义一些简单的Marker
对象了:
// listing 11.17
abstract class Marker
{
public function __construct(protected string $test)
{
abstract public function mark(string $response): bool;
}
// listing 11.18
class MarkLogicMarker extends Marker
{
private MarkParse $engine;
public function __construct(string $test)
{
parent:: __construct($test);
$this->engine = new MarkParse($test);
}
public function mark(string $response): bool
{
return $this->engine->evaluate($response);
}
}
// listing 11.19
class MatchMarker extends Marker
{
public function mark(string $response): bool
{
return ($this->test == $response);
}
}
// listing 11.20
class RegexpMarker extends Marker
{
public function mark(string $response): bool
{
return (preg_match("$this->test", $response) === 1);
}
}
对于Marker
类本身,应该没有什么特别令人惊讶的。注意,MarkParse
对象被设计为与附录 b 中开发的简单解析器一起工作。这里的关键在于我定义的结构,而不是策略本身的细节。我可以把RegexpMarker
换成MatchMarker
,对Question
职业没有影响。
当然,您仍然必须决定使用什么方法在具体的Marker
对象之间进行选择。我见过两种现实世界中解决这个问题的方法。首先,生产者使用单选按钮来选择首选的标记策略。在第二种情况下,使用标记条件本身的结构;也就是说,match 语句是空白的:
five
MarkLogic 语句前面有一个冒号:
:$input equals 'five'
一个正则表达式使用了正斜杠:
/f.ve/
下面是一些运行这些类的代码:
// listing 11.21
$markers = [
new RegexpMarker("/f.ve/"),
new MatchMarker("five"),
new MarkLogicMarker('$input equals "five"')
];
foreach ($markers as $marker) {
print get_class($marker) . "\n";
$question = new TextQuestion("how many beans make five", $marker);
foreach ([ "five", "four" ] as $response) {
print " response: $response: ";
if ($question->mark($response)) {
print "well done\n";
} else {
print "never mind\n";
}
}
}
我构建了三个策略对象,依次使用每个对象来帮助构建一个TextQuestion
对象。然后对两个样本响应尝试使用TextQuestion
对象。以下是输出(包括名称空间):
popp\ch11\batch02\RegexpMarker
response: five: well done
response: four: never mind
popp\ch11\batch02\MatchMarker
response: five: well done
response: four: never mind
popp\ch11\batch02\MarkLogicMarker
response: five: well done
response: five: never mind
在这个例子中,我通过mark()
方法将特定的数据($response
变量)从客户端传递给策略对象。有时,您可能会遇到这样的情况:当调用策略对象的操作时,您并不总是预先知道策略对象将需要多少信息。您可以通过将策略传递给客户端本身的一个实例来委托获取哪些数据的决策。然后,该策略可以查询客户端,以构建它需要的数据。
观察者模式
正交性是我之前描述过的一个优点。作为程序员,我们的目标之一应该是构建可以在对其他组件影响最小的情况下进行更改或移动的组件。如果我们对一个组件所做的每一个更改都必然会在代码库中的其他地方引起连锁反应,那么开发任务很快就会变成错误创建和消除的螺旋。
当然,正交往往只是一个梦想。系统中的元素必须嵌入对其他元素的引用。但是,您可以部署各种策略来最小化这种情况。您已经看到了各种各样的多态例子,在这些例子中,客户端理解组件的接口,但是实际的组件可能在运行时发生变化。
在某些情况下,您可能希望在组件之间插入比这更大的楔子。考虑一个负责处理用户访问系统的类:
// listing 11.22
classLogin
{
public const LOGIN_USER_UNKNOWN = 1;
public const LOGIN_WRONG_PASS = 2;
public const LOGIN_ACCESS = 3;
private array $status = [];
public function handleLogin(string $user, string $pass, string $ip): bool
{
$isvalid = false;
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
print "returning " . (($isvalid) ? "true" : "false") . "\n";
return $isvalid;
}
private function setStatus(int $status, string $user, string $ip): void
{
$this->status = [$status, $user, $ip];
}
public function getStatus(): array
{
return $this->status;
}
}
当然,在现实世界的例子中,handleLogin()
方法将根据存储机制来验证用户。实际上,这个类使用rand()
函数来伪造登录过程。给handleLogin()
打电话有三种可能的结果。状态标志可以设置为LOGIN_ACCESS
、LOGIN_WRONG_PASS
或LOGIN_USER_UNKNOWN
。
因为Login
类是保护你的业务团队财富的门户,它可能会在开发期间和以后的几个月里激发很多兴趣。营销人员可能会打电话给你,要求你记录 IP 地址。您可以向系统的Logger
类添加一个调用:
// listing 11.23
public function handleLogin(string $user, string $pass, string $ip): bool
{
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
Logger::logIP($user, $ip, $this->getStatus());
return $isvalid;
}
由于担心安全性,系统管理员可能会要求通知失败的登录。同样,您可以返回到登录方法并添加一个新呼叫:
// listing 11.24
if (! $isvalid) {
Notifier::mailWarning(
$user,
$ip,
$this->getStatus()
);
}
业务开发团队可能会宣布与特定 ISP 的合作,要求在特定用户登录时设置 cookie。等等,等等。
这些都是很容易满足的要求,但是解决它们需要为您的设计付出代价。类很快就非常紧密地嵌入到这个特定的系统中。如果不一行一行地检查代码,删除旧系统特有的所有内容,就不能把它取出来放到另一个产品中。当然,这并不太难,但是这样你就走上了剪切粘贴编码的道路。现在你的系统中有了两个相似但不同的Login
类,你会发现对其中一个的改进需要对另一个进行同样的修改——直到它们不可避免地、不体面地彼此脱离。
那么你能做些什么来拯救Login
类呢?观察者模式非常适合这里。
履行
观察者模式的核心是将客户端元素(观察者)从中心类(主体)中分离出来。当受试者知道的事件发生时,观察者需要被告知。同时,您不希望 subject 与其 observer 类有硬编码的关系。
为了实现这一点,您可以允许观察者向主题注册他们自己。您给了Login
类三个新方法,attach()
、detach()
和notify()
,并使用一个名为Observable
的接口来强制执行:
// listing 11.25
interface Observable
{
public function attach(Observer $observer): void;
public function detach(Observer $observer): void;
public function notify(): void;
}
// listing 11.26
class Login implements Observable
{
private array $observers = [];
public const LOGIN_USER_UNKNOWN = 1;
public const LOGIN_WRONG_PASS = 2;
public const LOGIN_ACCESS = 3;
public function attach(Observer $observer): void
{
$this->observers[] = $observer;
}
public function detach(Observer $observer): void
{
$this->observers = array_filter(
$this->observers,
function ($a) use ($observer) {
return (! ($a === $observer ));
}
);
}
public function notify(): void
{
foreach ($this->observers as $obs) {
$obs->update($this);
}
}
// ...
}
所以Login
类管理一个观察者对象列表。这些可以由第三方使用attach()
方法添加,并通过detach()
删除。调用notify()
方法是为了告诉观察者发生了一些有趣的事情。该方法简单地遍历观察器列表,对每个观察器调用update()
。
Login
类本身从它的handleLogin()
方法中调用notify()
:
// listing 11.27
public function handleLogin(string $user, string $pass, string $ip): bool
{
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
$this->notify();
return $isvalid;
}
下面是Observer
类的接口:
// listing 11.28
interface Observer
{
public function update(Observable $observable): void;
}
任何使用这个接口的对象都可以通过attach()
方法添加到Login
类中。这里有一个具体的例子:
// listing 11.29
class LoginAnalytics implements Observer
{
public function update(Observable $observable): void
{
// not type safe!
$status = $observable->getStatus();
print __CLASS __ . ": doing something with status info\n";
}
}
注意 observer 对象如何使用Observable
的实例来获得关于事件的更多信息。由 subject 类提供观察者可以查询以了解状态的方法。在这种情况下,我定义了一个名为getStatus()
的方法,观察者可以调用它来获得游戏当前状态的快照。
不过,这一增加也凸显了一个问题。通过调用Login::getStatus()
,LoginAnalytics
类承担了比它安全所能承担的更多的知识。它在一个Observable
对象上进行这个调用,但是不能保证这个对象也会是一个Login
对象。我有几个选择。我可以扩展Observable
接口,使其包含一个getStatus()
声明,并可能将其重命名为类似于ObservableLogin
的名称,以表明它是特定于Login
类型的。
或者,我可以保持Observable
接口的通用性,让Observer
类负责确保它们的主题是正确的类型。他们甚至可以把自己和主题联系起来。由于将会有不止一种类型的Observer
,并且由于我计划执行一些对它们都通用的内务处理,这里有一个抽象超类来处理这些繁琐的工作:
// listing 11.30
abstract class LoginObserver implements Observer
{
private Login $login;
public function __construct(Login $login)
{
$this->login = $login;
$login->attach($this);
}
public function update(Observable $observable): void
{
if ($observable === $this->login) {
$this->doUpdate($observable);
}
}
abstract public function doUpdate(Login $login): void;
}
LoginObserver
类在其构造函数中需要一个Login
对象。它存储一个引用并调用Login::attach()
。当调用update()
时,它检查提供的Observable
对象是否是正确的引用。然后它调用一个模板方法:doUpdate()
。我现在可以创建一套LoginObserver
对象,所有这些对象都是安全的,它们使用的是Login
对象,而不是任何旧的Observable
:
// listing 11.31
class SecurityMonitor extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
if ($status[0] == Login::LOGIN_WRONG_PASS) {
// send mail to sysadmin
print __CLASS__ . ": sending mail to sysadmin\n";
}
}
}
// listing 11.32
class GeneralLogger extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
// add login data to log
print __CLASS__ . ": add login data to log\n";
}
}
// listing 11.33
class PartnershipTool extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
// check $ip address
// set cookie if it matches a list
print __CLASS__ . ": set cookie if it matches a list\n";
}
}
创建和附加LoginObserver
类现在可以在实例化时一次性完成:
// listing 11.34
$login = new Login();
new SecurityMonitor($login);
new GeneralLogger($login);
new PartnershipTool($login);
所以现在我在主题类和观察者之间创建了一个灵活的关联。你可以在图 11-6 中看到例子的类图。
图 11-6
观察者模式
PHP 通过捆绑的 SPL(标准 PHP 库)扩展提供了对观察者模式的内置支持。SPL 是一套帮助解决常见的、主要面向对象的问题的工具。这把 OO 瑞士军刀的观察者方面由三个元素组成:SplObserver
、SplSubject
和SplObjectStorage
。SplObserver
和SplSubject
是接口,与本节示例中的Observer
和Observable
接口完全平行。SplObjectStorage
是一个实用程序类,旨在提供改进的对象存储和移除。下面是 Observer 实现的编辑版本:
// listing 11.35
class Login implements \SplSubject
{
private \SplObjectStorage $storage;
// ...
public function __construct()
{
$this->storage = new \SplObjectStorage();
}
public function attach(\SplObserver $observer): void
{
$this->storage->attach($observer);
}
public function detach(\SplObserver $observer): void
{
$this->storage->detach($observer);
}
public function notify(): void
{
foreach ($this->storage as $obs) {
$obs->update($this);
}
}
// ...
}
// listing 11.36
abstract class LoginObserver implements \SplObserver
{
public function __construct(private Login $login)
{
$login->attach($this);
}
public function update(\SplSubject $subject): void
{
if ($subject === $this->login) {
$this->doUpdate($subject);
}
}
abstract public function doUpdate(Login $login): void;
}
就SplObserver
(即Observer
)和SplSubject
(即Observable
)而言,没有真正的区别——当然,除了我不再需要声明接口,并且我必须根据新名称改变我的类型提示。SplObjectStorage
为你提供了一个真正有用的服务,不过。您可能已经注意到,在我最初的例子中,我的Login::detach()
实现将array_filter
(以及一个匿名函数)应用于$observers
数组,以便找到并移除参数对象。SplObjectStorage
类在幕后为您完成这项工作。它实现了attach()
和detach()
方法,可以传递给foreach
并像数组一样迭代。
Note
你可以在 www.php.net/spl
的 PHP 文档中读到更多关于 SPL 的内容。特别是,你会发现那里有很多迭代器工具。我将在第十三章中介绍 PHP 内置的迭代器接口。
另一种解决Observable
类和它的Observer
类之间通信问题的方法是通过update()
方法传递特定的状态信息,而不是 subject 类的实例。对于快速和肮脏的解决方案,这通常是我最初采取的方法。所以在这个例子中,update()
将期望一个状态标志、用户名和 IP 地址(为了便于携带,可能在一个数组中),而不是一个Login
的实例。这使您不必在Login
类中编写状态方法。另一方面,在 subject 类存储大量状态的情况下,将它的一个实例传递给update()
允许观察者有更大的灵活性。
您也可以完全锁定类型,通过让Login
类拒绝与除了特定类型的 observer 类(也许是LoginObserver
)之外的任何东西一起工作。如果您想这样做,那么您可以考虑对传递给attach()
方法的对象进行某种运行时检查;否则,您可能需要重新考虑Observable
接口。
我再一次在运行时使用了组合来构建一个灵活且可扩展的模型。Login
类可以从它的上下文中提取出来,放到一个完全不同的项目中,没有任何限制。在那里,它可能与一组不同的观察者一起工作。
访问者模式
如您所见,许多模式旨在运行时构建结构,遵循组合比继承更灵活的原则。无处不在的组合模式就是一个很好的例子。当您处理对象集合时,可能需要对结构应用各种操作,这些操作涉及处理每个单独的组件。这种操作可以内置到组件本身中。毕竟,组件通常最适合相互调用。
这种方法并非没有问题。您并不总是知道可能需要在结构上执行的所有操作。如果您在逐个案例的基础上向您的类添加对新操作的支持,您可能会使您的接口膨胀,并承担不真正适合的责任。正如您可能猜到的,访问者模式解决了这些问题。
问题
回想一下上一章的复合示例。对于一个游戏,我创造了一个组件大军,这样整体和它的部分可以互换。您看到了操作可以构建到组件中。通常,叶对象执行操作,复合对象调用其子对象来执行操作:
// listing 11.37
class Army extends CompositeUnit
{
public function bombardStrength(): int
{
$strength = 0;
foreach ($this->units() as $unit) {
$strength += $unit->bombardStrength();
}
return $strength;
}
}
// listing 11.38
class LaserCanonUnit extends Unit
{
public function bombardStrength(): int
{
return 44;
}
}
如果这个操作是复合类职责的一部分,就没有问题。然而,有更多的外围任务在界面上可能不那么愉快。
这里有一个转储关于叶节点的文本信息的操作。它可以被添加到抽象的Unit
类:
// listing 11.39
abstract class Unit
{
// ...
public function textDump($num = 0): string
{
$txtout = "";
$pad = 4 * $num;
$txtout .= sprintf("%{$pad}s", "");
$txtout .= get_class($this) . ": ";
$txtout .= "bombard: " . $this->bombardStrength() . "\n";
return $txtout;
}
// ...
}
然后可以在CompositeUnit
类中覆盖这个方法:
// listing 11.40
abstract class CompositeUnit extends Unit
{
// ...
public function textDump($num = 0): string
{
$txtout = parent::textDump($num);
foreach ($this->units as $unit) {
$txtout .= $unit->textDump($num + 1);
}
return $txtout;
}
}
我可以继续创建方法来计算树中单位的数量,将组件保存到数据库中,以及计算军队消耗的食物单位。
为什么我要在组合的接口中包含这些方法?只有一个真正令人信服的答案。我在这里包含了这些不同的操作,因为这是操作可以轻松访问复合结构中相关节点的地方。
尽管遍历的便利性确实是组合模式的一部分,但这并不意味着需要遍历树的每个操作都应该在组合模式的接口中占有一席之地。
所以这些是起作用的力量:我想充分利用我的对象结构提供的简单遍历,但是我想在不膨胀接口的情况下做到这一点。
履行
我将从接口开始。在抽象的Unit
类中,我定义了一个accept()
方法:
// listing 11.41
abstract class Unit
{
// ...
public function accept(ArmyVisitor $visitor): void
{
$refthis = new \ReflectionClass(get_class($this));
$method = "visit" . $refthis->getShortName();
$visitor->$method($this);
}
protected function setDepth($depth): void
{
$this->depth = $depth;
}
public function getDepth(): int
{
return $this->depth;
}
}
如您所见,accept()
方法期望一个ArmyVisitor
对象被传递给它。PHP 允许您动态地在您希望调用的ArmyVisitor
上定义方法,所以我基于当前类的名称构造了一个方法名,并在提供的ArmyVisitor
对象上调用该方法。如果当前的类是Army
,那么我调用ArmyVisitor::visitArmy()
。如果当前类是TroopCarrier
,那么我调用ArmyVisitor::visitTroopCarrier()
等等。这让我不用在类层次结构中的每个叶节点上实现accept()
。我在专区的时候,还加了两个方便的方法:getDepth()
和setDepth()
。这些可以用来存储和检索树中一个单元的深度。setDepth()
由单元的父单元调用,当它从CompositeUnit::addUnit()
添加到树中时:
// listing 11.42
abstract class CompositeUnit extends Unit
{
// ...
public function addUnit(Unit $unit): void
{
foreach ($this->units as $thisunit) {
if ($unit === $thisunit) {
return;
}
}
$unit->setDepth($this->depth + 1);
$this->units[] = $unit;
}
public function accept(ArmyVisitor $visitor): void
{
parent::accept($visitor);
foreach ($this->units as $thisunit) {
$thisunit->accept($visitor);
}
}
}
我在这个片段中包含了一个accept()
方法。这将调用Unit::accept()
来调用所提供的ArmyVisitor
对象上的相关 visit()方法。然后它遍历调用accept()
的所有子对象。事实上,因为accept()
覆盖了它的父操作,所以accept()
方法允许我做两件事:
-
为当前组件调用正确的访问者方法
-
通过
accept()
方法将 visitor 对象传递给所有当前元素的子元素(假设当前组件是复合的)
我还没有为ArmyVisitor
定义接口。accept()
方法应该会给你一些提示。visitor 类将为类层次结构中的每个具体类定义accept()
方法。这允许我为不同的对象提供不同的功能。在这个类的我的版本中,我还定义了一个默认的visit()
方法,如果实现类选择不为特定的Unit
类提供特定的处理,这个方法就会被自动调用:
// listing 11.43
abstract class ArmyVisitor
{
abstract public function visit(Unit $node);
public function visitArcher(Archer $node): void
{
$this->visit($node);
}
public function visitCavalry(Cavalry $node): void
{
$this->visit($node);
}
public function visitLaserCanonUnit(LaserCanonUnit $node): void
{
$this->visit($node);
}
public function visitTroopCarrierUnit(TroopCarrierUnit $node): void
{
$this->visit($node);
}
public function visitArmy(Army $node): void
{
$this->visit($node);
}
}
所以现在只需要提供ArmyVisitor
的实现,我已经准备好了。下面是作为一个ArmyVisitor
对象重新实现的简单文本转储代码:
// listing 11.44
class TextDumpArmyVisitor extends ArmyVisitor
{
private string $text = "";
public function visit(Unit $node): void
{
$txt = "";
$pad = 4 * $node->getDepth();
$txt .= sprintf("%{$pad}s", "");
$txt .= get_class($node) . ": ";
$txt .= "bombard: " . $node->bombardStrength() . "\n";
$this->text .= $txt;
}
public function getText(): string
{
return $this->text;
}
}
让我们看一些客户端代码,然后浏览整个过程:
// listing 11.45
$main_army = new Army();
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCanonUnit());
$main_army->addUnit(new Cavalry());
$textdump = new TextDumpArmyVisitor();
$main_army->accept($textdump);
print $textdump->getText();
该代码产生以下输出:
popp\ch11\batch08\Army: bombard: 50
popp\ch11\batch08\Archer: bombard: 4
popp\ch11\batch08\LaserCanonUnit: bombard: 44
popp\ch11\batch08\Cavalry: bombard: 2
我创建了一个Army
对象。因为Army
是复合的,所以它有一个addUnit()
方法,我用它来添加更多的Unit
对象。然后我创建了TextDumpArmyVisitor
对象,并将其传递给Army::accept()
。accept()
方法构造一个方法调用并调用TextDumpArmyVisitor::visitArmy()
。在这种情况下,我没有为Army
对象提供特殊处理,所以调用被传递给通用的visit()
方法。已经向visit()
传递了对Army
对象的引用。它调用它的方法(包括新添加的getDepth()
,它告诉任何需要知道该单元在组合树中的位置的人)来生成汇总数据。对visitArmy()
的调用已经完成,所以Army::accept()
操作现在依次调用其子操作accept()
,传递访问者。这样,ArmyVisitor
类访问树中的每个对象。
只添加了几个方法,我就创建了一种机制,通过这种机制,新功能可以插入到我的复合类中,而不会损害它们的接口,也不会有大量重复的遍历代码。
在游戏的某些方格中,军队需要缴税。税收官拜访军队,并对每一个找到的单位征收费用。不同的单位按不同的税率征税。在这里,我可以利用 visitor 类中的专用方法:
// listing 11.46
class TaxCollectionVisitor extends ArmyVisitor
{
private int $due = 0;
private string $report = "";
public function visit(Unit $node): void
{
$this->levy($node, 1);
}
public function visitArcher(Archer $node): void
{
$this->levy($node, 2);
}
public function visitCavalry(Cavalry $node): void
{
$this->levy($node, 3);
}
public function visitTroopCarrierUnit(TroopCarrierUnit $node): void
{
$this->levy($node, 5);
}
private function levy(Unit $unit, int $amount): void
{
$this->report .= "Tax levied for " . get_class($unit);
$this->report .= ": $amount\n";
$this->due += $amount;
}
public function getReport(): string
{
return $this->report;
}
public function getTax(): int
{
return $this->due;
}
}
在这个简单的例子中,我没有直接使用传递给各种访问方法的Unit
对象。然而,我确实使用了这些方法的特殊性质,根据调用Unit
对象的具体类型征收不同的费用。
下面是一些客户端代码:
// listing 11.47
$main_army = new Army();
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCanonUnit());
$main_army->addUnit(new Cavalry());
$taxcollector = new TaxCollectionVisitor();
$main_army->accept($taxcollector);
print $taxcollector->getReport();
print "TOTAL: ";
print $taxcollector->getTax() . "\n";
像以前一样,TaxCollectionVisitor
对象被传递给Army
对象的accept()
方法。再次,Army
在调用其子节点上的accept()
之前,将对自身的引用传递给了visitArmy()
方法。组件并不知道访问者执行的操作。它们只是与它的公共接口协作,每个接口都尽职尽责地将自己传递给其类型的正确方法。
除了在ArmyVisitor
类中定义的方法之外,TaxCollectionVisitor
还提供了两个汇总方法,getReport()
和getTax()
。调用这些函数会提供您可能期望的数据:
Tax levied for popp\ch11\batch08\Army: 1
Tax levied for popp\ch11\batch08\Archer: 2
Tax levied for popp\ch11\batch08\LaserCanonUnit: 1
Tax levied for popp\ch11\batch08\Cavalry: 3
TOTAL: 7
图 11-7 显示了本例中的参与者。
图 11-7
访问者模式
访客问题
那么,访问者模式是另一种结合了简单性和强大功能的模式。然而,在部署这种模式时,需要记住一些事情。
首先,尽管 Visitor 非常适合组合模式,但它实际上可以用于任何对象集合。因此,您可以将它用于对象列表,例如,其中每个对象都存储对其兄弟对象的引用。
通过外部化操作,您可能会冒损害封装的风险。也就是说,您可能需要公开您访问过的对象的内部,以便让访问者对它们做任何有用的事情。例如,您看到,对于第一个访问者示例,我被迫在Unit
接口中提供一个额外的方法,以便为TextDumpArmyVisitor
对象提供信息。您在之前的观察者模式中也看到了这种困境。
因为迭代与访问者对象执行的操作是分开的,所以您必须放弃一定程度的控制。例如,您不能很容易地创建一个在子节点迭代前后都做一些事情的visit()
方法。解决这个问题的一种方法是将迭代的责任转移到 visitor 对象中。这样做的问题是,您可能会在不同的访问者之间重复遍历代码。
默认情况下,我更喜欢将遍历保持在被访问类的内部,但是将它外部化会为您提供一个独特的优势。您可以在逐个访问者的基础上改变您处理被访问的类的方式。
命令模式
近年来,我很少在没有部署这种模式的情况下完成一个 web 项目。最初是在图形用户界面设计的环境中构思的,命令对象有助于良好的企业应用设计,鼓励控制器(请求和分派处理)和域模型(应用逻辑)层之间的分离。更简单地说,命令模式使系统组织良好,易于扩展。
问题
所有系统都必须决定如何响应用户的请求。在 PHP 中,决策过程通常由一系列接触点页面来处理。在选择页面(feedback.php
)时,用户清楚地表明了她需要的功能和界面。PHP 开发人员越来越多地选择单点联系方式(我将在下一章讨论)。然而,在这两种情况下,请求的接收者必须委派给更关心应用逻辑的层。在用户可以向不同页面发出请求的情况下,这种委托尤其重要。没有它,重复不可避免地进入项目。
因此,假设您有一个项目,其中包含一系列需要执行的任务。特别是,系统必须允许一些用户登录,另一些用户提交反馈。您可以创建处理这些任务的login.php
和feedback.php
页面,实例化专家类来完成工作。不幸的是,系统中的用户界面很少能清晰地映射到系统设计要完成的任务。例如,您可能需要在每个页面上都有登录和反馈功能。如果页面必须处理许多不同的任务,那么也许您应该将任务视为可以封装的东西。通过这样做,您可以轻松地向系统添加新任务,并在系统的各层之间建立一个边界。这就把我们带到了命令模式。
履行
命令对象的界面再简单不过了。它需要一个简单的方法:execute()
。
在图 11-8 中,我已经将Command
表示为一个抽象类。在这个简单的层次上,它可以被定义为一个接口。我倾向于为此使用抽象,因为我经常发现基类也可以为它的派生对象提供有用的公共功能。
图 11-8
命令类
command 模式中有三个其他参与者:客户机,它实例化 Command 对象;调用程序,它部署对象;和命令在其上操作的接收器。
接收方可以由客户端在其构造函数中提供给命令,也可以从某种工厂对象中获取。我喜欢后一种方法,保持构造函数方法没有参数。所有的Command
对象都可以用完全相同的方式实例化。
下面是抽象基类:
// listing 11.48
abstract class Command
{
abstract public function execute(CommandContext $context): bool;
}
这里有一个具体的Command
类:
// listing 11.49
class LoginCommand extends Command
{
public function execute(CommandContext $context): bool
{
$manager = Registry::getAccessManager();
$user = $context->get('username');
$pass = $context->get('pass');
$user_obj = $manager->login($user, $pass);
if (is_null($user_obj)) {
$context->setError($manager->getError());
return false;
}
$context->addParam("user", $user_obj);
return true;
}
}
LoginCommand
被设计成与AccessManager
对象一起工作。AccessManager
是一个虚构的类,处理登录用户进入系统的细节。注意,Command::execute()
方法需要一个CommandContext
对象——这在 Alur 等人的核心 J2EE 模式:最佳实践和设计策略 (Prentice Hall,2001)中被称为RequestHelper
。这是一种机制,请求数据可以通过它传递给Command
对象,响应可以通过它返回到视图层。以这种方式使用对象很有用,因为我可以在不破坏接口的情况下向命令传递不同的参数。CommandContext
本质上是一个关联数组变量的对象包装器,尽管它经常被扩展来执行额外的有用任务。下面是一个简单的CommandContext
实现:
// listing 11.50
class CommandContext
{
private array $params = [];
private string $error = "";
public function __construct()
{
$this->params = $_REQUEST;
}
public function addParam(string $key, $val): void
{
$this->params[$key] = $val;
}
public function get(string $key): string
{
if (isset($this->params[$key])) {
return $this->params[$key];
}
return null;
}
public function setError($error): string
{
$this->error = $error;
}
public function getError(): string
{
return $this->error;
}
}
因此,有了CommandContext
对象,LoginCommand
就可以访问请求数据:提交的用户名和密码。我使用Registry
,一个简单的类,用静态方法生成公共对象,返回LoginCommand
需要使用的AccessManager
对象。如果AccessManager
报告了一个错误,该命令将错误消息与CommandContext
对象一起提交给表示层使用,并返回false
。如果一切正常,LoginCommand
简单地返回true
。注意Command
对象本身并不执行太多的逻辑。它们检查输入、处理错误情况、缓存数据,以及调用其他对象来执行操作。如果您发现应用逻辑悄悄进入您的命令类,这通常是您应该考虑重构的信号。这样的代码会导致重复,因为它不可避免地要在命令之间复制和粘贴。您至少应该看看这样的功能属于哪里。它可能最好向下移动到您的业务对象中,或者可能移动到外观层中。在我的例子中,我仍然缺少客户机(生成命令对象的类)和 invoker(处理生成的命令的类)。选择在 web 项目中实例化哪个命令的最简单方法是在请求本身中使用一个参数。这是一个简化的客户端:
// listing 11.51
class CommandFactory
{
private static string $dir = 'commands';
public static function getCommand(string $action = 'Default'): Command
{
if (preg_match('/\W/', $action)) {
throw new \Exception("illegal characters in action");
}
$class = __NAMESPACE__ . "\\commands\\" . UCFirst(strtolower($action)) . "Command";
if (! class_exists($class)) {
throw new CommandNotFoundException("no '$class' class located");
}
$cmd = new $class();
return $cmd;
}
}
CommandFactory
类只是寻找一个特定的类。使用CommandFactory
类自己的名称空间、字符串“\commands”和CommandContext
对象的$action
参数构建一个完全限定的类名。请求中的最后一项应该已经传递给系统。感谢 Composer 的自动加载功能,我们不需要担心显式地要求一个类。如果该类存在,那么实例化一个对象并返回给调用者。我可以在这里添加更多的错误检查,确保找到的类属于Command
家族,并且构造函数不需要参数;然而,这个版本可以满足我的需求。这种方法的优点是您可以在任何时候用正确的名称空间创建一个可发现的Command
对象,并且系统会立即支持它。
调用者现在变得简单了:
// listing 11.52
class Controller
{
private CommandContext $context;
public function __construct()
{
$this->context = new CommandContext();
}
public function getContext(): CommandContext
{
return $this->context;
}
public function process(): void
{
$action = $this->context->get('action');
$action = (is_null($action)) ? "default" : $action;
$cmd = CommandFactory::getCommand($action);
if (! $cmd->execute($this->context)) {
// handle failure
} else {
// success
// dispatch view
}
}
}
下面是调用该类的一些代码:
// listing 11.53
$controller = new Controller();
$context = $controller->getContext();
$context->addParam('action', 'login');
$context->addParam('username', 'bob');
$context->addParam('pass','tiddles');
$controller->process();
print $context->getError();
在我调用Controller::process()
之前,我通过在控制器的构造函数中实例化的CommandContext
对象上设置参数来伪造一个 web 请求。process()
方法获取"action"
参数(如果没有动作参数,则返回到字符串"default"
)。然后,该方法将对象实例化委托给CommandFactory
对象。它对返回的命令调用execute()
。请注意,控制器对命令的内部结构一无所知。正是这种与命令执行细节的独立性,使得您可以添加新的Command
类,而对这个框架的影响相对较小。
这里还有一门课:
// listing 11.54
class FeedbackCommand extends Command
{
public function execute(CommandContext $context): bool
{
$msgSystem = Registry::getMessageSystem();
$email = $context->get('email');
$msg = $context->get('msg');
$topic = $context->get('topic');
$result = $msgSystem->send($email, $msg, $topic);
if (! $result) {
$context->setError($msgSystem->getError());
return false;
}
return true;
}
}
Note
我将回到第十二章中的命令模式,用一个Command
工厂类的更完整的实现。这里给出的运行命令的框架是您将遇到的另一种模式的简化版本:前端控制器。
这个类将响应一个"feedback"
动作字符串而运行,不需要控制器或CommandFactory
类的任何改变。
图 11-9 显示了Command
模式的参与者。
图 11-9
命令模式参与者
空对象模式
程序员面临的一半问题似乎都与类型有关。这是 PHP 越来越支持方法声明和返回的类型检查的原因之一。如果处理一个包含错误类型的变量是一个问题,那么处理一个不包含任何类型的变量至少同样糟糕。这种情况经常发生,因为当函数无法生成有用的值时,它们通常会返回null
。通过在项目中使用空对象模式,可以避免给自己和他人带来这个问题。正如你将看到的,当本章中的其他模式试图完成一些事情时,空对象被设计成尽可能优雅地什么也不做。
问题
如果你的方法已经被赋予了找对象的任务,有时候除了认输就没什么可做的了。由调用代码提供的信息可能是陈旧的,或者资源可能是不可用的。如果失败是灾难性的,您可以选择抛出一个异常。不过,通常情况下,你会想变得宽容一点。在这种情况下,返回一个null
值似乎是向客户端发出失败信号的好方法。
这里的问题是你的方法违反了它的契约。如果它已经承诺使用某个方法返回一个对象,那么返回 null 将强制客户端代码根据意外情况进行调整。
让我们再一次回到我们的游戏。让我们假设一个名为TileForces
的类跟踪某个特定图块上单元的信息。我们的游戏维护系统中单元的本地保存信息,一个名为UnitAcquisition
的组件负责将这些元数据转换成一个对象数组。
下面是TileForces
构造函数:
// listing 11.55
class TileForces
{
private int $x;
private int $y;
private array $units = [];
public function __construct(int $x, int $y, UnitAcquisition $acq)
{
$this->x = $x;
$this->y = $x;
$this->units = $acq->getUnits($this->x, $this->y);
}
// ...
}
TileForces
对象除了委托给所提供的UnitAcquisition
对象来获得一个单元对象数组之外,没有做什么。让我们建造一个假的UnitAcquisition
物体:
// listing 11.56
class UnitAcquisition
{
public function getUnits(int $x, int $y): array
{
// 1\. looks up x and y in local data and gets a list of unit ids
// 2\. goes off to a data source and gets full unit data
// here's some fake data
$army = new Army();
$army->addUnit(new Archer());
$found = [
new Cavalry(),
null,
new LaserCanonUnit(),
$army
];
return $found;
}
}
在这个类中,我隐藏了获取Unit
数据的过程。当然,在真实的系统中,这里会执行一些实际的查找。我已经满足于一些直接的实例化。不过,请注意,我在$found
数组中嵌入了一个偷偷摸摸的null
值。例如,如果我们的网络游戏客户端保存的元数据与服务器上的数据状态不一致,就会发生这种情况。
有了Unit
对象数组的武装,TileForces
可以提供一些功能:
// listing 11.57
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
$power += $unit->bombardStrength();
}
return $power;
}
让我们测试一下代码:
// listing 11.58
$acquirer = new UnitAcquisition();
$tileforces = new TileForces(4, 2, $acquirer);
$power = $tileforces->firepower();
print "power is {$power}\n";
由于这个隐藏的空值,这段代码导致了一个错误:
Error: Call to a member function bombardStrength() on null
TileForces::firepower()
循环通过它的$units
数组,在每个Unit
上调用bombardStrength()
。当然,试图对一个null
值调用一个方法会导致错误。
最显而易见的解决方案是在使用数组之前检查它的每个元素:
// listing 11.59
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
if (! is_null($unit)) {
$power += $unit->bombardStrength();
}
}
return $power;
}
就其本身而言,这不是太大的问题。但是想象一下TileForces
的一个版本,它对其$units
属性中的元素执行各种操作。一旦我们开始在多个地方复制is_null()
检查,我们又一次被呈现出一种特殊的代码味道。通常,解决并行客户端代码块的方法是用多态替换多个条件。我们在这里也可以这样做。
履行
空对象模式允许我们把什么都不做委托给一个期望类型的类。在这种情况下,我将创建一个NullUnit
类。
// listing 11.60
class NullUnit extends Unit
{
public function bombardStrength(): int
{
return 0;
}
public function getHealth(): int
{
return 0;
}
public function getDepth(): int
{
return 0;
}
}
Unit
的这个实现尊重接口,但是不做任何事情。现在,我可以修改UnitAcquisition
来创建一个NullUnit
,而不是使用一个null
:
// listing 11.61
public function getUnits(int $x, int $y): array
{
$army = new Army();
$army->addUnit(new Archer());
$found = [
new Cavalry(),
new NullUnit(),
new LaserCanonUnit(),
$army
];
return $found;
}
TileForces
中的客户端代码可以在NullUnit
对象上调用它喜欢的任何方法,而不会出现问题或错误:
// listing 11.62
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
$power += $unit->bombardStrength();
}
return $power;
}
看一看任何一个重要的项目,统计一下返回空值的方法对其编码人员强制进行的不恰当检查的数量。如果更多的人使用空对象,那么这些检查中有多少可以省去?
当然,有时你将需要知道你正在处理一个空对象。最明显的方法是用instanceof
操作符测试一个对象。然而,这甚至不如最初的is_null()
呼叫优雅。
也许最简洁的解决方案是给基类(返回false
)和空对象(返回true
)都添加一个isNull()
方法:
// listing 11.63
if (! $unit->isNull()) {
// do something
} else {
print "null - no action\n";
}
这让我们两全其美。可以安全地调用NullUnit
对象的任何方法。并且可以查询任何Unit
对象的空状态。
摘要
在这一章中,我总结了对“四人帮”模式的研究,重点强调了如何把事情做好。我首先向您展示了如何设计一种迷你语言,并使用解释器模式构建它的引擎。
在策略模式中,您遇到了另一种使用组合来增加灵活性和减少重复子类化的方法。使用 Observer 模式,您学习了如何解决向不同的组件通知系统事件的问题。您还回顾了复合示例;使用访问者模式,您学习了如何访问树中的每个组件,并对其应用许多操作。您甚至看到了命令模式如何帮助您构建可扩展的分层系统。最后,您用空对象模式为自己节省了大量检查空值的工作。
在下一章中,我将进一步超越“四人帮”,研究一些专门面向企业编程的模式。
十二、企业模式
PHP 首先是一种为网络设计的语言。而且,由于它对对象的广泛支持,我们可以利用在其他面向对象语言(尤其是 Java)的环境中孕育出来的模式。
在这一章中,我开发了一个例子,用它来说明我所涉及的模式。但是,请记住,选择使用一种模式,并不意味着就一定要使用所有适合它的模式。您也不应该认为这里介绍的实现是部署这些模式的唯一方式。相反,您应该使用这里的例子来帮助您理解所描述的模式的主旨,自由地提取您的项目所需要的东西。
因为要涵盖的内容太多,这是本书最长、最复杂的章节之一,一口气读完可能是个挑战。它分为引言和两个主要部分。这些分界线可能是很好的切入点。
我还在“架构概述”一节中描述了各个模式。虽然这些在某种程度上是相互依赖的,但是你应该能够直接跳到任何特定的模式,并独立地完成它,在你空闲的时候继续相关的模式。
本章将涵盖几个关键主题:
-
架构概述:介绍通常构成企业应用的层
-
注册表模式:管理应用数据
-
表示层:用于管理和响应请求以及向用户呈现数据的工具
-
业务逻辑层:了解系统的真正目的,即解决业务问题
架构概述
有了大量的基础知识,让我们首先概述即将出现的模式,然后介绍如何构建分层的应用。
模式
我将在本章中探讨几种模式。你可以从头到尾读一遍,或者浏览那些符合你需要或引起你兴趣的模式:
-
Registry :这种模式对于让数据对一个进程中的所有类可用很有用。通过谨慎使用序列化,它还可以用于跨会话甚至跨应用实例存储信息。
-
前端控制器:对于大型系统,如果您知道在管理许多不同的视图和命令时需要尽可能多的灵活性,可以使用这个。
-
应用控制器:创建一个类来管理视图逻辑和命令选择。
-
模板视图:创建只管理显示和用户界面的页面,用尽可能少的原始代码将动态信息合并到显示标记中。
-
页面控制器:重量较轻,但灵活性不如前端控制器,页面控制器解决了同样的需求。如果您希望快速得到结果,并且您的系统不太可能变得复杂,那么可以使用这种模式来管理请求和处理视图逻辑。
-
事务脚本:当您想要快速完成工作,并进行最少的预先规划时,请依靠过程库代码来实现您的应用逻辑。这种模式不适合扩展。
-
域模型:与事务脚本相反,使用该模式来构建业务参与者和流程的基于对象的模型。
Note
这里不单独描述命令模式(我在第十一章中有写);但是,在前端控制器和应用控制器模式中又会遇到这种情况。
应用和层
本章中的许多(实际上是大多数)模式都是为了促进应用中几个不同层的独立操作而设计的。就像类代表职责的专门化一样,企业系统的层次也是如此,尽管规模更大。图 12-1 显示了系统中各层的典型分解。
图 12-1
典型企业系统中的层
图 12-1 中所示的结构并不是一成不变的:这些层中的一些可以组合起来,不同的策略可以用于它们之间的通信,这取决于你的系统的复杂性。尽管如此,图 12-1 展示了一个强调灵活性和重用的模型,许多企业应用在很大程度上遵循它。
-
视图层包含系统用户实际看到并与之交互的界面。它负责呈现用户请求的结果,并提供向系统发出下一个请求的机制。
-
命令和控制层处理来自用户的请求。基于这种分析,它将完成请求所需的任何处理委托给业务逻辑层。然后,它选择最适合向用户呈现结果的视图。实际上,这一层和视图层通常被合并成一个单一的表示层。尽管如此,显示的角色应该与请求处理和业务逻辑调用的角色严格分开。
-
业务逻辑层负责处理请求的业务。它执行任何所需的计算并整理结果数据。
-
数据层将系统的其余部分与保存和获取持久信息的机制隔离开来。在一些系统中,命令和控制层使用数据层来获取它需要处理的业务对象。在其他系统中,尽可能隐藏数据层。
那么这样划分一个系统有什么意义呢?正如本书中的许多其他内容一样,答案在于脱钩。通过保持业务逻辑独立于视图层,只需很少或不需要重写就可以向系统添加新的接口。
想象一个管理事件列表的系统(在本章结束时,这将是一个非常熟悉的例子)。最终用户自然会需要一个漂亮的 HTML 界面。维护系统的管理员可能需要一个命令行界面来构建自动化系统。同时,您可能正在开发该系统的版本,以便与手机和其他手持设备一起使用。您甚至可以开始考虑 SOAP 或 RESTful API。
如果您最初将系统的底层逻辑与 HTML 视图层结合在一起(这仍然是一种常见的策略),这些需求将触发即时重写。另一方面,如果您已经创建了一个分层系统,您将能够附加新的表示策略,而不需要重新考虑您的业务逻辑和数据层。
出于同样的原因,持久性策略会发生变化。同样,您应该能够在存储模型之间切换,而对系统中的其他层影响最小。
测试是创建分层系统的另一个好理由。众所周知,Web 应用很难测试。在一个没有充分分层的系统中,自动化测试必须在一端协商 HTML 接口,而在另一端冒着触发对数据库的随机查询的风险,即使它们的关注点不是针对这两个领域。尽管有测试总比没有好,但这种测试必然是偶然的。另一方面,在分层系统中,面向其他层的类通常被编写为扩展抽象超类或实现接口。这个超类型可以支持多态性。在测试环境中,整个层可以由一组虚拟对象(通常称为“存根”或“模拟”对象)来代替。例如,通过这种方式,您可以使用一个假的数据层来测试业务逻辑。你可以在第十八章中读到更多关于测试的内容。
即使你认为测试是为 wimps 准备的,你的系统只有一个接口,层也是有用的。通过创建具有不同职责的层,您可以构建一个组成部分更易于扩展和调试的系统。通过将具有相同职责的代码放在一个地方来限制重复(而不是用数据库调用或显示策略来限制系统)。添加到这样一个系统中相对容易,因为你的改变往往是垂直的,而不是杂乱的水平。
分层系统中的一个新特性可能需要一个新的接口组件、额外的请求处理、更多的业务逻辑,以及对存储机制的修改。那就是垂直变化。在非分层系统中,您可以添加您的特性,然后记住五个单独的页面引用您修改的数据库表。或者是六个?您的新接口可能会在许多地方被调用,因此您需要在系统中进行工作,为此添加代码。这是横向修正。
当然,在现实中,你永远无法完全摆脱这种水平依赖,尤其是当涉及到界面中的导航元素时。然而,分级制度有助于最大限度地减少横向修正的需要。
Note
虽然这些模式中的许多已经存在了一段时间(毕竟,模式反映了久经考验的实践),但其名称和界限要么来自马丁·福勒关于企业模式的主要著作,企业应用架构的 (Addison-Wesley Professional,2002),要么来自有影响力的核心 J2EE 模式:最佳实践和设计策略 (Prentice Hall,2001)。为了保持一致,我倾向于在两种来源不同的地方使用福勒的命名约定。
本章中的所有例子都围绕着一个虚构的列表系统,它的名字听起来很古怪,“Woo”,代表“外面发生了什么”
该系统的参与者包括场地(如剧院、俱乐部或电影院)、空间(如屏幕 1 或楼上的舞台)和事件(如漫长的耶稣受难日或认真的重要性)。
我将介绍的操作包括创建一个场所、向一个场所添加一个空间以及列出系统中的所有场所。
请记住,本章的目的是阐明关键的企业设计模式,而不是构建一个工作系统。反映出设计模式相互依赖的本质,这些例子中的大多数在很大程度上与代码例子重叠,很好地利用了本章其他地方所涉及的内容。由于这段代码主要是为了演示企业模式而设计的,所以它的大部分并没有满足生产系统所要求的所有标准。特别是,我省略了可能妨碍清晰性的错误检查。您应该将这些示例作为说明它们实现的模式的一种方式,而不是作为框架或应用中的构建块。
开始前作弊
本书中的大多数模式在企业架构的各层中找到了自然的位置。但是一些模式是如此的基础,以至于它们站在这个结构之外。注册表模式就是一个很好的例子。事实上,注册是打破分层所带来的限制的一种强有力的方法。只有例外才能保证规则的顺利运行。
登记处
注册表模式完全是为了提供对对象的系统级访问。如今,全球化不好几乎成了一种信条。然而,像其他罪恶一样,全球数据具有致命的吸引力。这种情况如此之多,以至于面向对象的架构师觉得有必要在一个新的名称下重新发明全局变量。你在第九章中遇到了单例模式,尽管单例对象确实不会遇到困扰全局变量的所有问题。特别是,您不能意外地覆盖单例。因此,单身族是低脂肪的全球性人群。但是,您应该对单例对象保持怀疑,因为它们会让您将类锚定到一个系统中,从而引入耦合。
然而,单例有时非常有用,以至于许多程序员(包括我)都无法放弃它们。
问题
正如您所看到的,许多企业系统被划分为不同的层,每一层都只能通过严格定义的管道与其邻居进行通信。这种层的分离使得应用更加灵活。您可以在对系统其余部分影响最小的情况下替换或开发每一层。但是,当您在一个层中获得了以后在另一个非相邻层中需要的信息时,会发生什么呢?
假设我在一个ApplicationHelper
类中获取配置数据:
// listing 12.01
class ApplicationHelper
{
public function getOptions(): array
{
$optionfile = __DIR __ . "/data/woo_options.xml";
if (! file_exists($optionfile)) {
throw new AppException("Could not find options file");
}
$options = \simplexml_load_file($optionfile);
$dsn = (string)$options->dsn;
// what do we do with this now?
// ...
}
}
获取信息很容易,但是我如何将信息传递到数据层,以便以后使用呢?我必须在整个系统中传播的所有其他配置信息呢?
一个答案是在系统中从对象到对象传递信息:从负责处理请求的控制器对象到业务逻辑层中的对象,最后到负责与数据库对话的对象。
这是完全可行的。事实上,您可以传递ApplicationHelper
对象本身,或者一个更专门化的Context
对象。无论哪种方式,上下文信息都通过系统的各层传递给需要它的对象。
代价是,为了做到这一点,您必须改变所有传递上下文对象的对象的接口,不管它们是否需要使用它。显然,这在某种程度上破坏了松散耦合。
注册表模式提供了一种替代方案,但也有其自身的后果。
一个注册表仅仅是一个类,它通过静态方法(或者通过单例上的实例方法)提供对数据(通常是,但不仅仅是对象)的访问。因此,系统中的每个对象都可以访问这些对象。
术语“注册中心”来自于 Fowler 的企业应用架构的模式;但是,和所有模式一样,实现到处都是。在《务实的程序员:从熟练工到大师》一书中(Addison-Wesley Professional,1999),Andrew Hunt 和戴维·托马斯将注册表类比作警察事件公告板。一个轮班的侦探在黑板上留下证据和草图,然后由另一个轮班的新侦探捡起来。我也见过叫做白板和黑板的注册表模式。
履行
图 12-2 显示了一个Registry
对象,用于存储和服务Request
对象。
图 12-2
简单的注册表
下面是这个类的代码形式:
// listing 12.02
class Registry
{
private static ?Registry $instance = null;
private ?Request $request = null;
private function __construct()
{
}
public static function instance(): self
{
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
public function getRequest(): Request
{
if (is_null($this->request)) {
$this->request = new Request();
}
return $this->request;
}
}
// listing 12.03 class Request
{
}
然后,您可以从系统的任何部分访问相同的Request
:
// listing 12.04
$reg = Registry::instance();
print_r($reg->getRequest());
如你所见,Registry
只是一个单例类(如果你需要关于单例类的提示,请参见第九章)。代码通过instance()
方法创建并返回Registry
类的唯一实例。这可以用来检索一个Request
对象。
众所周知,我会不顾一切地使用基于密钥的系统,就像这样:
// listing 12.05
class Registry
{
private static ?Registry $instance = null;
private array $values = [];
private function __construct()
{
}
public static function instance(): self
{
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
public function get(string $key): mixed
{
if (isset($this->values[$key])) {
return $this->values[$key];
}
return null;
}
public function set(string $key, mixed $value): void
{
$this->values[$key] = $value;
}
}
这样做的好处是,您不需要为希望存储和服务的每个对象创建方法。然而,缺点是你通过后门重新引入了全局变量。使用任意字符串作为存储对象的键意味着在添加对象时,没有什么可以阻止系统的一部分覆盖键/值对。我发现在开发过程中使用这种类似地图的结构很有用,然后当我清楚需要存储和检索的数据时,就转移到显式命名的方法。
Note
注册表模式并不是管理系统所需服务的唯一方式。在第九章中,我们介绍了一个类似的策略,叫做依赖注入,它在 Symfony 等流行的框架中使用。
您还可以将注册表对象用作系统中公共对象的工厂。Registry
类不是存储提供的对象,而是创建一个实例,然后缓存引用。它也可以在后台进行一些设置,比如从配置文件中检索数据,或者合并一些对象:
// listing 12.06
// class Registry
private ?TreeBuilder $treeBuilder = null;
private ?Conf $conf = null;
// ...
public function treeBuilder(): TreeBuilder
{
if (is_null($this->treeBuilder)) {
$this->treeBuilder = new TreeBuilder($this->conf()->get('treedir'));
}
return $this->treeBuilder;
}
public function conf(): Conf
{
if (is_null($this->conf)) {
$this->conf = new Conf();
}
return $this->conf;
}
TreeBuilder
和Conf
只是虚拟类,包含它们是为了演示一个观点。需要一个TreeBuilder
对象的客户端类可以简单地调用Registry::treeBuilder()
,而不用担心初始化的复杂性。这种复杂性可能包括应用级的数据,比如虚拟的Conf
对象,系统中的大多数类应该与它们隔离开来。
注册表对象对于测试也很有用。静态的instance()
方法可以用来提供一个Registry
类的子类,以虚拟对象为基础。下面是我如何修改instance()
来达到这个目的:
// listing 12.07
// class Registry
private static $testmode = false;
// ...
public static function testMode(bool $mode = true): void
{
self::$instance = null;
self::$testmode = $mode;
}
public static function instance(): self
{
if (is_null(self::$instance)) {
if (self::$testmode) {
self::$instance = new MockRegistry();
} else {
self::$instance = new self();
}
}
return self::$instance;
}
当您需要测试您的系统时,您可以使用测试模式切换到一个假的注册表中。这可以提供存根(出于测试目的而伪造真实环境的对象)或模拟(也分析对它们的调用并评估其正确性的类似对象):
// listing 12.08
Registry::testMode();
$mockreg = Registry::instance();
你可以在第十八章中读到更多关于 mock 和 stub 对象的内容。
注册表、作用域和 PHP
术语范围通常用于描述代码结构上下文中对象或值的可见性。变量的生命周期也可以随着时间的推移来衡量。从这个意义上讲,您可以考虑三个级别的范围。标准是 HTTP 请求所覆盖的时间段。PHP 还提供了对会话变量的内置支持。在请求结束时,它们被序列化并保存到文件系统或数据库中,然后在下一次请求开始时恢复。存储在 cookie 中或在查询字符串中传递的会话 ID 用于跟踪会话所有者。因此,您可以将一些变量视为具有会话范围。您可以通过在请求之间存储一些对象来利用这一点,从而节省到数据库的行程。显然,您需要小心,不要以同一个对象的多个版本而告终,所以当您将一个也存在于数据库中的对象签入到会话中时,您可能需要考虑一个锁定策略。
在其他语言中,特别是 Java 和 Perl(运行在 ModPerl Apache 模块上),有应用范围的概念。占据这个空间的变量在应用的所有实例中都可用。这对 PHP 来说相当陌生;但是在较大的应用中,访问应用范围的空间来访问配置变量可能被认为是有用的。
在本书之前的版本中,我演示了会话和应用范围的注册表类的例子;但是在我第一次编写这个示例代码的十年左右的时间里,除了请求范围的注册表,我从来没有使用过任何东西。这种基于请求的方法有一个初始化成本,但是您通常会使用缓存策略来管理它。
结果
对象使它们的数据全局可用。这意味着任何充当注册表客户端的类都将表现出一种未在其接口中声明的依赖关系。如果您开始依赖Registry
对象获取系统中的大量数据,这可能会成为一个严重的问题。Registry
对于一组定义明确的数据项,对象最好少用。
表示层
当请求到达您的系统时,您必须解释它携带的需求,调用任何需要的业务逻辑,并最终返回响应。对于简单的脚本,整个过程通常完全发生在视图本身内部,只有重量级的逻辑和持久性代码被分离到库中。
Note
一个视图是视图层中的一个独立元素。它可以是一个 PHP 页面(或者一组复合视图元素),其主要职责是显示数据并提供用户生成新请求的机制。它也可以是 Twig 等系统中的模板。
随着系统规模的增长,这种默认策略变得越来越站不住脚,因为请求处理、业务逻辑调用和视图调度逻辑必然会在视图之间重复。
在这一节中,我将研究管理表示层这三个关键职责的策略。因为视图层与命令和控制层之间的界限经常相当模糊,所以将它们放在一个通用术语“表示层”下是有意义的
前端控制器
这种模式与具有多个入口点的传统 PHP 应用截然相反。前端控制器模式为所有传入请求提供了一个中心访问点,最终将向用户呈现结果的任务委托给视图。这是 Java 企业社区中的一个关键模式。在核心 J2EE 模式:最佳实践和设计策略中详细介绍了这一点,这仍然是最有影响力的企业模式资源之一。PHP 社区并不普遍喜欢这种模式,部分原因是初始化有时会导致开销。
我写的大多数系统都倾向于前端控制器。也就是说,我可能不会一开始就部署整个模式,但是如果我需要它提供的灵活性,我会知道将我的项目发展成前端控制器实现所必需的步骤。
问题
在整个系统的多个点处理请求的情况下,很难避免代码的重复。您可能需要对用户进行身份验证,将术语翻译成不同的语言,或者只是访问公共数据。当一个请求需要视图之间的通用操作时,您可能会发现自己需要复制和粘贴操作。这使得修改变得困难,因为一个简单的修改可能需要跨系统中的几个点进行部署。因此,代码的某些部分很容易与其他部分不一致。当然,第一步可能是将常见操作集中到库代码中,但是您仍然需要调用分布在整个系统中的库函数或方法。
管理从视图到视图的进展的困难是另一个问题,该问题可能出现在控制分布在其视图中的系统中。在一个复杂的系统中,根据输入和在逻辑层执行的任何操作的成功,一个视图中的提交可能导致任意数量的结果页面。从一个视图到另一个视图的转发可能会变得混乱,尤其是如果同一个视图可能在不同的流中使用。
履行
本质上,前端控制器模式为每个请求定义了一个中心入口点。它处理请求并使用它来选择要执行的操作。操作通常在根据命令模式组织的专用command
对象中定义。
图 12-3 显示了前控制器实施的概述。
图 12-3
控制器类和命令层次结构
事实上,您可能会部署几个助手类来平滑这个过程,但是让我们从核心参与者开始。下面是一个简单的Controller
类:
// listing 12.09
class Controller
{
private Registry $reg;
private function __construct()
{
$this->reg = Registry::instance();
}
public static function run(): void
{
$instance = new self();
$instance->init();
$instance->handleRequest();
}
private function init(): void
{
$this->reg->getApplicationHelper()->init();
}
private function handleRequest(): void
{
$request = $this->reg->getRequest();
$resolver = new CommandResolver();
$cmd = $resolver->getCommand($request);
$cmd->execute($request);
}
}
尽管这很简单,并且没有错误处理,但对于Controller
类来说没有什么更多的了。控制器位于系统的顶端,委托给其他类。正是这些其他类完成了大部分工作。run()
仅仅是一个调用init()
和handleRequest()
的方便方法。它是静态的,构造函数是私有的,所以客户端代码的唯一选择是启动系统的执行。我通常在一个名为index.php
的文件中这样做,这个文件只包含几行代码:
// listing 12.10
require_once(__DIR__ . "/../../../vendor/autoload.php");
use \popp\ch12\batch05\Controller;
Controller::run();
请注意看起来令人讨厌的 require 语句。实际上,只有在那里,系统的其余部分才能在不知道需要文件的情况下生存。autoload.php
脚本由 Composer 自动生成。它根据需要管理加载类文件的逻辑。如果这对你毫无意义,不要担心;我们将在第十五章中更详细地介绍自动装填。
init()
和handleRequest()
方法之间的区别实际上是 PHP 中的一个范畴。在一些语言中,init()
将只在应用启动时运行,而handleRequest()
或一个等效的将为每个用户请求运行。这个类注意到了设置和请求处理之间的相同区别,即使每个请求都调用了init()
。
init()
方法通过控制器的$reg
属性引用的Registry
类调用一个名为ApplicationHelper
的类。ApplicationHelper
类管理整个应用的配置数据。Controller::init()
调用ApplicationHelper
中的一个方法,也称为init()
,正如您将看到的,它初始化应用使用的数据。
handleRequest()
方法使用一个CommandResolver
来获取一个Command
对象,它通过调用Command::execute()
来运行这个对象。
应用助手
ApplicationHelper
类对于前端控制器来说并不重要。但是,大多数实现都必须获取基本的配置数据,所以我应该为此开发一个策略。下面是一个简单的ApplicationHelper
:
// listing 12.11
class ApplicationHelper
{
private string $config = __DIR__ . "/data/woo_options.ini";
private Registry $reg;
public function __construct()
{
$this->reg = Registry::instance();
public function init(): void
{
$this->setupOptions();
if (defined('STDIN')) {
$request = new CliRequest();
} else {
$request = new HttpRequest();
}
$this->reg->setRequest($request);
}
private function setupOptions(): void
{
if (! file_exists($this->config)) {
throw new AppException("Could not find options file");
}
$options = parse_ini_file($this->config, true);
$this->reg->setConf(new Conf($options['config']));
$this->reg->setCommands(new Conf($options['commands']));
}
}
这个类只是读取一个配置文件,并将各种对象添加到注册表中,从而使它们对更广泛的系统可用。init()
方法调用一个私有方法——setupOptions()
——该方法读取一个.ini
文件并将两个数组(每个数组位于一个名为Conf
的数组包装器的实例中)传递给Registry
对象。除了一个get()
和一个set()
方法之外,Conf
没有任何东西——尽管更复杂的配置类可能管理搜索和解析文件,以及管理找到的数据。这些Conf
数组中的一个用来保存一般的配置值,并传递给Registry::setConf()
。另一个数组用于将 URL 路径映射到Command
类,我将它传递给Registry::setCommands()
。
init()
方法还试图发现应用是在网络环境中运行还是在命令行上运行(通过检查常量STDIN
是否被定义)。根据测试的结果,它将一个独特的Request
子类传递给Registry
对象。
因为类除了存储和提供对象之外做得很少,所以它们不适合令人兴奋的源代码清单。为了完整起见,下面是由ApplicationHelper
使用或暗示的额外的Registry
方法:
// listing 12.12
// must be initialized by some smarter component
public function setRequest(Request $request): void
{
$this->request = $request;
}
public function getRequest(): Request
{
if (is_null($this->request)) {
throw new \Exception("No Request set");
}
return $this->request;
}
public function getApplicationHelper(): ApplicationHelper
{
if (is_null($this->applicationHelper)) {
$this->applicationHelper = new ApplicationHelper();
}
return $this->applicationHelper;
}
public function setConf(Conf $conf): void
{
$this->conf = $conf;
}
public function getConf(): Conf
{
if (is_null($this->conf)) {
$this->conf = new Conf();
}
return $this->conf;
}
public function setCommands(Conf $commands): void
{
$this->commands = $commands;
}
public function getCommands(): Conf
{
if (is_null($this->commands)) {
$this->commands = new Conf();
}
return $this->commands;
}
下面是简单的配置文件:
[config]
dsn=sqlite:/var/popp/src/ch12/batch05/data/woo.db
[commands]
/=\popp\ch12\batch05\DefaultCommand
CommandResolver
控制器需要一种方法来决定如何解释 HTTP 请求,这样它就可以调用正确的代码来完成该请求。您可以很容易地将这个逻辑包含在Controller
类本身中,但是我更喜欢使用一个专门的类来实现这个目的。这使得在必要时重构多态性变得容易。
前端控制器通常通过运行Command
对象来调用应用逻辑(我在第十一章中介绍了命令模式)。根据请求 URL 选择Command
(使用 URL 路径,或者现在不太常用的 GET 参数)。无论哪种方式,您最终都会得到一个可用于命令选择的令牌或模式。使用 URL 选择命令的方式不止一种。例如,您可以根据配置文件或数据结构来测试令牌(一种逻辑策略)。或者,您可以直接对文件系统上的类文件进行测试(一种物理策略)。
在上一章中,您看到了一个使用物理策略的命令工厂的例子。这一次,我将采用逻辑方法,将 URL 片段映射到命令类:
// listing 12.13
class CommandResolver
{
private static ?\ReflectionClass $refcmd = null;
private static string $defaultcmd = DefaultCommand::class;
public function __construct()
{
// could make this configurable
self::$refcmd = new \ReflectionClass(Command::class);
}
public function getCommand(Request $request): Command
{
$reg = Registry::instance();
$commands = $reg->getCommands();
$path = $request->getPath();
$class = $commands->get($path);
if (is_null($class)) {
$request->addFeedback("path '$path' not matched");
return new self::$defaultcmd();
}
if (! class_exists($class)) {
$request->addFeedback("class '$class' not found");
return new self::$defaultcmd();
}
$refclass = new \ReflectionClass($class);
if (! $refclass->isSubClassOf(self::$refcmd)) {
$request->addFeedback("command '$refclass' is not a Command");
return new self::$defaultcmd();
}
return $refclass->newInstance();
}
}
这个简单的类从注册表中获取一个Conf
对象,并使用 URL 路径(由Request::getPath()
方法提供)来尝试获取一个类名。如果找到了类名,并且该类既存在又扩展了Command
基类,那么它将被实例化并返回。
如果不满足这些条件中的任何一个,getCommand()
方法就会通过提供一个默认的Command
对象来适当地降级。
更复杂的实现(例如 Symfony 中路由逻辑使用的实现)将允许在这些路径中使用通配符。
您可能想知道为什么这段代码相信它所定位的Command
类不需要参数:
return $refclass->newInstance();
这个问题的答案在于Command
类本身的签名:
// listing 12.14
abstract class Command
{
final public function __construct()
{
}
public function execute(Request $request): void
{
$this->doExecute($request);
}
abstract protected function doExecute(Request $request): void;
}
通过声明构造函数方法final
,我使得子类无法覆盖它。因此,没有一个Command
类会要求它的构造函数有参数。
当创建命令类时,您应该尽可能地保持它们没有应用逻辑。一旦他们开始做应用类型的事情,你会发现他们变成了一种复杂的事务脚本,重复很快就会出现。命令是一种中继站:它们应该解释请求,调用域来处理一些对象,然后为表示层存放数据。一旦他们开始做任何比这更复杂的事情,可能就是重构的时候了。好消息是重构相对容易。当一个命令试图做太多事情时,并不难发现,解决方案通常很明确:将该功能下移到一个助手或域类。
请求
PHP 神奇地为我们处理了请求,并整齐地打包在超全局数组中。您可能已经注意到,我仍然使用一个类来表示一个请求。一个Request
对象被传递给CommandResolver
,然后再传递给Command
。
为什么不让这些类自己简单地查询$_REQUEST
、$_POST
或$_GET
数组呢?当然,我可以这样做,但是通过将请求操作集中在一个地方,我打开了新的选择。
例如,您可以对传入的请求应用过滤器。或者,如下一个例子所示,您可以从 HTTP 请求之外的地方收集请求参数,允许应用从命令行或测试脚本运行。
Request
对象也是需要传递给视图层的数据的有用存储库。事实上,许多系统为此提供了一个单独的Response
对象,但是我们在这里将保持精简。
下面是一个简单的Request
超类:
// listing 12.15
abstract class Request
{
protected array $properties = [];
protected array $feedback = [];
protected string $path = "/";
public function __construct()
{
$this->init();
}
abstract public function init(): void;
public function setPath(string $path): void
{
$this->path = $path;
}
public function getPath(): string
{
return $this->path;
}
public function getProperty(string $key): mixed
{
if (isset($this->properties[$key])) {
return $this->properties[$key];
}
return null;
}
public function setProperty(string $key, mixed $val): void
{
$this->properties[$key] = $val;
}
public function addFeedback(string $msg): void
{
array_push($this->feedback, $msg);
}
public function getFeedback(): array
{
return $this->feedback;
}
public function getFeedbackString($separator = "\n"): string
{
return implode($separator, $this->feedback);
}
public function clearFeedback(): void
{
$this->feedback = [];
}
}
正如您所看到的,这个类的大部分内容都是关于设置和获取属性的机制。init()
方法负责填充私有$properties
数组,它将由子类处理。值得注意的是,这个示例实现忽略了请求方法——这不是您在现实世界中想要做的事情。完整的实现应该管理 GET、POST 和 PUT 数组,并提供统一的查询机制。一旦有了一个Request
对象,就应该能够通过getProperty()
方法访问一个参数,该方法接受一个键字符串并返回相应的值(存储在$properties
数组中)。您也可以通过setProperty()
添加数据。
该类还管理一个$feedback
数组。这是一个简单的管道,控制器类可以通过它向用户传递消息。在更完整的实现中,我们可能希望区分错误消息和信息性消息。
你可能记得ApplicationHelper
实例化了HttpRequest
和CliRequest
中的一个。这是其中的第一个:
// listing 12.16
class HttpRequest extends Request
{
public function init(): void
{
// we're conveniently ignoring POST/GET/etc distinctions
// don't do that in the real world!
$this->properties = $_REQUEST;
$this->path = $_SERVER['PATH_INFO'];
$this->path = (empty($this->path)) ? "/" : $this->path;
}
}
CliRequest
以key=value
的形式从命令行获取参数对,并将它们分解成属性。它还检测带有path:
前缀的参数,并将提供的值赋给对象的$path
属性:
// listing 12.17
class CliRequest extends Request
{
public function init(): void
{
$args = $_SERVER['argv'];
foreach ($args as $arg) {
if (preg_match("/^path:(\S+)/", $arg, $matches)) {
$this->path = $matches[1];
} else {
if (strpos($arg, '=')) {
list($key, $val) = explode("=", $arg);
$this->setProperty($key, $val);
}
}
}
$this->path = (empty($this->path)) ? "/" : $this->path;
}
}
一个命令
你已经看到了Command
基类,第十一章详细介绍了命令模式,所以没有必要深入研究命令。不过,让我们用一个简单、具体的Command
对象来结束这一切:
// listing 12.18
class DefaultCommand extends Command
{
protected function doExecute(Request $request): void
{
$request->addFeedback("Welcome to WOO");
include(__DIR__ . "/main.php");
}
}
如果没有收到对特定Command
的明确请求,这就是由CommandResolver
提供的Command
对象。
您可能已经注意到,抽象基类自己实现了execute()
,向下调用其子类的doExecute()
实现。这使得我们可以简单地通过改变基类来为所有命令添加设置和清理代码。
向execute()
方法传递一个Request
对象,该对象提供对用户输入以及setFeedback()
方法的访问。DefaultCommand
利用这一点来设置欢迎信息。
最后,该命令简单地通过调用include()
将控制分派给一个视图。在Command
类中嵌入从命令到视图的映射是最简单的分派机制;但是对于小型系统来说,这已经足够了。在“应用控制器”一节中可以看到更灵活的策略。
文件main.php
包含一些 HTML 和对Request
对象的调用,以检查任何反馈(稍后我将更详细地介绍视图)。我现在已经准备好了运行系统的所有组件。这是我所看到的:
<html>
<head>
<title>Woo! it's WOO!</title>
</head>
<body>
<table>
<tr>
<td>
Welcome to WOO</td>
</tr>
</table>
</body>
</html>
正如您所看到的,默认命令设置的反馈消息已经进入了输出。让我们回顾一下导致这一结果的整个过程。
概观
这一节中涉及的类的细节可能掩盖了前端控制器模式的简单性。图 12-4 显示了说明请求生命周期的序列图。
图 12-4
运行中的前端控制器
如您所见,前端控制器将初始化委托给了ApplicationHelper
对象(它可以使用缓存来缩短任何昂贵的设置)。然后,Controller
从CommandResolver
对象中获取一个Command
对象。最后,它调用Command::execute()
来启动应用逻辑。
在这个模式的实现中,Command
本身负责委托给视图层。您可以在下一节中看到对此的改进。
结果
前控制器不适合胆小的人。在你开始看到好处之前,它确实需要大量的前期开发。如果您的项目需要快速周转,或者如果它足够小,前端控制器框架将比系统的其余部分更重,这是一个严重的缺点。
话说回来,一旦你在一个项目中成功部署了一个前置控制器,你会发现你可以以迅雷不及掩耳之势为别人重用它。您可以将它的大部分功能抽象成库代码,有效地为自己构建一个可重用的框架。
要求为每个请求加载所有配置信息是另一个缺点。所有方法都会在某种程度上受到这种影响,但是前端控制器通常需要额外的信息,例如命令和视图的逻辑图。
通过缓存这些数据,可以大大减少这种开销。最有效的方法是将数据作为原生 PHP 添加到系统中。如果你是一个系统的唯一维护者,这很好;但是如果您有非技术用户,您可能需要提供一个配置文件。不过,您仍然可以通过创建一个系统来读取配置文件,然后构建 PHP 数据结构,并将其写入缓存文件,从而实现原生 PHP 方法的自动化。一旦创建了本地 PHP 缓存,系统将优先使用它而不是配置文件,直到做出更改并且必须重新构建缓存。
另一方面,前端控制器集中了系统的表现逻辑。这意味着您可以在一个地方(至少在一组类中)对处理请求和选择视图的方式进行控制。这减少了重复并降低了出现错误的可能性。
前端控制器也非常具有可扩展性。一旦您有了一个启动并运行的核心,您就可以非常容易地添加新的Command
类和视图。
在这个例子中,命令处理它们自己的视图分派。如果您将前端控制器模式与帮助视图(可能还有命令)选择的对象一起使用,那么该模式允许对导航进行很好的控制,而当表示控制分布在整个系统中时,这种控制很难保持良好。我将在下一节讨论这样一个对象。
应用控制器
对于较小的系统来说,允许命令调用它们自己的视图是可以接受的,但这并不理想。最好尽可能地将命令从视图层中分离出来。
应用控制器负责将请求映射到命令,将命令映射到视图。这种分离意味着在不改变代码库的情况下切换不同的视图集变得更加容易。它还允许系统所有者改变应用的流程,同样不需要触及任何内部。通过允许命令解析的逻辑系统,该模式还使同一命令更容易在系统内的不同上下文中使用。
问题
记住例题的性质。管理员需要能够向系统中添加一个地点,并将其与一个空间相关联。因此,系统可能支持AddVenue
和AddSpace
命令。根据迄今为止的例子,这些命令将使用从路径(/addvenue
)到类(AddVenue
)的直接映射来选择。
一般来说,对AddVenue
命令的成功调用应该导致对AddSpace
命令的初始调用。这种关系可能被硬编码到类本身中,一旦成功,AddVenue
就会调用AddSpace
。AddSpace
可能会包含一个视图,该视图包含将空间添加到场地的表单。
这两个命令可以与至少两个不同的视图相关联,一个用于呈现输入表单的核心视图和一个错误或“谢谢”屏幕。根据已经讨论过的逻辑,Command
类本身将包含那些视图(使用条件测试来决定在什么情况下呈现哪个视图)。
只要命令总是以相同的方式使用,这种级别的硬编码就可以了。但是,如果我想在某些情况下对AddVenue
进行特殊的查看,或者如果我想改变一个命令引导另一个命令的逻辑(也许一个流程可能在成功的地点添加和空间添加的开始之间包括一个额外的屏幕),它就开始崩溃了。如果您的每个命令只使用一次,在与其他命令的一个关系中和一个视图中,那么您应该硬编码您的命令彼此之间的关系和它们的视图。否则,你应该继续读下去。
应用控制器类可以接管这个逻辑,释放出Command
类来专注于它们的工作,即处理输入、调用应用逻辑和处理任何结果。
履行
和往常一样,这个模式的关键是接口。应用控制器是一个类(或一组类),前端控制器可以使用它根据用户请求获取命令,并在命令运行后找到正确的视图。你可以在图 12-5 中看到这种关系的骨架。
图 12-5
应用控制器模式
与本章中的所有模式一样,目标是使客户端代码尽可能简单——因此有了 spartan 前端控制器类。但是,在接口背后,我必须部署一个实现。这里介绍的方法只是一种方法。当您阅读这一部分时,请记住模式的本质在于参与者(应用控制器、命令和视图)的交互方式,而不是这个实现的细节。
让我们从使用应用控制器的代码开始。
前端控制器
下面是FrontController
如何与AppController
类一起工作(简化并去除了错误处理):
// listing 12.19
// Controller
private function __construct()
{
$this->reg = Registry::instance();
}
private function handleRequest(): void
{
$request = $this->reg->getRequest();
$controller = new AppController();
$cmd = $controller->getCommand($request);
$cmd->execute($request);
$view = $controller->getView($request);
$view->render($request);
}
public static function run(): void
{
$instance = new self();
$instance->init();
$instance->handleRequest();
}
private function init(): void
{
$this->reg->getApplicationHelper()->init();
}
继续前面的例子,主要的区别是,除了将类名从CommandResolver
改为AppController
(不可否认,这是一个有点装饰性的动作),我们现在检索一个ViewComponent
以及一个Command
对象。注意,这段代码使用一个注册表对象来获取Request
对象。我们也可以将AppController
对象存储在Registry
中——即使它没有被其他组件使用。避免直接实例化的类通常更灵活,也更容易测试。
那么AppController
通过什么逻辑知道哪个视图与哪个命令相关联呢?与面向对象代码一样,接口比实现更重要。然而,让我们填写一个可能的方法。
实施概述
根据操作的不同阶段,一个Command
类可能需要不同的视图。AddVenue
命令的默认视图可能是一个数据输入表单。如果用户添加了错误的数据,表单可能会再次显示,或者显示错误页面。如果一切顺利,并且在系统中创建了场地,那么我可能希望转发到Command
对象链中的另一个:AddSpace
,也许。
Command
对象通过设置状态标志告诉系统它们的当前状态。下面是这个最小实现识别的标志(在Command
超类中设置为属性):
// listing 12.20
public const CMD_DEFAULT = 0;
public const CMD_OK = 1;
public const CMD_ERROR = 2;
public const CMD_INSUFFICIENT_DATA = 3;
应用控制器使用Request
对象找到并实例化正确的Command
类。一旦运行,Command
将与一个状态相关联。可以将Command
和 status 的组合与一个数据结构进行比较,以确定接下来应该运行哪个命令,或者如果不再需要运行命令,应该提供哪个视图。
配置文件
系统的所有者可以通过设置一组配置指令来确定命令和视图协同工作的方式。以下是摘录:
// listing 12.21
<woo-routing>
<control>
<command path="/" class="\popp\ch12\batch06\DefaultCommand">
<view name="main" />
<status value="CMD_ERROR">
<view name="error" />
</status>
</command>
<command path="/listvenues" class="\popp\ch12\batch06\ListVenues">
<view name="listvenues" />
</command>
<command path="/quickaddvenue" class="\popp\ch12\batch06\AddVenue">
<view name="quickadd" />
</command>
<command path="/addvenue" class="\popp\ch12\batch06\AddVenue">
<view name="addvenue" />
<status value="CMD_OK">
<forward path="/addspace" />
</status>
</command>
<command path="/addspace" class="\popp\ch12\batch06\AddSpace">
<view name="addspace" />
<status value="CMD_OK">
<forward path="/listvenues" />
</status>
</command>
</control>
</woo-routing>
这个 XML 片段展示了一种策略,用于从Command
类本身抽象出命令流及其与视图的关系。这些指令都包含在一个control
元素中。
每个命令元素定义了描述基本命令映射的path
和class
属性。然而,视图的逻辑更加复杂。位于command
顶层的view
元素定义了一个默认关系。换句话说,如果没有更具体的条件匹配,这个view
将被用于一个命令。status
元素定义了这些特定条件。它们的value
属性应该与你看到的命令状态相匹配。当一个命令的执行呈现一个CMD_OK
状态时,例如,如果在 XML 文档中定义了一个等价的状态,那么就会使用相应的视图元素。
一个view
元素定义了一个name
属性。该值用于构建模板文件的路径,然后可以包含该模板文件。
一个command
或者一个status
元素可能包含一个forward
元素而不是一个view
。Woo 系统将一个forward
视为一种特殊的view
,它不是呈现一个模板,而是用一个新的路径重新调用应用。
根据这个解释,让我们来看一下这个 XML 的一个片段:
// listing 12.22
<command path="/addvenue" class="\popp\ch12\batch06\AddVenue">
<view name="addvenue" />
<status value="CMD_OK">
<forward path="/addspace" />
</status>
</command>
当用/addvenue
路径调用系统时,调用AddVenue
命令。然后,它生成一个状态值,即CMD_DEFAULT
、CMD_OK
、CMD_ERROR
或CMD_INSUFFICIENT_DATA
中的一个。对于除CMD_OK
之外的任何状态,将调用addvenue
模板。然而,如果命令返回CMD_OK
状态,则条件匹配。元素可以简单地包含另一个视图来代替默认视图。不过,在这里,forward
元素开始发挥作用。通过转发到另一个command
,配置文件将处理视图的所有责任委托给新元素。然后,系统将在新请求中以/addspace
路径重新开始。
解析配置文件
感谢 SimpleXML 扩展,我们不需要做任何实际的解析——这是为我们处理的。剩下的工作就是遍历 SimpleXML 数据结构并构建我们自己的数据。这里有一个名为ViewComponentCompiler
的类就是这样做的:
// listing 12.23
class ViewComponentCompiler
{
private static $defaultcmd = DefaultCommand::class;
public function parseFile(string $file): Conf
{
$options = \simplexml_load_file($file);
return $this->parse($options);
}
public function parse(\SimpleXMLElement $options): Conf
{
$conf = new Conf();
foreach ($options->control->command as $command) {
$path = (string) $command['path'];
$cmdstr = (string) $command['class'];
$path = (empty($path)) ? "/" : $path;
$cmdstr = (empty($cmdstr)) ? self::$defaultcmd : $cmdstr;
$pathobj = new ComponentDescriptor($path, $cmdstr);
$this->processView($pathobj, 0, $command);
if (isset($command->status) && isset($command->status['value'])) {
foreach ($command->status as $statusel) {
$status = (string)$statusel['value'];
$statusval = constant(Command::class . "::" . $status);
if (is_null($statusval)) {
throw new AppException("unknown status: {$status}");
}
$this->processView($pathobj, $statusval, $statusel);
}
}
$conf->set($path, $pathobj);
}
return $conf;
}
public function processView(ComponentDescriptor $pathobj, int $statusval, \ SimpleXMLElement $el): void
{
if (isset($el->view) && isset($el->view['name'])) {
$pathobj->setView($statusval, new TemplateViewComponent((string)$el->view['name']));
}
if (isset($el->forward) && isset($el->forward['path'])) {
$pathobj->setView($statusval, new ForwardViewComponent((string)$el->forward['path']));
}
}
}
这里真正的动作发生在parse()
方法中,该方法接受一个SimpleXMLElement
对象进行遍历。我首先实例化一个Conf
对象(记住,这只是一个数组的包装)。然后我遍历 XML 中的命令元素。对于每个命令,我提取了path
和class
属性的值,并将这些数据传递给ComponentDescriptor
对象的构造函数。这个对象将管理与一个command
元素相关的信息包——您很快就会看到这个类。
然后我调用一个名为processView()
的私有方法,向它传递ComponentDescriptor
,一个零值整数(因为我们正在处理默认状态),以及一个对当前 XML 元素的引用(现在是command
)。根据在 XML 片段中找到的内容,processView()
要么创建一个TemplateViewComponent
要么创建一个ForwardViewComponent
,并将其传递给ComponentDescriptor::setView()
。当然,它可能什么也不匹配,也根本不打电话——但这可能是不可取的。也许我们会在更完整的实现中把它变成一个错误条件。
回到parse()
,我开始处理status
属性。我再次调用processView()
——但是这次使用的整数对应于status
元素的value
属性中的字符串。换句话说,字符串CMD_OK
变为 1,CMD_ERROR
变为 2,依此类推。PHP 的constant()
方法提供了一种进行这种转换的简洁方法。所以这次我传递给processView()
一个非零整数,以及status
XML 元素。
再次,processView()
用任何找到的ViewComponent
对象填充ComponentDescriptor
。
最后,我将ComponentDescriptor
对象存储在Conf
对象中,通过命令组件的path
值进行索引。
一旦循环结束,我就返回Conf
对象。
在你能跟随这个流程之前,它可能需要一点重新阅读;但本质上,这个过程非常简单:ViewComponentCompiler
构建一个由ComponentDescriptor
对象组成的数组(和前面一样,包装在Conf
对象中)。每个ComponentDescriptor
对象维护关于路径和Command
类的信息,以及由状态值(默认视图为 0)索引的一组ViewComponent
对象(管理模板显示或转发)。
尽管有这些忙碌的工作,重要的是要记住高层次的过程是非常简单的。我们正在构建潜在请求与命令和视图之间的关系。图 12-6 显示了这个初始化过程。
图 12-6
编译命令和视图
管理组件数据
您已经看到编译后的ComponentDescriptor
对象存储在一个Conf
对象中——本质上是一个关联数组的 getter 和 setter。这里的关键是系统识别的路径:例如/
,或者/addvenue
。
因此,让我们来看看管理命令、查看和转发信息的ComponentDescriptor
:
// listing 12.24
class ComponentDescriptor
{
private array $views = [];
public function __construct(private string $path, private string $cmdstr)
{
}
public function getCommand(): Command
{
$class = $this->cmdstr;
if (is_null($class)) {
throw new AppException("unknown class '$class'");
}
if (! class_exists($class)) {
throw new AppException("class '$class' not found");
}
$refclass = new \ReflectionClass($class);
if (! $refclass->isSubClassOf(Command::class)) {
throw new AppException("command '$class' is not a Command");
}
return $refclass->newInstance();
}
public function setView(int $status, ViewComponent $view): void
{
$this->views[$status] = $view;
}
public function getView(Request $request): ViewComponent
{
$status = $request->getCmdStatus();
$status = (is_null($status)) ? 0 : $status;
if (isset($this->views[$status])) {
return $this->views[$status];
}
if (isset($this->views[0])) {
return $this->views[0];
}
throw new AppException("no view found");
}
}
因此,您可以在这里看到存储和检索,但是还有更多的工作要做。命令信息(Command
的完整类名)是通过构造函数添加的,只有在调用getCommand()
时才被延迟转换成Command
对象。这个实例化和检查发生在一个私有方法中:resolveCommand()
。这里的代码应该看起来很熟悉——它实际上是从本章前面的CommandResolver
的等效功能中偷来的。
获取视图更容易。记住,每个视图组件都是通过setView()
方法存储的。在幕后,我们现在看到ViewComponent
对象在一个数组属性$views
中被管理,并由一个整数,一个Command
状态值索引。当客户端代码调用getView()
方法时,我们被传递一个Request
对象,其中可能已经缓存了Command
状态。我们通过一个新的方便的方法Request::getCmdStatus()
得到这个值。有了这些,只需检查$views
数组中相应的ViewComponent
元素。如果没有匹配,我们返回默认值,该值的索引为零。
这样,这个小类提供了 XML 文件中一个command
元素隐含的所有逻辑。
因为大部分实际工作是由助手类完成的,所以应用控制器本身相对来说比较单薄。让我们来看看:
// listing 12.25
class AppController
{
private static string $defaultcmd = DefaultCommand::class;
private static string $defaultview = "fallback";
public function getCommand(Request $request): Command
{
try {
$descriptor = $this->getDescriptor($request);
$cmd = $descriptor->getCommand();
} catch (AppException $e) {
$request->addFeedback($e->getMessage());
return new self::$defaultcmd();
}
return $cmd;
}
public function getView(Request $request): ViewComponent
{
try {
$descriptor = $this->getDescriptor($request);
$view = $descriptor->getView($request);
} catch (AppException) {
return new TemplateViewComponent(self::$defaultview);
}
return $view;
}
private function getDescriptor(Request $request): ComponentDescriptor
{
$reg = Registry::instance();
$commands = $reg->getCommands();
$path = $request->getPath();
$descriptor = $commands->get($path);
if (is_null($descriptor)) {
throw new AppException("no descriptor for {$path}", 404);
}
return $descriptor;
}
}
这个类中几乎没有实际的逻辑,因为大部分复杂性都被推给了各种助手类。getCommand()
和getView()
都调用私有方法getDescriptor()
,为当前请求获取一个ComponentDescriptor
。getDescriptor()
方法从Request
对象中获取当前路径,并使用它从Conf
对象中提取一个ComponentDescriptor
对象,该对象也由注册表存储并由getCommands()
返回。记住这个ComponentDescriptor
对象的数组之前是由ViewComponentCompiler
对象填充的,潜在的路径作为键。
一旦getCommand()
和getView()
有了ComponentDescriptor
对象,就可以各自调用其对应的方法。在getCommand()
里,我们叫ComponentDescriptor::getCommand()
;在getView()
中,我们称之为ComponentDescriptor::getView()
。
在我们继续之前,有一些细节需要总结。既然Command
对象不再调用视图,我们需要一种呈现模板的机制。这是由TemplateViewComponent
对象处理的。这些实现了一个接口,ViewComponent
:
// listing 12.26
interface ViewComponent
{
public function render(Request $request): void;
}
这里是TemplateViewComponent
:
// listing 12.27
class TemplateViewComponent implements ViewComponent
{
public function __construct(private string $name)
{
}
public function render(Request $request): void
{
$reg = Registry::instance();
$conf = $reg->getConf();
$path = $conf->get("templatepath");
if (is_null($path)) {
throw new AppException("no template directory");
}
$fullpath = "{$path}/{$this->name}.php";
if (! file_exists($fullpath)) {
throw new AppException("no template at {$fullpath}");
}
include($fullpath);
}
}
这个类用一个名称实例化——然后在render()
时间使用这个名称,结合一个路径配置值,以包含一个模板。
为什么ViewComponent
是一个接口?虽然TemplateViewComponent
处理渲染,但在这个实现中,我们也将转发视为一个视图过程。
这里是ForwardViewComponent
:
// listing 12.28
class ForwardViewComponent implements ViewComponent
{
public function __construct(private ?string $path)
{
}
public function render(Request $request): void
{
$request->forward($this->path);
}
}
这个类简单地在提供的Request
对象上调用forward()
。forward()
的实现因Request
子类型而异。对于HttpRequest
来说,就是设置位置头的问题:
// listing 12.29
//HttpRequest
public function forward(string $path): void
{
header("Location: {$path}");
exit;
}
对于CliRequest
,我们不能依赖服务器来处理转发,所以我们必须采取不同的方法:
// listing 12.30
// CliRequest
public function forward(string $path): void
{
// tack the new path onto the end the argument list
// last argument wins
$_SERVER['argv'][] = "path:{$path}";
Registry::reset();
Controller::run();
}
我们利用了这样一个事实,当参数数组被解析为一个路径时,找到的最终匹配最终被设置在Request
上。我们需要做的就是添加一个路径参数,清除注册表,并从头开始运行控制器。
这让我们兜了一圈,这是一个很好的概述时刻!
应用控制器可能用来获取视图和命令的策略可以有很大不同;关键是这些都隐藏在更广泛的系统之外。图 12-7 显示了前端控制器类使用应用控制器首先获取Command
对象,然后获取视图的高级过程。
图 12-8
视图中嵌入的页面控制器
图 12-7
使用应用控制器获取命令和视图
注意,图 12-7 中呈现的视图可能是ForwardViewComponent
(将使用新路径重新开始该过程)或TemplateViewComponent
(将包括一个模板文件)中的一个。
还记得图 12-7 中获取Command
和ViewComponent
对象过程中需要的数据是由我们的老朋友ApplicationHelper
整理的。提醒一下,下面是实现这一点的高级代码:
// listing 12.31
private function setupOptions(): void
{
//...
$vcfile = $conf->get("viewcomponentfile");
$cparse = new ViewComponentCompiler();
$commandandviewdata = $cparse->parseFile($vcfile);
$this->reg->setCommands($commandandviewdata);
}
命令类
既然命令不再负责调用它们的模板,那么有必要简要地看一下基类和实现。我们已经看到了Command
类中的新状态,但是还有一点需要注意:
// listing 12.32
abstract class Command
{
public const CMD_DEFAULT = 0;
public const CMD_OK = 1;
public const CMD_ERROR = 2;
public const CMD_INSUFFICIENT_DATA = 3;
final public function __construct()
{
}
public function execute(Request $request): void
{
$status = $this->doExecute($request);
$request->setCmdStatus($status);
}
abstract protected function doExecute(Request $request): int;
}
在模板方法模式的一个很好的例子中,execute()
方法调用抽象的doExecute()
方法,并将返回值缓存在Request
对象中。稍后,ComponentDescriptor
将使用它来选择要返回的正确视图。
具体的命令
下面是一个简单的AddVenue
命令可能的样子:
// listing 12.33
class AddVenue extends Command
{
protected function doExecute(Request $request): int
{
$name = $request->getProperty("venue_name");
if (is_null($name)) {
$request->addFeedback("no name provided");
return self::CMD_INSUFFICIENT_DATA;
} else {
// do some stuff
$request->addFeedback("'{$name}' added");
return self::CMD_OK;
}
return self::CMD_DEFAULT;
}
}
事实上,这缺少了与构建一个Venue
对象并将其保存到数据库中相关的功能代码,但是我们将会谈到所有这些。在这一点上,重要的是命令根据环境返回不同的状态。正如我们已经看到的,不同的状态将导致应用控制器选择和返回不同的视图。因此,如果我们使用示例 XML,当返回CMD_OK
时,转发机制将触发转发到/addspace
。只有/addvenue
才会以这种方式触发。如果导致该命令被调用的请求使用了路径/quickaddvenue
,则不会发生转发,并将显示quickaddvenue
视图。然而,AddVenue
命令对此一无所知。它坚持自己的核心职责。
结果
由于获取和应用描述命令和请求、命令和命令、命令和视图之间关系的元数据的工作量很大,所以建立一个功能齐全的应用控制器模式实例可能会很困难。
出于这个原因,我倾向于只在我的应用告诉我需要的时候才实现这样的东西。当我发现自己根据具体情况向调用不同视图或调用其他命令的命令添加条件时,我通常会听到这种低语。大约就在这个时候,我觉得命令流和显示逻辑开始脱离我的控制。
当然,应用控制器可以使用各种机制在命令和视图之间建立关联,而不仅仅是我在这里采用的方法。即使在所有情况下,您都从请求字符串、命令名和视图之间的固定关系开始,您仍然可以从构建应用控制器来封装这些关系中受益。当你必须重构以适应更多的复杂性时,它会给你相当大的灵活性。
页面控制器
虽然我很喜欢前端控制器模式,但这并不总是正确的方法。前期设计的投资倾向于奖励更大的系统,而惩罚简单的、需要立即得到结果的项目。页面控制器模式对您来说可能已经很熟悉了,因为它是一种常见的策略。尽管如此,有些问题还是值得探讨的。
问题
同样,问题是您需要管理请求、领域逻辑和表示之间的关系。对于企业项目来说,这几乎是一个常量。然而,不同的是对你的约束。
如果你有一个相对简单的项目,在这个项目中,大的预先设计可能会威胁到你的最后期限,而没有增加大量的价值,那么页面控制器是管理请求和视图的一个好的选择。
假设您想要呈现一个显示 Woo 系统中所有场馆列表的页面。即使完成了数据库检索代码,在没有前端控制器的情况下,要获得这个简单的结果仍然是一项艰巨的任务。
视图是一个地点列表;请求是一个地点列表。在错误允许的情况下,请求不会导致新的视图,正如您在复杂的任务中可能期望的那样。这里最简单的工作是将视图和控制器关联起来——通常在同一个页面中。
履行
尽管页面控制器项目的实际情况可能会变得非常残酷,但模式是简单的。控件与一个视图或一组视图相关。在最简单的情况下,这意味着控件位于视图本身中,尽管它可以被抽象,特别是当一个视图与其他视图紧密链接时(例如,当您可能需要在不同的环境下转到不同的页面时)。
下面是最简单的页面控制器:
// listing 12.34
<?php
namespace popp\ch12\batch07;
try {
$venuemapper = new VenueMapper();
$venues = $venuemapper->findAll();
} catch (\Exception) {
include('error.php');
exit(0);
}
// default page follows
?>
<html>
<head>
<title>Venues</title>
</head>
<body>
<h1>Venues</h1>
<?php foreach ($venues as $venue) { ?>
<?php print $venue->getName(); ?><br />
<?php } ?>
</body>
</html>
这份文件有两个要素。视图元素处理显示,而控制器元素管理请求并调用应用逻辑。尽管视图和控制器位于同一个页面,但是它们是严格分离的。
这个例子很简单(除了在幕后进行的数据库工作,在下一章中你会发现更多)。页面顶部的 PHP 块试图获取一个Venue
对象的列表,并将其存储在$venues
全局变量中。
如果出现错误,页面通过使用include()
委托给一个名为error.php
的页面,然后使用exit()
终止当前页面上的任何进一步处理。我同样可以使用 HTTP 转发。如果没有包含发生,则显示页面底部的 HTML(视图)。
你可以在图 12-8 中看到控制器和视图的组合。
这可以作为一个快速测试,但是任何规模或复杂性的系统都可能需要更多的支持。
页面控制器代码以前被隐式地从视图中分离出来。在这里,我从一个基本的页面控制器基类开始:
// listing 12.35
abstract class PageController
{
private Registry $reg;
abstract public function process(): void;
public function __construct()
{
$this->reg = Registry::instance();
}
public function init(): void
{
if (isset($_SERVER['REQUEST_METHOD'])) {
$request = new HttpRequest();
} else {
$request = new CliRequest();
}
$this->reg->setRequest($request);
}
public function forward(string $resource): void
{
$request = $this->getRequest();
$request->forward($resource);
}
public function render(string $resource, Request $request): void
{
include($resource);
}
public function getRequest(): Request
{
return $this->reg->getRequest();
}
}
这个类使用了一些您已经看过的工具——特别是Request
和Registry
类。PageController
类的主要角色是提供对Request
对象的访问,并管理视图的包含。随着越来越多的子类发现对公共功能的需求,这个目的列表在实际项目中会迅速增长。
一个子类可以存在于视图中,从而像以前一样默认显示它。或者,它可以独立于视图。我认为后一种方法更干净,所以我选择了这条路。下面是一个试图向系统添加新场地的PageController
:
// listing 12.36
class AddVenueController extends PageController
{
public function process(): void
{
$request = $this->getRequest();
try {
$name = $request->getProperty('venue_name');
if (is_null($request->getProperty('submitted'))) {
$request->addFeedback("choose a name for the venue");
$this->render(__DIR__ . '/view/add_venue.php', $request);
} elseif (is_null($name)) {
$request->addFeedback("name is a required field");
$this->render(__DIR__ . '/view/add_venue.php', $request);
return;
} else {
// add to database
$this->forward('listvenues.php');
}
} catch (Exception) {
$this->render(__DIR__ . '/view/error.php', $request);
}
}
}
AddVenueController
类只实现了process()
方法。process()
负责审核用户的提交。如果用户没有提交表单,或者没有正确地完成表单,则包括默认视图(add_venue.php
),提供反馈并呈现表单。如果我成功地添加了一个新的地点,那么该方法调用forward()
将用户发送到ListVenues
页面控制器。
请注意我在视图中使用的格式。我倾向于区分视图文件和类文件,在前者中使用全部小写的文件名,在后者中使用 camel case(将单词放在一起并使用大写字母来显示边界)。
您可能已经注意到,AddVenueController
类中没有导致它运行的东西。我可以将 runner 代码放在同一个文件中,但是这将使测试变得困难(因为包含类的行为将执行它的方法)。为此,我为每个页面创建了一个 runner 脚本。下面是 addvenue.php:
// listing 12.37
$addvenue = new AddVenueController();
$addvenue->init();
$addvenue->process();
下面是与AddVenueController
类相关联的视图:
// listing 12.38
<html>
<head>
<title>Add Venue</title>
</head>
<body>
<h1>Add Venue</h1>
<table>
<tr>
<td>
<?php
print $request->getFeedbackString("</td></tr><tr><td>");
?>
</td>
</tr>
</table>
<form action="/addvenue.php" method="get">
<input type="hidden" name="submitted" value="yes"/>
<input type="text" name="venue_name" />
</form>
</body>
</html>
如您所见,该视图除了显示数据和提供生成新请求的机制之外什么也不做。请求被发送给PageController
(通过/addvenue.php
runner),而不是返回给视图。记住,是PageController
类负责处理请求。
你可以在图 12-9 中看到页面控制器模式的更复杂版本的概述。
图 12-9
页面控制器类层次结构及其包含关系
结果
这种方法有一个很大的优点,那就是它对任何有网络经验的人来说都有意义。我请求venues.php
,这正是我得到的。即使是一个错误也在意料之中,因为“服务器错误”和“页面未找到”页面是每天的现实。
如果将视图从页面控制器类中分离出来,事情会变得稍微复杂一些,但是参与者之间近乎一对一的关系已经足够清楚了。
一旦完成处理,页面控制器就包含它的视图。但是,在某些情况下,它会转发到另一个页面控制器。所以,例如当AddVenue
成功添加一个场地时,它不再需要显示添加表单。相反,它委托给ListVenues
。
这是在PageController
中由forward()
方法处理的,就像我们已经看到的ForwardViewComponent
一样,简单地在Request
上调用forward()
。
尽管页面控制器类可能会委托给Command
对象,但这样做的好处并不像前端控制器那样明显。前端控制器类需要弄清楚请求的目的是什么;页面控制器类已经知道这一点。您将放在Command
中的轻量级请求检查和逻辑层调用在页面控制器类中同样容易,并且您受益于您不需要选择您的Command
对象的机制这一事实。
重复可能是一个问题,但是使用一个公共超类可以解决很多问题。您还可以节省设置时间,因为您可以避免加载在当前上下文中不需要的数据。当然,您也可以使用前端控制器来实现这一点,但是发现什么是需要的,什么是不需要的过程会复杂得多。
这种模式的真正缺点在于视图路径复杂的情况——尤其是同一视图在不同时间以不同方式使用时(添加和编辑屏幕就是一个很好的例子)。您会发现自己陷入了条件和状态检查中,并且很难对系统有一个总体的了解。
然而,从页面控制器开始并向前端控制器模式发展也不是不可能的。如果您正在使用一个PageController
超类,这一点尤其正确。根据经验,如果我估计一个系统应该用不到一周左右的时间来完成,并且在未来不需要更多的阶段,我会选择页面控制器,并从快速周转中受益。如果我正在构建一个大型项目,它需要随着时间的推移而增长,并且具有复杂的视图逻辑,我每次都会选择前端控制器。
模板视图和视图助手
模板视图几乎就是 PHP 中默认的视图,因为我可以混合表示标记(HTML)和系统代码(原生 PHP)。正如我以前说过的,这既是一件好事,也是一件坏事,因为将它们结合在一起的容易程度代表了将应用和显示逻辑结合在一起的诱惑——这可能会带来灾难性的后果。
在 PHP 中,对视图进行编程在很大程度上是一种约束。如果严格来说这不是一个显示问题,那么就用最大的怀疑来对待任何代码。
为此,视图助手模式(Alur 等人)提供了一个助手类,该类可以是特定于一个视图的,也可以在多个视图之间共享,以帮助完成任何需要最少量代码的任务。
问题
如今,在显示页面中直接嵌入 SQL 查询和其他业务逻辑的情况越来越少,但这种情况还是会发生。我在前面的章节中已经非常详细地介绍了这种特殊的邪恶,所以我将保持简短。
包含太多代码的网页对 Web 制作者来说可能很难处理,因为表示组件会在循环和条件中纠缠在一起。
表示中的业务逻辑迫使你坚持使用那个接口。如果不移植大量的应用代码,你就不能轻松地切换到一个新的视图。
将视图从逻辑中分离出来的系统也更容易测试。这是因为测试可以应用到逻辑层的功能上,而不会干扰到表现。
在表示层嵌入逻辑的系统中也经常出现安全问题。在这样的系统中,因为处理用户输入的数据库查询和代码往往分散在表格、表单和列表中,所以很难识别潜在的危险。
随着许多操作从一个视图到另一个视图重复出现,在模板中嵌入应用代码的系统很容易成为复制的牺牲品,因为相同的代码结构从一页粘贴到另一页。在这种情况下,错误和维护噩梦肯定会随之而来。
为了防止这种情况发生,您应该在其他地方处理应用,并且只允许视图管理表示。这通常是通过使视图成为数据的被动接收者来实现的。当一个视图确实需要询问系统时,提供一个视图助手对象来代表视图做任何相关的工作是一个好主意。
履行
一旦创建了一个更广泛的框架,视图层就不再是一个巨大的编程挑战。当然,这仍然是一个巨大的设计和信息架构问题,但那是另一本书!
模板视图是由 Fowler 命名的。这是大多数企业程序员使用的主要模式。在某些语言中,一个实现可能涉及到创建一个模板系统,将标签翻译成系统设置的值。PHP 中也有这个选项。您可以使用像 excellent Twig 这样的模板引擎。不过,我更倾向于使用 PHP 现有的功能,但是要小心使用。
为了让视图有所作为,它必须能够获取数据。我喜欢定义视图可以使用的视图助手。
下面是一个简单的视图助手类:
// listing 12.39
class ViewHelper
{
public function sponsorList(): string
{
// do something complicated to get the sponsor list return "Bob's Shoe Emporium";
}
}
这个类目前所做的只是提供一个赞助商列表字符串。让我们假设有一些相对复杂的过程来获取或格式化这些我们不应该嵌入模板本身的数据。随着应用的发展,您可以扩展它以提供额外的功能。如果您发现自己在一个视图中做的事情占用了不止几行,那么它很可能属于视图助手。在较大的应用中,您可以在继承层次结构中提供多个视图助手对象,以便为系统的不同部分提供不同的工具。
我可能会从某种工厂——也许是注册中心——获得一个视图助手。从模板的角度来看,最简单的方法是在render()
方法中提供一个助手实例:
// listing 12.40
public function render(string $resource, Request $request): void
{
$vh = new ViewHelper();
// now the template will have the $vh variable include($resource);
}
下面是一个使用视图助手的简单视图:
// listing 12.41
<html>
<head>
<title>Venues</title>
</head>
<body>
<h1>Venues</h1>
<div>
Proudly sponsored by: <?php echo $vh->sponsorList(); ?>
</div> Listing venues
</body>
</html>
视图(list_venues.php
)在$vh
变量中被授予一个 ViewHelper 实例。它调用sponsorList()
方法并打印结果。
显然,这个例子没有将代码从视图中排除,但是它严格限制了需要完成的代码的数量和种类。该页面包含一个简单的 echo 语句,并且随着复杂性的增加,不可避免地需要其他方法调用,但是设计人员应该能够轻松地处理这种代码。
稍微有问题的是if
语句和循环。这些很难委托给视图助手,因为它们通常与格式化输出绑定在一起。我倾向于将简单的条件和循环(在构建显示数据行的表格时非常常见)都放在模板视图中;但是为了使它们尽可能简单,我尽可能地委托测试子句之类的事情。
结果
数据传递到视图层的方式有点令人不安,因为视图并没有真正的固定接口来保证它的环境。我倾向于认为每一个观点都是和整个系统签订了合同。该视图实际上是在对应用说,“如果我被调用,那么我有权访问对象This
、对象That
和对象TheOther
由应用来确保这一点。
您可以通过在助手类中提供访问器方法来使视图更严格,但是我总是发现通过一个Request
、Response
或Context
对象为视图层动态注册对象更容易。
虽然模板通常本质上是被动的,由最后一次请求产生的数据填充,但有时视图可能需要发出辅助请求。视图助手是提供这种功能的好地方,它对视图本身隐藏了所需数据的机制。即使是视图助手也应该做尽可能少的工作,委托给一个命令或者通过一个 facade 联系域层。
Note
你在第十章看到了门面模式。Alur 等人在 Session Facade 模式(旨在限制细粒度的网络事务)中研究了 Facade 在企业编程中的一种用途。Fowler 还描述了一种称为服务层的模式,它提供了一个简单的访问层内复杂性的点。
业务逻辑层
如果控制层协调与外部世界的通信,并整理系统对它的响应,那么逻辑层继续处理应用的业务。这一层应该尽可能避免分析查询字符串、构造 HTML 表和编写反馈消息时产生的噪音和创伤。业务逻辑是关于做需要做的事情——应用的真正目的。其他一切的存在只是为了支持这些任务。
在经典的面向对象应用中,业务逻辑层通常由对系统要解决的问题进行建模的类组成。正如您将看到的,这是一个灵活的设计决策。它还需要大量的前期规划。
那么,让我们从启动和运行系统的最快方式开始。
交易脚本
事务脚本模式(企业应用架构模式)描述了许多系统自动进化的方式。它简单、直观、有效,尽管随着系统的增长,它会变得不那么简单。事务脚本内联处理请求,而不是委托给专门的对象。这是典型的快速解决方案。这也是一个很难归类的模式,因为它结合了本章中其他层的元素。我选择将其作为业务逻辑层的一部分,因为模式的动机是实现系统的业务目标。
问题
每个请求都必须以某种方式处理。如您所见,许多系统都提供了评估和过滤输入数据的层。不过,理想情况下,这一层应该调用为满足请求而设计的类。这些类可以被分解来表示系统中的力量和职责,也许有一个门面接口。然而,这种方法需要一定程度的精心设计。对于一些项目(通常规模小,性质紧急),这样的开发开销是不可接受的。在这种情况下,您可能需要将您的业务逻辑构建到一组程序操作中。每个操作都将被精心设计来处理特定的请求。
因此,问题是需要提供一种快速有效的机制来实现系统的目标,而不需要在复杂的设计中进行潜在的昂贵投资。
这种模式的最大好处是可以快速获得结果。每个脚本接受输入并操纵数据库以确保结果。除了在同一个类中组织相关的方法并将事务脚本类保持在它们自己的层中(即,尽可能独立于命令、控制和视图层),几乎不需要预先设计。
虽然业务逻辑层类倾向于与表示层明确分离,但它们通常更多地嵌入在数据层中。这是因为检索和存储数据是这些类经常执行的任务的关键。在本章的后面,你会看到将逻辑对象从数据库中分离出来的机制。不过,事务脚本类通常了解数据库的所有信息(尽管它们可以使用网关类来处理实际查询的细节)。
履行
让我们回到我的事件列表示例。在这种情况下,系统支持三种关系数据库表:venue
、space
、event
。一个venue
可能有多个spaces
(例如,一个剧院可能有多个舞台,一个舞蹈俱乐部可能有不同的房间)。每个space
都会举办许多活动。以下是模式:
CREATE TABLE 'venue' (
'id' int(11) NOT NULL auto_increment,
'name' text,
PRIMARY KEY ('id')
)
CREATE TABLE 'space' (
'id' int(11) NOT NULL auto_increment,
'venue' int(11) default NULL,
'name' text,
PRIMARY KEY ('id')
)
CREATE TABLE 'event' (
'id' int(11) NOT NULL auto_increment,
'space' int(11) default NULL,
'start' mediumtext,
'duration' int(11) default NULL,
'name' text,
PRIMARY KEY ('id')
)
很明显,这个系统需要增加场地和事件的机制。其中的每一个都代表一个事务。我可以给每个方法一个自己的类(并根据你在第十一章中遇到的命令模式来组织我的类)。不过,在这种情况下,我将把这些方法放在一个单独的类中,尽管是作为继承层次结构的一部分。结构如图 12-10 所示。
图 12-10
一个事务脚本类及其超类
那么为什么这个例子包括一个抽象超类呢?在任何大小的脚本中,我都可能向这个层次结构中添加更多具体的类。因为这些中的大多数至少会共享一些核心功能,所以将其放在一个公共的父节点中是有意义的。
事实上,这本身就是一种模式(Fowler 将其命名为层超类型)。如果一个层中的类具有相同的特征,那么将它们组合成一个类型,在基类中定位实用程序操作是有意义的。在本章的其余部分,你会经常看到这一点。
在这种情况下,基类获取一个PDO
对象,并将其存储在一个属性中。
// listing 12.42
abstract class Base
{
private \PDO $pdo;
private string $config = __DIR__ . "/data/woo_options.ini";
public function __construct()
{
$reg = Registry::instance();
$options = parse_ini_file($this->config, true);
$conf = new Conf($options['config']);
$reg->setConf($conf);
$dsn = $reg->getDSN();
if (is_null($dsn)) {
throw new AppException("No DSN");
}
$this->pdo = new \PDO($dsn);
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
}
public function getPdo(): \PDO
{
return $this->pdo;
}
}
我使用Registry
类获取一个 DSN 字符串,并将其传递给 PDO 构造函数。我通过一个 getter 方法使 PDO 对象可用:getPdo()
。事实上,很多这种工作可以推回到注册表对象本身——在本章和下一章的其他地方,你会看到我采用的一种策略。
这里是VenueManager
类的开始,它设置了我的 SQL 语句:
// listing 12.43
class VenueManager extends Base
{
private string $addvenue = "INSERT INTO venue
( name )
VALUES( ? )";
private string $addspace = "INSERT INTO space
( name, venue )
VALUES( ?, ? )";
private string $addevent = "INSERT INTO event
( name, space, start, duration )
VALUES( ?, ?, ?, ? )";
// ...
这里没什么新东西。这些是事务脚本将使用的 SQL 语句。它们是以 PDO 类的prepare()
方法接受的格式构造的。问号是将传递给execute()
的值的占位符。现在是时候定义旨在满足特定业务需求的第一种方法了:
// listing 12.44
// VenueManager
public function addVenue(string $name, array $spaces): array
{
$pdo = $this->getPdo();
$ret = [];
$ret['venue'] = [$name];
$stmt = $pdo->prepare($this->addvenue);
$stmt->execute($ret['venue']);
$vid = $pdo->lastInsertId();
$ret['spaces'] = [];
$stmt = $pdo->prepare($this->addspace);
foreach ($spaces as $spacename) {
$values = [$spacename, $vid];
$stmt->execute($values);
$sid = $pdo->lastInsertId();
array_unshift($values, $sid);
$ret['spaces'][] = $values;
}
return $ret;
}
如您所见,addVenue()
需要一个地点名称和一组空间名称。它使用这些来填充venue
和space
表。它还创建了一个包含该信息的数据结构,以及为每行新生成的 ID 值。
记住,如果有错误,就会抛出一个异常。我在这里没有捕捉到任何异常,所以任何由prepare()
抛出的东西也会被这个方法抛出。这是我想要的结果,尽管我应该在我的文档中说明这个方法抛出了异常。
创建了venue
行后,我循环遍历$spaces
,在space
表中为每个元素添加一行。注意,在我创建的每一个space
行中,我都包含了作为外键的场所 ID,将行与场所相关联。
第二个事务脚本同样简单明了:
// listing 12.45
// VenueManager
public function bookEvent(int $spaceid, string $name, int $time, int $duration): void
{
$pdo = $this->getPdo();
$stmt = $pdo->prepare($this->addevent);
$stmt->execute([$name, $spaceid, $time, $duration]);
}
这个脚本的目的是向events
表中添加一个与空间相关联的事件。
结果
事务脚本模式是快速获得良好结果的有效方法。这也是许多程序员多年来一直使用的模式之一,没有想到它可能需要一个名称。有了一些好的助手方法,比如我添加到基类中的那些方法,您就可以专注于应用逻辑,而不会在数据库摆弄中陷入太多的麻烦。
我见过事务脚本出现在 lessc 欢迎上下文中。我认为我正在编写一个比通常适合这种模式更复杂、对象更多的应用。随着最后期限的压力开始显现,我发现我正在将越来越多的逻辑放在一个领域模型上的一个薄薄的门面中(见下一节)。尽管结果没有我想要的那么优雅,但我不得不承认,应用似乎并没有因为其隐含的重新设计而受到影响。
在大多数情况下,当您确定小型项目不会发展成大型项目时,您会选择事务脚本方法。这种方法不能很好地扩展,因为当脚本不可避免地相互交叉时,重复经常开始蔓延。当然,你可以在某种程度上解决这个问题,但是你可能无法完全去除它。
在我的例子中,我决定将数据库代码嵌入事务脚本类本身。但是,正如您所看到的,代码希望将数据库工作与应用逻辑分开。我可以把它完全从类中抽出来,创建一个 gateway 类,它的作用是代表系统处理数据库交互。
领域模型
领域模型是原始的逻辑引擎,本章中的许多其他模式都在努力创建、培育和保护它。它是在你的项目中起作用的力量的抽象表示。这是一种形式的平面,在这里,您的业务问题发挥出它们的本性,不受令人讨厌的物质问题(如数据库和网页)的阻碍。
如果这看起来有点华丽,让我们回到现实吧。领域模型是您的系统的真实世界参与者的表示。在领域模型中,“对象即事物”的经验法则比其他地方更正确。在其他任何地方,对象都倾向于体现责任。在领域模型中,它们通常描述一组属性,并添加代理。他们是做事情的事物。
问题
如果您一直在使用事务脚本,您可能会发现重复成为一个问题,因为不同的脚本需要执行相同的任务。这在一定程度上可以被排除,但是随着时间的推移,很容易陷入剪切粘贴编码。
您可以使用领域模型来提取和具体化您的系统的参与者和过程。您可以创建Space
和Event
类,而不是使用脚本将空间数据添加到数据库中,然后将事件数据与其相关联。预订某个空间的活动就像打电话到Space::bookEvent()
一样简单。像检查时间冲突这样的任务变成了Event::intersects()
等等。
显然,对于像 Woo 这样简单的例子,一个事务脚本就足够了。但是随着领域逻辑变得越来越复杂,领域模型的替代品变得越来越有吸引力。复杂的逻辑可以更容易地处理,并且在对应用域建模时,您需要更少的条件代码。
履行
领域模型设计起来相对简单。与该主题相关的大部分复杂性在于旨在保持模型纯净的模式——也就是说,将它与应用中的其他层分离开来。
将领域模型的参与者从表示层中分离出来,很大程度上是为了确保他们保持自我。将参与者从数据层中分离出来要困难得多。尽管理想的情况是只根据领域模型所代表和解决的问题来考虑领域模型,但是数据库的现实是难以逃避的。
领域模型类相当直接地映射到关系数据库中的表是很常见的,这无疑使生活变得更加容易。例如,图 12-11 显示了一个类图,它描绘了 Woo 系统的一些参与者。
图 12-11
领域模型的摘录
图 12-11 中的对象反映了为事务脚本示例设置的表。这种直接关联使系统更容易管理,但这并不总是可能的,尤其是当您在应用之前使用数据库模式时。这种联系本身就是问题的根源。如果您不小心,您可能最终会对数据库建模,而不是对您试图解决的问题和压力建模。
仅仅因为域模型经常反映数据库的结构,并不意味着它的类应该了解它。通过将模型从数据库中分离出来,您可以使整个层更容易测试,并且不太可能受到模式变化甚至存储机制变化的影响。它还将每个类的责任集中在其核心任务上。
下面是一个简化的Venue
对象及其父类:
// listing 12.46
abstract class DomainObject
{
public function __construct(private int $id)
{
}
public function getId(): int
{
return $this->id;
}
public static function getCollection(string $type): Collection
{
// dummy implementation
return Collection::getCollection($type);
}
public function markDirty(): void
{
// next chapter!
}
}
// listing 12.47
class Venue extends DomainObject
{
private SpaceCollection $spaces;
public function __construct(int $id, private string $name)
{
$this->name = $name;
$this->spaces = self::getCollection(Space::class);
parent:: __construct($id);
}
public function setSpaces(SpaceCollection $spaces): void
{
$this->spaces = $spaces;
}
public function getSpaces(): SpaceCollection
{
return $this->spaces;
}
public function addSpace(Space $space): void
{
$this->spaces->add($space);
$space->setVenue($this);
}
public function setName(string $name): void
{
$this->name = $name;
$this->markDirty();
}
public function getName(): string
{
return $this->name;
}
}
有几点可以将这个类与没有持久性的类区分开来。我没有使用数组,而是使用一个类型为SpaceCollection
的对象来存储Venue
可能包含的任何Space
对象(尽管可以说,无论您是否在使用数据库,类型安全数组都是一个额外的好处!).因为这个类使用一个特殊的集合对象,而不是一个Space
对象数组,所以构造函数需要在启动时实例化一个空集合。它通过调用层超类型上的静态方法来实现这一点:
$this->spaces = self::getCollection(Space::class);
我将在下一章回到这个系统的集合对象以及如何获取它们。不过现在,超类只是返回一个空数组。
Note
在本章和下一章,我将讨论对Venue
和Space
对象的修改。这些是简单的领域对象,它们共享一个公共的功能核心。如果您正在编写代码,您应该能够将我讨论的概念应用到任何一个类中。例如,Space
类可能不维护Space
对象的集合,但是它可能以完全相同的方式管理Event
对象。
我希望构造函数中有一个$id
参数,我将它传递给超类进行存储。得知$id
参数表示数据库中一行的惟一 ID 时,应该不会感到惊讶。还要注意,我在超类上调用了一个名为markDirty()
的方法(这将在你遇到工作单元模式时涉及到)。
结果
领域模型的设计需要像您需要模拟的业务流程一样简单或复杂。它的美妙之处在于,在设计模型时,您可以将注意力集中在问题中的力量上,处理其他层中的持久性和表示等问题——理论上是这样的。
实际上,我认为大多数开发人员在设计他们的领域模型时至少会关注数据库。没有人想设计这样的结构,当涉及到将对象放入和取出数据库时,它会迫使您(或者,更糟的是,您的同事)陷入复杂代码的困境。
就设计和规划而言,域模型和数据层之间的这种分离代价相当大。可以将数据库代码直接放在模型中(尽管您可能希望设计一个网关来处理实际的 SQL)。对于相对简单的模型,特别是如果每个类广泛地映射到一个表,这种方法可能是一个真正的胜利,为您节省了设计一个外部系统来协调对象和数据库的大量设计开销。
摘要
我在这里涉及了大量的内容(尽管我也遗漏了很多)。你不应该被本章中大量的代码吓倒。模式意味着在正确的情况下使用,并在有用的时候结合使用。使用本章中描述的那些你认为满足你的项目需求的方法,不要认为你必须在开始一个项目之前建立一个完整的框架。另一方面,这里有足够的材料来形成一个框架的基础,或者,很有可能,提供一些关于你可能选择部署的一些预构建框架的架构的见解。
还有更多!我让您徘徊在持久性的边缘,只有一些关于集合和地图绘制器的诱人提示来戏弄您。在下一章中,我将介绍一些使用数据库的模式,以及将对象与数据存储的细节隔离开来的模式。
十三、数据库模式
大多数复杂的 web 应用或多或少都会处理持久性。商店必须召回他们的产品和顾客记录。游戏必须记住他们的玩家和游戏的状态。社交网站必须记录你的 238 个朋友,以及你对上世纪八九十年代男子乐队莫名其妙的喜爱。无论应用是什么,它都有可能在幕后记分。在这一章中,我看了一些有帮助的模式。
本章将涵盖以下内容:
-
数据层接口:定义存储层和系统其余部分之间接触点的模式
-
对象观察:跟踪对象,避免重复,自动保存和插入操作
-
灵活的查询:允许您的客户端编码人员在不考虑底层数据库的情况下构建查询
-
创建已发现对象的列表:构建可迭代集合
-
管理你的数据库组件:抽象工厂模式的回归
数据层
在与客户的讨论中,通常是表示层占主导地位。字体、颜色和易用性是交谈的主要话题。在开发人员中,数据库常常显得很重要。我们关心的不是数据库本身;我们可以相信它的工作,除非我们非常不幸。不,是我们用来将数据库表的行和列转换成数据结构的机制导致了问题。在这一章中,我将看看有助于这一过程的代码。
并非这里介绍的所有内容都位于数据层本身。相反,我将一些有助于解决持久性问题的模式进行了分组。Clifton Nock、Martin Fowler 和 Alur 等人描述了所有这些模式。
数据映射器
如果你认为我在第十二章的“域模型”一节中掩盖了从数据库中保存和检索Venue
对象的问题,这里你至少可以找到一些答案。数据映射器模式在几个地方被描述为数据访问对象。首先,它被 Alur 等人在核心 J2EE 模式:最佳实践和设计策略 (Prentice Hall,2001)中涉及。Martin Fowler 在企业应用架构模式中也谈到了这一点(Addison-Wesley Professional,2002)。请注意,数据访问对象并不完全匹配数据映射器模式,因为它会生成数据传输对象;但是因为这些物体被设计成如果你加水就会变成真实的东西,所以图案足够接近。
正如您所想象的,数据映射器是一个负责处理从数据库到对象的转换的类。
问题
对象的组织方式不同于关系数据库中的表。如您所知,关系数据库表是由行和列组成的网格。一行可能通过外键与不同(甚至相同)表中的另一行相关。另一方面,对象倾向于更加有机地相互关联。一个对象可能包含另一个对象,不同的数据结构将以不同的方式组织相同的对象,在运行时以新的关系组合和重组对象。关系数据库被优化来管理大量的表格数据,而类和对象封装了较小的集中信息块。
类和关系数据库之间的这种脱节通常被描述为对象-关系阻抗不匹配(或简称为阻抗不匹配)。
那么,如何实现这种转变呢?一个答案是让一个类(或一组类)负责这个问题,有效地对领域模型隐藏数据库,并管理翻译中不可避免的粗糙边缘。
履行
尽管通过仔细的编程,创建一个单独的Mapper
类来服务多个对象是可能的,但是在领域模型中看到一个单独的Mapper
用于一个主要的类是很常见的。
图 13-1 显示了三个具体的Mapper
类和一个抽象超类。
图 13-1
映射器类
事实上,因为Space
对象实际上从属于Venue
对象,所以有可能将SpaceMapper
类分解到VenueMapper
中。为了这些练习,我将把它们分开。
如您所见,这些类提供了保存和加载数据的常见操作。基类存储通用功能,将处理特定于对象的操作的责任委托给其子类。通常,这些操作包括实际的对象生成和为数据库操作构造查询。
基类经常在操作之前或之后执行内务处理,这就是为什么模板方法被用于显式委托(例如,从像insert()
这样的具体方法到像doInsert()
这样的抽象方法的调用,等等)。).实现决定了哪些基类方法以这种方式具体化,你将在本章后面看到。
下面是一个Mapper
基类的简化版本:
// listing 13.01
abstract class Mapper
{
protected \PDO $pdo;
public function __construct()
{
$reg = Registry::instance();
$this->pdo = $reg->getPdo();
}
public function find(int w0024;id): ?DomainObject
{
$this->selectstmt()->execute([$id]);
$row = $this->selectstmt()->fetch();
$this->selectstmt()->closeCursor();
if (! is_array($row)) {
return null;
}
if (! isset($row['id'])) {
return null;
}
$object = $this->createObject($row);
return $object;
}
public function createObject(array $raw): DomainObject
{
$obj = $this->doCreateObject($raw); return $obj;
}
public function insert(DomainObject $obj): void
{
$this->doInsert($obj);
}
abstract public function update(DomainObject $obj): void;
abstract protected function doCreateObject(array $raw): DomainObject;
abstract protected function doInsert(DomainObject $object): void;
abstract protected function selectStmt(): \PDOStatement;
abstract protected function targetClass(): string;
}
构造函数方法使用一个Registry
来获得一个 PDO 对象。对于这样的类,注册中心确实显示了它的价值。从控制层到Mapper
并不总是有一条合理的路径可以传递数据。管理映射器创建的另一种方式是将它交给Registry
类本身。与其实例化它,映射器会期望提供给一个 PDO 对象作为构造函数参数:
// listing 13.02
abstract class Mapper
{
public function __construct(protected \PDO $pdo)
{
}
}
客户端代码将使用诸如getVenueMapper()
之类的方法从Registry
获取一个新的VenueMapper
。这将实例化一个映射器,同时生成 PDO 对象。对于后续请求,该方法将返回缓存的映射器。因为它允许我让映射者不知道配置的复杂性(即使有注册中心作为门面),后一种方法是我通常选择的方法。
回到Mapper
类,insert()
方法除了委托给doInsert()
之外什么也不做。如果不是因为我知道这里使用的实现在适当的时候会有用,我会选择抽象的insert()
方法。
find()
负责调用准备好的语句(由实现的子类提供)并获取行数据。它通过调用createObject()
结束。当然,将数组转换成对象的细节会因情况而异,所以实现由抽象的doCreateObject()
方法处理。再一次,createObject()
似乎什么也不做,只是委托给子实现;再一次,我将很快添加内务处理,使模板方法模式的使用值得麻烦。
子类还将实现根据特定标准查找数据的定制方法(例如,我将想要定位属于Venue
对象的Space
对象)。
你可以从孩子的角度来看看这个过程:
// listing 13.03
class VenueMapper extends Mapper
{
private \PDOStatement $selectStmt;
private \PDOStatement $updateStmt;
private \PDOStatement $insertStmt;
public function __construct()
{
parent:: __construct();
$this->selectStmt = $this->pdo->prepare(
"SELECT * FROM venue WHERE id=?"
);
$this->updateStmt = $this->pdo->prepare(
"UPDATE venue SET name=?, id=? WHERE id=?"
);
$this->insertStmt = $this->pdo->prepare(
"INSERT INTO venue ( name ) VALUES( ? )"
);
}
protected function targetClass(): string
{
return Venue::class;
}
public function getCollection(array $raw): VenueCollection
{
return new VenueCollection($raw, $this);
}
protected function doCreateObject(array $raw): Venue
{
$obj = new Venue(
(int)$raw['id'],
$raw['name']
);
return $obj;
}
protected function doInsert(DomainObject $obj): void
{
$values = [$obj->getName()];
$this->insertStmt->execute($values);
$id = $this->pdo->lastInsertId();
$obj->setId((int)$id);
}
public function update(DomainObject $obj): void
{
$values = [
$obj->getName(),
$obj->getId(),
$obj->getId()
];
$this->updateStmt->execute($values);
}
public function selectStmt(): \PDOStatement
{
return $this->selectStmt;
}
}
这个类再一次被剥夺了一些即将到来的好东西。尽管如此,它完成了它的工作。构造函数准备了一些 SQL 语句供以后使用。
Note
注意,在VenueMapper
中,doCreateObject()
声明了它的返回类型Venue
,而不是父Mapper
类中指定的DomainObject
。同样的道理也适用于getCollection()
,它在这里声明了VenueCollection
,而不是在Mapper
类中指定的更通用的返回类型Collection
。这是 PHP 7.4 中引入的返回类型协方差的一个例子,它允许你在子类中声明更专门化的返回类型。
父类Mapper
实现find()
,后者调用selectStmt()
来获取准备好的SELECT
语句。假设一切顺利,Mapper
调用VenueMapper::doCreateObject()
。在这里,我使用关联数组来生成一个Venue
对象。
从客户的角度来看,这个过程本身很简单:
// listing 13.04
$mapper = new VenueMapper();
$venue = $mapper->find(2);
print_r($venue);
对print_r()
的调用是确认find()
成功的快速方法。在我的系统中(在 ID 为2
的venue
表中有一行),这个片段的输出如下:
popp\ch13\batch01\Venue Object
(
[name:popp\ch13\batch01\Venue:private] => The Likey Lounge
[spaces:popp\ch13\batch01\Venue:private] =>
[id:popp\ch13\batch01\DomainObject:private] => 2
)
doInsert()
和update()
方法与find()
建立的过程相反。每个都接受一个DomainObject
,从中提取行数据,并使用结果信息调用PDOStatement::execute()
。注意,doInsert()
方法在所提供的对象上设置了一个 ID。请记住,在 PHP 中对象是通过引用传递的,因此客户端代码将通过自己的引用看到这种变化。
另一件要注意的事情是doInsert()
和update()
并不是真正的类型安全。他们会毫无怨言地接受任何DomainObject
子类。您应该执行一个instanceof
测试,如果传递了错误的对象,就抛出一个Exception
。
同样,这是一个关于插入和更新的客户端视角:
// listing 13.05
$mapper = new VenueMapper();
$venue = new Venue(-1, "The Likey Lounge");
// add the object to the database
$mapper->insert($venue);
// find the object again – just to prove it works!
$venue = $mapper->find($venue->getId());
print_r($venue);
// alter our object
$venue->setName("The Bibble Beer Likey Lounge");
// call update to enter the amended data
$mapper->update($venue);
// once again, go back to the database to prove it worked
$venue = $mapper->find($venue->getId());
print_r($venue);
为什么我对我希望添加到数据库中的Venue
对象的 id 使用了负值?正如你将在本章后面看到的,我使用这个约定来区分已经分配了数据库 id 的对象和没有分配的对象。VenueMapper::doInsert()
不检查 id——它只是使用地点的名称创建一个新行,然后在提供的Venue
对象上设置生成的数据库 id。下面是 doInsert()方法:
// listing 13.06
protected function doInsert(DomainObject $obj): void
{
$values = [$obj->getName()];
$this->insertStmt->execute($values);
$id = $this->pdo->lastInsertId();
$obj->setId((int)$id);
}
处理多行
find()
方法非常简单,因为它只需要返回一个对象。但是,如果需要从数据库中提取大量数据,该怎么办呢?您的第一个想法可能是返回一个对象数组。这是可行的,但是这种方法有一个主要问题。
如果返回一个数组,集合中的每个对象都需要首先实例化,如果结果集有 1000 个对象,这可能是不必要的开销。另一种方法是简单地返回一个数组,让调用代码挑选出对象实例。这是可能的,但是这违背了Mapper
类的真正目的。
有一种方法可以让你鱼与熊掌兼得。可以使用内置的Iterator
接口。
Iterator
接口需要实现类来定义查询列表的方法。如果你这样做,你的类可以像数组一样在foreach
循环中使用。
表 13-1 显示了Iterator
接口需要的方法。
表 13-1
迭代器接口定义的方法
|名字
|
描述
|
| --- | --- |
| rewind()
| 将指针发送到列表的开头 |
| current()
| 返回当前指针位置的元素 |
| key()
| 返回当前键(即指针值) |
| next()
| 向前移动指针 |
| valid()
| 确认在当前指针位置有一个元素 |
为了实现一个Iterator
,你需要实现它的方法并跟踪你在数据集中的位置。您如何获取、排序或过滤数据对客户端是隐藏的。
下面是一个Iterator
实现,它包装了一个数组,但也在其构造函数中接受了一个Mapper
对象,原因很明显:
// listing 13.07
abstract class Collection implements \Iterator
{
protected int $total = 0;
private int $pointer = 0;
private array $objects = [];
public function __construct(protected array $raw = [], protected ?Mapper $mapper = null)
{
$this->total = count($raw);
if (count($raw) && is_null($mapper)) {
throw new AppException("need Mapper to generate objects");
}
}
public function add(DomainObject $object): void
{
$class = $this->targetClass();
if (! ($object instanceof $class)) {
throw new AppException("This is a {$class} collection");
}
$this->notifyAccess();
$this->objects[$this->total] = $object;
$this->total++;
}
abstract public function targetClass(): string;
protected function notifyAccess(): void
{
// deliberately left blank!
}
private function getRow(int $num): ?DomainObject
{
$this->notifyAccess();
if ($num >= $this->total || $num < 0) {
return null;
}
if (isset($this->objects[$num])) {
return $this->objects[$num];
}
if (isset($this->raw[$num])) {
$this->objects[$num] = $this->mapper->createObject($this->raw[$num]);
return $this->objects[$num];
}
return null;
}
public function rewind(): void
{
$this->pointer = 0;
}
public function current(): ?DomainObject
{
return $this->getRow($this->pointer);
}
public function key(): mixed
{
return $this->pointer;
}
public function next(): void
{
$row = $this->getRow($this->pointer);
if (! is_null($row)) {
$this->pointer++;
}
}
public function valid(): bool
{
return (! is_null($this->current()));
}
}
构造函数期望不带参数或带两个参数(最终可能转换为对象的原始数据和映射器引用)被调用。
假设客户端已经设置了$raw
参数(这将是一个执行此操作的Mapper
对象),它将与所提供的数据集的大小一起存储在一个属性中。如果提供了原始数据,还需要一个Mapper
的实例,因为它会将每一行转换成一个对象。
如果没有参数传递给构造函数,类开始时为空;但是,请注意,add()
方法可用于添加到集合中。
该类维护两个数组:$objects
和$raw
。如果客户端请求一个特定的元素,getRow()
方法首先在$objects
中查看它是否已经实例化了一个元素。如果是,则返回。否则,该方法在$raw
中查找行数据。$raw
只有当Mapper
对象也存在时,数据才存在,因此相关行的数据可以传递给前面遇到的Mapper::createObject()
方法。这将返回一个DomainObject
对象,该对象缓存在带有相关索引的$objects
数组中。新创建的DomainObject
对象返回给用户。
该类的其余部分是对$pointer
属性的简单操作和对getRow()
的调用。它还包括notifyAccess()
方法,当您遇到惰性加载模式时,这将变得很重要。
你可能已经注意到了Collection
类是抽象的。您需要为每个域类提供特定的实现:
// listing 13.08
class VenueCollection extends Collection
{
public function targetClass(): string
{
return Venue::class;
}
}
VenueCollection
类只是扩展了Collection
并实现了一个targetClass()
方法。这与超类的add()
方法中的类型检查一起,确保了只有Venue
对象可以添加到集合中。如果你想更安全的话,你也可以在构造函数中提供额外的检查。
注意,我使用了::class
语法来获得类的完全限定的字符串表示。这个特性是从 PHP 5.5 开始才有的。在此之前,我必须小心地自己提供完整的namespace
路径。
显然,这个类应该只与一个VenueMapper
一起工作。但是,实际上,这是一个相当类型安全的集合,尤其是就域模型而言。
当然,Event
和Space
对象也有并行类。图 13-2 显示了一些Collection
类。
图 13-2
使用集合管理多行
因为域模型需要实例化Collection
对象,并且因为我可能需要在某个时候切换实现(特别是出于测试目的),所以我在注册表中提供了方便的 getter 方法来获取空集合。随着系统的增长,您可以将其委托给专门的工厂。不过,在开发过程中,我倾向于从最简单的方法开始,默认使用注册表来创建大多数对象。下面是我如何得到一个空的VenueCollection
对象:
// listing 13.09
$reg = Registry::instance();
$collection = $reg->getVenueCollection();
$collection->add(new Venue(-1, "Loud and Thumping"));
$collection->add(new Venue(-1, "Eeezy"));
$collection->add(new Venue(-1, "Duck and Badger"));
foreach ($collection as $venue) {
print $venue->getName() . "\n";
}
同样,在这个例子中,我使用了一个惯例,即对于一个还没有添加到数据库中的对象,我使用的 id 是–1。Collection
对象不关心它的DomainObject
成员是否已经被插入。
有了我在这里构建的实现,您就没有什么可以对这个集合做的了;然而,添加elementAt()
、deleteAt()
、count()
以及类似的方法是一个微不足道的练习。(也很好玩!好好享受!)
使用生成器代替迭代器接口
尽管实现一个Iterator
接口并不难,但却是一件苦差事。从 PHP 5.5 开始,您可以使用一种更简单的(通常内存效率更高的)机制,称为生成器。生成器是一个可以返回多个值的函数,通常在一个循环中。一个生成器函数将使用yield
,而不是使用return
关键字。当 PHP 处理器在一个函数中看到yield
时,它将返回一个Generator
类型的Iterator
给调用代码。这个新对象可以像任何Iterator
一样对待。巧妙的是,使用yield
产生一个值的循环将继续运行,但只是在Generator
被要求下一个()值时。实际上,流程看起来是这样的:
-
客户端代码调用一个生成器函数(一个包含
yield
关键字的函数)。 -
生成器函数包含一个通过 yield 返回多个值的循环或重复过程。遇到 yield 时,PHP 处理器创建一个
Generator
对象,并将其返回给客户端代码。 -
发生器功能中的重复过程暂时冻结在这一点上。
-
客户端代码接受这个
Generator
,并像对待任何一个Iterator
一样对待它——很可能将它传递给foreach
。 -
随着
foreach
的每次迭代,Generator
对象从生成器函数中获取下一个值。
所以我可以为我的Collection
基类使用这个特性。因为生成器函数(或方法)返回一个Generator
,Collection
本身将不再是可迭代的——相反,我将使用一个生成器方法作为工厂:
// listing 13.10
abstract class GenCollection
{
protected int $total = 0;
private array $objects = [];
public function __construct(protected array $raw = [], protected ?Mapper $mapper = null)
{
$this->total = count($raw);
if (count($raw) && is_null($mapper)) {
throw new AppException("need Mapper to generate objects");
}
}
public function add(DomainObject $object): void
{
$class = $this->targetClass();
if (! ($object instanceof $class )) {
throw new AppException("This is a {$class} collection");
}
$this->notifyAccess();
$this->objects[$this->total] = $object;
$this->total++;
}
public function getGenerator(): \Generator
{
for ($x = 0; $x < $this->total; $x++) {
yield $this->getRow($x);
}
}
abstract public function targetClass(): string;
protected function notifyAccess(): void
{
// deliberately left blank!
}
private function getRow(int $num): ?DomainObject
{
$this->notifyAccess();
if ($num >= $this->total || $num < 0) {
return null;
}
if (isset($this->objects[$num])) {
return $this->objects[$num];
}
if (isset($this->raw[$num])) {
$this->objects[$num] = $this->mapper->createObject($this->raw[$num]);
return $this->objects[$num];
}
return null;
}
}
如您所见,这使得基类更加紧凑。我已经能够取消current()
和reset()
等等。一个缺点是Collection
本身不能直接迭代。相反,客户端代码必须调用getGenerator()
并迭代yield
返回的Generator
对象,如下所示:
// listing 13.11
$genvencoll = new GenVenueCollection();
$genvencoll->add(new Venue(-1, "Loud and Thumping"));
$genvencoll->add(new Venue(-1, "Eeezy"));
$genvencoll->add(new Venue(-1, "Duck and Badger"));
$gen = $genvencoll->getGenerator();
foreach ($gen as $wrapper) {
print_r($wrapper);
}
因为我不想在我的系统中增加这个额外的层,所以对于这个例子,我将坚持使用已实现的Iterator
版本。然而,生成器确实是用最少的设置制作轻量级迭代器的好方法。
获取集合对象
我们已经看到,我已经决定使用Registry
作为收藏的工厂。我也用它来提供Mapper
对象。代码如下:
// listing 13.12
public function getVenueMapper(): VenueMapper
{
return new VenueMapper();
}
public function getSpaceMapper(): SpaceMapper
{
return new SpaceMapper();
}
public function getEventMapper(): EventMapper
{
return new EventMapper();
}
public function getVenueCollection(): VenueCollection
{
return new VenueCollection();
}
public function getSpaceCollection(): SpaceCollection
{
return new SpaceCollection();
}
public function getEventCollection(): EventCollection
{
return new EventCollection();
}
我开始打破Registry
中的界限。再多几个 getter 方法,就该重构这段代码来使用抽象工厂模式了。不过,我把这个留给你(如果你需要提醒,请回到第九章)。
既然访问Mapper
和Collection
类如此容易,那么Venue
类可以被扩展来管理Space
对象的持久性。该类提供了将单个的Space
对象添加到它的SpaceCollection
或者切换到一个全新的SpaceCollection
的方法:
// listing 13.13
// Venue
public function getSpaces(): SpaceCollection
{
if (is_null($this->spaces)) {
$reg = Registry::instance();
$this->spaces = $reg->getSpaceCollection();
}
return $this->spaces;
}
public function setSpaces(SpaceCollection $spaces): void
{
$this->spaces = $spaces;
}
public function addSpace(Space $space): void
{
$this->getSpaces()->add($space);
$space->setVenue($this);
}
setSpaces()
操作当前认为集合中的所有Space
对象都引用当前的Venue
。向方法中添加检查是非常容易的。这个版本保持事情简单。注意,我只在调用getSpaces()
时实例化了$spaces
属性。稍后,我将演示如何扩展这个惰性实例化来限制数据库请求。
VenueMapper
需要为它创建的每个Venue
对象设置一个SpaceCollection
:
// listing 13.14
// VenueMapper
protected function doCreateObject(array $raw): Venue
{
$obj = new Venue(
(int)$raw['id'],
$raw['name']
);
$spacemapper = new SpaceMapper();
$spacecollection = $spacemapper->findByVenue($raw['id']);
$obj->setSpaces($spacecollection);
return $obj;
}
VenueMapper::doCreateObject()
方法创建一个SpaceMapper
并从中获取一个SpaceCollection
。如您所见,SpaceMapper
类实现了一个findByVenue()
方法。这将我们带到生成多个对象的查询。为了简洁起见,我从最初的清单中为woo\mapper\Mapper
省略了Mapper::findAll()
方法。在这里,它被恢复了:
// listing 13.15
// Mapper
public function findAll(): Collection
{
$this->selectAllStmt()->execute([]);
return $this->getCollection(
$this->selectAllStmt()->fetchAll()
);
}
abstract protected function selectAllStmt(): \PDOStatement;
abstract protected function getCollection(array $raw): Collection;
这个方法调用一个子方法:selectAllStmt()
。像selectStmt()
一样,它应该包含一个准备好的语句对象来获取表中的所有行。下面是在SpaceMapper
类中创建的PDOStatement
对象:
// listing 13.16
// SpaceMapper::__construct()
$this->selectAllStmt = $this->pdo->prepare(
"SELECT * FROM space"
);
$this->findByVenueStmt = $this->pdo->prepare(
"SELECT * FROM space WHERE venue=?"
);
我在这里包含了另一个语句,$findByVenueStmt
,它用于定位特定于个人Venue
的Space
对象。
findAll()
方法调用另一个新方法getCollection()
,向其传递找到的数据。这里是SpaceMapper::getCollection()
:
// listing 13.17
public function getCollection(array $raw): SpaceCollection
{
return new SpaceCollection($raw, $this);
}
完整版本的Mapper
类应该将getCollection()
和selectAllStmt()
声明为抽象方法,这样所有的映射器都能够返回包含它们的持久域对象的集合。然而,为了获得属于Venue
的Space
对象,我需要一个更有限的集合。您已经看到了用于获取数据的准备好的语句;现在,这里是生成集合的SpaceMapper::findByVenue()
方法:
// listing 13.18
public function findByVenue($vid): SpaceCollection
{
$this->findByVenueStmt->execute([$vid]);
return new SpaceCollection($this->findByVenueStmt->fetchAll(), $this);
}
除了使用的 SQL 语句之外,findByVenue()
方法与findAll()
相同。回到VenueMapper
,结果集合通过Venue::setSpaces()
设置在Venue
对象上。
所以Venue
对象现在是从数据库中新鲜到达的,所有的Space
对象都在一个整洁的类型安全列表中。该列表中的对象在被请求之前都不会被实例化。
图 13-3 显示了客户端类获取SpaceCollection
的过程,以及SpaceCollection
类如何与SpaceMapper::createObject()
交互,将其原始数据转换成返回给客户端的对象。
图 13-3
获取 shapecollection 并使用它来获取空间对象
结果
我采用的将Space
对象添加到Venue
对象的方法的缺点是,我必须两次访问数据库。在大多数情况下,我认为这是值得付出的代价。另外,请注意在VenueMapper::doCreateObject()
获取正确填充的SpaceCollection
的工作可以转移到Venue::getSpaces()
,这样辅助数据库连接将只在需要时发生。这种方法可能是这样的:
// listing 13.19
// Venue
public function getSpaces2(): SpaceCollection
{
if (is_null($this->spaces)) {
$reg = Registry::instance();
$finder = $reg->getSpaceMapper();
$this->spaces = $finder->findByVenue($this->getId());
}
return $this->spaces;
}
然而,如果效率成为一个问题,那么将SpaceMapper
完全分解出来并使用 SQL join 一次性检索您需要的所有数据应该是足够容易的。
当然,您的代码可能会因此变得不那么可移植,但是效率优化总是要付出代价的!
最终,您的Mapper
类的粒度会有所不同。如果一个对象类型由另一个单独存储,那么你可以考虑只为容器设置一个Mapper
。
这种模式的强大之处在于它在领域层和数据库之间实现了强有力的解耦。对象承担了幕后的压力,并能适应各种关系扭曲。
也许这种模式最大的缺点是创建具体的Mapper
类所涉及的大量繁重工作。然而,有大量的样板代码可以自动生成。为Mapper
类生成公共方法的一种简洁方式是通过反射。您可以查询一个域对象,发现它的 setter 和 getter 方法(可能与一个参数命名约定一起),并生成基本的Mapper
类以备修改。这就是本章介绍的所有Mapper
类最初是如何产生的。
使用 mappers 需要注意的一个问题是一次加载太多对象的危险。然而,Iterator
实现在这里帮助了我们。因为一个Collection
对象最初只保存行数据,所以只有当一个特定的Venue
被访问并从数组转换为对象时,才会发出第二个请求(对一个Space
对象)。正如您将看到的,这种形式的延迟加载还可以进一步增强。
你应该小心波纹负载。创建映射器时要注意,使用另一个映射器来获取对象的属性可能只是冰山一角。这个二级映射器本身可以在构造它自己的对象时使用更多。如果不小心的话,您可能会发现表面上看似简单的查找操作引发了数十个其他类似的操作。
您还应该了解您的数据库应用为构建高效查询而制定的任何准则,并准备好进行优化(如果需要,在逐个数据库的基础上)。适用于多个数据库应用的 SQL 语句很好;快速应用更好。虽然引入条件(或策略类)来管理相同查询的不同版本是一件麻烦的事情,并且在前一种情况下可能是丑陋的,但是不要忘记所有这些肮脏的优化都巧妙地隐藏在客户端代码之外。
身份地图
你还记得 PHP 4 中传值错误的噩梦吗?当你认为指向同一个对象的两个变量结果指向不同但非常相似的对象时,随之而来的纯粹的混乱?噩梦又回来了。
问题
下面是为测试数据映射器示例而创建的一些测试代码的变体:
// listing 13.20
$mapper = new VenueMapper();
$venue = new Venue(-1, "The Likey Lounge");
$mapper->insert($venue);
$venue1 = $mapper->find($venue->getId());
$venue2 = $mapper->find($venue->getId());
$venue1->setName("The Something Else");
$venue2->setName("The Bibble Beer Likey Lounge");
print $venue->getName() . "\n";
print $venue1->getName() . "\n";
print $venue2->getName() . "\n";
原始代码的目的是演示添加到数据库中的对象也可以通过Mapper
提取,并且是相同的。完全相同,也就是说,除了与同一个物体之外,其他方面都完全相同。在这里,我通过使用三个版本的Venue
——一个原始版本和从数据库中提取的两个实例,使问题变得明显。我修改了新实例的名称,并输出了所有三个名称。以下是我的输出:
The Likey Lounge
The Something Else
The Bibble Beer Likey Lounge
请记住,我使用的约定是,一个全新的DomainObject
(即数据库中尚不存在的)应该用一个–1 id 值进行实例化。多亏了VenueMapper::insert()
方法,我的初始Venue
对象将被数据库自动生成的 id 值更新。
我通过将新的Venue
对象分配给旧的对象来回避这个问题,所以我没有以多个类似克隆的对象结束。不幸的是,你并不总是能控制局面。在一个单个请求中,同一个对象可能在不同的时间被引用。如果您修改了它的一个版本并保存到数据库中,您能确定对象的另一个版本(可能已经存储在一个Collection
对象中)不会覆盖您的修改吗?
重复的对象不仅在系统中有风险,它们也代表了相当大的开销。一些流行的对象可能在一个进程中被加载三次或四次,除了一次之外,所有这些对数据库的访问都是完全多余的。
幸运的是,解决这个问题相对简单。
履行
身份映射只是一个对象,它的任务是跟踪系统中的所有对象,从而有助于确保本应是一个对象的对象不会变成两个。
// listing 13.21
class ObjectWatcher
{
private array $all = [];
private static ?ObjectWatcher $instance = null;
private function __construct()
{
}
public static function instance(): self
{
if (is_null(self::$instance)) {
self::$instance = new ObjectWatcher();
}
return self::$instance;
}
public function globalKey(DomainObject $obj): string
{
return get_class($obj) . "." . $obj->getId();
}
public static function add(DomainObject $obj): void
{
$inst = self::instance();
$inst->all[$inst->globalKey($obj)] = $obj;
}
public static function exists(string $classname, int $id): ?DomainObject
{
$inst = self::instance();
$key = "{$classname} . {$id}";
if (isset($inst->all[$key])) {
return $inst->all[$key];
}
return null;
}
}
图 13-4 展示了一个身份映射对象如何与你见过的其他类集成。
图 13-4
身份地图
很明显,身份地图的主要技巧是识别物体。这意味着您需要以某种方式标记每个对象。您可以在这里采取许多不同的策略。系统中所有对象已经使用的数据库表键不好,因为不能保证 ID 在所有表中是唯一的。
您还可以使用数据库来维护一个全局键表。每次创建一个对象时,都要迭代键表的运行总数,并将全局键与其所在行中的对象相关联。这样做的开销相对较小,而且很容易做到。
如你所见,我选择了一种更简单的方法。我将对象的类名与其表 ID 连接起来。不能有两个 ID 为 4 的类型为popp\ch13\batch03\Event
的对象,所以我的键popp\ch13\batch03\Event.4
对于我的目的来说足够安全。
globalKey()
方法处理这方面的细节。该类提供了一个用于添加新对象的add()
方法。在一个数组属性中,每个对象都用其唯一的键标记,$all
。
exists()
方法接受一个类名和一个$id
而不是一个对象。我不想必须实例化一个对象来查看它是否已经存在!该方法从该数据构建一个键,并检查它是否索引了$all
属性中的一个元素。如果找到了一个对象,就适时地返回一个引用。
我只在一个类中使用了作为身份映射的ObjectWatcher
类。Mapper
类提供了生成对象的功能,所以在那里添加检查是有意义的:
// listing 13.22
// Mapper
public function find(int $id): ?DomainObject
{
$old = $this->getFromMap($id);
if (! is_null($old)) {
return $old;
}
// work with db return $object;
}
abstract protected function targetClass(): string;
private function getFromMap($id): ?DomainObject
{
return ObjectWatcher::exists(
$this->targetClass(),
$id
);
}
private function addToMap(DomainObject $obj): void
{
ObjectWatcher::add($obj);
}
public function createObject($raw): ?DomainObject
{
$old = $this->getFromMap((int)$raw['id']);
if (! is_null($old)) {
return $old;
}
$obj = $this->doCreateObject($raw);
$this->addToMap($obj);
return $obj;
}
public function insert(DomainObject $obj): void
{
$this->doInsert($obj);
$this->addToMap($obj);
}
该类提供了两个方便的方法:addToMap()
和getFromMap()
。这些让我免去了记住静态调用ObjectWatcher
的完整语法的麻烦。更重要的是,它们向下调用子实现(例如,VenueMapper
)来获取当前等待实例化的类的名称。
这是通过调用targetClass()
实现的,这是一个由所有具体的Mapper
类实现的抽象方法。它应该返回Mapper
设计用来生成的类名。下面是SpaceMapper
类对targetClass()
的实现:
// listing 13.23
// SpaceMapper
protected function targetClass(): string
{
return Space::class;
}
find()
和createObject()
首先通过将对象 ID 传递给getFromMap()
来检查现有对象。如果找到一个对象,它被返回给客户端,方法执行结束。然而,如果还没有这个对象的版本,那么对象实例化继续进行。在createObject()
中,新对象被传递给addToMap()
以防止将来发生任何冲突。
那么为什么我要两次经历这个过程的一部分,在find()
和createObject()
中都调用getFromMap()
?答案就在Collections
身上。当这些生成对象时,它们通过调用createObject()
来实现。我需要确保由一个Collection
对象封装的行没有过时,并确保将对象的最新版本返回给用户。
结果
只要在从数据库生成对象或向数据库添加对象的所有上下文中使用身份映射,在您的流程中出现重复对象的可能性几乎为零。
当然,这只在您的流程中有效。不同的进程将不可避免地同时访问同一对象的不同版本。考虑并发访问导致数据损坏的可能性非常重要。如果有严重的问题,您可能需要考虑锁定策略。您还可以考虑将对象存储在共享内存中,或者使用 Memcached 之类的外部对象缓存系统。你可以在 https://memcached.org/
了解 Memcached,在 https://www.php.net/memcache
了解 PHP 对它的支持。
工作单位
你什么时候保存你的物品?直到我发现了工作单元模式(由 David Rice 在 Martin Fowler 的企业应用架构模式中撰写),我在完成一个命令时从表示层发出保存命令。这被证明是一个昂贵的设计决策。
工作单元模式帮助您只保存那些需要保存的对象。
问题
有一天,我在浏览器窗口中回显我的 SQL 语句来跟踪一个问题,结果大吃一惊。我发现我在同一个请求中一遍又一遍地保存相同的数据。我有一个整洁的复合命令系统,这意味着一个命令可能会触发其他几个命令,并且每个命令都会自动清理。
我不仅保存了同一个对象两次,还保存了不需要保存的对象。
因此,这个问题在某些方面类似于身份图解决的问题。这个问题包括不必要的对象加载;这个问题存在于过程的另一端。正如这些问题是相辅相成的,解决方案也是如此。
履行
为了确定需要什么样的数据库操作,您需要跟踪发生在对象身上的各种事件。也许最好的地方是在物品本身。
您还需要维护为每个数据库操作(即插入、更新、删除)安排的对象列表。这里我只讨论插入和更新操作。哪里是存储对象列表的好地方?碰巧我已经有了一个ObjectWatcher
对象,所以我可以进一步开发它:
// listing 13.24
// ObjectWatcher
private array $all = [];
private array $dirty = [];
private array $new = [];
private array $delete = []; // unused in this example
private static ?ObjectWatcher $instance = null;
public static function addDelete(DomainObject $obj): void
{
$inst = self::instance();
$inst->delete[$inst->globalKey($obj)] = $obj;
}
public static function addDirty(DomainObject $obj): void
{
$inst = self::instance();
if (! in_array($obj, $inst->new, true)) {
$inst->dirty[$inst->globalKey($obj)] = $obj;
}
}
public static function addNew(DomainObject $obj): void
{
$inst = self::instance();
// we don't yet have an id
$inst->new[] = $obj;
}
public static function addClean(DomainObject $obj): void
{
$inst = self::instance();
unset($inst->delete[$inst->globalKey($obj)]);
unset($inst->dirty[$inst->globalKey($obj)]);
$inst->new = array_filter(
$inst->new,
function ($a) use ($obj) {
return !($a === $obj);
}
);
}
public function performOperations(): void
{
foreach ($this->dirty as $key => $obj) {
$obj->getFinder()->update($obj);
}
foreach ($this->new as $key => $obj) {
$obj->getFinder()->insert($obj);
print "inserting " . $obj->getName() . "\n";
}
$this->dirty = [];
$this->new = [];
}
ObjectWatcher
类仍然是一个身份映射,并通过$all
属性继续提供跟踪系统中所有对象的功能。这个例子只是给类增加了更多的功能。
你可以在图 13-5 中看到ObjectWatcher
类的工作单元方面。
图 13-5
ObjectWatcher 类中的工作单元方面
当对象在从数据库中提取后被更改时,它们被描述为“脏的”。脏对象存储在$dirty
数组属性中(通过addDirty()
方法),直到更新数据库的时候。客户端代码可能出于自身原因决定脏对象不应进行更新。它可以通过将脏对象标记为干净来确保这一点(通过addClean()
方法)。正如您所料,一个新创建的对象应该被添加到$new
数组中(通过addNew()
方法)。该数组中的对象计划插入数据库。我没有在这些例子中实现删除功能,但是原理应该足够清楚了。
addDirty()
和addNew()
方法各自将一个对象添加到它们各自的数组属性中。然而,addClean()
从$dirty
数组中移除给定对象,将其标记为不再等待更新。
当最终需要处理存储在这些数组中的所有对象时,应该调用performOperations()
方法(可能来自控制器类或其助手)。这个方法通过$dirty
和$new
数组循环,或者更新或者添加对象。
ObjectWatcher
类现在提供了一种更新和插入对象的机制。客户端代码仍然缺少向ObjectWatcher
对象添加对象的方法。
因为操作的是这些对象,所以它们可能最适合执行此通知。下面是我可以添加到DomainObject
类中的一些实用方法——请特别注意构造函数方法:
// listing 13.25
abstract class DomainObject
{
public function __construct(private int $id = -1)
{
if ($id < 0) {
$this->markNew();
}
}
abstract public function getFinder(): Mapper;
public function getId(): int
{
return $this->id;
}
public function setId(int $id): void
{
$this->id = $id;
}
public function markNew(): void
{
ObjectWatcher::addNew($this);
}
public function markDeleted(): void
{
ObjectWatcher::addDelete($this);
}
public function markDirty(): void
{
ObjectWatcher::addDirty($this);
}
public function markClean(): void
{
ObjectWatcher::addClean($this);
}
}
如您所见,如果没有向当前对象传递$id
属性,构造函数方法会将当前对象标记为新对象(通过调用markNew()
)。
Note
请记住,对于未插入的数据库行,我们的约定是 id 为–1。这允许我们总是要求一个整数值,然后测试该值是否大于零,以确定行数据是否应该被视为新的。当然,您可以选择对新数据使用空值,并将DomainObject
中的构造函数签名改为private ?int $id = null
。
这可以称得上是一种魔法,应该谨慎对待。按照现在的情况,这段代码指定了一个新的对象插入数据库,而不需要对象创建者的任何干预。想象一下,你的团队中一个新的程序员编写了一个一次性的脚本来测试一些领域行为。那里没有持久性代码的迹象,所以一切都应该足够安全,不是吗?现在想象一下这些测试对象,可能有有趣的名字,正在进入持久存储。魔法很好,但清晰更好。最好要求客户端代码将某种标志传递到构造函数中,以便将新对象放入队列中进行插入。
我还需要给Mapper
类添加一些代码:
// listing 13.26
// Mapper
public function createObject($raw): DomainObject
{
$old = $this->getFromMap($raw['id']);
if (! is_null($old)) {
return $old;
}
$obj = $this->doCreateObject($raw);
$this->addToMap($obj);
return $obj;
}
剩下唯一要做的事情是向域模型类中的方法添加markDirty()
调用。请记住,脏对象是指从数据库中检索出来后发生了更改的对象。这是这种模式中有轻微鱼腥味的一个方面。显然,确保所有扰乱对象状态的方法都被标记为脏是很重要的,但是这项任务的手动性质意味着人为错误的可能性非常大。
下面是Space
对象中调用markDirty()
的一些方法:
// listing 13.27
// Space
public function setVenue(Venue $venue): void
{
$this->venue = $venue;
$this->markDirty();
}
public function setName(string $name): void
{
$this->name = $name;
$this->markDirty();
}
下面是一些代码,用于向数据库添加新的Venue
和Space
,取自一个Command
类:
// listing 13.28
// a -1 id value represents a brand new Venue or Space
$venue = new Venue(-1, "The Green Trees");
$venue->addSpace(
new Space(-1, 'The Space Upstairs')
);
$venue->addSpace(
new Space(-1, 'The Bar Stage')
);
// this could be called from the controller or a helper class ObjectWatcher::instance()->performOperations();
我在ObjectWatcher
中添加了一些调试代码,所以您可以看到在请求结束时发生了什么:
inserting The Green Trees
inserting The Space Upstairs
inserting The Bar Stage
因为我的Venue
和Space
对象是用 ids 实例化的,所以它们被DomainObject
视为新的。在内部,在每种情况下,域对象构造函数调用DomainObject::markNew()
,然后调用ObjectWatcher::addNew()
。当ObjectWatcher::performOperations()
最终被调用时,这些对象被插入到数据库中(而不是在那里更新),我的调试输出被触发。
因为高级控制器对象通常调用performOperations()
方法,所以在大多数情况下,您需要做的只是创建或修改一个对象,工作类的单元(ObjectWatcher
)将在请求结束时完成它的工作。
结果
这种模式非常有用,但是有几个问题需要注意。您需要确保所有的修改操作确实将有问题的对象标记为脏。不这样做可能会导致难以发现的错误。
您可能想看看测试修改对象的其他方法。反射听起来是一个不错的选择,但是您应该研究这种测试的性能含义——这种模式是为了提高效率,而不是破坏效率。
延迟加载
惰性加载是大多数 web 程序员很快就学会的核心模式之一,因为它是避免大量数据库命中的重要机制,这是我们都想做的事情。
问题
在本章的例子中,我在Venue
、Space
和Event
对象之间建立了一个关系。当一个Venue
对象被创建时,它被自动赋予一个SpaceCollection
对象。如果我要在一个Venue
中列出每个Space
对象,这将自动启动一个数据库请求,以获取与每个Space
相关的所有Events
。这些都存储在一个EventCollection
对象中。如果我不想查看任何事件,我还是会无缘无故地多次访问数据库。有许多场馆,每个场馆有两到三个场地,每个场地管理几十个,也许几百个活动,这是一个昂贵的过程。
显然,在某些情况下,我们需要抑制这种集合的自动包含。下面是SpaceMapper
中获取Event
数据的代码:
// listing 13.29
// SpaceMapper
protected function doCreateObject(array $raw): Space
{
$obj = new Space((int)$raw['id'], $raw['name']);
$venmapper = new VenueMapper();
$venue = $venmapper->find((int)$raw['venue']);
$obj->setVenue($venue);
$eventmapper = new EventMapper();
$eventcollection = $eventmapper->findBySpaceId((int)$raw['id']);
$obj->setEvents($eventcollection);
return $obj;
}
doCreateObject()
方法首先获取与空间相关联的Venue
对象。这并不昂贵,因为它几乎肯定已经存储在ObjectWatcher
对象中。然后方法调用EventMapper::findBySpaceId()
方法。这是系统可能遇到问题的地方。
履行
正如您可能知道的那样,惰性加载意味着将属性的获取推迟到客户端实际请求的时候。
最简单的方法是在包含对象中显式延迟。下面是我如何在Space
对象中做到这一点:
// listing 13.30
// Space
public function getEvents2(): EventCollection
{
if (is_null($this->events)) {
$reg = Registry::instance();
$eventmapper = $reg->getEventMapper();
$this->events = $eventmapper->findBySpaceId($this->getId());
}
return $this->events;
}
该方法检查是否设置了$events
属性。如果没有设置,那么该方法获取一个查找器(即一个Mapper
)并使用它自己的$id
属性来获取与之关联的EventCollection
。显然,为了让这个方法为我们节省一个潜在的不必要的数据库查询,我还需要修改SpaceMapper
代码,这样它就不会像前面的例子那样自动预加载一个EventCollection
对象!
这种方法可以很好地工作,尽管有点混乱。把这些乱七八糟的东西收拾掉不是很好吗?这将我们带回到了制作Collection
对象的Iterator
实现。我已经在这个接口后面隐藏了一个秘密(事实上,在客户机访问它的时候,原始数据可能还没有被用来实例化一个域对象)。也许我可以藏得更多。
这里的想法是创建一个EventCollection
对象,它推迟对数据库的访问,直到发出请求。这意味着客户端对象(比如Space
)不需要知道它在第一个实例中持有一个空的Collection
。就客户端而言,它持有一个完全正常的EventCollection
。
这里是DeferredEventCollection
对象:
// listing 13.31
class DeferredEventCollection extends EventCollection
{
private bool $run = false;
public function __construct(
Mapper $mapper,
private \PDOStatement $stmt,
private array $valueArray
) {
parent:: construct([], $mapper);
}
protected function notifyAccess(): void
{
if (! $this->run) {
$this->stmt->execute($this->valueArray);
$this->raw = $this->stmt->fetchAll();
$this->total = count($this->raw);
}
$this->run = true;
}
}
如您所见,这个类扩展了一个标准的EventCollection
。它的构造函数需要Mapper
和PDOStatement
对象以及一组应该与准备好的语句匹配的术语。在第一个实例中,该类什么也不做,只是存储其属性并等待。没有对数据库进行查询。
您可能还记得,Collection
基类定义了我在“数据映射器”一节中提到的名为notifyAccess()
的空方法。这是从任何方法调用的,其调用是来自外部世界的调用的结果。
DeferredEventCollection
覆盖此方法。现在,如果有人试图访问Collection
,这个类知道是时候结束伪装,获取一些真正的数据了。它通过调用PDOStatement::execute()
方法来做到这一点。与PDOStatement::fetch()
一起,这产生了一个适合传递给Mapper::createObject()
的字段数组。
下面是EventMapper
中实例化一个DeferredEventCollection
的方法:
// listing 13.32
// EventMapper
public function findBySpaceId(int $sid): DeferredEventCollection
{
return new DeferredEventCollection(
$this,
$this->selectBySpaceStmt,
[$sid]
);
}
结果
不管您是否明确地将延迟加载逻辑添加到您的域类中,延迟加载都是一个需要养成的好习惯。
除了类型安全之外,对属性使用集合而不是数组的特别好处是,如果需要的话,这给了你改进延迟加载的机会。
域对象工厂
数据映射器模式很简洁,但是它也有一些缺点。特别是,一个Mapper
类会占用很多资源。它组成 SQL 语句;它将数组转换为对象;当然,它将对象转换回数组,准备向数据库添加数据。这种多功能性使Mapper
级变得方便而强大。但是,它会在一定程度上降低灵活性。当一个映射器必须处理许多不同种类的查询时,或者当其他类需要共享一个Mapper
的功能时,尤其如此。在本章的剩余部分,我将分解数据映射器,把它分解成一组更集中的模式。这些更细粒度的模式组合起来复制了 Data Mapper 中管理的全部职责,其中一些或全部可以与该模式结合使用。Clifton Nock 在数据访问模式 (Addison-Wesley,2003)中很好地定义了它们,我在出现重叠的地方使用了他的名字。
先说一个核心功能:域对象的生成。
问题
您已经遇到过Mapper
级显示自然断层线的情况。当然,createObject()
方法由Mapper
内部使用,但是Collection
对象也需要它来按需创建域对象。这要求我们在创建一个Collection
对象时传递一个Mapper
引用。尽管允许回调没有错(正如您在 Visitor 和 Observer 模式中看到的),但是将创建域对象的责任转移到它自己的类型中会更好。这可以由Mapper
和Collection
类共享。
域对象工厂在数据访问模式中描述。
履行
想象一组Mapper
类,广泛地组织起来,每个类都面向自己的域对象。域对象工厂模式只需要您从每个Mapper
中提取出createObject()
方法,并将其放在一个并行层次结构中自己的类中。图 13-6 显示了这些新的类别。
图 13-6
域对象工厂类
域对象工厂类只有一个核心职责,因此它们往往很简单:
// listing 13.33
abstract class DomainObjectFactory
{
abstract public function createObject(array $row): DomainObject;
}
下面是一个具体的实现:
// listing 13.34
class VenueObjectFactory extends DomainObjectFactory
{
public function createObject(array $row): Venue
{
$obj = new Venue((int)$row['id'], $row['name']);
return $obj;
}
}
当然,您可能还想缓存对象以防止重复和不必要的数据库访问,就像我在Mapper
类中所做的那样。您可以将addToMap()
和getFromMap()
方法移到这里,或者您可以在ObjectWatcher
和您的createObject()
方法之间建立一个观察者关系。我将把细节留给你。请记住,防止域对象的克隆在您的系统中横行是您的责任!
结果
域对象工厂将数据库行数据与对象字段数据分离。您可以在createObject()
方法中执行任意数量的调整。这个过程对客户是透明的,客户的责任是提供原始数据。
通过将这个功能从Mapper
类中分离出来,它就可供其他组件使用了。下面是一个修改过的Collection
实现,例如:
// listing 13.35
abstract class Collection implements \Iterator
{
protected int $total = 0;
protected array $raw = [];
private int $pointer = 0;
private array $objects = [];
// Collection
public function __construct(array $raw = [], protected ?DomainObjectFactory $dofact = null)
{
if (count($raw) && ! is_null($dofact)) {
$this->raw = $raw;
$this->total = count($raw);
}
$this->dofact = $dofact;
}
// ...
DomainObjectFactory
可用于按需生成对象:
// listing 13.36
private function getRow(int $num): ?DomainObject
{
// ...
if (isset($this->raw[$num])) {
$this->objects[$num] = $this->dofact->createObject($this->raw[$num]);
return $this->objects[$num];
}
}
因为域对象工厂与数据库是分离的,所以它们可以更有效地用于测试。例如,我可能会创建一个模拟的DomainObjectFactory
来测试Collection
代码。这比模拟一个完整的Mapper
对象要容易得多(你可以在第十八章中读到更多关于 mock 和 stub 对象的内容)。
将整体组件分解成可组合部分的一个普遍影响是不可避免的类的增加。不应该低估混淆的可能性。即使每个组件及其与其他组件的关系都符合逻辑并且定义清晰,我也经常发现绘制包含数十个名称相似的组件的包的图表很有挑战性。
在情况好转之前,情况会变得更糟。我已经看到数据映射器中出现了另一条断层线。Mapper::getCollection()
法方便;但是同样,其他类可能想为一个域类型获取一个Collection
对象,而不必使用面向数据库的类。所以我有两个相关的抽象组件:Collection
和DomainObjectFactory
。根据我正在处理的域对象,我将需要一组不同的具体实现:例如,VenueCollection
和VenueObjectFactory
,或者SpaceCollection
和SpaceObjectFactory
。当然,这个问题直接将我们引向抽象工厂模式。
图 13-7 所示为PersistenceFactory
级。我将用它来组织构成接下来几个模式的各种组件。
图 13-7
使用抽象工厂模式组织相关组件
身份对象
我在这里介绍的映射器实现在定位域对象时存在一定的不灵活性。找到一个单独的对象没有问题。找到所有相关的领域对象也很容易。但是,介于两者之间的任何东西都需要您添加一个特殊的方法来构建查询(EventMapper::findBySpaceId()
就是一个很好的例子)。
identity 对象(Alur 等人也称为数据传输对象)封装了查询条件,从而将系统与数据库语法分离。
问题
很难提前知道您或其他客户编码人员需要在数据库中搜索什么。域对象越复杂,查询中可能需要的过滤器数量就越多。您可以根据具体情况向您的Mapper
类添加更多的方法,从而在一定程度上解决这个问题。当然,这不是很灵活,并且可能涉及重复,因为您需要在单个Mapper
类中和跨系统中的映射器创建许多相似但不同的查询。
identity 对象封装了数据库查询的条件方面,使得不同的组合可以在运行时进行组合。例如,给定一个名为Person
的域对象,客户可以调用身份对象上的方法来指定一名男性,年龄在 30 岁以上 40 岁以下,身高不到 6 英尺。这个职业应该设计成可以灵活组合条件(也许你对目标的身高不感兴趣,或者你想取消年龄下限)。身份对象在某种程度上限制了客户端编码人员的选择。如果您没有编写代码来容纳一个income
字段,那么如果不进行调整,它就不能被包含在查询中。然而,应用不同条件组合的能力确实在灵活性方面向前迈进了一步。让我们看看这是如何工作的。
履行
身份对象通常由一组方法组成,您可以调用这些方法来构建查询标准。设置好对象的状态后,可以将它传递给负责构造 SQL 语句的方法。
图 13-8 显示了一组典型的IdentityObject
类。
图 13-8
使用身份对象管理查询条件
您可以使用基类来管理常见操作,并确保您的条件对象共享一个类型。这里有一个比图 13-8 所示的类更简单的实现:
// listing 13.37
abstract class IdentityObject
{
private ?string $name = null;
public function setName(string $name): void
{
$this->name = $name;
}
public function getName(): ?string
{
return $this->name;
}
}
这里没什么太费事的。这些类只是存储所提供的数据,并根据请求放弃这些数据。下面是一些可能使用EventIdentityObject
构建WHERE
子句的代码:
// listing 13.38
$idobj = new EventIdentityObject();
$idobj->setMinimumStart(time());
$idobj->setName("A Fine Show");
$comps = [];
$name = $idobj->getName();
if (! is_null($name)) {
$comps[] = "name = '{$name}'";
}
$minstart = $idobj->getMinimumStart();
if (! is_null($minstart)) {
$comps[] = "start > {$minstart}";
}
$start = $idobj->getStart();
if (! is_null($start)) {
$comps[] = "start = '{$start}'";
}
$clause = " WHERE " . implode(" and ", $comps);
print "{$clause}\n";
这种模式将足够好,但它不适合我懒惰的灵魂。对于一个大型域对象,您必须构建的 getters 和 setters 的数量之多令人望而生畏。然后,按照这个模型,您必须编写代码来输出WHERE
子句中的每个条件。我甚至懒得处理我的示例代码中的所有情况(对我来说没有setMaximumStart()
方法),所以想象一下我在现实世界中构建身份对象的喜悦。
幸运的是,您可以部署各种策略来自动收集数据和生成 SQL。例如,在过去,我在基类中填充了字段名称的关联数组。它们本身由比较类型索引:大于、等于、小于或等于。子类提供了将这些数据添加到底层结构的便利方法。然后,SQL 构建器可以循环遍历该结构,以动态构建其查询。我确信实现这样一个系统只是一个着色的问题,所以我将在这里研究它的一个变体。
我会使用流畅的界面。这是一个类,它的 setter 方法返回对象实例,允许用户以流畅的、类似语言的方式将对象链接在一起。这将满足我的懒惰,但我仍然希望,给客户编码器一个灵活的定义标准的方式。
我从创建woo\mapper\Field
开始,这个类被设计用来保存每个字段的比较数据,这些数据将在WHERE
子句中结束:
// listing 13.39
class Field
{
protected array $comps = [];
protected bool $incomplete = false;
// sets up the field name (age, for example)
public function __construct(protected string $name)
{
}
// add the operator and the value for the test
// (> 40, for example) and add to the $comps property
public function addTest(string $operator, $value): void
{
$this->comps[] = [
'name' => $this->name,
'operator' => $operator,
'value' => $value
];
}
// comps is an array so that we can test one field in more than one way public function getComps(): array
{
return $this->comps;
}
// if $comps does not contain elements, then we have
// comparison data and this field is not ready to be used in
// a query
public function isIncomplete(): bool
{
return empty($this->comps);
}
}
这个简单的类接受并存储一个字段名。通过addTest()
方法,该类构建了一个包含operator
和value
元素的数组。这允许我们为单个字段维护多个比较测试。现在,这里是新的IdentityObject
类:
// listing 13.40
class IdentityObject
{
protected ?Field $currentfield = null;
protected array $fields = [];
private array $enforce = [];
// an identity object can start off empty, or with a field
public function __construct(?string $field = null, ?array $enforce = null)
{
if (! is_null($enforce)) {
$this->enforce = $enforce;
}
if (! is_null($field)) {
$this->field($field);
}
}
// field names to which this is constrained
public function getObjectFields(): array
{
return $this->enforce;
}
// kick off a new field.
// will throw an error if a current field is not complete
// (ie age rather than age > 40)
// this method returns a reference to the current object
// allowing for fluent syntax
public function field(string $fieldname): self
{
if (! $this->isVoid() && $this->currentfield->isIncomplete()) {
throw new \Exception("Incomplete field");
}
$this->enforceField($fieldname);
if (isset($this->fields[$fieldname])) {
$this->currentfield = $this->fields[$fieldname];
} else {
$this->currentfield = new Field($fieldname);
$this->fields[$fieldname] = $this->currentfield;
}
return $this;
}
// does the identity object have any fields yet
public function isVoid(): bool
{
return empty($this->fields);
}
// is the given fieldname legal?
public function enforceField(string $fieldname): void
{
if (! in_array($fieldname, $this->enforce) && ! empty($this->enforce)) {
$forcelist = implode(', ', $this->enforce);
throw new \Exception("{$fieldname} not a legal field ($forcelist)");
}
}
// add an equality operator to the current field
// ie 'age' becomes age=40
// returns a reference to the current object (via operator())
public function eq($value): self
{
return $this->operator("=", $value);
}
// less than
public function lt($value): self
{
return $this->operator("<", $value);
}
// greater than
public function gt($value): self
{
return $this->operator(">", $value);
}
// does the work for the operator methods
// gets the current field and adds the operator and test value
// to it
private function operator(string $symbol, $value): self
{
if ($this->isVoid()) {
throw new \Exception("no object field defined");
}
$this->currentfield->addTest($symbol, $value);
return $this;
}
// return all comparisons built up so far in an associative array
public function getComps(): array
{
$ret = [];
foreach ($this->fields as $field) {
$ret = array_merge($ret, $field->getComps());
}
return $ret;
}
}
要弄清楚这里发生了什么,最简单的方法是从一些客户端代码开始,然后逆向工作:
// listing 13.41
$idobj = new IdentityObject();
$idobj->field("name")
->eq("'The Good Show'")
->field("start")
->gt(time())
->lt(time() + (24 * 60 * 60));
我从创建IdentityObject
开始。调用field()
导致一个Field
对象被创建并被分配为$currentfield
属性。注意,field()
返回了对identity
对象的引用。这允许我们在调用field()
的后面挂起更多的方法调用。比较方法eq()
、gt()
等等各自调用operator()
。这将检查是否有当前的Field
对象可以使用;如果是,它将传递操作符和提供的值。再次,eq()
返回一个对象引用,这样我就可以添加新的测试或者再次调用add()
开始处理一个新的字段。
注意客户端代码几乎像句子一样:字段"name"
等于"The Good Show"
,字段"start"
大于当前时间,但相差不到一天。
当然,通过失去那些硬编码的方法,我也失去了一些安全性。这就是$enforce
数组的设计目的。子类可以通过一组约束来调用基类:
// listing 13.42
class EventIdentityObject extends IdentityObject
{
public function __construct(string $field = null)
{
parent:: construct(
$field,
['name', 'id', 'start', 'duration', 'space']
);
}
}
EventIdentityObject
类现在强制一组字段。如果我尝试使用随机字段名,会发生以下情况:
// listing 13.43
try {
$idobj = new EventIdentityObject();
$idobj->field("banana")
->eq("The Good Show")
->field("start")
->gt(time())
->lt(time() + (24 * 60 * 60));
print $idobj;
} catch (\Exception $e) {
print $e->getMessage();
}
下面是输出:banana not a legal field (name, id, start, duration, space)
结果
身份对象允许客户端编码人员定义搜索标准,而无需参考数据库查询。它们还使您不必为用户可能需要的各种查找操作构建特殊的查询方法。
身份对象的部分作用是保护用户免受数据库细节的影响。因此,如果您构建一个自动化的解决方案,比如前面例子中的 fluent 接口,那么您使用的标签应该明确地指向您的域对象,而不是底层的列名,这一点很重要。当它们不同时,您应该为它们之间的别名建立一个机制。
当您使用专门化的实体对象(每个域对象一个)时,使用抽象工厂(就像上一节描述的PersistenceFactory
)来为它们和其他相关对象提供服务是很有用的。
现在我可以表示搜索条件,我可以用它来构建查询本身。
选择工厂和更新工厂模式
我已经从Mapper
类中窃取了一些职责。有了这些模式,Mapper
不需要创建对象或集合。有了由身份对象处理的查询标准,it 就不必再管理find()
方法的多种变体。下一步是删除查询创建的责任。
问题
任何与数据库对话的系统都必须生成查询,但是系统本身是围绕域对象和业务规则组织的,而不是围绕数据库。本章中的许多模式可以说是在表格数据库和更有机的领域树状结构之间架起了一座桥梁。然而,有一个转换的时刻——领域数据被转换成数据库可以理解的形式。正是在这一点上,真正的脱钩发生了。
履行
当然,您以前已经在数据映射器模式中看到过一些这样的功能。不过,在这个专门化中,我可以从 identity object 模式提供的附加功能中受益。这将使查询生成更加动态,原因很简单,因为潜在的变化非常多。
图 13-9 显示了我简单的选择和更新工厂。
图 13-9
选择和更新工厂
选择和更新工厂通常也是这样组织的,使得它们与系统中的域对象并行(可能通过身份对象作为中介)。正因为如此,它们也是我的PersistenceFactory
的候选者:我维护的抽象工厂,作为领域对象持久性工具的一站式商店。以下是更新工厂基类的实现:
// listing 13.44
abstract class UpdateFactory
{
abstract public function newUpdate(DomainObject $obj): array;
protected function buildStatement(string $table, array $fields, ?array $conditions = null): array
{
$terms = array();
if (! is_null($conditions)) {
$query = "UPDATE {$table} SET ";
$query .= implode(" = ?,", array_keys($fields)) . " = ?";
$terms = array_values($fields);
$cond = [];
$query .= " WHERE ";
foreach ($conditions as $key => $val) {
$cond[] = "$key = ?";
$terms[] = $val;
}
$query .= implode(" AND ", $cond);
} else {
$qs = [];
$query = "INSERT INTO {$table} (";
$query .= implode(",", array_keys($fields));
$query .= ") VALUES (";
foreach ($fields as $name => $value) {
$terms[] = $value;
$qs[] = '?';
}
$query .= implode(",", $qs);
$query .= ")";
}
return [$query, $terms];
}
}
在接口方面,这个类唯一做的事情就是定义newUpdate()
方法。这将返回一个数组,其中包含一个查询字符串和一个应用于该字符串的术语列表。buildStatement()
方法完成构建更新查询所涉及的一般工作,具体工作由子类处理各个域对象。buildStatement()
接受一个表名、一个字段及其值的关联数组以及一个类似的条件关联数组。该方法将这些结合起来创建查询。这里有一个具体的UpdateFactory
类:
// listing 13.45
class VenueUpdateFactory extends UpdateFactory
{
public function newUpdate(DomainObject $obj): array
{
// note type checking removed
$id = $obj->getId();
$cond = null;
$values['name'] = $obj->getName();
if ($id > 0) {
$cond['id'] = $id;
}
return $this->buildStatement("venue", $values, $cond);
}
}
在这个实现中,我直接使用一个DomainObject
。在一次更新中可能同时操作许多对象的系统中,我可以使用 identity 对象来定义我想要操作的集合。这将形成$cond
数组的基础,这里只保存id
数据。
newUpdate()
提取生成查询所需的数据。这是将对象数据转换为数据库信息的过程。注意对$id
值的检查。如果 id 设置为–1,那么这是一个新的域对象,我们不会提供条件值buildStatement()
。buildStatement()
使用条件语句的存在来确定是否生成INSERT
或UPDATE
。
注意,newUpdate()
方法将接受任何DomainObject
。这是为了让所有的UpdateFactory
类可以共享一个接口。添加一些进一步的类型检查来确保不会传入错误的对象是一个好主意。
下面是一些测试VenueUpdateFactory
类的快速代码:
// listing 13.46
$vuf = new VenueUpdateFactory();
print_r($vuf->newUpdate(new Venue(334, "The Happy Hairband")));
Array
(
[0] => UPDATE venue SET name = ? WHERE id = ?
[1] => Array
(
[0] => The Happy Hairband
[1] => 334
)
)
现在生成一个INSERT
语句:
// listing 13.47
$vuf = new VenueUpdateFactory();
print_r($vuf->newUpdate(new Venue(-1, "The Lonely Hat Hive")));
Array
(
[0] => INSERT INTO venue (name) VALUES (?)
[1] => Array
(
[0] => The Lonely Hat Hive
)
)
您可以看到SelectionFactory
类的类似结构。下面是基类:
// listing 13.48
abstract class SelectionFactory
{
abstract public function newSelection(IdentityObject $obj): array;
public function buildWhere(IdentityObject $obj): array
{
if ($obj->isVoid()) {
return ["", []];
}
$compstrings = [];
$values = [];
foreach ($obj->getComps() as $comp) {
$compstrings[] = "{$comp['name']} {$comp['operator']} ?";
$values[] = $comp['value'];
}
$where = "WHERE " . implode(" AND ", $compstrings);
return [$where, $values];
}
}
这个类再次以抽象类的形式定义了公共接口。newSelection()
期待一个IdentityObject
。同样需要一个IdentityObject
,但是对于类型来说是本地的,是实用方法buildWhere()
。它使用IdentityObject::getComps()
方法来获取构建WHERE
子句所需的信息,并构建一个值列表,这两者都以两元素数组的形式返回。
下面是一个具体的SelectionFactory
类:
// listing 13.49
class VenueSelectionFactory extends SelectionFactory
{
public function newSelection(IdentityObject $obj): array
{
$fields = implode(',', $obj->getObjectFields());
$core = "SELECT $fields FROM venue";
list($where, $values) = $this->buildWhere($obj);
return [$core . " " . $where, $values];
}
}
这构建了 SQL 语句的核心,然后调用buildWhere()
来添加条件子句。事实上,在我的测试代码中,一个具体的SelectionFactory
与另一个具体的SelectionFactory
唯一不同的地方就是表的名称。如果我没有很快发现我需要独特的专门化,我将重构这些子类并使用单个具体的SelectionFactory
。这将从PersistenceFactory
中查询表名。
同样,这里有一些客户端代码:
// listing 13.50
$vio = new VenueIdentityObject();
$vio->field("name")->eq("The Happy Hairband");
$vsf = new VenueSelectionFactory();
print_r($vsf->newSelection($vio));
(
[0] => SELECT name,id FROM venue WHERE name = ?
[1] => Array
(
[0] => The Happy Hairband
)
)
结果
通用身份对象实现的使用使得使用单个参数化的SelectionFactory
类变得更加容易。如果您选择硬编码的身份对象——即由一系列 getter 和 setter 方法组成的身份对象——您很可能必须为每个域对象构建一个单独的SelectionFactory
。
与身份对象相结合的查询工厂的最大好处之一是可以生成的查询范围。这也会导致缓存问题。这些方法会动态地生成查询,并且很难知道您何时在重复工作。建立一种比较标识对象的方法可能是值得的,这样您就可以返回一个缓存的字符串,而不需要所有这些工作。在更高的层次上,也可以考虑类似的数据库语句池。
我在本章后半部分提到的模式组合的另一个问题是,它们是灵活的,但是它们没有那么灵活。我的意思是,它们被设计成在一定范围内具有极强的适应性。不过,这里没有太多例外情况的空间。虽然类的创建和维护更加麻烦,但它们非常适合任何类型的性能组装或数据杂耍,你可能需要在它们干净的 API 后面执行。这些更优雅的模式面临的问题是,由于它们专注的职责和对组成的强调,很难跨越聪明之处,做一些愚蠢但强大的事情。
幸运的是,我没有丢失我的高级接口——仍然有一个控制器级别,如果有必要的话,我可以在这个级别阻止聪明行为。
数据映射器现在还剩下什么?
因此,我已经从数据映射器中剥离了对象、查询和集合生成,更不用说条件的管理了。它还能剩下什么呢?嗯,在遗迹形式中需要一个非常像映射器的东西。我仍然需要一个对象,它位于我创建的其他对象之上,并协调它们的活动。它可以帮助完成缓存任务和处理数据库连接(尽管面向数据库的工作还可以进一步委派)。Clifton Nock 称这些数据层控制器为域对象组装器。
这里有一个例子:
// listing 13.51
class DomainObjectAssembler
{
protected \PDO $pdo;
public function __construct(private PersistenceFactory $factory)
{
$reg = Registry::instance();
$this->pdo = $reg->getPdo();
}
public function getStatement(string $str): \PDOStatement
{
if (! isset($this->statements[$str])) {
$this->statements[$str] = $this->pdo->prepare($str);
}
return $this->statements[$str];
}
public function findOne(IdentityObject $idobj): DomainObject
{
$collection = $this->find($idobj);
return $collection->next();
}
public function find(IdentityObject $idobj): Collection
{
$selfact = $this->factory->getSelectionFactory();
list ($selection, $values) = $selfact->newSelection($idobj);
$stmt = $this->getStatement($selection);
$stmt->execute($values);
$raw = $stmt->fetchAll();
return $this->factory->getCollection($raw);
}
public function insert(DomainObject $obj): void
{
$upfact = $this->factory->getUpdateFactory();
list($update, $values) = $upfact->newUpdate($obj);
$stmt = $this->getStatement($update);
$stmt->execute($values);
if ($obj->getId() < 0) {
$obj->setId((int)$this->pdo->lastInsertId());
}
$obj->markClean();
}
}
如你所见,这不是一个抽象类。它不是自己分解成专门化,而是使用PersistenceFactory
来确保获得当前域对象的正确组件。
图 13-10 显示了我剔除Mapper
后建立的高层参与者。
图 13-10
本章中开发的一些持久性类
除了建立数据库连接和执行查询,该类还管理SelectionFactory
和UpdateFactory
对象。在选择的情况下,它也可以使用Collection
类来生成返回值。
从客户的角度来看,创建一个DomainObjectAssembler
很容易。这只是获得正确的具体PersistenceFactory
对象并将其传递给构造函数的问题:
// listing 13.52
$factory = PersistenceFactory::getFactory(Venue::class);
$finder = new DomainObjectAssembler($factory);
当然,这里的“客户”不太可能是指最终客户。我们可以通过向PersistenceFactory
本身添加一个getFinder()
方法,并将前面的例子转换成一行代码,从而将更高级别的类与这种复杂性隔离开来,就像这样:
$finder = PersistenceFactory::getFinder(Venue::class);
不过,我会把它留给你。
然后,客户端编码人员可能会继续获取一组Venue
对象:
// listing 13.53
$idobj = $factory->getIdentityObject()
->field('name')
->eq('The Eyeball Inn');
$collection = $finder->find($idobj);
foreach ($collection as $venue) {
print $venue->getName() . "\n";
}
摘要
一如既往,您选择使用的模式将取决于您的问题的性质。我很自然地倾向于使用身份对象的数据映射器。我喜欢整洁的自动化解决方案,但我也需要知道我可以脱离系统,在需要时进行手动操作,同时保持干净的界面和分离的数据库层。例如,我可能需要优化一个 SQL 查询,或者使用一个连接来跨多个表获取数据。即使您正在使用一个复杂的基于模式的第三方框架,您可能会发现提供的花哨的对象关系映射并不完全符合您的需要。对一个好的框架和一个好的自主开发的系统的一个测试是,你能容易地把你自己的黑客插入到适当的位置,而不降低系统的整体完整性。我喜欢优雅、优美的解决方案,但我也是一个实用主义者!
同样,我在这一章已经讲了很多。以下是我们所研究的模式以及如何使用它们的简要概述:
-
数据映射器:创建专家类,用于将领域模型对象映射到关系数据库或从关系数据库映射出来
-
身份映射:跟踪系统中的所有对象,防止重复实例化和不必要的数据库访问
-
工作单元:将对象保存到数据库的过程自动化,确保只更新已更改的对象,只插入新创建的对象
-
惰性加载:推迟对象创建,甚至数据库查询,直到真正需要它们的时候
-
域对象工厂:封装对象创建功能
-
身份对象:允许客户端在不参考底层数据库的情况下构建查询条件
-
查询(选择和更新)工厂:封装构造 SQL 查询的逻辑
-
域对象组装器:构造一个控制器,管理数据存储和检索的高级过程
在下一章中,我们从代码中解脱出来,我将介绍一些有助于项目成功的更广泛的实践。
十四、好的(和坏的)实践
到目前为止,在这本书里,我一直专注于编码,特别是设计在构建灵活的、可重用的工具和应用中的作用。然而,开发并没有随着代码而结束。有可能从书本和课程中获得对语言的扎实理解,但在运行和部署项目时仍然会遇到问题。
在这一章中,我将超越代码,介绍一些构成成功开发过程基础的工具和技术。本章将涵盖以下内容:
-
第三方软件包:从哪里获得,何时使用
-
构建:创建和部署包
-
版本控制:给开发过程带来和谐
-
文档:编写易于理解、使用和扩展的代码
-
单元测试:一个自动化缺陷检测和预防的工具
-
标准:为什么从众有时是好的
-
一个使用虚拟化的工具,这样所有的开发者都可以在一个类似于生产环境的系统中工作,不管他们的硬件或者操作系统是什么
-
持续集成(Continuous integration):使用这种实践和工具集来自动化项目构建和测试,并在出现问题时得到提醒
超越代码
当我第一次从独立工作中毕业并在一个开发团队中任职时,我惊讶于其他开发人员似乎必须知道这么多东西。善意的争论在看似至关重要的问题上无休止地酝酿着:哪个是最好的文本编辑器?团队应该标准化集成开发环境吗?我们应该强加一个编码标准吗?我们应该如何测试我们的代码?我们应该在开发的时候记录吗?有时,这些问题似乎比代码本身更重要,我的同事们似乎通过某种奇怪的渗透过程获得了该领域的百科知识。
我读过的关于 PHP、Perl 和 Java 的书当然没有在很大程度上偏离代码本身。正如我已经讨论过的,大多数关于编程平台的书籍很少偏离它们对代码设计中函数和语法的紧密关注。如果设计偏离了主题,你可以肯定,更广泛的问题,如版本控制和测试,很少被讨论。这不是批评——如果一本书声称涵盖了一种语言的主要特征,那么这就是它所做的一切也就不足为奇了。
然而,在学习代码的过程中,我发现我忽略了项目日常生活中的许多机制。我发现其中一些细节对我参与开发的项目的成败至关重要。在这一章中,以及在接下来的章节中的更详细的内容中,我将超越代码来探索一些工具和技术,你的项目的成功可能依赖于它们。
借一个轮子
当在项目中面临一个具有挑战性但又不连续的需求时(可能需要解析一种特定的格式,或者在与远程服务器的对话中使用一种新的协议),构建一个满足这种需求的组件有很多好处。这也是学习手艺的最好方法之一。在创建包的过程中,您深入了解了一个问题,并将可能有更广泛应用的新技术归档。
你立刻投资于你的项目和你自己的技能。通过将功能保留在系统内部,您可以让用户不必下载第三方软件包。偶尔,你也可以回避棘手的许可问题。当你测试你自己设计的一个组件并发现,奇迹中的奇迹,它工作了——它完全按照你在罐子上写的那样工作时,没有什么比这更令人满意的了。
当然,这一切都有黑暗的一面。许多软件包代表了数千个工时的投资:一种您手头可能没有的资源。您可以通过只开发项目特别需要的功能来解决这个问题,而第三方工具也可以满足无数的其他需求。然而,问题仍然存在:如果一个免费的工具存在,为什么你要浪费你的天赋去复制它呢?您有时间和资源来开发、测试和调试您的包吗?这一次部署在别处不是更好吗?
说到轮子发明,我是最糟糕的罪犯之一。找出问题并发明解决方案是我们作为程序员的基本职责。与编写一些胶水将三四个现有组件粘在一起相比,着手一些严肃的架构是一个更有回报的前景。当这种诱惑来临时,我提醒自己过去的项目。尽管从零开始构建的选择在我的经历中从未扼杀过一个项目,但我看到它吞噬了时间表,扼杀了利润空间。我坐在那里,眼里闪着狂热的光芒,策划着情节,旋转着类图,当我沉迷于我的组件的细节时,没有注意到大画面已经成为遥远的记忆。
现在,当我规划一个项目时,我会试着对代码库中的内容和第三方需求有一个感觉。例如,您的应用可能会生成(或读取)一个 RSS 提要;您可能需要验证电子邮件地址并自动发送邮件、验证用户身份或读取标准格式的配置文件。所有这些需求都可以通过外包来满足。
在这本书的前几个版本中,我建议 PEAR (PHP 扩展和应用存储库)是软件包的发展方向。然而,时代变了,PHP 世界已经非常明确地转向了 Composer 依赖管理器及其默认的存储库 packagest(https://packagist.org
)。因为 Composer 基于每个项目来管理包,所以它不太可能出现可怕的依赖地狱综合症(不同的包需要相同库的不兼容版本)。此外,所有动作都转移到了 Composer/Packagist,这意味着您更有可能在那里找到您想要的东西。此外,许多 PEAR 包都可以通过 Packagist(包装商)( https://packagist.org/packages/pear/
)获得。
所以,一旦你确定了你的需求,你的第一站应该是 Packagist 网站。然后,您可以使用 Composer 来安装您的软件包并管理软件包依赖性。我将在下一章更详细地介绍 Composer。
为了让您对使用 Composer 和 Packagist 可以做些什么有所了解,下面是您可以在那里找到的软件包可以做的一些事情:
-
使用
pear/cache_lite
缓存输出 -
使用
athletic/athletic
基准库测试代码的效率 -
用
doctrine/dbal
抽象数据库访问的细节 -
使用
simplepie/simplepie
提取 RSS 提要 -
用
pear/mail
发送带有附件的邮件 -
用
symfony/config
解析配置文件格式 -
用
league/uri
解析和操作 URL
Packagist 网站提供了一个强大的搜索工具。你可能会在那里找到满足你需求的软件包,或者你可能需要使用搜索引擎扩大搜索范围。无论哪种方式,您都应该在着手重新发明轮子之前花时间评估现有的包。
你们有一种需求——并且有解决这种需求的一揽子方案——这一事实不应成为你们审议的起点和终点。虽然最好使用一个包,这样可以节省不必要的开发,但是在某些情况下,它会增加开销而没有真正的好处。例如,您的客户需要您的应用发送邮件,这并不意味着您应该自动使用 pear/mail 包。PHP 提供了一个非常好的mail()
函数,所以这可能是您的第一站。一旦您意识到您需要根据 RFC822 标准验证所有电子邮件地址,并且设计团队希望通过电子邮件发送图像附件,您就可以开始以不同的方式权衡这些选项。碰巧的是,pear/mail 支持这两个特性(后者与mail_mime
结合使用)。
许多程序员,包括我自己,经常过分强调原始代码的创建,有时会损害他们的项目。
Note
不愿使用第三方工具和解决方案通常是机构层面的固有问题。这种以怀疑的态度对待外部产品的倾向有时被称为不是这里发明的综合症。作为进一步的说明,技术评论家和科幻迷保罗·特里戈指出不是这里发明的也是伊恩·M·班克斯文化系列中一艘船的名字。
这种对作者身份的强调可能是可重用代码的创造似乎多于实际使用的一个原因。
卓有成效的程序员将原始代码视为帮助他们设计项目成功结果的工具之一。这样的程序员看着他们手头的资源,并有效地部署它们。如果有一个方案可以承受一定的压力,那么这就是胜利。借用 Perl 世界的一句格言:好的程序员是懒惰的。
友好相处
萨特的名言“地狱是其他人”的真实性在一些软件项目中每天都得到证明。这可能描述了客户和开发人员之间的关系,这种关系的典型表现是缺乏沟通会导致特性的蔓延和优先级的扭曲。但是这个上限也适用于快乐交流和合作的团队成员,当谈到共享代码的时候。
一旦一个项目有多个开发人员,版本控制就成了一个问题。一个单独的编码员可能就地处理代码,在开发的关键点保存她的工作目录的副本。引入另一个程序员,这个策略在几分钟内就失效了。如果新的开发人员在同一个开发目录中工作,那么一个程序员在保存时很有可能会覆盖他同事的工作,除非两个人都非常小心地总是在不同的文件上工作。
或者,我们的两个开发人员可以各自开发一个版本的代码库。这很好,直到调和两个版本的时刻到来。除非开发人员已经处理了完全不同的文件集,否则合并两个或更多开发链的任务会变得非常令人头疼。
这就是 Git、Subversion 和类似工具的用武之地。使用版本控制系统,你可以检查出你自己版本的代码库,然后继续工作,直到你对结果满意为止。然后,您可以用同事所做的任何更改来更新您的版本。版本控制软件会自动将这些更改合并到您的文件中,并通知您它无法处理的任何冲突。一旦您测试了这个新的混合体,您就可以将它保存到中央存储库中,让其他开发人员可以使用它。
版本控制系统为您提供了其他好处。它们保存了项目所有阶段的完整记录,因此您可以回滚到项目生命周期中的任何一点,或者获取其快照。您还可以创建分支,这样您就可以在维护一个公开发布版本的同时维护一个前沿的开发版本。
一旦你在一个项目中使用了版本控制,你就不想在没有版本控制的情况下尝试另一个项目。同时处理一个项目的多个分支可能是一个概念性的挑战,尤其是在开始的时候,但是好处很快就变得明显了。版本控制太有用了,离不开它。我在第十七章中介绍了 Git。
Note
这本书的当前版本是使用 Git 作为协作工具用纯文本编写和编辑的。
赋予你的代码翅膀
你见过你的代码因为太难构建而搁浅吗?对于就地开发的项目来说尤其如此。这样的项目融入到它们的上下文中,密码和目录、数据库以及助手应用调用都被编程到代码中。部署这类项目可能是一项艰巨的任务,程序员团队需要通过挑选源代码来修改设置,以便适应新的环境。
通过提供一个集中的配置文件或类,可以在一个地方更改设置,从而在一定程度上缓解这个问题。但即便如此,构建也可能是一件苦差事。安装的难易程度将对您发布的任何应用的受欢迎程度产生重大影响。它还会阻碍或鼓励开发过程中的多次频繁部署。
与任何重复且耗时的任务一样,构建应该是自动化的。构建工具可以确定安装位置的默认值、检查和更改权限、创建数据库、初始化变量以及其他任务。事实上,构建工具可以做您需要的任何事情,从发布到完全部署的源目录中获取应用。
当然,这并不能免除用户向代码中添加环境信息的责任,但是它可以使这个过程变得简单,只需回答几个问题或提供几个命令行开关。
亚马逊的 AWS Elastic Beanstalk 等云产品使得根据需要创建测试和试运行环境成为可能。为了充分利用这些资源,良好的构建和安装解决方案是必不可少的。如果您不能即时部署您的系统,那么自动配置服务器是没有用的。
开发人员可以使用各种构建工具。PEAR 和 Composer 都管理安装(PEAR 集中管理,Composer 管理本地vendor
目录)。您可以为任一系统创建自己的包,然后用户可以轻松地下载和安装这些包。然而,构建不仅仅是将文件 A 放在位置 B 的过程。
在第十九章,我会看一个叫 Phing 的应用。这个开源项目是流行的 Ant 构建工具的一个移植,它是用 Java 编写的,也是为 Java 编写的。Phing 是用 PHP 编写的,但它在架构上类似于 Ant,并为其构建文件使用相同的 XML 格式。
Composer 非常好地执行有限数量的任务,并提供尽可能简单的配置。Phing 一开始更令人生畏,但是它有着巨大的灵活性。您不仅可以使用 Phing 来自动化从文件复制到 XSLT 转换的任何事情,如果您需要扩展该工具,还可以轻松编写和合并您自己的任务。Phing 是使用 PHP 的面向对象特性编写的,其设计强调模块化和易于扩展。
构建工具和那些为包或依赖管理而设计的工具并不互相排斥。通常,在开发过程中使用构建工具来运行测试,执行项目内务管理,并准备最终通过 PEAR、Composer 甚至基于发行版的包管理系统(如 RPM 和 Apt)部署的包。
标准
我以前提到过,这本书已经把它的重点从梨转移到作曲家。这是因为 Composer 比 PEAR 好太多了吗?我确实喜欢很多关于 Composer 的东西,这些东西可能会影响我的决定。然而,这本书改变的主要原因是其他人都改变了。Composer 已经成为依赖管理的标准。这是至关重要的,因为这意味着当我在 Packagist 找到一个包时,我也有可能找到它的所有依赖项和相关包。我甚至会在那里找到很多梨包。
因此,为依赖关系管理选择一个标准可以确保可用性和互操作性。但是标准不仅仅适用于包和依赖,还适用于系统工作的方式和我们编码的方式。如果我们在协议上达成一致,那么我们的系统和团队可以无缝地相互集成。而且,随着越来越多的组件跨越来越多的系统混合,这变得越来越重要。
如果需要一种明确的处理方式,比如说日志记录,那么我们采用最好的协议显然是理想的。但是建议的质量(将规定格式、日志级别等。)可能没有我们都遵守它这一事实重要。如果只有你一个人在执行最好的标准,那是没有好处的。
在第十五章中,我将更详细地讨论标准,特别是 PHP-FIG 小组管理的一组建议。这些 PSR(PHP 标准建议)涵盖了从缓存到安全的所有内容。在这一章中,我将关注 PSR-1 和 PSR-12,这两个建议解决了编码风格的棘手问题(你喜欢把括号放在哪里?你对别人告诉你改变做事方式有什么感觉?).然后我继续讨论 PSR-4 的绝对优势,包括自动加载(对 PSR-4 的支持是 Composer 擅长的另一个领域)。
Vagrant
你的团队使用什么操作系统?当然,有些组织要求特定的硬件和软件组合。不过,通常会有混合。一个开发人员可能有一台运行 Fedora 的开发机器。另一个人可能会信赖他的 MacBook,第三个人可能会坚持使用他的外星人 Windows box(他可能喜欢用它来玩游戏)。
很有可能制作系统将完全在别的东西上运行——也许是 CentOS。
让一个系统跨多个平台工作可能是一件痛苦的事情,如果这些平台都不像生产系统,这可能是一个风险。您真的不想在上线后发现与生产操作系统相关的问题。当然,在实践中,您可能会首先部署到一个临时环境。即便如此,早点抓住这些问题不是更好吗?
流浪者是一种利用虚拟化给所有团队成员一个尽可能接近生产的开发环境的技术。启动和运行应该像调用一两个命令一样简单,最棒的是,每个人都可以坚持使用他们最喜欢的机器和发行版(我是 Fedora 迷,请记住)。
我在第二十章中讲述流浪者。
测试
当你创建一个类时,你可能非常确定它是有效的。毕竟,您将在开发过程中测试它的速度。您还将在组件就位的情况下运行您的系统,检查它是否集成良好,以及您的新功能是否可用并按预期执行。
你能确定你的类会像预期的那样继续工作吗?这似乎是一个愚蠢的问题。毕竟,你已经检查过你的代码一次;为什么它要任意停止工作?嗯,当然不会;没有什么事情是随意发生的,如果您从不在系统中添加任何代码,您就可以轻松地呼吸了。另一方面,如果您的项目是活动的,那么不可避免的是,您的组件的上下文将会改变,并且组件本身很可能会以多种方式改变。
让我们依次来看这些问题。首先,改变一个组件的上下文是如何引入错误的?即使在一个组件彼此很好地解耦的系统中,它们仍然是相互依赖的。您的类使用的对象返回值、执行操作并接受数据。如果这些行为中的任何一个发生变化,对您的类的操作的影响可能会导致一种很容易捕捉到的错误——这种类型的错误,您的系统会显示一个方便的错误消息,其中包括文件名和行号。然而,更阴险的是,这种变化不会导致引擎级别的错误,但仍然会混淆您的组件。如果您的类基于另一个类的数据做出假设,该数据的变化可能会导致它做出错误的决策。您的类现在有错误,并且没有更改一行代码。
很可能你会继续改变你刚刚完成的课程。通常,这些变化是微小而明显的——事实上,如此微小,以至于您不需要仔细检查您在开发过程中执行的检查。不管怎样,你可能已经把它们都忘记了,除非你以某种方式保存它们(也许像我有时做的那样,在你的类文件的底部注释掉了)。然而,小的变化会导致大的意想不到的后果——如果您想在适当的地方放置一个测试工具,这些后果可能已经被发现了。
一个测试工具是一组自动化的测试,它可以被应用到你的系统中作为一个整体,也可以被应用到它的单独的类中。部署好了,测试工具可以帮助你防止错误的发生和重复出现。一个简单的改变可能导致一连串的错误,测试工具可以帮助您定位并消除这些错误。这意味着你可以自信地做出改变,不会破坏任何东西。对你的系统进行改进,然后看到一个失败测试的列表,这是非常令人满意的。这些都是可能在您的系统中传播的错误,但现在它不会再遭受这些错误了。
持续集成
你有没有制定过让一切都好起来的时间表?你从一个任务开始:也许是代码或者一个学校项目。又大又吓人,潜伏着失败。但是你拿出一张纸,把它分成容易处理的任务。你决定要读的书和要写的组件。也许你用不同的颜色突出显示任务。事实证明,就个人而言,没有一项任务真的那么可怕。渐渐地,随着你的计划,你征服了最后期限。只要每天做一点点,就没事了。你可以放松。
不过,有时候,这个时间表有一种魔力。你把它像盾牌一样举起来,保护自己免受怀疑和潜移默化的恐惧,也许这一次你会崩溃和燃烧。只有在几周之后,你才会意识到日程表本身并不神奇。你实际上也必须做这项工作。当然,到那时,在时间表令人安心的力量的催眠下,你已经让事情顺其自然了。除了制定一个新的时间表,别无他法。这一次,就不那么让人放心了。
测试和构建也是如此。你必须进行测试。你必须定期在新鲜的环境中构建你的项目;否则,魔法不起作用。
如果编写测试是一件痛苦的事情,那么运行测试也是一件苦差事,尤其是当它们变得越来越复杂,并且失败会打断你的计划的时候。当然,如果你更经常地运行它们,你可能会有更少的失败,并且那些你确实有过的失败会有很好的机会与你头脑中新鲜的新代码相关联。
在沙盒里很容易变得舒服。毕竟,您已经有了所有的玩具:让您的生活变得简单的小 scriptlets、开发工具和有用的库。问题是你的项目也可能在你的沙箱里太舒服了。它可能开始依赖于您在构建文件中遗漏的未提交代码或依赖项。这意味着除了你工作的地方,其他地方都坏了。
唯一的答案是建设,建设,再建设。每次都在一个相当原始的环境中进行。
当然,提出这样的建议当然很好;做这件事完全是另一回事。作为一个品种,程序员往往喜欢编码。他们想把会议和家务减到最少。这就是持续集成(CI)的用武之地。CI 既是一种实践,也是一套使实践尽可能简单的工具。理想情况下,构建和测试应该是完全自动的,或者至少可以通过一个命令或点击来启动。任何问题都会被跟踪,在问题变得太严重之前,您会得到通知。我会在第二十一章里多讲讲 CI。
摘要
开发人员的目标总是交付一个工作系统。写好代码是实现这一目标的重要部分,但不是全部。
在这一章中,我介绍了 Composer 和 Packagist 的依赖管理。我还讨论了协作的两大辅助工具:流浪者和版本控制。我介绍了为什么版本控制需要自动化构建,还介绍了 Phing,它是 Ant 的 PHP 实现,是一个 Java 构建工具。最后,我讨论了软件测试并介绍了 CI,这是一套自动化构建和测试的工具。
十五、PHP 标准
除非你是律师或卫生检查员,否则标准的话题可能不会让你心跳加速。然而,帮助我们实现的标准是值得兴奋的。标准促进了互操作性,这使我们能够访问大量兼容的工具和框架组件。
本章将涵盖标准的几个重要方面:
-
为什么是标准:什么是标准以及它们为什么重要
-
PHP 标准建议:它们的起源和目的
-
PSR-1:基本编码标准
-
PSR-12:扩展编码风格
-
PSR-4:自动加载
为什么是标准?
设计模式互操作。这是他们的核心。设计模式中描述的一个问题提出了一个特定的解决方案,这反过来又产生了架构上的结果。这些都可以通过新的模式得到很好的解决。模式还有助于开发人员进行互操作,因为它们提供了共享的词汇表。面向对象的系统倾向于遵循友好的原则。
随着我们越来越多地共享彼此的组件,这种非正式的互操作性趋势并不总是足够的。正如我们所看到的,Composer(或者我们选择的包管理系统)允许我们在项目中混合和匹配工具。这些组件可以被设计成独立的库,也可以是一个更大的框架的一部分。无论哪种方式,一旦部署到我们的系统中,它们必须能够与任意数量的其他组件一起工作和协作。通过坚持核心标准,我们降低了工作遇到兼容性问题的可能性。
从某种意义上来说,标准的本质不如它被遵守的事实重要。例如,就我个人而言,我并不喜欢 PSR-12 风格指导原则的每一个方面。在大多数情况下,包括这本书,我都采用了这个标准。我团队中的其他开发人员会希望发现我的代码更容易使用,因为他们会发现它是一种熟悉的格式。对于其他标准,比如自动加载,不遵守通用标准将导致组件在没有额外中间件的情况下根本无法协同工作。
标准可能不是编程中最令人兴奋的方面。然而,他们的核心有一个有趣的矛盾。标准似乎会扼杀创造力。毕竟标准告诉你什么能做什么不能做。你必须服从。你可能会认为这算不上创新。然而,我们把互联网带给我们生活的创造力的巨大繁荣归功于这样一个事实,即这个网络中的每个节点都符合开放标准。困在围墙花园里的专有系统必然在范围和寿命上受到限制——不管它们的代码有多聪明,它们的界面有多光滑。互联网通过其共享协议,确保任何站点都可以链接到任何其他站点。大多数浏览器支持标准的 HTML、CSS 和 JavaScript。我们可以在这些标准中构建的接口并不总是我们想象中最令人印象深刻的(尽管限制比以前少得多);尽管如此,遵守这些原则使我们能够最大限度地扩大工作范围。
如果使用得当,标准可以促进开放、合作,并最终促进创造力。这是真的,即使标准本身有一些限制。
PHP 标准推荐有哪些?
在 2009 年 php[tek]大会上,一群框架开发人员成立了一个组织,他们称之为 php 框架互操作组(PHP-FIG)。从那以后,开发人员从其他关键组件加入进来。他们的目的是建立标准,以便他们的系统能够更好地共存。
该小组对标准提案进行投票,这些提案从起草到审查,最后获得通过。
表 15-1 列出了撰写本文时的现行标准。
表 15-1
被接受的 PHP 标准建议
|PSR 数
|
名字
|
描述
|
| --- | --- | --- |
| one | 基本编码标准 | PHP 标签和基本命名约定等基础知识 |
| three | 记录器接口 | 日志级别和记录器行为的规则 |
| four | 自动装载标准 | 命名类和名称空间的约定,以及它们到文件系统的映射 |
| six | 缓存接口 | 缓存管理规则,包括数据类型、缓存项生命周期、错误处理等。 |
| seven | HTTP 消息接口 | HTTP 请求和响应的约定 |
| Eleven | 容器接口 | 依赖注入容器的公共接口 |
| Twelve | 扩展编码风格指南 | 代码格式,包括大括号、参数列表等的放置规则。 |
| Thirteen | 链接定义接口 | 描述超媒体链接的接口 |
| Fourteen | 事件调度程序 | 事件管理的定义 |
| Fifteen | HTTP 处理程序 | HTTP 服务器请求处理程序的公共接口 |
| Sixteen | 简单缓存 | 缓存库的公共接口(PSR-6 的简化) |
| Seventeen | HTTP 工厂 | 创建符合 PSR 7 标准的 HTTP 对象的工厂的通用标准 |
| Eighteen | 客户端 | 用于发送 HTTP 请求和接收 HTTP 响应的接口 |
为什么特别是 PSR?
那么,为什么选择一个标准而不是另一个呢?碰巧 PHP 框架互操作小组 PSRs 的发起者——有一个非常好的血统,因此标准本身是有意义的。但是,这些也是主要框架和组件正在采用的标准。如果您正在使用 Composer 向您的项目添加功能,您已经在使用符合 PSRs 的代码。通过使用它的自动加载惯例和样式指南,您很可能构建了准备好与其他人和组件协作的代码。
Note
一套标准本身并不优于另一套标准。当您选择是否采用一个标准时,您的选择可能是由您对推荐标准优点的判断所决定的。或者,你可以根据你工作的环境做出务实的选择。例如,如果你在 WordPress 社区工作,你可能想要采用在 https://make.wordpress.org/core/handbook/best-practices/coding-standards/php/
的核心贡献者手册中定义的风格。这样的选择是标准的一部分,它是关于人和软件的合作。
PSR 是一个很好的选择,因为它们受到关键框架和组件项目的支持,包括 Phing、Composer、PEAR、Symfony 和 Zend 2。像模式一样,标准也是有感染力的——你可能已经从中受益了。
PSR 是给谁的?
表面上,PSR 是为框架的创建者设计的。然而,PHP-FIG 小组的成员迅速扩大,包括了工具和框架的创建者,这一事实表明标准具有广泛的相关性。也就是说,除非您正在创建一个日志记录器,否则您可能不需要太担心 PSR-3 的细节(除了确保您使用的任何日志记录工具本身是兼容的)。另一方面,如果你已经读完了这本书的其余部分,那么你很有可能在使用工具的同时也在创造工具。因此,您也可能会在当前的标准或未来的标准中找到与您相关的内容。
然后是对我们所有人都很重要的标准。例如,尽管风格指南很乏味,但它们与每个程序员都有关系。虽然管理自动加载的规则实际上适用于那些创建自动加载器的人(并且最主要的游戏可能是 Composer 的),但是它们也从根本上影响我们如何组织我们的类、我们的包和我们的文件。
出于这些原因,在本章的剩余部分,我将集中讨论编码风格和自动加载。
风格编码
我倾向于发现像“你的牙套放错地方了”这样的拉请求评论非常令人恼火。这种投入通常看起来吹毛求疵,危险地接近自行车脱落。
Note
如果你没有遇到它,动词“自行车棚”指的是倾向于在一些评论家批评不重要的元素,一个项目在审查。这意味着选择这些元素是因为它们符合评论者的能力范围。因此,考虑到要评估的是一座摩天大楼,一个特别的经理可能不会关注巨大而复杂的玻璃钢塔,而是关注后面更容易理解的自行车棚。维基百科有一个很好的历史术语: https://en.wikipedia.org/wiki/Law_of_triviality
。
然而,我逐渐认识到,遵循一种共同的风格有助于提高代码的质量。这主要是可读性的问题(不考虑特定规则背后的推理)。如果团队遵守相同的缩进、括号位置、参数列表等规则,那么开发人员可以快速评估并贡献同事的代码。
因此,对于本书的这个版本,我承诺编辑所有代码示例,使它们符合 PSR-1 和 PSR-12。我也请我的同事兼技术编辑保罗·特雷哥让我这样做。这是一个在计划阶段就很容易做出的承诺——比我预期的要多得多。这让我想起了我学到的第一堂风格指南课。如果可能的话,尽早为你的项目采用一个标准。重构一种代码风格可能会占用资源,并使检查跨越大重组时代的代码差异变得困难。
那么我必须应用什么变化呢?让我们从基础开始。
PSR-1 基本编码标准
这些是 PHP 代码的基础。你可以在 www.php-fig.org/psr/psr-1/
找到它们的详细信息。让我们把它们分解开来。
开始和结束标签
首先,一个 PHP 部分应该用<?php
或者<?=
打开。换句话说,不应该使用简短的开始标记<?
,也不应该使用任何其他变体。一个部分应该只以?>
结束(或者,正如我们将在下一部分看到的,根本没有标记)。
Note
PSR 遵循一套词汇定义,如应该和必须来确定指令应该遵守的程度。虽然本章将依赖于这些单词的简单英语含义,但在 PSR 上下文中的绝对预期含义是在 www.ietf.org/rfc/rfc2119.txt.
处定义的
副作用
一个 PHP 文件应该声明类、接口、函数之类的,或者应该执行一个动作(比如读写一个文件或者向浏览器发送输出);然而,它不应该两者兼而有之。如果你习惯于使用require_once()
来包含其他的类文件,这会让你马上出错,因为包含另一个文件是一个副作用。正如模式产生模式一样,标准往往需要其他标准。处理类依赖的正确方法是通过 PSR-4 兼容的自动加载程序。
那么,你声明的一个类用它的一个方法写文件合法吗?这是完全可以接受的,因为文件的包含不会产生这种效果。换句话说,这是一种执行效果,而不是副作用。
那么什么样的文件可能执行动作而不是声明类呢?想想启动应用的脚本。
以下是作为包含的直接结果而执行操作的列表:
// listing 15.01
namespace popp\ch15\batch01;
require_once(__DIR __ . "/../../../vendor/autoload.php");
$tree = new Tree();
print "loaded " . get_class($tree) . "\n";
下面是一个 PHP 文件,它声明了一个没有副作用的类:
// listing 15.02
namespace popp\ch15\batch01;
class Tree
{
}
Note
在其他章节中,我在很大程度上省略了namespace
声明和use
指令,以便专注于代码。因为这一章是关于格式化类文件的机制,我将在适当的地方包含namespace
和use
语句。
命名
类必须用大写字母声明,也称为 studly caps 或 PascalCase。换句话说,类名应该以大写字母开头。名称的其余部分应该小写,除非它由多个单词组成。在这种情况下,每个单词都应该以大写字母开头,如下所示:
class MyClassName
属性可以用任何方式命名,尽管需要一致性。我倾向于使用 camel case,这种方法类似于 studly caps,但没有前导大写字母:
private $myPropertyName
方法必须在 camel case 中声明:
public function myMethodName()
类常量必须大写,单词之间用下划线分隔:
public const MY_NAME_IS = 'matt';
更多规则和示例
应该根据 PSR-4 自动加载标准来声明类、命名空间和文件。不过,我们将在本章的后半部分谈到这一点。PHP 文档必须保存为 UTF-8 编码的文件。
最后,对于 PSR 一号,让我们把它全部弄错——然后把它纠正过来。下面是一个打破所有规则的类文件:
// listing 15.03
<?
require_once("conf/ConfFile.ini");
class conf_reader {
const ModeFile = 1;
const Mode_DB = 2;
private $conf_file;
private $confValues= [];
function read_conf() {
// implementation
}
}
?>
你能发现所有的问题吗?首先,我使用了一个简短的开始标记。我也没有声明一个namespace
(尽管我们还没有详细讨论这个需求)。在给我的类命名时,我使用下划线,没有大写字母,而不是大写字母。我对常量名称使用了两种格式,这两种格式都不是必需的——所有大写字母都应该用下划线分隔。虽然我的两个财产名称都是合法的,但我没能使它们保持一致;具体来说,我对$conf_file
使用了下划线,对$confValues
使用了驼色。在给我的方法命名为read_conf()
时,我使用了下划线而不是大小写。
// listing 15.04
<?php
namespace popp\ch15\batch01;
class ConfReader {
const MODEFILE = 1;
const MODE_DB = 2;
private $conf_file;
private $confValues= [];
function readConf() {
// implementation
}
}
?>
PSR-12 扩展编码风格
扩展的编码风格(PSR-12)建立在 PSR-1 的基础上,并取代了一个废弃的标准:PSR-2。让我们来看看一些规则。
开始和结束一个 PHP 文档
我们已经看到,PSR-1 要求 PHP 块用<``?php
打开。PSR-12 规定纯 PHP 文件不应该有结尾的?>
标签,而应该以一个空行结束。用一个结束标记结束一个文件,然后让一个额外的新行悄悄进入,这太容易了。这可能会导致设置 HTTP 头时出现格式错误和错误(在内容已经发送到浏览器后,您不能这样做)。
表 15-2 按顺序描述了可能构成有效 PHP 文档的语句。
表 15-2
PHP 语句
|声明
|
例子
|
| --- | --- |
| 打开 PHP 标签 | <?php
|
| 文件级文档块 | /**``* File doc``*/
|
| 声明语句 | declare(strict_types=1);
|
| 命名空间声明 | namespace popp;
|
| 使用导入语句(类) | use other\Service;
|
| 使用导入语句(函数) | use function other\{getAll, calculate};
|
| 使用导入语句(常量) | use const other\{NAME, VERSION};
|
一个 PHP 文档应该遵循表 15-2 中的结构(尽管任何合法 PHP 代码不必要的元素都可以省略)。namespace
声明后面应该有一个空行,一组use
声明后面应该有一个空行。不要在同一行中放置多个use
声明:
// listing 15.05
namespace popp\ch15\batch01;
use popp\ch10\batch06\PollutionDecorator;
use popp\ch10\batch06\DiamondDecorator;
use popp\ch10\batch06\Plains;
// begin class
开始和结束课程
关键字class
、类名以及extends
和implements
必须都放在同一行。当一个类实现多个接口时,每个接口名可以包含在类声明的同一行中,也可以缩进在自己的行中。如果您选择将您的接口名称放在多行上,那么第一项必须放在它自己的行上,而不是直接放在implements
关键字之后。类括号应该在类声明的之后的行开始,并在它们自己的行结束(直接在类内容之后)。因此,类声明可能看起来像这样:
// listing 15.06
class EarthGame extends Game implements
Playable,
Savable
{
// class body
}
但是,您同样可以将接口名称放在一行中:
// listing 15.07
class EarthGame extends Game implements Playable, Savable
{
// class body
}
使用特征
当添加一个特征到一个类中时,你必须将use
语句直接添加到类的左括号之后。尽管 PHP 允许你将你的特征分组到一行中,但 PSR-12 要求你将每个use
语句放在自己的一行中。如果你的类除了提供use
语句之外还提供了自己的元素,你必须在处理非信任内容之前留出一个空行。否则,必须直接在最后一个use
语句后关闭该行的类块。
这里有一个类,它导入了两个特征并提供了自己的方法:
// listing 15.08
namespace popp\ch15\batch01;
class Tree
{
use GrowTools;
use TerrainUtil;
public function draw(): void
{
// implementation
}
}
如果为as
或insteadof
语句声明一个块,它应该分布在多行上。左大括号应该与use
语句在同一行开始。然后,该块应该在每个语句中使用一行。最后,右大括号应该在它自己的一行结束,就像这样:
// listing 15.09
namespace popp\ch15\batch01;
class Marsh
{
use GrowTools {
GrowTools::dimension as size;
}
use TerrainUtil;
public function draw(): void
{
// implementation
}
}
声明属性和常数
属性和常量必须具有声明的可见性(public
、private
或protected
)。var
关键字是不可接受的。我们已经在 PSR 协议 1 中介绍了属性和常量名称的格式。
开始和结束方法
所有方法都必须具有声明的可见性(public
、private
或protected
)。可见性关键字必须在 abstract
或final
之后,但必须在 static
之前。带有默认值的方法参数应该放在参数列表的末尾。
单行声明
方法括号应该从方法名后面的行开始,并在它们自己的行(直接在方法代码后面)结束。方法参数列表不应该以空格开始或结束(也就是说,它们应该紧挨着括号)。对于每个参数,逗号应该与前面的参数名(或默认值)齐平,但其后应该跟一个空格。让我们用一个例子来说明:
// listing 15.10
final public static function generateTile(int $diamondCount, bool $polluted = false): array
{
// implementation
}
多行声明
在有许多参数的情况下,单行方法声明是不实际的。在这种情况下,您可以打破参数列表,使每个参数(包括类型、参数变量、默认值和逗号)都缩进在自己的行上。在这种情况下,右括号应该放在参数列表后面的一行,与方法声明的开始对齐。左大括号应该跟在同一行的右括号后面,用空格隔开。方法体应该在新的一行开始。同样,这听起来比实际情况复杂得多。举个例子应该更清楚:
// listing 15.11
public function __construct(
int $size,
string $name,
bool $wraparound = false,
bool $aliens = false
) {
// implementation
}
返回类型
返回类型声明应该与右括号在同一行。冒号应该直接跟在右括号后面。冒号应该用一个空格与返回类型分开。对于多行声明,返回类型声明应该在同一行的左大括号之前,用空格隔开。
// listing 15.12
final public static function findTilesMatching(
int $diamondCount,
bool $polluted = false
): array {
// implementation
}
PSR-12 并不强制要求使用返回类型声明。但是,由于引入了 void、mixed 和 nullable 类型,应该可以提供一个匹配所有情况的声明。
线条和缩进
你应该使用四个空格而不是制表符来缩进。值得检查一下你的编辑器设置——你可以配置好的编辑器在你按下Tab
键时使用空格而不是制表符。您还应该在每行达到 120 个字符之前换行(尽管这不是强制性的)。行必须以 Unix 换行符结尾,而不是其他特定于平台的组合(如 MAC 中的 CR 和 Windows 中的 CR/LF)。再次,检查你的编辑器的设置,因为它可能会使用你的操作系统的默认行尾字符。
调用方法和函数
不要在方法名和左括号之间加空格。您可以对方法调用中的参数列表应用与方法声明中的参数列表相同的规则。换句话说,对于单行调用,在左括号之后或右括号之前不要留空格。每个参数后应紧跟一个逗号,下一个参数前应留出一个空格。如果需要对一个方法调用使用多行,每个参数应该缩进在自己的行上,右括号应该换行:
// listing 15.13
$earthgame = new EarthGame(
5,
"earth",
true,
true
);
$earthgame::generateTile(5, true);
控制流程
流量控制关键字(if
、for
、while
等)。)后面必须跟一个空格。但是,左括号后面不能有空格。同样,右括号前面不能有空格。所以,里面的东西应该放在它们的支架里。与类和(单行)函数声明不同,流控制块的左括号必须与右括号在同一行开始。右大括号应该自成一行。这里有一个简单的例子:
// listing 15.14
$tile = [];
for ($x = 0; $x < $diamondCount; $x++) {
if ($polluted) {
$tile[] = new PollutionDecorator(new DiamondDecorator(new Plains()));
} else {
$tile[] = new DiamondDecorator(new Plains());
}
}
注意for
和if
后面的空格。for
和if
表达式与包含它们的括号对齐。在这两种情况下,右括号后面是一个空格,然后是流量控制体的左括号。
括号中的表达式可以拆分成多行,每行至少缩进一次。在表达式被破坏的地方,布尔运算符可以放在每一行的开头或结尾,但是您的选择必须一致。
// listing 15.15
$ret = [];
for (
$x = 0;
$x < count($this->tiles);
$x++
) {
if (
$this->tiles[$x]->isPolluted() &&
$this->tiles[$x]->hasDiamonds() &&
! ($this->tiles[$x]->isPlains())
) {
$ret[] = $x;
}
}
return $ret;
检查和修复您的代码
即使这一章涵盖了《PSR 协议 12》中的每一条指令(实际上并没有),你也很难记住所有的指令。毕竟,我们还有其他事情要考虑——比如我们系统的设计和实现。那么,假设我们已经接受了编码标准的价值,我们如何在不花费太多时间和精力的情况下遵守呢?当然,我们使用工具。
PHP_CodeSniffer
允许您检测甚至修复违反标准的行为——不仅仅是针对 PSR。在 https://github.com/squizlabs/PHP_CodeSniffer
按照说明就可以拿到。有 Composer 和 PEAR 选项,但您可以通过以下方式下载 PHP 归档文件:
curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar
curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcbf.phar
为什么下载两次?第一个用于phpcs
,它诊断和报告违规行为。第二个是给phpcbf
的,可以修很多。让我们测试一下这些工具。首先,这是一段格式杂乱的代码:
// listing 15.16
namespace popp\ch15\batch01;
class ebookParser {
function __construct(string $path , $format=0 ) {
if ($format>1)
$this->setFormat( 1 );
}
function setformat(int $format) {
// do something with $format
}
}
与其在这里讨论这些问题,不如让PHP_CodeSniffer
来帮我们解决:
$ php phpcs.phar --standard=PSR12 src/ch15/batch01/phpcsBroken.php
FILE: /var/popp/src/ch15/batch01/phpcsBroken.php
---------------------------------------------------------------------------
FOUND 16 ERRORS AFFECTING 6 LINES
---------------------------------------------------------------------------
5 | ERROR | [x] Header blocks must be separated by a single blank line
6 | ERROR | [ ] Class name "ebookParser" is not in PascalCase format
6 | ERROR | [x] Opening brace of a class must be on the line after the definition
8 | ERROR | [ ] Visibility must be declared on method " construct"
8 | ERROR | [x] Expected 0 spaces between argument "$path" and comma; 1 found
8 | ERROR | [x] Incorrect spacing between argument "$format" and equals sign; expected 1 but found 0
8 | ERROR | [x] Incorrect spacing between default value and equals sign for argument "$format"; expected 1 but found 0
8 | ERROR | [x] Expected 0 spaces before closing parenthesis; 1 found
8 | ERROR | [x] Opening brace should be on a new line
9 | ERROR | [x] Inline control structures are not allowed
9 | ERROR | [x] Expected at least 1 space before ">"; 0 found
9 | ERROR | [x] Expected at least 1 space after ">"; 0 found
10 | ERROR | [x] Space after opening parenthesis of function call prohibited
10 | ERROR | [x] Expected 0 spaces before closing parenthesis; 1 found
13 | ERROR | [ ] Visibility must be declared on method "setformat"
13 | ERROR | [x] Opening brace should be on a new line
---------------------------------------------------------------------------
PHPCBF CAN FIX THE 13 MARKED SNIFF VIOLATIONS AUTOMATICALLY
---------------------------------------------------------------------------
Time: 82ms; Memory: 6MB
对于几行代码来说,这是一个令人疲惫的问题。幸运的是,正如输出所示,我们可以不费吹灰之力就修复很多错误(应用于副本,以便下次保留我的格式错误):
$ php phpcbf.phar --standard=PSR12 src/ch15/batch01/EbookParser.php
PHPCBF RESULT SUMMARY
----------------------------------------------------------------------
FILE FIXED REMAINING
----------------------------------------------------------------------
/var/popp/src/ch15/batch01/EbookParser.php 13 3
----------------------------------------------------------------------
A TOTAL OF 13 ERRORS WERE FIXED IN 1 FILE
----------------------------------------------------------------------
Time: 96ms; Memory: 6MB
现在,如果我们再次运行phpcs
,我们将会看到情况有了很大的改善:
$ php phpcs.phar --standard=PSR2 src/ch15/batch01/EbookParser.php
FILE: /var/popp/src/ch15/batch01/EbookParser.php
----------------------------------------------------------------------
FOUND 3 ERRORS AFFECTING 3 LINES
----------------------------------------------------------------------
7 | ERROR | Class name "ebookParser" is not in PascalCase format
10 | ERROR | Visibility must be declared on method " construct"
17 | ERROR | Visibility must be declared on method "setformat"
----------------------------------------------------------------------
Time: 76ms; Memory: 6MB
我将继续添加可见性声明,然后更改类名——这是一项快速的工作!现在我有了一个时髦的代码文件:
// listing 15.17
namespace popp\ch15\batch01;
class EbookParser
{
public function __construct(string $path, $format = 0)
{
if ($format > 1) {
$this->setFormat(1);
}
}
private function setformat(int $format): void
{
// do something with $format
}
}
PSR-4 自动装弹
我们在第五章中看到了 PHP 对自动加载的支持。在那一章中,我们看到了如何使用spl_autoload_register()
函数根据一个尚未卸载的类的名称自动请求文件。这虽然厉害,但也是一种幕后魔术。这在单个项目中是没问题的,但是如果多个组件聚集在一起,并且都使用不同的约定来加载类文件,就会造成很大的混乱。
自动加载标准(PSR-4)要求框架符合一组通用的规则,从而为魔术增加了一些纪律。
这对开发者来说是个好消息。这意味着我们或多或少可以忽略需要文件的机制,转而关注类依赖。
对我们很重要的规则
PSR-4 的主要目的是为自动装载机开发者定义规则。然而,这些规则不可避免地决定了我们必须声明名称空间和类的方式。这里是一些基本的。
完全限定的类名(即,类名,包括其名称空间)必须包括初始的“供应商”名称空间。因此,一个类必须至少有一个名称空间。
假设我们的供应商名称空间是popp
。我们可以这样声明一个类:
// listing 15.18
namespace popp;
class Services
{
}
这个类的完全限定类名是popp\Services
。
路径中的初始命名空间必须对应于一个或多个基目录。我们可以用它将一组子名称空间映射到一个起始目录。例如,如果我们想使用名称空间popp\library
(而不是名称空间popp
下的任何东西),那么我们可以将它映射到一个顶级目录,这样我们就不必维护一个空的popp/
目录。
让我们设置一个composer.json
文件来执行映射:
{
"autoload": {
"psr-4": {
"popp\\library\\": "mylib"
}
}
}
注意,我甚至不需要调用基目录,"library"
。这是从popp\library
到mylib
目录的任意映射。现在我可以在mylib
目录下创建一个类文件:
// listing 15.19
// mylib/LibraryCatalogue.php
namespace popp\library;
use popp\library\inventory\Book;
class LibraryCatalogue
{
private array $books = [];
public function addBook(Book $book): void
{
$this->books[] = $book;
}
}
为了被找到,LibraryCatalogue
类必须放在一个完全相同名称的文件中(带有明显增加的.php
扩展名)。
在基本目录(mylib
)已经与初始名称空间(popp\library
)相关联之后,在后续目录和子名称空间之间必须有直接关系。碰巧我已经在我的LibraryCatalogue
类中引用了一个名为popp\library\inventory\Book
的类。因此,该类文件应该放在mylib/inventory
目录中:
// listing 15.20
// mylib/library/inventory/Book.php
namespace popp\library\inventory;
class Book
{
// implementation
}
还记得路径中的初始名称空间必须对应一个或多个基目录的规则吗?到目前为止,我们已经在popp\library
和mylib
之间建立了一一对应的关系。实际上,我们没有理由不能将popp\library
名称空间映射到多个基本目录。让我们将名为additional
的目录添加到映射中;下面是对composer.json
的修正:
{
"autoload": {
"psr-4": {
"popp\\library\\": ["mylib", "additional"]
}
}
}
现在,我可以创建额外的/inventory 目录和一个要放入其中的类:
// listing 15.21
// additional/inventory/Ebook.php
namespace popp\library\inventory;
class Ebook extends Book
{
// implementation
}
接下来,让我们创建一个顶级 runner 脚本index.php
,来实例化这些类:
// listing 15.22
require_once("vendor/autoload.php");
use popp\library\LibraryCatalogue;
// will be found under mylib/
use popp\library\inventory\Book;
// will be found under additional/
use popp\library\inventory\Ebook;
$catalogue = new LibraryCatalogue();
$catalogue->addBook(new Book());
$catalogue->addBook(new Ebook());
Note
您必须使用 Composer 来生成自动加载文件vendor/autoload.php
,并且在您访问您在composer.json
中声明的逻辑之前,必须以某种方式包含该文件。您可以通过运行命令composer install
来做到这一点(或者如果您只想在已经安装的环境中重新生成自动加载文件,可以通过运行composer dump-autoload
)。你可以在第十六章中了解更多关于作曲家的知识。
还记得关于副作用的规则吗?一个 PHP 文件应该声明类、接口、函数等等;或者,它应该执行一个操作。然而,它不应该两者兼而有之。这个脚本属于采取行动类别。重要的是,它调用require_once()
来包含使用composer.json
文件中的配置生成的自动加载代码。由于这个原因,所有的类都被定位了,尽管Ebook
已经被放置在一个完全独立于其他类的基本目录中。
为什么我要为同一个核心名称空间维护两个独立的目录?一个可能的原因是您希望将单元测试与生产代码分开。您还可以管理并非每个系统版本都附带的插件和扩展。
Note
务必在 www.php-fig.org/psr/
时刻关注所有 PSR 标准。这是一个快速发展的领域,你可能会发现与你相关的标准正在路上。
摘要
在这一章中,我稍微考虑了一下标准并不那么令人兴奋的可能性,然后为它们的力量做了一个案例。标准解决了我们的集成问题,这样我们就可以继续工作,做令人惊奇的事情。我研究了 PSR-1 和 PSR-12,它们是基本编码和更广泛编码风格的标准。接下来,我继续讨论 PSR-4,自动装载机的标准。最后,我通过一个基于 Composer 的例子展示了 PSR-4 兼容的自动加载。
十六、用 Composer 使用和创建组件
程序员渴望产生可重用的代码。这是面向对象编码的伟大目标之一。我们喜欢从特定环境的混乱中抽象出有用的功能,将特定的解决方案变成可以反复使用的工具。从另一个角度来看,如果程序员喜欢可重用,他们讨厌重复。通过创建可重新应用的库,程序员无需在多个项目中实现类似的解决方案。
即使我们在自己的代码中避免了重复,还有一个更广泛的问题。对于您创建的每个工具,有多少其他程序员实现了相同的解决方案?这是大规模的浪费努力:对于程序员来说,与其在一个主题上产生成百上千个变体,不如合作并把精力集中在改进一个工具上,这样不是更明智吗?
为了做到这一点,我们需要获得现有的库。但是我们需要的包可能需要其他库来完成它们的工作。因此,我们需要一个工具,可以处理下载和安装包,以及管理他们的依赖关系。这就是作曲家的用武之地;除此之外,它还能做更多的事情。
本章将涵盖几个关键问题:
-
安装:下载并设置作曲家
-
需求:使用
composer.json
获取包 -
版本:指定版本,以便在不破坏系统的情况下获得最新代码
-
打包师:为公共访问配置你的代码
-
私有存储库:利用使用私有存储库的 Composer
什么是作曲家?
严格来说,Composer 是一个依赖管理器,而不是一个包管理器。这似乎是因为它在本地基础上处理组件关系,而不是像 Yum 和 Apt 那样集中处理。如果你认为这是一个过于细微的区别,你可能是对的。无论我们如何定义它,Composer 都允许您指定包。它将它们下载到一个本地目录(vendor
),找到并下载所有的依赖项,然后通过一个自动加载器将这些代码提供给你的项目。
一如既往,我们需要从获得工具开始。
安装作曲者
您可以在 https://getcomposer.org/download/
下载作曲家。你会在那里找到一个安装机制。您也可以安装一个稳定的 phar 文件,如下所示:
$ wget https://getcomposer.org/composer-stable.phar
$ chmod 755 composer-stable.phar
$ sudo mv composer-stable.phar ~/bin/composer
我下载存档文件并运行chmod
以确保它是可执行的。然后我将它复制到一个中心位置,这样我就可以在系统的任何地方轻松运行它。现在我可以测试这个命令了:
$ composer --version
Composer version 2.0.8 2020-12-03 17:20:38
安装(一组)软件包
为什么我用括号做了那个奇怪的动作?因为包不可避免地产生包——有时是很多包。
不过,让我们从一个独立的库开始。想象一下,我们正在构建一个需要与 Twitter 通信的应用。稍微研究一下,我就想到了abraham/twitteroauth
包。为了安装它,我需要生成一个名为composer.json
的 JSON 文件,然后定义一个require
元素:
{
"require": {
"abraham/twitteroauth": "2.0.*"
}
}
我从一个除了composer.json
文件以外为空的目录开始。但是,一旦我运行 Composer 命令,我们将看到一个变化:
$ composer update
Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking abraham/twitteroauth (2.0.1)
- Locking composer/ca-bundle (1.2.8)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive
Generating autoload files
那么产生了什么呢?让我们来看看:
$ ls
composer.json composer.lock vendor
Composer 将软件包安装到vendor/
中。它还生成一个名为composer.lock
的文件。这指定了安装的所有软件包的确切版本。如果您使用版本控制,您应该提交这个文件。如果另一个开发人员用一个composer.lock
文件运行composer install
,包版本将完全按照指定安装在她的系统上。通过这种方式,团队可以彼此保持同步,并且您可以确保您的生产环境与开发和测试环境完全匹配。
您可以通过再次运行composer update
来覆盖锁定文件。这将生成一个新的锁文件。通常情况下,您将运行这个来保持最新的包版本(如果您使用通配符,就像我一样,或者使用范围)。
从命令行安装软件包
如您所见,我可以使用编辑器创建composer.json
文件。但是你也可以让作曲家为你做。如果您需要从单个包开始,这将特别有用。当您在命令行上调用composer require
时,Composer 会下载指定的包并安装到vendor/
中。它还会生成一个composer.json
文件,然后您可以编辑和扩展该文件:
$ composer require abraham/twitteroauth
Using version ².0 for abraham/twitteroauth
./composer.json has been created
Running composer update abraham/twitteroauth
Loading composer repositories with package information Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
- Locking abraham/twitteroauth (2.0.1)
- Locking composer/ca-bundle (1.2.8)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive
Generating autoload files
版本
Composer 旨在支持语义版本控制。本质上,这涉及到用三个数字定义一个包的版本,用点分隔:主要版本、次要版本和补丁。如果您修复了一个 bug,没有添加任何功能,并且没有破坏向后兼容性,那么您应该增加补丁的编号。如果添加新功能,但不破坏向后兼容性,应该增加中间的次要号。如果您的新版本破坏了向后兼容性(换句话说,如果这个新版本突然切换,客户端代码将会中断),那么您应该增加第一个主版本号。
Note
您可以在 https://semver.org
阅读更多关于语义版本控制的约定。
在您的composer.json
文件中指定版本时,您应该记住这一点:如果您在范围或通配符方面过于自由,您可能会发现您的系统在更新时会崩溃。
表 16-1 显示了使用 Composer 指定版本的一些方法。
表 16-1
作曲者和软件包版本
|类型
|
例子
|
笔记
|
| --- | --- | --- |
| 确切的 | 1.2.2 | 仅安装给定的版本 |
| 通配符 | 1.2.* | 安装精确指定的数字,但要找到与通配符匹配的最新可用版本 |
| 范围 | 1.0.0–1.1.7 | 安装一个不低于第一个数字且不高于最后一个数字的版本 |
| 比较 | >1.2.0<=1.2.2 | 使用<
、<=
、>
和>=
指定复杂范围。您可以用一个空格(相当于“和”)或者用||
来指定“或”来组合这些指令 |
| 颚化符(主要版本) | ~1.3 | 给定的数字是最小值,指定的最终数字可以增加。所以对于~1.3,1.3 是最小值,在 2.0.0 或更高版本上不可能有匹配 |
| 脱字号 | ¹.3 | 将匹配下一个重大更改,但不包括下一个重大更改。因此,虽然~1.3.1 与 1.4 及更高版本不匹配,但¹.3.1 将与 1 . 3 . 1 至 2.0.0 版本匹配,但不包括 2 . 0 . 0 版本。这通常是最有用的捷径 |
Note
通过向版本约束字符串添加稳定性后缀,您可以进一步影响 composer 选择包的方式。通过添加@
,后跟dev
、alpha
、beta
和RC
(从最不稳定到最稳定),您将允许 composer 在其计算中考虑不稳定的版本。Composer 可以通过查看 git 标记名来解决这个问题。所以1.2.*@dev
可以匹配标签1.2.2-dev
。您还可以使用稳定性标志stable
来表示您不希望包含前沿代码。这将匹配未定义为dev
、beta
等的版本标签。
要求-开发
通常,您在开发过程中需要在生产环境中不必要的包。例如,您可能希望在本地运行测试,但是您不太可能需要在您的公共站点上提供 PHPUnit。
Composer 通过支持单独的require-dev
元素来解决这个问题。您可以在这里添加包,就像您可以为require
元素添加包一样:
{
"require-dev": {
"phpunit/phpunit": "*"
},
"require": {
"abraham/twitteroauth": "².0",
"ext-xml": "*"
}
}
现在,当我们运行composer update
时,PHPUnit 和所有种类的依赖包都被下载和安装:
$ composer update
Loading composer repositories with package information Updating dependencies
Lock file operations: 36 installs, 0 updates, 0 removals
- Locking abraham/twitteroauth (2.0.1)
- Locking composer/ca-bundle (1.2.8)
- Locking doctrine/instantiator (1.4.0)
...
Writing lock file
Installing dependencies from lock file (including require-dev) Package operations: 36 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive
...
6 package suggestions were added by new dependencies, use `composer suggest` to see details.
Generating autoload files
但是,如果您在生产环境中安装,您可以将--no-dev
标志传递给composer install
,Composer 将只下载那些在require
元素中指定的包:
$ composer install --no-dev
Installing dependencies from lock file
Verifying lock file contents can be installed on current platform.
Package operations: 2 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive Generating autoload files
Note
当您运行composer install
命令时,Composer 会创建一个名为composer.lock
的文件。这记录了你在vendor/
下安装的每个文件的确切版本。如果您在composer.json
旁边有一个composer.lock
文件的情况下运行composer install
,如果它们不存在,Composer 将获取它记录的包版本。这很有用,因为您可以将一个composer.lock
文件提交到您的版本控制库,并确保您的团队将下载您已经安装的所有包的相同版本。如果您需要覆盖composer.lock
,要么是为了获得最新版本的包,要么是因为您已经更改了composer.json
,您应该运行composer update
来覆盖锁文件。
作曲和自动加载
我们在第十五章中详细介绍了自动装载。然而,为了完整起见,还是值得简单地看一下。Composer 生成一个名为autoload.php
的文件,该文件为它下载的包处理类加载。您也可以通过包含autoload.php
(通常使用require_once()
)来为您自己的代码利用这一功能。一旦你这样做了,只要你的目录和文件名反映了你的命名空间和类名,你在你的系统中声明的任何类都会在你的代码中被自动找到。
换句话说,名为poppbook\megaquiz\command\CommandContext
的类必须放在poppbook/megaquiz/command/
目录中名为CommandContext.php
的文件中。
如果您想把事情搞混(可能通过省略一两个冗余的前导目录或者通过在搜索路径中添加一个测试目录),那么您可以使用autoload
元素将一个名称空间映射到您的文件结构,如下所示:
"autoload": {
"psr-4": {
"poppbook\\megaquiz\\": ["src", "test"]
}
}
为了生成最新的autoload.php
文件,我需要运行composer install
(也将安装锁文件中指定的任何东西)或composer update
(也将安装与composer.json
中的规范相匹配的最新包)。如果你不想安装或更新任何软件包,你可以使用composer dump-autoload
,它只会生成自动加载文件。
现在,只要包含了autoload.php
,我的类很容易被发现。多亏了我的autoload
配置,poppbook\megaquiz\command\CommandContext
现在可以在src/command/CommandContext.php
中找到了。不仅如此,因为我引用了不止一个目标(test
和src
,我还可以在test/
目录下创建属于poppbook\megaquiz\
名称空间的测试类。
转到第十五章中的“PSR-4 自动加载”部分,以了解更深入的示例。
创建自己的包
如果您过去曾经使用过 PEAR,那么您可能会期望这里关于创建包的一节包含一个全新的包文件。事实上,我们已经在本章中创建了一个包。我们只需要添加更多的信息,然后找到一种方法使我们的代码对其他人可用。
添加包信息
您真的不需要添加那么多信息来制作一个可行的包,但是您绝对需要一个name
,这样您的包才能被找到。我还将包含description
和authors
元素,并创建一个名为megaquiz
的假产品,你会发现它偶尔会在其他章节中出现:
"name": "poppbook/megaquiz",
"description": "a truly mega quiz",
"authors": [
{
"name": "matt zandstra",
"email": "matt@getinstance.com"
}
],
这些字段应该是不言自明的。例外情况可能是前面的名称空间,在本例中是poppbook
,它与实际的包名之间用正斜杠隔开。这就是所谓的厂商名称。正如您所预料的,当您的包被安装时,供应商名称将成为vendor/
下的一个顶级目录。这通常是 GitHub 或 Bitbucket 中的包所有者使用的组织名称。
所有这些都准备好了,您就可以将您的包提交到您选择的版本控制主机了。如果你不确定这涉及到什么,你可以在第十七章了解更多。
Note
Composer 支持一个version
字段,但是在 Git 中使用一个标签来跟踪包的版本被认为是更好的实践。Composer 会自动识别这一点。
请记住,您不应该推送vendor
目录(至少通常不应该——该规则有一些有争议的例外)。然而,沿着composer.json
跟踪生成的composer.lock
文件是一个好主意。
平台包
虽然您不能使用 Composer 来安装系统范围的软件包,但是您可以指定系统范围的需求,这样您的软件包将只安装在准备好的系统中。
一个平台包用一个键来指定,尽管在一些情况下,这个键可以用破折号按类型进一步分解。我在表 16-2 中列出了可用的类型。
表 16-2
平台包
|类型
|
例子
|
描述
|
| --- | --- | --- |
| 服务器端编程语言(Professional Hypertext Preprocessor 的缩写) | "php": "8.*"
| PHP 版本 |
| 延长 | "ext-xml": ">2"
| PHP 扩展 |
| 库 | "lib-iconv": "~2"
| PHP 使用的系统库 |
| 嗯,嗯 | "hhvm": "~2"
| HHVM 版本(HHVM 是支持 PHP 扩展版本的虚拟机) |
让我们试一试:
{
"require": {
"abraham/twitteroauth": "2.0.*",
"ext-xml": "*",
"ext-gd": "*"
}
}
在前面的代码中,我指定我的包需要xml
和gd
扩展名。现在该跑了update
:
$ composer update
Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires PHP extension ext-gd * but it is missing from your system. Install or enable PHP's gd extension.
看起来好像我是为 XML 而设置的;但是,我的系统上没有安装 GD,一个图像操作包,所以 Composer 抛出一个错误。
通过包装商分销
如果你一直在阅读这一章,你可能会想知道我们一直在安装的包实际上是从哪里来的。这感觉很像魔术,但是(如你所料)幕后有一个包存储库。它被称为 Packagist,可以在 https://packagist.org
找到。只要您的代码可以在公共 git 存储库中找到,就可以通过 Packagist 获得。
让我们试一试。我已经将我的megaquiz
项目推送到 GitHub,所以现在我需要告诉 Packagist 关于我的存储库。注册后,我只需添加我的存储库的 URL。你可以在图 16-1 中看到这一点。
图 16-1
向 Packagist 添加包
一旦我添加了megaquiz
,Packagist 就会定位存储库,检查composer.json
文件,并显示一个控制面板。你可以在图 16-2 中看到。
图 16-2
软件包控制面板
Packagist 告诉我,我还没有设置许可证信息。我可以在任何时候通过向composer.json
文件添加一个license
元素来修复这个问题:
"license": "Apache-2.0",
Packagist 也找不到任何版本信息。我将通过向 GitHub 存储库添加一个标记来解决这个问题:
$ git tag -a 'v1.0.0' -m 'v1.0.0'
$ git push --tags
Note
如果你认为我浏览这些垃圾是作弊,那你就对了。我在第十七章中详细介绍了 Git 和 GitHub。
现在 Packagist 知道了我的版本号。你可以在图 16-3 中确认。
图 16-3
包装商知道版本
现在,任何人都可以包含另一个包中的megaquiz
。下面是一个最小的composer.json
文件:
{
"require": {
"poppbook/megaquiz": "*"
}
}
我指定了供应商名称和包名称。冒险地说,我很乐意接受任何版本。让我们继续安装:
$ composer update
Loading composer repositories with package information
Updating dependencies
Lock file operations: 3 installs, 0 updates, 0 removals
- Locking abraham/twitteroauth (2.0.1)
- Locking composer/ca-bundle (1.2.8)
- Locking poppbook/megaquiz (v1.0.0)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 3 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive
- Installing poppbook/megaquiz (v1.0.0): Extracting archive
Generating autoload files
注意,我在设置megaquiz
时指定的依赖项也被下载了。
保密
当然,你并不总是想向全世界发布你的代码。有时,您只需要与一小部分授权用户共享。
这里有一个名为getinstance/wtnlang-php
的私有包,其中包含一个脚本语言库:
{
"name": "getinstance/wtnlang-php",
"description": "it's a wtn language",
"license": "private",
"authors": [
{
"name": "matt zandstra",
"email": "matt@getinstance.com"
}
],
"autoload": {
"psr-4": {
"getinstance\\wtnlang\\": ["src/", "test/unit"]
}
},
"require": {
"abraham/twitteroauth": "*",
"aura/cli": "~2.1.0",
"monolog/monolog": "¹.23"
},
"require-dev": {
"phpunit/phpunit": "⁷"
}
}
它托管在一个私有的 Bitbucket 存储库中,所以不能通过 Packagist 使用。那么我如何将它包含在项目中呢?我只需要告诉作曲家去哪里找。我可以通过创建或添加到repositories
元素来做到这一点:
{
"repositories": [
{
"type": "vcs",
"url": "git@bitbucket.org:getinstance/wtnlang-php.git"
}
],
"require": {
"poppbook/megaquiz": "*",
"getinstance/wtnlang-php": "dev-develop"
}
}
我可以在require
块中为getinstance/wtnlang-php
指定一个版本,这将对应于 git 存储库中的一个标签,但是,通过使用dev-
前缀,我可以调用一个分支。这在开发过程中非常有用。所以现在,只要我可以访问 getinstance/wtnlang-php,我就可以同时安装我的私有包和megaquiz
:
$ composer update
Loading composer repositories with package information
Updating dependencies
Nothing to modify in lock file
Installing dependencies from lock file (including require-dev)
Package operations: 7 installs, 0 updates, 0 removals
- Installing composer/ca-bundle (1.2.8): Extracting archive
- Installing psr/log (1.1.3): Extracting archive
- Installing monolog/monolog (1.26.0): Extracting archive
- Installing aura/cli (2.1.2): Extracting archive
- Installing abraham/twitteroauth (2.0.1): Extracting archive
- Installing getinstance/wtnlang-php (dev-develop de3bf14): Cloning de3bf1456c
- Installing poppbook/megaquiz (v1.0.0): Extracting archive Generating autoload files
摘要
在阅读本章之前,您应该了解利用 Composer 软件包为您的项目增加功能是多么容易。通过composer.json
文件,您还可以让其他用户访问您的代码,无论是通过使用 Packagist 公开还是通过指定您自己的存储库。这种方法自动为您的用户下载依赖项,并允许第三方软件包使用您的软件包,而无需捆绑。
十七、将 Git 用于版本控制
所有的灾难都有其临界点,即秩序最终崩溃、事件失控的时刻。你在这样的项目中发现过自己吗?你能发现那个关键时刻吗?
也许是当你“仅仅做了几个改变”就发现你让周围的一切都崩溃了(更糟糕的是,你不太确定如何回到你刚刚破坏的稳定点)。可能是当你意识到你的团队中的三个成员一直在同一个类上工作,并且愉快地分享彼此的工作。或者可能是当你发现一个你已经实现了两次的 bug 修复不知何故又从代码库中消失了。如果有一种工具可以帮助你管理协同工作,允许你拍摄你的项目的快照,如果需要的话回滚它们,然后合并多个开发链,这不是很好吗?在这一章中,我们来看看 Git,一个可以做所有这些事情的工具,以及更多。
本章将涵盖使用 Git 的以下方面:
-
基本配置:探索设置 Git 的一些技巧
-
导入:开始新项目
-
提交变更:将您的工作保存到存储库中
-
更新:把别人的作品和你自己的融合在一起
-
分支:维护平行的开发链
为什么要使用版本控制?
如果还没有的话,版本控制将会改变你的生活(如果仅仅是你作为开发人员的生活)。有多少次你在一个项目中到达了一个稳定的时刻,吸了一口气,然后又一次陷入开发混乱中?当展示您正在进行的工作时,恢复到稳定版本有多容易?当然,当项目达到稳定状态时,您可能已经保存了项目的快照,这可能是通过复制您的开发目录实现的。现在,假设您的同事正在处理相同的代码库。也许他和你一样保存了一份稳定的代码副本。不同的是,他的副本是他作品的快照,而不是你的。当然,他也有一个混乱的开发目录。因此,您需要协调项目的四个版本。现在想象一个有四个程序员和一个 web UI 开发人员的项目。你看起来很苍白。也许你想躺下来?
Git 的存在就是为了解决这个问题。使用 Git,所有开发人员都可以从一个中央存储库中克隆他们自己的代码库副本。每当他们的代码达到一个稳定点时,他们可以从服务器上下载最新的代码,并将其与自己最近的工作合并。当他们准备好了,并且在他们修复了任何冲突并且运行了所有测试之后,他们可以把他们的新的稳定合成推回到共享存储库中。
Git 是一个分布式版本控制系统。这意味着,一旦用户获得了一个分支,他们就可以提交给自己的本地存储库,而不需要网络连接。这有许多好处。这意味着日常操作更快,你可以在飞机、火车和汽车上轻松工作。然而,最终,您可以与您的队友共享一个权威的存储库。
事实上,每个开发人员都可以将其工作合并到一个中央存储库中,这意味着协调多个开发链变得更加容易。更好的是,您可以根据日期或标签来检查代码库的版本。因此,当您的代码达到一个稳定点时,例如,适合向客户显示正在进行的工作,您可以用任意标签标记它。然后,当你的客户突然来到你的办公室,想要给投资者留下深刻印象时,你可以使用这个标签来检查正确的代码库。
等等!还有呢!您还可以同时管理多个开发链。如果这听起来不必要的复杂,想象一个成熟的项目。您已经发布了第一个版本,并且正在开发第二个版本。版本 1。n 同时走开?当然不是。您的用户一直在发现错误并要求增强。您可能离发布版本 2 还有几个月的时间,那么您在哪里进行和测试更改呢?Git 允许您维护代码库的不同分支。因此,您可以创建版本 1 的 bug 修复分支。n 用于当前生产代码的开发。在关键点上,这个分支可以合并回版本 2 的代码(主干),这样您的新版本就可以从版本 1 的改进中获益。n。
Note
Git 不是唯一可用的版本控制系统。你可能还想看看 Subversion ( http://subversion.apache.org/
)或者 Mercurial ( http://mercurial.selenic.com/
)。这一章必然是对一个大题目的简要介绍。然而,幸运的是,斯科特·沙孔的Pro Git(2014 年出版)深入而清晰地涵盖了这个主题。不仅如此,在 https://git-scm.com/book/en/v2
网站上还有网页版。
让我们来看看实践中的一些特性。
获取 Git
如果您正在使用一个类似 Unix 的操作系统(比如 Linux 或 FreeBSD),那么您可能已经安装了 Git 并可以使用了。
Note
我用一个前导美元符号($
)来显示在命令行输入的命令,以表示命令提示符,以区别于它们可能产生的任何输出。
尝试从命令行键入以下内容:
$ git help
您应该会看到一些使用信息,以确认您已经准备好开始使用。如果您还没有 Git,那么您应该查阅发行版的文档。您几乎肯定可以使用 Yum 或 Apt 之类的简单安装机制,或者您可以直接从 http://git-scm.com/downloads
获取 Git。
Note
技术编辑 Paul Tregoing 也推荐 Git for Windows(https://gitforwindows.org/
)Git 自带的,自然也是一套有用的开源工具。
使用在线 Git 存储库
你可能已经注意到了,这本书经常是单干的。我几乎从不认为你应该重新发明轮子;相反,在购买现成的车轮之前,你至少应该对车轮结构有所了解。出于这个原因,我将在下一节介绍建立和维护您自己的中央 git 存储库的机制。不过,还是现实点吧。您几乎肯定会使用专门的主机来管理您的存储库。有很多这样的软件可供选择,尽管最大的玩家可能是 Bitbucket ( https://bitbucket.org
)、GitHub ( https://github.org
)和 GitLab ( https://about.gitlab.com/
)。
那么,你应该选择哪个呢?根据经验,GitHub 可能是开源产品的标准。所以,我会和 GitHub 签约我的项目。图 17-1 显示了我的下一个决定,是在公共库还是私有库之间。我将选择公共项目(因为我正在创建一个开源项目)。
图 17-1
GitHub 项目入门
如你所见,在图 17-1 中,我还没有完全打完megaquiz
。此时,GitHub 为导入我的项目提供了一些有用的说明。你可以在图 17-2 中看到这些。
图 17-2
GitHub 的导入说明
不过,我还没有准备好运行这些命令。当我将文件推送到服务器时,GitHub 需要能够验证我。为了做到这一点,它需要我的公钥。我将在下一节“配置 Git 服务器”中描述生成这种密钥的一种方法一旦我有了公钥,我就可以从 GitHub 的用户设置界面的SSH and GPG keys
链接添加它。
在图 17-3 中可以看到 GitHub 的 SSH 和 GPG 键的设置画面。
图 17-3
添加 SSH 密钥
现在,我准备开始向我的存储库添加文件。不过,在我们开始之前,我们应该后退一步,花一些时间来遵循自己动手的路线。
配置 Git 服务器
Git 在两个关键方面不同于传统的版本控制系统。首先,在幕后,它存储文件的快照,而不是在提交之间对文件所做的更改。第二,对用户来说更明显的是,它在您的系统本地运行,直到您选择推送到远程存储库或从远程存储库拉出。这意味着您不需要依赖互联网连接来继续工作。
为了使用 Git,您不需要一个单独的远程存储库;但是在实践中,如果你和一个团队一起工作,拥有一个共享的权力来源几乎总是有意义的。
在这一节中,我将介绍启动和运行远程 Git 服务器所需的步骤。我假设 root 用户可以访问 Linux 机器。
创建远程存储库
为了创建 Git 存储库,我必须首先创建一个包含目录。我通过 SSH 登录到一个新配置的远程服务器。我将在/var/git
下创建我的存储库。一般来说,只有 root 用户可以在那里创建和修改目录,所以我使用sudo
运行下面的命令:
$ sudo mkdir -p /var/git/megaquiz
$ cd /var/git/megaquiz/
我创建了/var/git
,它是我的存储库的父目录和一个名为megaquiz
的样例项目的子目录。现在我可以准备目录本身了:
$ sudo git init --bare
Initialized empty Git repository in /var/git/megaquiz/
--bare flag
告诉 Git 在没有工作目录的情况下初始化存储库。如果您试图推送到一个不是以这种方式创建的存储库,Git 将会抱怨。
目前只有 root 用户可以在/var/git
下乱来。我可以通过创建一个名为git
的用户和组并使其成为目录的所有者来改变这一点:
$ sudo adduser git
$ sudo chown -R git:git /var/git
为本地用户准备存储库
尽管这是一个指定的远程服务器,我也应该确保本地用户可以提交到存储库。如果不小心的话,这可能会导致所有权和权限问题(特别是当拥有sudo
特权的用户推送代码时)。
$ sudo chmod -R g+rws /var/git
这给予了git
组的成员对/var/git
的写访问权,并导致在此创建的所有文件和目录都采用git
组。现在,只要我确保他们是git
组的成员,本地用户就能够写入存储库。不仅如此,创建的任何文件都可以被组中的其他成员写入。
您可以将本地用户添加到git
组,如下所示:
$ sudo usermod -aG git bob
现在用户bob
是git
组的成员。
为用户提供访问权限
前面提到的bob
用户的所有者可以登录到服务器,并通过他的 shell 与存储库进行交互。但是,一般来说,您不希望向所有用户提供 shell 访问。在任何情况下,大多数用户都喜欢利用 Git 的分布式特性,并在本地处理他们的克隆数据。
授予用户 SSH 访问权限的一种方式是通过公钥认证。为此,您首先需要获得用户的公共 SSH 密钥。用户可能已经有了这个——在 Linux 机器上,他可能会在配置目录中的一个名为id_rsa.pub
的文件中找到这个密钥。否则,他可以很容易地生成新的密钥。在类似 Unix 的机器上,这就是运行ssh-keygen
命令并复制它生成的值:
$ ssh-keygen
$ cat .ssh/id_rsa.pub
作为存储库管理员,我会要求您提供这个密钥的副本。一旦有了它,我必须将它添加到存储库服务器上的git
用户的 SSH 设置中。这仅仅是将公钥粘贴到.ssh/authorized_keys
文件中的问题。我可能需要为我设置的第一个密钥创建.ssh
配置目录(我从git
用户的主目录运行这些命令):
$ mkdir .ssh
$ chmod 0700 .ssh
# create authorized_keys file and paste in the user's key:
$ vi .ssh/authorized_keys
$ chmod 0700 .ssh/authorized_keys
Note
SSH 访问失败的一个常见原因是创建了权限过于宽松的配置文件。SSH 配置环境应该只对帐户的所有者可读和可写。Michael Stahnke (Apress,2005)的 Pro OpenSSH 全面介绍了 SSH。
关闭 git 用户的 Shell 访问
任何服务器都不应该比它需要的更开放。您可能希望让您的用户能够访问 Git 命令,但可能仅此而已。
通过查看文件/etc/passwd
,您可以看到与 Linux 服务器上的用户相关联的 shell。下面是我的远程服务器上的git
帐户的相关行:
git:x:1001:1001::/home/git:/bin/bash
Git 提供了一个名为git-shell
的特殊 shell,它限制用户只能使用选定的命令。我可以通过编辑/etc/passwd
来启用这个登录程序:
git:x:1001:1001::/home/git:/usr/bin/git-shell
现在,如果我尝试通过 SSH 登录,我会被告知分数并被注销:
$ ssh git@poppch17.vagrant.internal
Last login: Thu Dec 31 14:25:05 2020 from 192.168.33.1
fatal: Interactive git shell is not enabled.
hint: ~/git-shell-commands should exist and have read and execute access. Connection to 192.168.33.71 closed.
开始一个项目
现在我有了一个远程 Git 服务器,并且可以从我的本地帐户访问它,是时候在/var/git/megaquiz
将我正在进行的工作添加到存储库中了。
在开始之前,我会仔细检查一下我的文件和目录,并删除我可能找到的任何临时项目。
做不到这一点是常见的烦恼。要监视的临时项目包括自动生成的文件,如 composer 包、构建目录、安装程序日志等。
Note
您可以通过在存储库中放置一个名为.gitignore
的文件来指定要忽略的文件和模式。在 Linux 系统上,man gitignore
命令应该提供文件名通配符的例子,您可以修改这些例子来排除由您的构建过程、编辑器和 ide 创建的各种锁文件和临时目录。该文本也可在 http://git-scm.com/docs/gitignore
在线获取。
在继续之前,我应该用 Git 注册我的身份——这样可以更容易地跟踪谁在存储库中做了什么:
$ git config --global user.name "poppbook"
$ git config --global user.email "poppbook@getinstance.com"
现在,我已经建立了我的个人详细信息,并确保我的项目是干净的,我可以设置它并将其代码推送到服务器:
$ cd /home/mattz/work/megaquiz
$ git init
Initialized empty Git repository in /home/mattz/work/megaquiz/.git/
现在是时候添加我的文件了:
$ git add .
Git 现在正在跟踪megaquiz
下的所有文件和目录。被跟踪的文件可以有三种状态:未修改、修改或暂存。您可以通过运行命令git status
来检查这一点:
$ git status
# On branch master
#
# Initial commit
#
# Changes to be committed:
# (use "git rm --cached <file>..." to unstage)
#
# new file: composer.json
# new file: composer.lock
# new file: main.php
# new file: src/command/Command.php
# new file: src/command/CommandContext.php
# new file: src/command/FeedbackCommand.php
# new file: src/command/LoginCommand.php
# new file: src/quizobjects/User.php
# new file: src/quiztools/AccessManager.php
# new file: src/quiztools/ReceiverFactory.php
#
感谢我之前的git add
命令,我所有的文件都准备好提交了。我现在可以继续执行commit
命令了:
$ git commit -m'my first commit'
[master (root-commit) a5ca2d4] my first commit
10 files changed, 1638 insertions(+)
create mode 100644 composer.json
create mode 100644 composer.lock
create mode 100755 main.php
create mode 100755 src/command/Command.php
create mode 100755 src/command/CommandContext.php
create mode 100755 src/command/FeedbackCommand.php
create mode 100755 src/command/LoginCommand.php
create mode 100755 src/quizobjects/User.php
create mode 100755 src/quiztools/AccessManager.php
create mode 100644 src/quiztools/ReceiverFactory.php
我通过-m
标志添加了一条消息。如果我忽略了这一点,那么 Git 将启动一个编辑器,我可以用它来添加我的签入消息。
如果您习惯于 CVS 和 Subversion 等版本控制系统,您可能会认为我们已经完成了。虽然我可以愉快地从这里继续编辑、添加、提交和分支,但是如果我想使用中央存储库共享这些代码,我还需要考虑一个额外的阶段。正如我们将在本章后面看到的,Git 允许我们管理多个项目分支。多亏了这个特性,我可以为每个版本维护一个分支,而且还可以将我最前沿的高风险开发安全地保留在我的产品代码之外。当我们开始时,Git 建立了一个名为master
的分支。我可以用命令git branch
确认我的分支的状态:
$ git branch -a
* master
-a
标志指定 Git 应该向我们显示所有分支(缺省情况是忽略远程分支)。输出显示master
支路。
事实上,我还没有将我的本地存储库与远程服务器关联起来。是时候纠正这个错误了:
$ git remote add origin git@poppch17.vagrant.internal:/var/git/megaquiz
考虑到它所做的工作,这个命令安静得令人失望。事实上,这相当于告诉 Git“将昵称origin
与给定的服务器位置相关联”。此外,在本地分支机构master
和远程对等机构之间建立跟踪关系。”
为了确认所有这些,我用 Git 检查远程句柄origin
是否已经设置好:
$ git remote -v
origin git@poppch17.vagrant.internal:/var/git/megaquiz (fetch)
origin git@poppch17.vagrant.internal:/var/git/megaquiz (push)
当然,如果你使用像 GitHub 这样的服务,你会使用图 17-2 所示的git remote add
步骤。在我的例子中,它看起来像这样:
$ git remote add origin git@github.com:poppbook/megaquiz.git
但是,不要运行前面的命令,除非您真的想推送到我的 GitHub repo!我现在坚持使用我的自托管 Git 存储库。
然而,我还没有向我的 Git 服务器发送任何实际的文件,所以这是我的下一步:
$ git push origin master
Counting objects: 16, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (15/15), done.
Writing objects: 100% (16/16), 8.87 KiB | 0 bytes/s, done.
Total 16 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), done.
To git@github.com:poppbook/megaquiz.git
* [new branch] master -> master
现在我可以再次运行git branch
命令来确认master
分支的远程版本已经出现:
$ git branch -a
* master
remotes/origin/master
或者,只查看远程分支:
$ git branch –r
origin/master
Note
我建立了一个叫做的跟踪分支。这是与远程 twin 相关联的本地分支。
克隆存储库
为了这一章的目的,我已经虚构了一个名为 Bob 的团队成员。鲍勃正在和我一起做大测验项目。自然,他想要自己版本的代码。我已经将他的公钥添加到 Git 服务器中,所以他可以开始了。在 GitHub 的平行世界中,我邀请了 Bob 加入我的项目,他在自己的帐户中添加了自己的公钥。效果是一样的;Bob 可以使用命令git clone
获取存储库:
$ git clone git@github.com:poppbook/megaquiz.git
Cloning into 'megaquiz'...
remote: Enumerating objects: 16, done.
remote: Counting objects: 100% (16/16), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 16 (delta 2), reused 16 (delta 2), pack-reused 0
Receiving objects: 100% (16/16), 8.87 KiB | 0 bytes/s, done.
Resolving deltas: 100% (2/2), done.
现在我们两个都可以在本地开发,当我们准备好的时候,彼此分享我们的代码。
更新和提交
当然,Bob 是一个优秀且有才华的人——除了一个非常令人讨厌的共同特点:他不能不管别人的代码。
Bob 既聪明又好奇,很容易被闪亮的新开发途径所激发,他热衷于帮助优化新代码。结果,无论我走到哪里,我似乎都能看到鲍勃的手。Bob 补充了我的文档,他实现了我在喝咖啡时提到的一个想法。我可能要杀了鲍勃。然而,与此同时,我必须处理这样一个事实,即我正在处理的代码需要与 Bob 的输入合并。
这里有一个文件叫做quizobjects/User.php
。目前,它只包含最少的骨头:
namespace poppbook\megaquiz\quizobjects;
class User
{
}
我决定添加一些文档。我首先向我的文件版本添加一个文件注释:
namespace popp\ch17\megaquiz\quizobjects;
/**
* @license http://www.example.com Borsetshire Open License
* @package quizobjects
*/
class User
{
}
记住,一个文件可以有三种状态:未修改、已修改和已登台。User.php
文件现在已经从未修改的移动到已修改的。我可以用git status
命令看到这一点:
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: src/quizobjects/User.php
#
no changes added to commit (use "git add" and/or "git commit -a")
User.php
已被修改,但尚未准备提交。我可以使用命令git add
改变这种状态:
$ git add src/quizobjects/User.php
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: src/quizobjects/User.php
#
现在我准备提交:
$ git commit -m'added documentation' src/quizobjects/User.php
[master 997622c] added documentation
1 file changed, 5 insertions(+)
Git 提交只影响我的本地存储库。如果我确信世界已经为我的改变做好了准备,我必须将我的代码推送到远程存储库:
$ git push origin master
Counting objects: 9, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 537 bytes | 0 bytes/s, done.
Total 5 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To git@github.com:poppbook/megaquiz.git
ce5a604..997622c master -> master
与此同时,在自己的沙盒中工作的鲍勃一如既往地热衷于此,他创建了一个类评论:
namespace popp\ch17\megaquiz\quizobjects;
/**
* @package quizobjects
*/
class User
{
}
现在轮到鲍勃了add
、commit
和push
。因为添加和提交部分经常一起运行,所以 Git 允许您将它们合并到一个命令中:
$ git commit -a -m'my great documentation'
[master 13de456] my great documentation
1 file changed, 4 insertions(+)
所以我们现在有两个不同版本的User.php
。一个是我刚刚推送到远程存储库的版本,另一个是 Bob 的版本,已提交,但尚未推送到远程存储库。让我们看看当 Bob 试图将他的本地版本推送到远程存储库时会发生什么:
$ git push origin master
To git@github.com:poppbook/megaquiz.git
! [rejected] master -> master (fetch first)
error: failed to push some refs to 'git@github.com:poppbook/megaquiz.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first merge the remote changes (e.g.,
hint: 'git pull') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
如您所见,如果有更新要应用,Git 不会让您推送。Bob 必须首先下载我版本的User.php
文件:
$ git pull origin master
remote: Enumerating objects: 9, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 5 (delta 1), reused 5 (delta 1), pack-reused 0 Unpacking objects: 100% (5/5), done.
From github.com:poppbook/megaquiz
* branch master -> FETCH_HEAD
Auto-merging src/quizobjects/User.php
CONFLICT (content): Merge conflict in src/quizobjects/User.php
Automatic merge failed; fix conflicts and then commit the result.
Git 很乐意将来自两个源的数据合并到同一个文件中,只要变化不重叠。Git 无法处理影响相同行的更改。它如何决定什么是有优先权的?存储库应该覆盖 Bob 的更改,还是相反?这两种变化应该共存吗?哪个应该先走?Git 别无选择,只能报告冲突,让 Bob 解决问题。
以下是 Bob 打开文件时看到的内容:
/**
<<<<<<< HEAD
* @package quizobjects
*/
=======
* @license http://www.example.com Borsetshire Open License
* @package quizobjects
*/
>>>>>>> f36c6244521dbd137b37b76414e3cea2071958d2
namespace poppbook\megaquiz\quizobjects;
class User
{
}
Git 包括 Bob 的注释和冲突的更改,以及告诉他哪个部分来自哪里的元数据。冲突的信息由一行等号隔开。Bob 的输入由一行小于符号后跟“HEAD”来表示。远程更改包含在分界线的另一边。
现在 Bob 已经确定了冲突,他可以编辑文件来修复冲突:
/**
* @package quizobjects
* @license http://www.example.com Borsetshire Open License
* @package quizobjects
*/
namespace poppbook\megaquiz\quizobjects;
class User
{
}
接下来,Bob 通过暂存文件来解决冲突:
$ git add src/quizobjects/User.php
$ git commit -m'documentation merged'
[master c99d3f5] documentation merged
现在,他终于可以推送到远程存储库了:
$ git push origin master
添加和删除文件和目录
项目在发展过程中会改变形状。版本控制软件必须考虑到这一点,允许用户添加新文件,并删除那些碍事的无用文件。
添加文件
您已经多次看到了add
子命令。在我的项目设置期间,我使用它将我的代码添加到空的megaquiz
存储库中,并随后准备提交文件。通过在未跟踪的文件或目录上运行git add
,您要求 Git 跟踪它——并准备提交。在这里,我将一个名为CompositeQuestion.php
的文档添加到项目中:
$ touch src/quizobjects/CompositeQuestion.php
$ git add src/quizobjects/CompositeQuestion.php
在现实世界中,我可能会从给CompositeQuestion.php
添加一些内容开始。这里,我将自己限制在使用标准的touch
命令创建一个空文件。一旦我添加了一个文档,我仍然必须调用commit
子命令来完成添加:
$ git commit -m'initial check in'
[master 323bec3] initial check in
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 src/quizobjects/CompositeQuestion.php
CompositeQuestion.php
现在位于本地存储库中。
删除文件
如果我发现我太仓促了,需要删除文档,那么毫不奇怪我可以使用名为rm
的子命令:
$ git rm src/quizobjects/CompositeQuestion.php
rm 'src/quizobjects/CompositeQuestion.php'
再次需要一个commit
来完成工作。和往常一样,我可以通过运行git status
来确认这一点:
$ git status
# On branch master
# Your branch is ahead of 'origin/master' by 1 commit.
# (use "git push" to publish your local commits)
#
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# deleted: src/quizobjects/CompositeQuestion.php
#
$ git commit -m'removed Question'
[master 5bf88aa] removed CompositeQuestion
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 src/quizobjects/CompositeQuestion.php
添加目录
还可以用add
和rm
添加和删除目录。假设 Bob 想要创建一个新目录:
$ mkdir resources
$ touch resources/blah.gif
$ git add resources/
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: resources/blah.gif
#
注意resources
的内容是如何自动添加到存储库中的。现在 Bob 可以提交,然后以通常的方式将全部内容推送到远程存储库。
Note
对目录使用git add
时要小心;简直是贪心!该命令将选取给定目录下的任何文件和目录。用git status
检查操作总是一个好主意。
删除目录
如您所料,您可以使用rm
子命令删除目录。然而,在这种情况下,我必须告诉 Git,我希望它通过向子命令传递一个-r
标志来删除目录的内容。在这里,我完全不同意鲍勃增加一个resources
目录的决定:
$ git rm -r resources/
rm 'resources/blah.gif'
标记发布
一切顺利的话,一个项目将最终达到准备就绪的状态,您将想要交付或部署它。每当您发布一个版本时,您应该在您的存储库中留下一个书签,这样您就可以随时在那个时候重新访问代码。如您所料,您可以使用git tag
命令在代码中创建一个标记:
git tag -a 'v1.0.0' -m'release 1.0.0'
您可以通过运行不带参数的git tag
来查看与您的存储库相关联的标签:
$ git tag
v1.0.0
到目前为止,我们一直在当地开展工作。为了将标签放到远程存储库中,我们必须使用带有git push
子命令的--tags
标志:
$ git push origin --tags
Counting objects: 1, done.
Writing objects: 100% (1/1), 159 bytes | 0 bytes/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To git@github.com:poppbook/megaquiz.git
* [new tag] v1.0.0 -> v1.0.0
使用--tags
标志导致所有本地标签被推送到远程储存库。
当然,你在 GitHub repo 上的任何操作都可以在网站上被跟踪。你可以在图 17-4 中看到我的发布标签。
图 17-4
查看 GitHub 上的标签
一旦你可以用一个标签来标记你的代码,你就可以考虑如何重新访问旧版本了。然而,对于这一点,您应该首先花些时间研究分支 Git 在这方面特别擅长。
分支项目
一旦我的项目发布了,我就可以打包离开,去做一些新的事情,对吗?毕竟,它写得如此优雅,以至于不可能出现 bug,更不用说如此详细地说明,没有用户可能需要任何新功能!
同时,回到现实世界,我必须继续在至少两个层面上使用代码库。Bug 报告现在应该会陆续出现,对 1.2.0 版本的需求会随着对奇妙新特性的需求而膨胀。我如何调和这些力量?我需要修复被报告的错误,并且我需要继续主要的开发工作。当下一个版本稳定时,我可以作为开发的一部分修复 bug 并一次性发布所有内容。但是用户可能要等很长时间才能看到问题得到解决。这显然是不能接受的。另一方面,我可以边走边释放。在这种情况下,我会冒着发布不完整代码的风险。显然,我的发展需要两股力量。我将继续向项目的主分支(通常称为主干)添加新的和有风险的特性,但是我现在应该为我的新版本创建一个分支,在这个分支上我只能添加错误修复。
Note
这种管理分行的方式绝不是城里唯一的游戏。开发人员经常争论组织分支和管理发布和错误修复的最佳方式。最流行的方法之一是 git-flow(在 https://danielkummer.github.io/git-flow-cheatsheet/
中有清晰的描述)。在这种做法下,master
就是发布分支。新代码在一个develop
分支上运行,并在发布时合并到master
中。每个活跃开发的单元都有自己的特性分支,当稳定时,这些特性分支被合并到develop
中。
我可以使用git checkout
命令创建并切换到一个新的分支。首先,让我们快速看一下我的分支的状态:
$ git branch -a
* master
remotes/origin/master
如您所见,我有一个单独的分支,master
,和它的远程对等物。现在,我将创建并切换到一个新的分支:
$ git checkout -b megaquiz-branch1.0
Switched to a new branch 'megaquiz-branch1.0'
为了跟踪我对分支的使用,我将使用一个特定的文件作为例子,src/command/FeedbackCommand.php
。看来我及时创建了我的 bug 修复分支。用户开始报告他们无法使用系统中的反馈机制。我找到了窃丨听丨器:
//...
$result = $msgSystem->despatch($email, $msg, $topic);
if (! $user) {
$this->context->setError($msgSystem->getError());
//...
事实上,我应该测试$result
而不是$user
。以下是我的编辑:
//...
$result = $msgSystem->dispatch($email, $msg, $topic);
if (! $result) {
$this->context->setError($msgSystem->getError());
//...
因为我正在分支megaquiz-branch1.0
上工作,所以我可以提交这个变更:
$ git add src/command/FeedbackCommand.php
$ git commit -m'bugfix'
[megaquiz-branch1.0 6e56ade] bugfix
1 file changed, 1 insertion(+), 1 deletion(-)
当然,这个提交是本地的。我需要使用git
push 命令将分支放到远程存储库上:
$ git push origin megaquiz-branch1.0
Counting objects: 9, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 456 bytes | 0 bytes/s, done.
Total 5 (delta 3), reused 0 (delta 0)
remote: Resolving deltas: 100% (3/3), completed with 3 local objects. remote:
remote: Create a pull request for 'megaquiz-branch1.0' on GitHub by visiting:
remote: https://github.com/poppbook/megaquiz/pull/new/megaquiz-branch1.0
remote:
To git@github.com:poppbook/megaquiz.git
* [new branch] megaquiz-branch1.0 -> megaquiz-branch1.0
现在,鲍勃怎么办?他不可避免地会想参与进来并修复一些错误。首先,他调用git fetch
,它从服务器获取任何新信息。然后他可以用git branch -a
查看所有可用的分支。
$ git fetch
$ git branch -a
* master
remotes/origin/HEAD -> origin/master
remotes/origin/master
remotes/origin/megaquiz-branch1.0
现在 Bob 可以切换到本地分支,该分支将跟踪远程分支:
$ git checkout megaquiz-branch1.0
Branch megaquiz-branch1.0 set up to track remote branch megaquiz-branch1.0 from origin.
Switched to a new branch 'megaquiz-branch1.0'
鲍勃现在可以走了。他可以添加和提交自己的修复;当他用力时,它们会停在远处的树枝上。
同时,我想在主干上添加一些前沿的增强功能——也就是我的master
分支。让我们从我的本地存储库的角度再次查看我的分支的状态:
$ git branch -a
master
* megaquiz-branch1.0
remotes/origin/master
remotes/origin/megaquiz-branch1.0
我可以通过调用git checkout
切换到现有的分支:
$ git checkout master
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.
当我现在看command/FeedbackCommand.php
时,我发现我的错误修复已经神奇地消失了。当然还是存放在megaquiz-branch1.0
下。稍后,我可以将补丁合并到master
分支,所以没有必要担心。相反,我可以专注于添加新代码:
class FeedbackCommand extends Command
{
public function execute(CommandContext $context): bool
{
// new and risky development
// goes here
$msgSystem = ReceiverFactory::getMessageSystem();
$email = $context->get('email');
// ...
我在这里所做的只是添加一个注释来模拟代码的添加。我现在可以提交并推动这个:
$ git commit -am'new development on master'
$ git push origin master
所以我现在有平行的分支。当然,迟早,我会希望我的主分支从我在megaquiz-branch1.0
提交的错误修复中受益。
我可以在命令行上做到这一点,但首先让我们暂停一下,看看 GitHub 和 Bitbucket 等类似服务支持的一个特性。pull 请求(通常缩写为 PR)允许我在合并分支之前请求代码审查。所以在 megaquiz-branch1.0 击中 master 之前,我可以让 Bob 检查我的工作。正如你在图 17-5 中看到的,GitHub 检测到了分支,并给了我一个机会来发出我的拉请求。
图 17-5
GitHub 使得发布拉请求变得容易
在提交拉取请求之前,我点击了按钮并添加了一条评论。你可以在图 17-6 中看到结果。
图 17-6
发出拉取请求
现在 Bob 可以检查我的更改,并添加他可能有的任何评论。GitHub 向他展示了到底发生了什么变化。你可以在图 17-7 中看到 Bob 的评论。
图 17-7
拉请求所涵盖的变更
一旦 Bob 批准了我的 pull 请求,我就可以直接从浏览器进行合并,或者返回到命令行。这很简单。Git 提供了一个名为merge
的子命令:
$ git checkout master
Already on 'master'
事实上,我已经在主分支上了——但是确定一下也无妨。现在我执行实际的合并:
$ git merge --no-commit megaquiz-branch1.0
Auto-merging src/command/FeedbackCommand.php
Automatic merge went well; stopped before committing as requested
通过传入--no-commit
标志,我保持合并未提交——这给了我另一个检查一切正常的机会。一旦我满意了,我就可以开始行动了。
$ git commit -m'merge from megaquiz-branch1.0'
[master e1b5169] merge from megaquiz-branch1.0
Note
合并还是不合并?选择并不总是像看起来那么简单。例如,在某些情况下,您的 bug 修复可能是一种临时的工作,被主干上更彻底的重构所取代,或者由于规范的变化而不再适用。这是一个必要的判断。然而,我工作过的大多数团队倾向于在可能的情况下合并到主干,同时将分支上的工作保持在最低限度。对我们来说,新特性通常出现在主干上,并通过“尽早和经常发布”的策略快速到达用户手中。
现在,当我在master
分支上查看FeedbackCommand
的版本时,我确认所有的变更已经被合并:
public function execute(CommandContext $context): bool
{
// new and risky development
// goes here
$msgSystem = ReceiverFactory::getMessageSystem();
$email = $context->get('email');
$msg = $context->get('pass');
$topic = $context->get('topic');
$result = $msgSystem->despatch($email, $msg, $topic);
if (! $result) {
$this->context->setError($msgSystem->getError());
return false;
}
execute()
方法现在包括我模拟的master
开发和 bug 修复。
当我第一次“发布”MegaQuiz 版本时,我创建了一个分支,这就是我们一直在做的事情。但是,请记住,我也在那个阶段创建了一个标记。我当时承诺过,我会告诉你如何访问标签。事实上,您已经看到了。您可以基于标签创建一个本地分支,就像 Bob 创建我们的 bug fix 分支的本地版本一样。不同的是,这个新分支是全新的。它不跟踪现有的远程分支:
$ git checkout -b v1.0.0-branch v1.0.0
Switched to a new branch 'v1.0.0-branch'
然而,现在我有了这个新的分支,我可以像你看到的那样推它和分享它。
Note
Git 是一个非常通用和有用的工具。像所有强大的工具一样,它的使用偶尔会导致意想不到的后果。对于那些你已经把自己逼入绝境,需要快速重置的时刻,科技编辑保罗·特雷哥推荐 https://dangitgit.com/en
(实际上,他推荐的是更瑞典的版本!).该网站充满了可能会拯救你的理智的食谱,所以如果你认真使用 Git 的话,它非常值得收藏。
另外两个值得拥有的 git 命令是git stash
和git stash apply
。当您忙于本地编辑,但被要求切换分支时,您的第一个选择是提交正在进行的工作。但是,您可能不想提交粗糙的代码。您可能认为您唯一的选择是丢弃您的本地更改或将它们复制到临时文件中。但是,如果您运行git stash
,所有的本地更改都会隐藏在幕后,您的分支会返回到上次提交时的状态。你可以离开去做你的紧急工作,当你准备好的时候,运行git stash apply
来取回你未提交的工作。就像魔法一样!
摘要
Git 包含大量的工具,每一个都有令人望而生畏的选项和功能。我只能希望在有限的篇幅内提供一个简要的介绍。尽管如此,如果您只使用我在本章中介绍的功能,您应该会在自己的工作中看到好处,无论是通过防止数据丢失还是改进协作。
在本章中,我们浏览了 Git 的基础知识。在导入项目之前,我简要地看了一下配置。我签出、提交和更新了代码,然后向您展示了如何标记和导出一个版本。在本章的最后,我简要介绍了分支,展示了它们在维护项目中的并发开发和 bug 修复链中的作用。
在某种程度上,我忽略了一个问题。我们建立了一个原则,即开发人员应该签出他们自己的项目版本。但是,总的来说,项目不会原地运行。为了测试他们的更改,开发人员需要在本地部署代码。有时,这就像复制几个目录一样简单。然而,更常见的情况是,部署必须解决一系列配置问题。在下一章,我们将研究一些自动化这一过程的技术。
十八、将 PHPUnit 用于测试
系统中的每个组件都依赖于其对等组件的操作和接口的一致性来持续平稳运行。根据定义,发展会破坏系统。当您改进您的类和包时,您必须记住修改与它们一起工作的任何代码。对于某些更改,这会产生连锁反应,影响远离您最初更改的代码的组件。敏锐的警觉和对系统依赖性的广博知识有助于解决这个问题。当然,虽然这些都是优秀的优点,但是系统很快就会变得太复杂,以至于每一个不想要的效果都很难预测,尤其是因为系统经常结合了许多开发人员的工作。为了解决这个问题,定期测试每个组件是一个好主意。这当然是一项重复而复杂的任务;因此,它非常适合自动化。
在 PHP 程序员可用的测试解决方案中,PHPUnit 可能是最普遍的,当然也是功能最全的工具。在本章中,您将了解以下关于 PHPUnit 的内容:
-
安装:使用 Composer 安装 PHPUnit
-
编写测试:创建测试用例并使用断言方法
-
异常处理:确认失败的策略
-
运行多个测试:将测试收集到套件中
-
构建断言逻辑:使用约束
-
伪造组件:模拟和存根
-
测试 web 应用:使用和不使用附加工具进行测试
功能测试和单元测试
测试在任何项目中都是必不可少的。即使你没有正式化这个过程,你也一定已经发现自己在开发非正式的行动列表,来测试你的系统。这个过程很快变得令人厌倦,这可能会导致你的项目出现交叉手指的情况。
一种测试方法是从项目的界面开始,模拟用户协商系统的各种方式。虽然有各种自动化过程的框架,但这可能是您手工测试时要走的路。这些功能测试有时被称为验收测试,因为成功执行的一系列操作可以被用作结束项目阶段的标准。使用这种方法,您通常将系统视为一个黑盒——您的测试仍然故意忽略协作形成被测系统的隐藏组件。
功能测试是从外部进行的,而单元测试是从内部进行的。单元测试倾向于关注类,测试方法在测试用例中组合在一起。每个测试用例对一个类进行严格的测试,检查每种方法是否如宣传的那样执行,是否如预期的那样失败。我们的目标是尽可能地从更广泛的背景中孤立地测试每个组件。这通常为您提供了一个清醒的结论,即您分离系统各部分的任务是成功的。
测试可以作为构建过程的一部分运行,直接从命令行运行,甚至可以通过网页运行。在这一章中,我将集中讨论命令行。
单元测试是确保系统设计质量的好方法。测试揭示了类和函数的职责。一些程序员甚至提倡测试优先的方法。他们说,你应该在开始上课之前写测试。这规定了一个类的目的,确保一个干净的接口和简短、集中的方法。就我个人而言,我从未渴望达到这种程度的纯净——这不适合我的编码风格。尽管如此,我还是试图边走边写测试。维护测试工具为我提供了重构代码所需的安全性。我可以下载并替换整个包,因为我知道我很有可能在系统的其他地方捕捉到意外的错误。
手工测试
在上一节中,我说过测试在每个项目中都是必不可少的。我可以说测试在每个项目中都是不可避免的。我们都会测试。悲剧的是,我们经常把这个好作品丢掉。
所以,让我们创建一些类来测试。这是一个存储和检索用户信息的类。为了便于演示,它生成数组,而不是您通常期望使用的User
对象:
// listing 18.01
class UserStore
{
private array $users = [];
public function addUser(string $name, string $mail, string $pass): bool
{
if (isset($this->users[$mail])) {
throw new \Exception(
"User {$mail} already in the system"
);
}
if (strlen($pass) < 5) {
throw new \Exception(
"Password must have 5 or more letters"
);
}
$this->users[$mail] = [
'pass' => $pass,
'mail' => $mail,
'name' => $name
];
return true;
}
public function notifyPasswordFailure(string $mail): void
{
if (isset($this->users[$mail])) {
$this->users[$mail]['failed'] = time();
}
}
public function getUser(string $mail): array
{
return ($this->users[$mail]);
}
}
这个类用addUser()
方法接受用户数据,并通过getUser()
检索它。用户的电子邮件地址用作检索的关键字。如果您和我一样,您将在开发时编写一些示例实现,只是为了检查事情是否如您设计的那样运行:
// listing 18.02
$store = new UserStore();
$store->addUser(
"bob williams",
"bob@example.com",
"12345"
);
$store->notifyPasswordFailure("bob@example.com");
$user = $store->getUser("bob@example.com");
print_r($user);
以下是输出:
Array
(
[pass] => 12345
[mail] => bob@example.com
[name] => bob williams
[failed] => 1609766967
)
这是我在处理文件包含的类时可能会添加到文件底部的那种东西。当然,测试验证是手工执行的;由我来观察结果,并确认由UserStore::getUser()
返回的数据与我最初添加的信息相符。然而,这是一种考验。
下面是一个客户端类,它使用UserStore
来确认用户提供了正确的认证信息:
// listing 18.03
class Validator
{
public function __construct(private UserStore $store)
{
}
public function validateUser(string $mail, string $pass): bool
{
if (! is_array($user = $this->store->getUser($mail))) {
return false;
}
if ($user['pass'] == $pass) {
return true;
}
$this->store->notifyPasswordFailure($mail);
return false;
}
}
该类需要一个UserStore
对象,它将该对象保存在$store
属性中。这个属性被validateUser()
方法用来确保,首先,给定电子邮件地址引用的用户存在于存储中,其次,用户的密码与提供的参数匹配。如果这两个条件都满足,该方法返回true
。再一次,我可能会边走边测试这一点:
// listing 18.04
$store = new UserStore();
$store->addUser("bob williams", "bob@example.com", "12345");
$validator = new Validator($store);
if ($validator->validateUser("bob@example.com", "12345")) {
print "pass, friend!\n";
}
我实例化了一个UserStore
对象,用数据填充并传递给一个新实例化的Validator
对象。然后,我可以确认用户名和密码组合。
一旦我最终对自己的工作感到满意,我就可以完全删除这些健全检查,或者把它们注释掉。这是对宝贵资源的严重浪费。这些测试可以作为我开发时检查系统的工具的基础。PHPUnit 是可能帮助我做到这一点的工具之一。
PHPUnit 简介
PHPUnit 是 xUnit 测试工具家族的一员。其前身是 SUnit,这是一个由 Kent Beck 发明的框架,用于测试用 Smalltalk 语言构建的系统。xUnit 框架可能是作为一个流行的工具而建立的,然而,它是由 Java 实现 jUnit 以及像极限编程(XP)和 Scrum 这样的敏捷方法的兴起而建立的,所有这些都非常强调测试。
您可以使用 Composer 获得 PHPUnit:
{
"require-dev": {
"phpunit/phpunit": "⁹"
}
}
一旦您运行了 composer install,您将在vendor/bin/phpunit
找到phpunit
脚本。或者,您可以下载一个 PHP 归档文件(。phar)文件。然后,您可以使归档文件成为可执行文件:
$ wget https://phar.phpunit.de/phpunit.phar
$ chmod 755 phpunit.phar
$ sudo mv phpunit.phar /usr/local/bin/phpunit
Note
我显示了在命令行输入的命令,并在前面加了一个$
来表示命令提示符,以区别于它们可能产生的任何输出。
创建测试用例
有了 PHPUnit,我可以为UserStore
类编写测试。每个目标组件的测试应该收集在一个扩展了PHPUnit\Framework\TestCase
的类中,这是 PHPUnit 包提供的类之一。下面是如何创建一个最小的测试用例类:
// listing 18.05
namespace popp\ch18\batch01;
use PHPUnit\Framework\TestCase;
class UserStoreTest extends TestCase
{
protected function setUp(): void
{
}
protected function tearDown(): void
{
}
}
我将测试用例类命名为UserStoreTest
。将测试放在与被测类相同的名称空间中通常是有用的。这将使您能够方便地访问被测试的类和它的同类,并且您的测试文件的结构将很可能反映您的系统的结构。请记住,由于 Composer 对 PSR-4 的支持,您可以在同一个包中为类文件维护单独的目录结构。
下面是我们在 Composer 中实现这一点的方法:
"autoload": {
"psr-4": {
"popp\\": ["myproductioncode/", "mytestcode/"]
}
}
在这段代码中,我指定了两个映射到popp
名称空间的目录。我现在可以并行维护这些代码,这样就可以很容易地将我的测试和生产代码分开。
每个测试方法都会自动调用setUp()
方法,这允许我们为测试建立一个稳定的、适当准备的环境。在每个测试方法运行后调用tearDown()
。如果您的测试改变了系统的大环境,您可以使用这个方法来重置状态。由setUp()
和tearDown()
管理的公共平台被称为夹具。
为了测试UserStore
类,我需要它的一个实例。我可以在setUp()
中实例化它,并将其分配给一个属性。让我们也创建一个测试方法:
// listing 18.06
namespace popp\ch18\batch01;
use PHPUnit\Framework\TestCase;
class UserStoreTest extends TestCase
{
private UserStore $store;
protected function setUp(): void
{
$this->store = new UserStore();
}
protected function tearDown(): void
{
}
public function testGetUser(): void
{
$this->store->addUser("bob williams", "a@b.com", "12345");
$user = $this->store->getUser("a@b.com");
$this->assertEquals("a@b.com", $user['mail']);
$this->assertEquals("bob williams", $user['name']);
$this->assertEquals("12345", $user['pass']);
}
}
Note
记住setUp()
和tearDown()
对于你的类中的每个测试方法都被调用一次。如果你想包含在一个类中所有测试方法之前运行一次的代码,你可以实现setUpBeforeClass()
方法。相反,对于应该在一个类中所有测试方法之后运行的代码,实现tearDownAfterClass()
。
测试方法应该以单词“Test”开始命名,并且不需要任何参数。这是因为测试用例类是使用反射操作的。
Note
反射在第五章中有详细介绍。
运行测试的对象查看类中的所有方法,并且只调用那些匹配这个模式的方法(即以“test”开头的方法)。
在示例中,我测试了用户信息的检索。我不需要为每个测试实例化UserStore
,因为我在setUp()
中处理了它。因为每个测试都会调用setUp()
,所以$store
属性肯定会包含一个新实例化的对象。
在testgetUser()
方法中,我首先向UserStore::addUser()
提供虚拟数据,然后检索该数据并测试其每个元素。
在我们运行测试之前,还有一个问题需要注意。我使用了不带require
或require_once
的use
语句。换句话说,我依靠自动加载。如果在 Composer 中安装了 phpunit,并且项目的自动加载文件是在相同的上下文中生成的,那么查找和包含自动加载文件是自动处理的。然而,情况可能并不总是如此。例如,我可能正在运行一个全局 PHPUnit 命令,它对我的本地自动加载一无所知,或者我可能已经下载了一个 phar 文件。在这种情况下,我如何告诉我的测试如何定位生成的autoload.php
文件?我可以在测试类(或超类)中放一个require_once
语句,但是这将打破 PSR-1 规则,即类文件不应该有副作用。最简单的方法是从命令行告诉 PHPUnit 关于autoload.php
文件的信息:
$ phpunit src/ch18/batch01/UserStoreTest.php --bootstrap vendor/autoload.php
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.012, Memory: 4.00 MB
OK (1 test, 3 assertions)
断言方法
编程中的断言是一种语句或方法,它允许您检查关于系统某个方面的假设。在使用断言时,你通常定义一个期望,即事情是这样的,即$cheese
是"blue"
或者$pie
是"apple"
。如果你的期望是混乱的,某种警告将会产生。断言是给系统增加安全性的一个很好的方式,PHP 原生地内联支持它们,并允许您在生产环境中关闭它们。
Note
关于 PHP 对断言的支持的更多信息,请参见手册页 https://php.net/assert
。
PHPUnit 通过一组方法支持断言,这些方法可以静态调用,也可以在扩展了PHPUnit\Framework\TestCase
的类的实例上调用。
在前面的例子中,我使用了一个TestCase
方法,assertEquals()
。此方法比较其提供的两个参数,并检查它们是否等价。如果它们不匹配,测试方法将被记为失败的测试。子类化了PHPUnit\Framework\TestCase
之后,我可以使用一组断言方法。表 18-1 中列出了其中一些方法。
表 18-1
PHPUnit\Framework\TestCase
断言方法
方法
|
描述
|
| --- | --- |
| assertEquals($val1, $val2, $message)
| 如果$val1
不等于$val2
则失败 |
| assertFalse($expression, $message)
| 评估$expression
;如果没有解析为false
则失败 |
| assertTrue($expression, $message)
| 评估$expression
;如果没有解析为true
则失败 |
| assertNotNull($val, $message)
| 如果$val
为null
则失败 |
| assertNull($val, $message)
| 如果$val
不是null
则失败 |
| assertSame($val1, $val2, $message)
| 如果$val1
和$val2
不是对同一个对象的引用,或者如果它们是不同类型或不同值的变量,则失败 |
| assertNotSame($val1, $val2, $message)
| 如果$val1
和$val2
是对相同类型和值的相同对象或变量的引用,则失败 |
| assertMatchesRegularExpression($regexp, $val, $message)
| 如果正则表达式$regexp
与$val
不匹配,则失败 |
测试异常
作为一名程序员,你的重点通常是让东西工作并且工作得很好。通常,这种心态会贯穿到测试中,尤其是当你测试你自己的代码时。诱惑是测试一个方法的行为是否如广告所说的那样。很容易忘记测试失败有多重要。方法的错误检查有多好?它会在应该抛出异常的时候抛出异常吗?它抛出了正确的异常吗?例如,如果在问题发生之前操作已经完成了一半,那么它会在错误发生后进行清理吗?作为测试人员,您的任务是检查所有这些。幸运的是,PHPUnit 可以提供帮助。
下面是一个测试,用于检查操作失败时UserStore
类的行为:
// listing 18.07
public function testAddUserShortPass(): void
{
try {
$this->store->addUser("bob williams", "bob@example.com", "ff");
} catch (\Exception $e) {
$this->assertEquals("Password must have 5 or more letters", $e->getMessage());
return;
}
$this->fail("Short password exception expected");
}
如果你回头看一下UserStore::addUser()
方法,你会看到如果用户的密码少于五个字符,我会抛出一个异常。我的测试试图证实这一点。我在一个try
子句中添加了一个拥有非法密码的用户。如果抛出了预期的异常,那么流程跳转到 catch 子句,一切正常。如果addUser()
方法没有像预期的那样抛出异常,执行流程将到达fail()
方法调用。
测试异常被抛出的另一种方法是使用名为expectException()
的断言方法,该方法需要您期望抛出的异常类型的名称(或者是Exception
或者是一个子类)。如果测试方法在没有抛出正确异常的情况下退出,测试将会失败。
Note
PHP 5.2.0 中加入了expectException()
方法。
下面是之前测试的快速重新实现:
// listing 18.08
public function testAddUserShortPassNew(): void
{
$this->expectException(\Exception::class);
$this->store->addUser("bob williams", "bob@example.com", "ff");
}
那么,既然有一种测试异常的简洁方法,我为什么还要展示旧的方法呢?在大多数情况下,最简单的方法——使用expectException()
——将是最好的。但是,有时您可能想要对异常、被测对象的状态执行进一步的测试,或者您可能想要清除一些副作用。在这种情况下,走老派路线可能还是有意义的。
运行测试套件
如果我在测试UserStore
类,我也应该测试Validator
。下面是一个名为ValidateTest
的类的简化版本,它测试了Validator::validateUser()
方法:
// listing 18.09
namespace popp\ch18\batch02;
use PHPUnit\Framework\TestCase;
class ValidatorTest extends TestCase
{
private Validator $validator;
protected function setUp(): void
{
$store = new UserStore();
$store->addUser("bob williams", "bob@example.com", "12345");
$this->validator = new Validator($store);
}
public function testValidateCorrectPass(): void
{
$this->assertTrue(
$this->validator->validateUser("bob@example.com", "12345"),
"Expecting successful validation"
);
}
}
那么现在我有了不止一个测试用例,我如何一起运行它们呢?最好的方法是将您的测试类放在一个公共的根目录下。然后您可以指定这个目录,PHPUnit 将运行它下面的所有测试:
$ phpunit src/ch18/batch02/
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
........ 8 / 8 (100%)
Time: 00:00.026, Memory: 6.00 MB
OK (8 tests, 11 assertions)
限制
在大多数情况下,您将在测试中使用现成的断言。事实上,一口气下来,你可以独自完成很多事情。然而,从 PHPUnit 3.0 开始,PHPUnit\Framework\ TestCase
包含了一组返回PHPUnit\Framework\Constraint
对象的工厂方法。您可以将它们组合起来并传递给TestCase::AssertThat()
,以便构建您自己的断言。
是时候快速举例了。UserStore
对象不允许添加重复的电子邮件地址。这里有一个测试证实了这一点:
// listing 18.10
// UserStoreTest
public function testAddUserDuplicate()
{
try {
$ret = $this->store->addUser("bob williams", "a@b.com", "123456");
$ret = $this->store->addUser("bob stevens", "a@b.com", "123456");
$this->fail("Exception should have been thrown");
} catch (\Exception $e) {
$const = $this->logicalAnd(
$this->logicalNot($this->containsEqual("bob stevens")),
$this->isType('array'),
);
$this->AssertThat($this->store->getUser("a@b.com"), $const);
}
}
这个测试将一个用户添加到UserStore
对象,然后添加第二个具有相同电子邮件地址的用户。因此,测试确认第二次调用addUser()
时抛出异常。在catch
子句中,我使用方便的方法构建了一个约束对象。这些返回对应的PHPUnit\Framework\Constraint
实例。让我们分解上一个示例中的复合约束:
$this->logicalNot($this->containsEqual("bob stevens"))
这将返回一个PHPUnit\Framework\Constraint\Traversable\TraversableContainsEqual
对象。当传递给AssertThat
时,如果测试主题不包含与给定值匹配的元素("bob stevens"
),该对象将生成一个错误。不过,我否定了这一点,将这个约束传递给另一个:PHPUnit\Framework\Constraint\Not
。我再次使用一个方便的方法,通过TestCase
类(实际上是通过一个超类,Assert
)可用:
$this->logicalNot( $this->contains("bob stevens"))
现在,如果测试值(必须是可遍历的)包含一个与字符串"bob stevens"
匹配的元素,AssertThat
断言将失败。通过这种方式,您可以构建非常复杂的逻辑结构。当我完成时,我的约束可以总结如下:“如果测试值是一个数组并且不包含字符串"bob stevens"
,就不要失败。”你可以用这种方式构建更多的约束。通过将两者都传递给AssertThat()
,针对一个值运行约束。
当然,您可以用标准的断言方法实现所有这些,但是约束有两个优点。首先,它们形成了良好的逻辑块,组件之间有清晰的关系(尽管很好地使用格式可能是支持清晰性所必需的)。第二,也是更重要的,约束是可重用的。您可以建立一个复杂约束的库,并在不同的测试中使用它们。您甚至可以将复杂的约束相互结合:
$const = $this->logicalAnd(
$a_complex_constraint,
$another_complex_constraint
);
表 18-2 显示了TestCase
类中可用的一些约束方法。
表 18-2
一些约束方法
|测试用例方法
|
约束失败,除非…
|
| --- | --- |
| greaterThan($num)
| 测试值大于$num
|
| containsEqual($val)
| 测试值(可遍历)包含与$val
匹配的元素 |
| identicalTo($val)
| 测试值是对与$val
相同的对象的引用,或者对于非对象,是相同的类型和值 |
| greaterThanOrEqual($num)
| 测试值大于或等于$num
|
| lessThan($num)
| 测试值小于$num
|
| lessThanOrEqual($num)
| 测试值小于或等于$num
|
| equalTo($value)
| 测试值等于$value
|
| equalTo($value, $delta)
| 测试值等于$value
。$delta
定义数值比较的误差范围 |
| stringContains($str, $casesensitive=true)
| 测试值包含$str
。默认情况下,这是区分大小写的 |
| matchesRegularExpression($pattern)
| 测试值与$pattern
中的正则表达式匹配 |
| logicalAnd(PHPUnit_Framework_Constraint $const, [, $const..])
| 所有提供的约束都通过 |
| logicalOr(PHPUnit_Framework_Constraint $const, [, $const..])
| 至少有一个提供的约束匹配 |
| logicalNot(PHPUnit_Framework_Constraint $const)
| 提供的约束未通过 |
模拟和存根
单元测试的目的是在尽可能大的程度上隔离包含组件的系统来测试组件。然而,真空中很少有组件存在。即使是很好的解耦类也需要访问其他对象作为方法参数。许多类还直接处理数据库或文件系统。
您已经看到了处理这个问题的一种方法。setUp()
和tearDown()
方法可用于管理 fixture(即,测试的一组公共资源,可能包括数据库连接、配置的对象、文件系统上的暂存区等。).
另一种方法是伪造您正在测试的类的上下文。这包括创建假装做真实事情的对象。例如,您可能将一个假的数据库映射器传递给测试对象的构造函数。因为这个假对象与真正的映射器类共享一个类型(从一个公共的抽象基础扩展或者甚至覆盖真正的类本身),所以您的主体并不知道。你可以用有效数据填充假对象。为单元测试提供这种沙箱的对象被称为存根。它们可能是有用的,因为它们允许您将注意力集中在您想要测试的类上,而不会无意中同时测试您系统的整个架构。
然而,赝品可以比这更进一步。因为您正在测试的对象很可能以某种方式调用一个假对象,所以您可以启动它来确认您所期望的调用。以这种方式使用一个假对象作为间谍被称为行为验证,这是区分模拟对象和存根的地方。
您可以通过创建硬编码的类来返回某些值并报告方法调用,从而自己构建模拟。这是一个简单的过程,但可能很耗时。
PHPUnit 提供了更简单、更动态的解决方案。它将为您动态生成模拟对象。它通过检查您想要模仿的类并构建一个覆盖其方法的子类来实现这一点。一旦有了这个模拟实例,就可以在其上调用方法,用数据填充它,并为成功设置条件。
让我们建立一个例子。UserStore
类包含一个名为notifyPasswordFailure()
的方法,它为给定用户设置一个字段。当设置密码失败时,这个函数应该由Validator
调用。在这里,我模拟了UserStore
类,这样它既向Validator
对象提供数据,又确认它的notifyPasswordFailure()
方法按预期被调用:
// listing 18.11
// ValidatorTest
public function testValidateFalsePass(): void
{
$store = $this->createMock(UserStore::class);
$this->validator = new Validator($store);
$store->expects($this->once())
->method('notifyPasswordFailure')
->with($this->equalTo('bob@example.com'));
$store->expects($this->any())
->method("getUser")
->will($this->returnValue([
"name" => "bob williams",
"mail" => "bob@example.com",
"pass" => "right"
]));
$this->validator->validateUser("bob@example.com", "wrong");
}
模拟对象使用一个流畅的接口;也就是说,它们有类似语言的结构。这些使用起来比描述起来容易得多。这种结构从左到右工作,每次调用返回一个对象引用,然后可以通过进一步的修改方法调用(本身返回一个对象)来调用。这有助于简单使用,但调试起来很痛苦。
在前面的例子中,我调用了TestCase
方法createMock()
,并将我希望模仿的类的名称"UserStore"
传递给它。这将动态生成一个类,并从中实例化一个对象。我将这个模拟对象存储在$store
中,并将其传递给Validator
。这不会导致错误,因为对象新生成的类扩展了UserStore
。我已经骗过了Validator
接受了一名间谍加入其中。
由PHPUnit
生成的模拟对象有一个expects()
方法。这个方法需要一个匹配器对象(实际上它的类型是PHPUnit\Framework\MockObject\Matcher\Invocation
,但是不用担心;您可以使用TestCase
中的便利方法来生成您的匹配器)。匹配器定义期望的基数;也就是说,它规定了一个方法应该被调用的次数。
表 18-3 显示了TestCase
类中可用的匹配器方法。
表 18-3
一些匹配器方法
|测试用例方法
|
匹配失败,除非…
|
| --- | --- |
| any()
| 对相应的方法进行了零次或多次调用(对于返回值但不测试调用的存根对象很有用) |
| never()
| 不会调用相应的方法 |
| atLeastOnce()
| 对相应的方法进行了一次或多次调用 |
| once()
| 对相应的方法进行了一次调用 |
| exactly($num)
| $num
调用相应的方法 |
| at($num)
| 在$num
索引处对相应方法的调用(对模拟的每个方法调用都被记录和索引) |
设置了匹配需求之后,我需要指定一个方法来应用它。例如,expects()
返回一个对象(PHPUnit\Framework\MockObject\Builder\ InvocationMocker
,如果你一定要知道的话),这个对象有一个叫做method()
的方法。我可以简单地用方法名来调用它。这足以让一些真正的嘲笑完成:
// listing 18.12
$store->expects($this->once())
->method('notifyPasswordFailure');
然而,我需要更进一步,检查传递给notifyPasswordFailure()
的参数。InvocationMocker::method()
返回它被调用的对象的实例。InvocationMocker
包含一个方法名with()
,它接受一个可变的参数列表进行匹配。它还接受约束对象,因此您可以测试范围等等。有了这些,您就可以完成语句并确保预期的参数被传递给notifyPasswordFailure()
:
// listing 18.13
$store->expects($this->once())
->method('notifyPasswordFailure')
->with($this->equalTo('bob@example.com'));
你可以看到为什么这被称为流畅的界面。它读起来有点像一个句子:“$store
对象期望对notifyPasswordFailure()
方法with
参数bob@example.com
的单次调用。”
注意,我向with()
传递了一个约束。其实那是多余的;任何裸参数都在内部被转换为约束,所以我可以这样写语句:
// listing 18.14
$store->expects($this->once())
->method('notifyPasswordFailure')
->with('bob@example.com');
有时,您只想使用 PHPUnit 的模拟作为存根,也就是说,作为返回值以允许您的测试运行的对象。在这种情况下,您可以从对method()
的调用中调用InvocationMocker::will()
。will()
方法需要返回值(或者多个值,如果该方法要被重复调用的话),相关联的方法应该准备好返回这些值。你可以用TestCase::returnValue()
或者TestCase::onConsecutiveCalls()
来传递这个返回值。再说一次,做起来比描述起来容易得多。这是我之前的例子中的片段,在这个例子中,我让UserStore
返回一个值:
Note
TestCase::returnValue()
和TestCase::onConsecutiveCalls()
并不是唯一可以用存根设置返回值的方法。还有returnValueMap()
、returnArguments()
、returnCallback()
、returnSelf()
。
// listing 18.15
$store->expects($this->any())
->method("getUser")
->will($this->returnValue([
"name" => "bob@example.com",
"pass" => "right"
]));
我准备好UserStore
模拟来期待对getUser()
的任意数量的调用。现在,我关心的是提供数据,而不是测试电话。接下来,我调用will()
,结果是用我想要返回的数据调用TestCase::returnValue()
(这恰好是一个PHPUnit\ Framework\MockObject\Stub\ReturnStub
对象,尽管如果我是你,我只会记得你用来获取它的便利方法)。
您也可以将调用TestCase::onConsecutiveCalls()
的结果传递给will()
。它接受任意数量的参数,每个参数都将在被重复调用时被您模仿的方法返回。
失败时测试成功
尽管大多数人都认为测试是一件好事,但是只有在它救了你几次之后,你才会真正爱上它。让我们模拟一种情况,系统中某个部分的变化会对其他部分产生意想不到的影响。
UserStore
类已经运行了一段时间,在一次代码审查中,大家一致认为该类生成User
对象比生成关联数组更简洁。下面是新版本:
// listing 18.16
namespace popp\ch18\batch03;
class UserStore
{
private array $users = [];
public function addUser(string $name, string $mail, string $pass): bool
{
if (isset($this->users[$mail])) {
throw new \Exception(
"User {$mail} already in the system"
);
}
$this->users[$mail] = new User($name, $mail, $pass);
return true;
}
public function notifyPasswordFailure(string $mail): void
{
if (isset($this->users[$mail])) {
$this->users[$mail]->failed(time());
}
}
public function getUser(string $mail): ?User
{
if (isset($this->users[$mail])) {
return ( $this->users[$mail] );
}
return null;
}
}
下面是简单的User
类:
// listing 18.17
namespace popp\ch18\batch03;
class User
{
private string $pass; private ?string $failed;
public function __construct(private string $name, private string $mail, string $pass)
{
if (strlen($pass) < 5) {
throw new \Exception(
"Password must have 5 or more letters"
);
}
$this->pass = $pass;
}
public function getMail(): string
{
return $this->mail;
}
public function getPass(): string
{
return $this->pass;
}
public function failed(string $time): void
{
$this->failed = $time;
}
}
当然,我修改了UserStoreTest
类来解释这些变化。请考虑以下设计用于数组的代码:
// listing 18.18
public function testGetUser()
{
$this->store->addUser("bob williams", "a@b.com", "12345");
$user = $this->store->getUser("a@b.com");
$this->assertEquals($user['mail'], "a@b.com");
$this->assertEquals($user['name'], "bob williams");
$this->assertEquals($user['pass'], "12345");
}
现在,它被转换成用于对象的代码,如下所示:
// listing 18.19
public function testGetUser(): void
{
$this->store->addUser("bob williams", "a@b.com", "12345");
$user = $this->store->getUser("a@b.com");
$this->assertEquals($user->getMail(), "a@b.com");
}
然而,当我开始运行我的测试套件时,我得到了一个警告,我的工作还没有完成:
$ phpunit src/ch18/batch03/
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
....F 5 / 5 (100%)
Time: 00:00.019, Memory: 6.00 MB
There was 1 failure:
1) popp\ch18\batch03\ValidatorTest::testValidateCorrectPass Expecting successful validation
Failed asserting that false is true.
/var/popp/src/ch18/batch03/ValidatorTest.php:26
FAILURES!
Tests: 5, Assertions: 5, Failures: 1.
虽然我的与User
相关的测试通过了,但是我的ValidatorTest
类发现了一个事实,即我没有更新Validator
来解释新的返回值。下面是失败的测试:
// listing 18.20
public function testValidateCorrectPass(): void
{
$this->assertTrue(
$this->validator->validateUser("bob@example.com", "12345"),
"Expecting successful validation"
);
}
下面是让我失望的Validator::validateUser()
方法:
// listing 18.21
public function validateUser($mail, $pass): bool
{
if (! is_array($user = $this->store->getUser($mail))) {
return false;
}
if ($user['pass'] == $pass) {
return true;
}
$this->store->notifyPasswordFailure($mail);
return false;
}
所以User::getUser()
现在返回一个对象而不是一个数组。getUser()
成功时返回包含用户数据的数组,失败时返回null
。我通过使用is_array()
函数检查数组来验证用户。当然,现在这个条件永远不会满足,并且validateUser()
方法将总是返回false
。如果没有测试框架,Validator
会简单地拒绝所有无效用户,而不会大惊小怪或发出警告。
使validateUser()
方法一致是一个相对快速的解决方法。
// listing 18.22
public function validateUser($mail, $pass): bool
{
$user = $this->store->getUser($mail);
if (is_null($user)) {
return false;
}
$testpass = $user->getPass();
if ($testpass == $pass) {
return true;
}
$this->store->notifyPasswordFailure($mail);
return false;
}
现在,想象一下在一个没有测试框架的周五晚上对UserStore::getUser()
做了一点小小的修改。想想那些会把你从酒吧、扶手椅或餐馆拽出来的疯狂短信:“你做了什么?我们所有的顾客都被关在外面了!”
最阴险的错误不会导致解释器报告有问题。它们隐藏在完全合法的代码中,悄悄地破坏你系统的逻辑。许多错误不会在你工作的地方显现出来;它们是在那里造成的,但几天甚至几周后,影响会在其他地方出现。一个测试框架至少可以帮助您发现其中的一些问题,防止而不是发现系统中的问题。
编写代码时编写测试,并经常运行它们。如果有人报告了一个 bug,首先在你的框架中添加一个测试来确认它。接下来,修复 bug 以便通过测试。虫子有一个有趣的习惯,就是在同一个地方重复出现。编写测试来证明错误,然后防止后续问题,这被称为回归测试。顺便说一句,如果你有一个单独的回归测试目录,记得用描述性的方式命名你的文件。在一个项目中,我们的团队决定用 Bugzilla 票号来命名我们的回归测试。我们最终得到了一个包含 400 个测试文件的目录,每个文件都有一个类似于test_973892.php
的名字。寻找一个单独的测试变成了一件乏味的苦差事!
编写 Web 测试
您应该以这样一种方式来设计您的 web 系统,使得它们可以很容易地从命令行或 API 调用中被调用。在第十二章中,你看到了一些可能对你有所帮助的技巧。特别是,如果创建一个Request
类来封装 HTTP 请求,那么从命令行或方法参数列表中填充实例就像从请求参数中填充一样容易。然后,系统可以在不知道其上下文的情况下运行。
如果您发现一个系统很难在不同的环境中运行,这可能表明存在设计问题。例如,如果您有许多硬编码到组件中的文件路径,那么您很可能会遇到紧耦合问题。您应该考虑将把您的组件与其上下文联系起来的元素移动到封装对象中,这些对象可以从中央存储库中获得。在第十二章中也提到了注册表模式,它可能会帮助你解决这个问题。
一旦您的系统可以通过方法调用直接运行,您会发现无需任何额外的工具就可以相对容易地编写高级 web 测试。
然而,您可能会发现,即使是考虑最周全的项目也需要一些重构来为测试做好准备。根据我的经验,这几乎总能带来设计上的改进。我将通过改装第十二章和第十三章中 Woo 例子的一个方面来演示这一点。
重构 Web 应用进行测试
从测试人员的角度来看,我们实际上把 Woo 的例子留在了一个合理的状态。因为系统使用单个前端控制器,所以有一个简单的 API 接口。下面是我命名为Runner.php
的文件中的一个简单脚本:
// listing 18.23
require_once("vendor/autoload.php");
use popp\ch18\batch04\woo\controller\Controller;
Controller::run();
这很容易添加到单元测试中,对吗?但是命令行参数呢?在某种程度上,这已经在Request
类中进行了处理:
// listing 18.24
public function init()
{
if (isset($_SERVER['REQUEST_METHOD'])) {
if ($_SERVER['REQUEST_METHOD']) {
$this->properties = $_REQUEST;
return;
}
}
foreach ($_SERVER['argv'] as $arg) {
if (strpos($arg, '=')) {
list($key, $val) = explode("=", $arg);
$this->setProperty($key, $val);
}
}
}
Note
只是提醒一下,如果你实现了自己的Request
类,你应该分别捕获和存储GET
、POST
,甚至PUT
属性,而不是将它们转储到一个单独的$request
属性中。
init()
方法检测流程是否在服务器上下文中运行,并相应地填充$properties
数组(直接或通过setProperty()
)。这对于命令行调用来说很好。例如,这意味着我可以运行这样的程序:
$ php src/ch18/batch04/Runner.php cmd=AddVenue venue_name=bob
前面一行生成了以下响应:
<html>
<head>
<title>Add a Space for venue bob</title>
</head>
<body>
<h1>Add a Space for Venue 'bob'</h1>
<table>
<tr>
<td>
'bob' added (22)</td></tr><tr><td>please add name for the space</td>
</tr>
</table> [add space]
<form method="post">
<input type="text" value="" name="space_name"/>
<input type="hidden" name="cmd" value="AddSpace" />
<input type="hidden" name="venue_id" value="22" />
<input type="submit" value="submit" />
</form>
</body>
</html>
尽管这适用于命令行,但是通过方法调用传递参数仍然有点棘手。一个不恰当的解决方案是在调用控制器的run()
方法之前手动设置$argv
数组。不过,我不太喜欢这样。直接使用魔法数组感觉是完全错误的,而且每一端涉及的字符串操作会加重罪过。然而,更仔细地观察Controller
类,会发现一个可以帮助我们的设计决策:
// listing 18.25
// Controller
public function handleRequest()
{
$request = ApplicationRegistry::getRequest();
$app_c = ApplicationRegistry::appController();
while ($cmd = $app_c->getCommand($request)) {
$cmd->execute($request);
}
$this->invokeView($app_c->getView($request));
}
这个方法被设计成由静态的run()
方法调用。注意Request
对象是如何不被直接实例化的。相反,我是从ApplicationRegistry
那里获得的。当注册表保存一个对象的单个实例时,比如Request
,我可以获取对它的引用,并在通过调用控制器启动系统运行之前,从我的测试中加载数据。这样,我可以模拟一个 web 请求。因为我的系统使用一个Request
对象作为 web 请求的唯一接口,所以它与数据源是分离的。只要Request
是正常的,系统就不会关心它的数据最终是来自测试还是来自网络服务器。作为一般原则,在可能的情况下,我更喜欢将实例化推回注册中心。
如果我的所有对象都是由一个单独的ApplicationRegistry
创建的,那么我可以重载静态注册表工厂方法(ApplicationRegistry::instance
),并且完全控制我的应用在测试期间使用的所有数据。如果设置了标志,这种方法将返回一个用假组件填充的模拟注册表,从而创建一个完全模拟的环境。我喜欢愚弄我的系统。
然而,在这里,我将通过用测试数据预加载我的Request
对象来演示第一个更保守的技巧。
简单的 Web 测试
这里有一个测试用例,它在 Woo 系统上执行一个非常基本的测试:
// listing 18.26
namespace popp\ch18\batch04;
use popp\ch18\batch04\woo\base\ApplicationRegistry;
use popp\ch18\batch04\woo\controller\ApplicationHelper;
use PHPUnit\Framework\TestCase;
class AddVenueTest extends TestCase
{
public function testAddVenueVanilla(): void
{
$this->runCommand("AddVenue", ["venue_name" => "bob"]);
}
private function runCommand($command = null, array $args = null): void
{
$reg = ApplicationRegistry::instance();
$applicationHelper = ApplicationHelper::instance();
$applicationHelper->init();
$request = ApplicationRegistry::getRequest();
if (! is_null($args)) {
foreach ($args as $key => $val) {
$request->setProperty($key, $val);
}
}
if (! is_null($command)) {
$request->setProperty('cmd', $command);
}
woo\controller\Controller::run();
}
}
事实上,与其说它测试了什么,不如说它证明了系统可以被调用。真正的工作是在runCommand()
方法中完成的。这里没有什么特别聪明的地方。我从ApplicationRegistry
中获得一个Request
对象,并用方法调用中提供的键和值填充它。因为Controller
将对它的Request
对象使用相同的源,我知道它将使用我设置的值。
运行该测试确认一切正常。我看到了我期望的输出。问题是这个输出是由视图打印的,所以很难测试。我可以通过缓冲输出很容易地解决这个问题:
// listing 18.27
namespace popp\ch18\batch04;
use popp\ch18\batch04\woo\base\ApplicationRegistry;
use popp\ch18\batch04\woo\controller\ApplicationHelper;
use PHPUnit\Framework\TestCase;
class AddVenueTest2 extends TestCase
{
public function testAddVenueVanilla(): void
{
$output = $this->runCommand("AddVenue", ["venue_name" => "bob"]);
self::AssertMatchesRegularExpression("/added/", $output);
}
private function runCommand($command = null, array $args = null): string
{
$applicationHelper = ApplicationHelper::instance();
$applicationHelper->init(); ob_start();
$request = ApplicationRegistry::getRequest();
if (! is_null($args)) {
foreach ($args as $key => $val) {
$request->setProperty($key, $val);
}
}
if (! is_null($command)) {
$request->setProperty('cmd', $command);
}
woo\controller\Controller::run();
$ret = ob_get_contents();
ob_end_clean();
return $ret;
}
}
通过在缓冲区中捕获系统的输出,我能够从runCommand()
方法返回它。接下来,我对返回值应用一个简单的断言来进行检查。当然,这种方法存在多个问题。
以下是来自命令行的视图:
$ phpunit src/ch18/batch04/AddVenueTest2.php
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.029, Memory: 6.00 MB
OK (1 test, 1 assertion)
如果您打算以这种方式在一个系统上运行大量测试,那么创建一个 web UI 超类来保存runCommand()
是有意义的。
我在这里掩饰了一些你将在自己的测试中面临的细节。您需要确保系统能够与可配置的存储位置一起工作。您不希望您的测试进入您用于开发环境的同一个数据存储。这是设计改进的另一个机会。寻找硬编码的文件路径和 DSN 值,并将它们推回到注册表中。接下来,确保您的测试在沙箱中工作,但是在您的测试用例的setUp()
方法中设置这些值。最后,考虑交换一个MockRequestRegistry
,你可以用存根、模拟和各种其他偷偷摸摸的假货来充电。
像这样的方法非常适合测试 web 应用的输入和输出。然而,有一些明显的限制。这种方法无法捕捉浏览器体验。在 web 应用使用 JavaScript 和其他客户端技巧的情况下,测试系统生成的文本不会告诉您用户看到的界面是否正常。
幸运的是,有一个解决方案。
介绍硒
Selenium ( www.selenium.dev/
)由一组自动化 web 测试的命令组成。它还提供了用于创作和运行浏览器测试的工具和 API。
在这个简短的介绍中,我将为我在第十二章中创建的 Woo 系统创建一个快速测试。该测试将通过一个名为 php-webdriver 的 API 与 Selenium 服务器协同工作。
获取硒
您可以在 www.selenium.dev/downloads/
下载 Selenium 组件。出于本例的目的,您将需要 Selenium 独立服务器。
一旦你下载了这个包,你应该会找到一个名为selenium-server-standalone-3.141.59.jar
的文件(当然,你的版本号可能会不同)。把这个文件复制到中心的某个地方。要继续,您需要在系统上安装 Java。一旦确认了这一点,就可以启动 Selenium 服务器了。
在这里,我将服务器复制到/usr/local/lib
目录。然后我启动服务器:
$ sudo cp selenium-server-standalone-3.141.59.jar /usr/local/lib/
$ java -jar /usr/local/lib/selenium-server-standalone-3.141.59.jar
17:58:20.098 INFO [GridLauncherV3.parse] - Selenium server version: 3.141.59, revision: e82be7d358
17:58:20.200 INFO [GridLauncherV3.lambda$buildLaunchers$3] - Launching a standalone
Selenium Server on port 4444
2020-09-13 17:58:20.254:INFO::main: Logging initialized @678ms to org.seleniumhq.jetty9.util.log.StdErrLog
17:58:20.459 INFO [WebDriverServlet.<init>] - Initialising WebDriverServlet
17:58:20.541 INFO [SeleniumServer.boot] - Selenium Server is up and running on port 4444
请注意,启动输出告诉我们应该使用哪个端口来与服务器通信。这个以后会派上用场的。
然而,我们可能只是走了一半。为了减少模糊错误的机会,我发现最好下载正确版本的 ChromeDriver,这是一个向浏览器传递 UI 命令的库。目前,Chrome 似乎是用 Selenium 进行测试的最佳浏览器选择。如果你还没有在你的本地系统上安装 Chrome,那就从安装开始吧。查看Help :: About Google Chrome
菜单,确定您的浏览器版本。然后在 https://sites.google.com/a/chromium.org/chromedriver/downloads
下载与你的浏览器版本对应的 ChromeDriver 版本。有了这个库,您可以再次启动 Selenium:
$ java -jar -Dwebdriver.chrome.driver="./chromedriver" /usr/local/lib/selenium-server- standalone-3.141.59.jar
现在我准备好继续了。
PHPUnit 和硒
尽管 PHPUnit 过去已经提供了使用 Selenium 的 API,但是它的支持一直是不完整的,它的文档更是如此。因此,为了尽可能多地访问 Selenium 的特性,将 PHPUnit 与一个旨在提供我们需要的绑定的工具结合使用是有意义的。
php-webdriver 简介
WebDriver 是 Selenium 控制浏览器的机制,它是在 Selenium 2 中引入的。Selenium 开发人员为 WebDriver 提供了 Java、Python 和 C # APIs。有一些可用的 PHP APIs。我选择使用 php-webdriver,它是由脸书的开发者开发的。它正在积极开发中,并反映了官方的 API。当你想查找一项技术时,这是非常方便的,因为你在网上找到的许多例子都是用 Java 提供的,这意味着只要移植一点代码,它们就可以很容易地应用于 php-webdriver。
您可以使用 Composer 将 php-webdriver 添加到项目中:
{
"require-dev": {
"phpunit/phpunit": "9.*",
"php-webdriver/webdriver" : "*"
}
}
更新您的composer.json
文件,运行composer update
,您应该准备好了。
创建测试框架
我将使用 Woo 应用的一个实例,它将在我的系统上运行,URL 为: http://popp.vagrant.internal/webwoo/
。
我将从一个样板测试类开始:
// listing 18.28
namespace popp\ch18\batch04;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use PHPUnit\Framework\TestCase;
class SeleniumTest1 extends TestCase
{
protected function setUp(): void
{
}
public function testAddVenue(): void
{
}
}
我指定了一些我将使用的 php-webdriver 类,然后创建了一个基本的测试类。现在是让这个测试有所作为的时候了。
连接到 Selenium
记住,在启动时,服务器输出它的连接 URL。为了连接到 Selenium,我需要将这个 URL 和一个功能数组传递给一个名为RemoteWebDriver
的类:
// listing 18.29
namespace popp\ch18\batch04;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use PHPUnit\Framework\TestCase;
class SeleniumTest2 extends TestCase
{
private $driver;
protected function setUp(): void
{
$options = new ChromeOptions();
$capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(ChromeOptions::CAPABILITY, $options);
$this->driver = RemoteWebDriver::create(
"http://127.0.0.1:4444/wd/hub",
$capabilities
);
}
public function testAddVenue(): void
{
}
}
如果您用 Composer 安装了 php-webdriver,您可以在vendor/php-webdriver/webdriver/lib/Remote/WebDriverCapabilityType.php
的类文件中看到完整的功能列表。然而,对于我目前的目的,我真的只需要指定浏览器名称。我将主机字符串和$capabilities
数组传递给静态的RemoteWebDriver::create()
方法,并将结果对象引用存储在$driver
属性中。当我运行这个测试时,我应该看到 Selenium 启动了一个新的浏览器窗口,为我的测试做准备。
编写测试
我想测试一个简单的工作流程。我将导航到AddVenue
页面,添加一个地点,然后添加一个空间。这涉及到与三个网页的交互。
这是我的测试:
// listing 18.30
namespace popp\ch18\batch04;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use Facebook\WebDriver\WebDriverBy;
use PHPUnit\Framework\TestCase;
class SeleniumTest3 extends TestCase
{
protected function setUp(): void
{
$options = new ChromeOptions();
// uncomment this line to run in 'headless' mode
// $options->addArguments(['--headless', '--no-sandbox']);
$capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(ChromeOptions::CAPABILITY, $options);
$this->driver = RemoteWebDriver::create(
"http://127.0.0.1:4444/wd/hub",
$capabilities
);
}
public function testAddVenue(): void
{
$this->driver->get("http://popp.vagrant.internal/webwoo/AddVenue.php");
$venel = $this->driver->findElement(WebDriverBy::name("venue_name"));
$venel->sendKeys("my_test_venue");
$venel->submit();
$tdel = $this->driver->findElement(WebDriverBy::xpath("//td[1]"));
$this->assertMatchesRegularExpression("/'my_test_venue' added/", $tdel->getText());
$spacel = $this->driver->findElement(WebDriverBy::name("space_name"));
$spacel->sendKeys("my_test_space");
$spacel->submit();
$el = $this->driver->findElement(WebDriverBy::xpath("//td[1]"));
$this->assertMatchesRegularExpression("/'my_test_space' added/", $el->getText());
}
}
下面是我在命令行上运行这个测试时发生的情况:
$ phpunitsrc/ch18/batch04/SeleniumTest3.php
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
.. 1 / 1 (100%)
Time: 00:00.029, Memory: 6.00 MB
OK (1 test, 2 assertion)
当然,并不全是这样。Selenium 还启动一个浏览器窗口,并在其上执行我指定的操作。不得不承认,我觉得这个效果有点诡异!
让我们浏览一下代码。首先,我调用WebDriver::get()
,它获取我的起始页。注意,这个方法需要一个完整的 URL(不需要位于 Selenium 服务器主机的本地)。在这种情况下,我在一个流浪的虚拟机上配置了一个 Apache 服务器来提供一个模拟的AddVenue.php
脚本。Selenium 会将指定的文档加载到它已经启动的浏览器中。你可以在图 18-1 中看到这一页。
图 18-1
Selenium 加载的 AddVenue 页面
一旦页面加载完毕,我就可以通过 WebDriver API 访问它了。我可以使用RemoteWebDriver::findElement()
方法获取对页面元素的引用。这需要一个类型为WebDriverBy
的对象。WebDriverBy
类提供了一组工厂方法,每个方法返回一个WebDriverBy
对象,该对象被配置为指定定位元素的特定方式。我的表单元素有一个设置为"venue_name"
的name
属性,所以我使用WebDriverBy::name()
方法告诉findElement()
以这种方式查找元素。表 18-4 列出了所有可用的工厂方法。
表 18-4
WebDriverBy 工厂方法
|方法
|
描述
|
| --- | --- |
| className()
| 通过 CSS 类名查找元素 |
| cssSelector()
| 通过 CSS 选择器查找元素 |
| id()
| 通过 id 查找元素 |
| name()
| 按名称属性查找元素 |
| linkText()
| 通过链接文本查找元素 |
| partialLinkText()
| 通过链接文本片段查找元素 |
| tagName()
| 按标签查找元素 |
| xpath()
| 查找匹配 Xpath 表达式的元素 |
一旦我有了对类型为RemoteWebElement
的对象venue_name
表单元素的引用,我就可以使用它的sendKeys()
方法来设置一个值。需要注意的是sendKeys()
不仅仅是设置一个值。它还模拟向元素中键入内容的行为。这对于测试使用 JavaScript 捕获键盘事件的系统非常有用。
使用新的值集,我提交表单。API 在这方面很聪明。当我在一个元素上调用submit()
时,Selenium 定位包含它的表单并提交它。
当然,提交表单会导致加载一个新页面。因此,接下来我检查一切是否如我所料。我再次使用了WebDriver::findElement()
,不过这次我传递给它一个为 Xpath 配置的WebDriverBy
对象。如果我的搜索成功,findElement()
将返回一个新的RemoteWebElement
对象。另一方面,如果我的搜索失败了,那么产生的异常将导致我的测试失败。假设一切正常,我使用RemoteWebElement::getText()
方法获取元素的值。
在这个阶段,我已经提交了表单,并检查了返回的 web 页面的状态。可以看到图 18-2 中的页面。
图 18-2
AddSpace 页面
现在剩下的就是再次填充表单,提交并检查新页面。我使用你已经遇到的技术来达到这个目的。
当然,我在这里只是触及了硒的皮毛。但我希望这次讨论足以让你对可能性有所了解。如果你想了解更多,在 www.selenium.dev/documentation/en/
有完整的硒手册。
一个警告
人们很容易被自动化测试所带来的好处冲昏头脑。我将单元测试添加到我的项目中,并使用 PHPUnit 进行功能测试。也就是说,我在系统级和类级进行测试。我看到了实实在在的好处,但我相信这是有代价的。
测试给你的开发增加了很多成本。例如,当您将安全性构建到项目中时,您也在构建过程中添加了时间惩罚,这会影响发布。编写测试所需的时间是其中的一部分,但是运行测试所需的时间也是其中的一部分。在一个系统上,我们可能有几套针对多个数据库和多个版本控制系统运行的功能测试。再添加几个类似的上下文变量,我们就会面临运行测试套件的真正障碍。当然,没有运行的测试是没有用的。对此的一个答案是完全自动化您的测试,因此运行由类似于cron
的调度应用启动。另一个方法是维护一个测试子集,开发人员可以在提交代码时轻松运行这个子集。这些应该和你更长更慢的测试一起进行。
另一个需要考虑的问题是许多测试工具的脆弱性。您的测试可能会让您有信心进行更改,但是随着您的测试覆盖范围随着您的系统的复杂性而增加,打破多个测试变得更加容易。当然,这往往是你想要的。您想知道预期行为何时不会发生,或者意外行为何时会发生。
然而,测试工具经常会因为相对微小的变化而中断,例如反馈字符串的措辞。每一个中断的测试都是一件紧急的事情,但是不得不改变 30 个测试用例来解决架构或输出中的一个小变更是令人沮丧的。单元测试不太容易出现这种问题,因为总的来说,它们独立地关注每个组件。
保持测试与发展中的系统同步所涉及的成本是您必须考虑的一个权衡。总的来说,我相信收益是值得付出的。
您还可以做一些事情来减少测试工具的脆弱性。在某种程度上,编写测试时考虑到变化是一个好主意。例如,我倾向于使用正则表达式来测试输出,而不是直接的等式测试。当我从输出字符串中删除换行符时,测试几个关键字不太可能使我的测试失败。当然,让你的测试过于宽容也是一种危险,所以这是一个运用你的判断力的问题。
另一个问题是,除了您希望测试的组件之外,您应该在多大程度上使用 mocks 和 stubs 来伪造系统。有些人坚持认为你应该尽可能地隔离你的组件,并模仿它周围的一切。这在一些项目中对我有效。然而,在其他情况下,我发现维护一个模拟系统会成为一个时间陷阱。您不仅需要花费成本来保持您的测试与您的系统保持一致,而且您必须保持您的模拟是最新的。想象一下改变一个方法的返回类型。如果您未能更新相应存根对象的方法以返回新的类型,客户端测试可能会错误地通过。对于一个复杂的伪系统来说,存在着 bug 潜入模仿对象的真实危险。调试测试是令人沮丧的工作,尤其是当系统本身没有问题的时候。
我倾向于见机行事。默认情况下,我使用 mocks 和 stubs,但是如果成本开始增加,我不会对转移到真正的组件感到抱歉。您可能会失去对测试主题的一些关注,但是这带来了额外的好处,即源自组件上下文的错误至少是系统的真实问题。当然,你可以结合使用真实和虚假的元素。例如,我经常在测试模式下使用内存数据库。
正如您可能已经收集到的,当涉及到测试时,我不是一个理论家。我经常通过组合真实的和模拟的组件来“欺骗”;因为启动数据是重复的,我经常将测试设备集中到马丁·福勒称之为对象母亲的地方。这些类是简单的工厂,生成用于测试的对象。一些人讨厌这种共享设备。
在指出了测试可能迫使你面对的一些问题之后,有必要重申几点,在我看来,这胜过了所有的反对意见。测试完成几件事:
-
它有助于防止错误(在开发和重构过程中发现错误的程度)。
-
它帮助您发现 bug(当您扩展测试覆盖时)。
-
它鼓励你专注于系统的设计。
-
它让您可以改进代码设计,而不必担心更改会导致比它们解决的问题更多的问题。
-
当你发布代码时,它给你信心。
在我编写测试的每个项目中,我迟早会有机会感激这个事实。
摘要
在这一章中,我回顾了我们作为开发人员编写的,但是经常被不加思考地抛弃的测试。从那以后,我介绍了 PHPUnit,它允许您在开发过程中编写相同类型的一次性测试,但之后保留它们并感受持久的好处!我创建了一个测试用例实现,并且介绍了可用的断言方法。我还研究了约束并探索了模拟对象的复杂世界。接下来,我展示了测试重构如何改进设计,演示了一些测试 web 应用的技术——首先使用 PHPUnit,然后使用 Selenium。最后,我冒着激怒某些人的风险,警告了测试所带来的成本,并讨论了相关的权衡。
十九、将 Phing 用于自动构建
如果版本控制是硬币的一面,那么自动化构建就是另一面。版本控制允许多个开发人员在单个项目上协同工作。随着许多编码人员各自在自己的空间部署一个项目,自动化构建很快变得必不可少。一个开发人员可能在/usr/local/apache/htdocs
中有她的面向 web 的目录;另一个可能使用/home/bibble/public_html
。开发人员可能使用不同的数据库密码、库目录或邮件机制。一个灵活的代码库可能很容易适应所有这些差异,但是更改设置和手动复制文件系统中的目录以使工作正常进行的工作很快就会变得令人厌倦——特别是如果您需要一天几次(或者一小时几次)安装正在进行的代码。
您已经看到 Composer 自动化了包的安装。您几乎肯定希望通过 Composer 或 PEAR 包将项目交付给最终用户,因为这种机制对用户来说很简单,而且包管理系统可以处理依赖性。但是在创建一个包之前,有许多工作可能需要自动化。例如,您可能需要生成模板生成的代码。您应该运行测试并提供创建和更新数据库表的机制。最后,您可能希望自动化生产就绪包的创建。在这一章中,我将向您介绍 Phing,它处理的就是这样的工作。本章将涵盖以下内容:
-
获取并安装 Phing :谁构建构建器?
-
属性:设置和获取数据
-
类型:描述项目的复杂部分
-
目标:将一个构建分解成可调用的、相互依赖的>功能集
-
任务:完成工作的事情
什么是 Phing?
Phing 是一个用于构建项目的 PHP 工具。它非常接近于非常流行(也非常强大)的 Java 工具 Ant 的模型。蚂蚁之所以如此命名,是因为它很小,但却能够建造非常大的东西。Phing 和 Ant 都使用一个 XML 文件(通常命名为build.xml
)来决定如何安装或处理项目。
PHP 世界真的需要一个好的构建解决方案。认真的开发者在过去有许多选择。首先,可以使用make
,这是一个无处不在的 Unix 构建工具,仍然用于大多数 C 和 Perl 项目。然而,make
对语法非常挑剔,需要相当多的 shell 知识,甚至包括脚本——这对于一些没有通过 Unix 或 Linux 命令行编程的 PHP 程序员来说是一个挑战。更重要的是,make
只提供了很少的内置工具用于常见的构建操作,比如转换文件名和内容。它实际上只是外壳命令的粘合剂。这使得编写跨平台安装的程序变得困难。不是所有的环境都有相同版本的make
或者根本没有。即使你有make
,你也可能没有 makefile(驱动make
的配置文件)需要的所有命令。
PHing 和make
的关系从它的名字就可以说明:Phing 代表 Phing 不是 GNU make
。这种有趣的递归是一个常见的编码器笑话(例如,GNU 本身代表 GNU 不是 Unix)。
Phing 是一个本地 PHP 应用,它解释用户创建的 XML 文件,以便对项目执行操作。这种操作通常包括将文件从一个分发目录复制到不同的目标目录,但是还有更多的内容要做。Phing 可用于生成文档、运行测试、调用命令、运行任意 PHP 代码、创建包、替换文件中的关键字、去除注释以及生成 tar/gzipped 包版本。即使 Phing 还不能满足您的需求,但它被设计成易于扩展。
因为 Phing 本身就是一个 PHP 应用,所以运行它只需要一个最新的 PHP 引擎。由于 Phing 是一个用于安装 PHP 应用的应用,所以 PHP 可执行文件的存在是一个相当安全的赌注。
获取和安装 Phing
如果安装一个安装工具很困难,那么一定是哪里出了问题!然而,假设您的系统上有 PHP 5 或更好的版本(如果您没有,这本书不适合您!),安装 Phing 再简单不过了。
您可以通过 Composer 获得并安装 Phing。您应该将此添加到您的composer.json
文件中:
{
"require-dev": {
"phing/phing": "2.*"
}
}
Note
当我写这篇文章的时候,Phing 版本 3 还在 alpha 中。本章(以及第二十一章中)的所有例子都可以很好地使用它,但是安装需要一些非正统的黑客技术。希望当你读到这里的时候,这些小淘气已经被解决了。在 Phing 主页查看安装说明: www.phing.info/#install
。
撰写构建文档
您现在应该准备好开始使用 Phing 了!让我们测试一下:
$ phing vendor/bin/phing -v
Phing 2.16.3
phing
命令的-v
标志使脚本返回版本信息。当您读到本文时,版本号可能已经更改,但是当您在系统上运行该命令时,应该会看到类似的消息。
Note
如果您使用 Composer 安装了 Phing,可运行的脚本文件将安装在您的本地vendor/bin/
目录中。要运行 Phing,您应该将该目录添加到您的$PATH
环境变量中,或者使用可执行文件的显式路径。在以后的例子中,我将省略路径。
现在我将不带参数运行phing
命令:
$ phing
Buildfile: build.xml does not exist!
如你所见,Phing 在没有指令的情况下丢失了。默认情况下,它会寻找一个名为build.xml
的文件。让我们构建一个最小化文档,这样至少可以消除错误消息:
// listing 19.01
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz" default="main" description="my project" basedir="/tmp">
<target name="main"/>
</project>
这是您在构建文件中可以做到的最低限度。如果我们将前面的例子保存为build.xml
并再次运行phing
,我们应该会得到一些更有趣的输出:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'main' has no tasks or dependencies
megaquiz > main:
BUILD FINISHED
Total time: 0.0976 seconds
你可能会想,付出很多努力却一无所获,但我们必须从某个地方开始!如您所见,Phing 还指出了这个构建文件没有什么非常有用的地方。再次查看构建文件。因为我们处理的是 XML,所以我包含了一个 XML 声明。您可能知道,XML 注释看起来像这样:
<!-- this is an XML comment. OK? -->
因此,因为它是一个注释,所以我的构建文件中的第二行被忽略。您可以在构建文件中放入任意多的注释,随着注释的增加,您应该充分利用这一事实。如果没有合适的注释,大型构建文件可能很难理解。
任何构建文件的真正开始都是project
元素。project
元素最多可以包含五个属性。其中,name
和default
是必选的。name
属性建立了项目的名称;default
定义一个在命令行上没有指定的情况下运行的目标。可选的description
属性可以提供汇总信息。您可以使用一个basedir
属性来指定构建的上下文目录。如果省略,将采用当前工作目录。最后,您可以使用phingVersion
指定构建文件应该使用的 Phing 应用的最低版本。您可以在表 19-1 中看到这些属性的汇总。
表 19-1
项目元素的属性
|属性
|
需要
|
描述
|
缺省值
|
| --- | --- | --- | --- |
| Name
| 是 | 项目的名称 | 没有人 |
| Description
| 不 | 简要的项目总结 | 没有人 |
| Default
| 是 | 要运行的默认目标 | 没有人 |
| phingVersion
| 不 | 要运行的 Phing 的最低版本 | 没有人 |
| Basedir
| 不 | 将在其中运行生成的文件系统上下文 | 当前目录(。) |
| Strict
| 不 | 在严格模式下运行:将警告视为错误一旦我定义了一个project
元素,我必须创建至少一个目标——我在default
属性中引用的目标 | false
|
目标
在某种意义上,目标类似于函数。目标是为了实现一个目标而组合在一起的一组动作:例如,将一个目录从一个地方复制到另一个地方,或者生成文档。
在我之前的例子中,我包括了一个目标的最简单的实现:
// listing 19.02
<target name="main"/>
如您所见,目标必须至少定义一个name
属性。我在project
元素中利用了这一点。因为默认元素指向main
目标,所以只要 Phing 在没有命令行参数的情况下运行,就会调用这个目标。输出证实了这一点:
megaquiz > main:
目标可以被组织成相互依赖。通过在一个目标和另一个目标之间建立依赖关系,您告诉 Phing,在它所依赖的目标运行之前,第一个目标不应该运行。现在,我将向我的构建文件添加一个依赖项:
// listing 19.03
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<target name="runfirst" />
<target name="runsecond" depends="runfirst"/>
<target name="main" depends="runsecond"/>
</project>
如您所见,我为目标元素引入了一个新的属性。depends
告诉 Phing 被引用的目标应该在当前目标之前执行,所以我可能希望将某些文件复制到某个目录的目标在对该目录中的所有文件运行转换的目标之前被调用。我在示例中添加了两个新目标:main
依赖的runsecond
和runsecond
依赖的runfirst
。下面是我用这个构建文件运行 Phing 时发生的情况:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies
megaquiz > runfirst:
megaquiz > runsecond:
megaquiz > main:
BUILD FINISHED
Total time: 0.1250 seconds
如您所见,依赖关系是受尊重的。Phing 遇到了main
目标,看到了它的依赖关系,并返回到runsecond
。runsecond
自有依赖,Phing 调用runfirst
。
满足其依赖性后,Phing 可以调用runsecond
。最后,main
被调用。depends
属性可以一次引用多个目标。可以提供一个以逗号分隔的依赖项列表,每个依赖项将依次得到尊重。
现在我有多个目标可以使用,我可以从命令行覆盖项目元素的default
属性:
$ phing runsecond
Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies
megaquiz > runfirst:
megaquiz > runsecond:
BUILD FINISHED
Total time: 0.1043 seconds
通过传入一个目标名称,我导致默认属性被忽略。匹配我的参数的target
被调用(以及它所依赖的target
)。这对于调用专门的任务很有用,例如清理构建目录或运行安装后脚本。
target
元素还支持一个可选的description
属性,您可以为其分配目标用途的简要描述:
// listing 19.04
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main"
description="A quiz engine">
<target name="runfirst"
description="The first target" />
<target name="runsecond"
depends="runfirst, housekeeping"
description="The second target" />
<target name="main"
depends="runsecond"
description="The main target" />
</project>
向目标添加描述对正常的构建过程没有任何影响。但是,如果用户使用-
projecthelp
标志运行 Phing,描述将用于总结项目:
$ phing -projecthelp
Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies
A quiz engine
Default target:
-------------------------------------------------------------------
Main The main target
Main targets:
-------------------------------------------------------------------
Main The main target
Runfirst The first target
Runsecond The second target
注意,我也向project
元素添加了description
属性。如果您想从这样的清单中隐藏一个目标,您可以添加一个隐藏属性。这对于提供内务处理功能的目标很有用,但这些功能不应直接从命令行调用:
// listing 19.05
<target name="housekeeping" hidden="true">
<!-- useful things that should not be called directly -->
</target>
性能
Phing 允许您使用property
元素来设置这些值。
属性类似于脚本中的全局变量。因此,它们通常被声明在项目的顶部,以便开发人员能够很容易地确定构建文件中的内容。在这里,我创建了一个处理数据库信息的构建文件:
// listing 19.06
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<property name="dbname" value="megaquiz" />
<property name="dbpass" value="default" />
<property name="dbhost" value="localhost" />
<target name="main">
<echo>database: ${dbname}</echo>
<echo>pass: ${dbpass}</echo>
<echo>host: ${dbhost}</echo>
</target>
</project>
我引入了一个新元素:property
。property
需要name
和value
属性。我已经在目标main
中添加了三个property
的实例。
我还引入了echo
元素。这是一个任务的例子。我将在下一节更全面地探讨任务。不过现在,只要知道echo
做了您所期望的事情就足够了——它导致其内容被输出。注意这里引用属性值的语法。通过使用美元符号,并用花括号将属性名括起来,告诉 Phing 用属性值替换字符串:
${propertyname}
这个构建文件实现的只是声明三个属性,并将它们打印到标准输出中。这就是它的作用:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
[echo] database: megaquiz
[echo] pass: default
[echo] host: localhost
BUILD FINISHED
Total time: 0.0989 seconds
既然已经介绍了属性,我可以结束对目标的探索了。元素接受两个额外的属性:if
和unless
。每一个都应该用属性的名称来设置。当您使用带有属性名的if
时,只有设置了给定的属性,才会执行目标。如果未设置属性,目标将静默退出。这里,我注释掉了dbpass
属性,并使用if
属性使main
任务需要它:
// listing 19.07
<project name="megaquiz"
default="main">
<property name="dbname" value="megaquiz" />
<!--<property name="dbpass" value="default" />-->
<property name="dbhost" value="localhost" />
<target name="main" if="dbpass">
<echo>database: ${dbname}</echo>
<echo>pass: ${dbpass}</echo>
<echo>host: ${dbhost}</echo>
</target>
</project>
让我们再次运行phing
:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
BUILD FINISHED
Total time: 0.0957 seconds
如您所见,我没有提出错误,但是main
任务没有运行。为什么我会想这么做?还有另一种在项目中设置属性的方法。它们可以在命令行上指定。您告诉 Phing,您要传递给它一个带有-D
标志的属性,后跟一个属性赋值。所以论点应该是这样的:
-Dname=value
在我的例子中,我希望通过命令行使用dbname
属性:
$ phing -Ddbpass=userset
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
[echo] database: megaquiz
[echo] pass: userset
[echo] host: localhost
BUILD FINISHED
Total time: 0.0978 seconds
主目标的if
属性满足dbpass
属性的存在,目标被允许执行。
如您所料,unless
属性与if
相反。如果设置了一个属性,并且在目标的unless
属性中引用了该属性,那么目标将不会运行。如果您希望能够从命令行抑制特定的目标,这将非常有用。因此,我可能会在主目标中添加类似这样的内容:
// listing 19.08
<target name="main" unless="suppressmain">
<echo>database: ${dbname}</echo>
<echo>pass: ${dbpass}</echo>
<echo>host: ${dbhost}</echo>
</target>
除非suppressmain
属性存在,否则main
将被执行:
$ phing -Dsuppressmain
我已经包装了target
元素;表 19-2 显示了其属性的汇总。
表 19-2
目标元素的属性
|属性
|
需要
|
描述
|
| --- | --- | --- |
| name
| 是 | 目标的名称 |
| depends
| 不 | 当前所依赖的目标 |
| if
| 不 | 仅当给定属性存在时执行目标 |
| unless
| 不 | 仅当给定属性不存在时执行目标 |
| logskipped
| 不 | 如果目标被跳过(例如,由于if
/ unless
),则在输出中添加一个通知 |
| hidden
| 不 | 从列表和摘要中隐藏目标 |
| description
| 不 | 目标目的的简短摘要 |
当在命令行上设置属性时,它会重写生成文件中的任何和所有属性声明。还有一种情况是属性值会被覆盖。默认情况下,如果一个属性被声明了两次,那么原始值将优先。您可以通过在第二个property
元素中设置一个名为override
的属性来改变这种行为。这里有一个例子:
// listing 19.09
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<property name="dbpass" value="default" />
<target name="main">
<property name="dbpass" override="yes" value="specific" />
<echo>pass: ${dbpass}</echo>
</target>
</project>
我设置了一个名为dbpass
的属性,赋予它初始值"default"
。在主目标中,我再次设置属性,添加一个设置为"yes"
的override
属性,并提供一个新值。新值反映在输出中:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
[echo] pass: specific
BUILDFINISHED
Total time: 0.0978 seconds
如果我没有在第二个属性元素中设置override
元素,那么"default"
的原始值将保持不变。需要注意的是,目标不是函数:没有局部范围的概念。如果在任务中重写某个属性,该属性将在整个生成文件中对所有其他任务保持重写状态。当然,您可以通过在重写之前将属性值存储在临时属性中,然后在完成本地工作后重置它来解决这个问题。
到目前为止,我已经处理了您自己定义的属性。Phing 还提供了内置属性。您引用这些属性的方式与您引用自己声明的属性的方式完全相同。这里有一个例子:
// listing 19.10
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<target name="main">
<echo>name: ${phing.project.name}</echo>
<echo>base: ${project.basedir}</echo>
<echo>home: ${user.home}</echo>
<echo>pass: ${env.DBPASS}</echo>
</target>
</project>
我只引用了几个内置的 Phing 属性。phing.project.name
解析为在project
元素的name
属性中定义的项目名称;project.basedir
给出起始目录;并且user.home
提供执行用户的主目录(这对于提供默认安装位置很有用)。
最后,属性引用中的env
前缀表示操作系统环境变量。所以通过指定$
,我在寻找一个名为DBPASS
的环境变量。在这里,我对这个文件运行 Phing:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
[echo] name: megaquiz
[echo] base: /var/popp/src/ch19
[echo] home: /home/vagrant
[echo] pass: ${env.DBPASS}
BUILD FINISHED
Total time: 0.1056 seconds
请注意,最后一个属性尚未翻译。这是找不到属性时的默认行为,引用该属性的字符串保持不变。如果我设置了DBPASS
环境变量并再次运行,我应该会在输出中看到该变量:
$ export DBPASS=wooshpoppow
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:
[echo] name: megaquiz
[echo] base: /var/popp/src/ch19
[echo] home: /home/vagrant
[echo] pass: wooshpoppow
BUILD FINISHED
Total time: 0.1044 seconds
现在您已经看到了设置属性的三种方式:property
元素、命令行参数和环境变量。
有第四种方法可以补充这些方法。您可以使用单独的文件来指定属性值。随着我的项目越来越复杂,我倾向于这种方法。让我们回到一个基本的构建文件:
// listing 19.11
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<target name="main">
<echo>database: ${dbname}</echo>
<echo>pass: ${dbpass}</echo>
<echo>host: ${dbhost}</echo>
</target>
</project>
正如您所看到的,这个构建文件只是输出属性,而没有首先声明它们或者检查它们的值是否存在。这是我不带参数运行这个程序时得到的结果:
$ phing
...
[echo] database: ${dbname}
[echo] pass: ${dbpass}
[echo] host: ${dbhost}
...
现在我将在一个单独的文件中声明我的属性。我把它叫做megaquiz.properties
:
dbname=filedb
dbpass=filepass
dbhost=filehost
现在我可以用 Phing 的propertyfile
选项将这个文件应用到我的构建过程中:
$ phing -propertyfile megaquiz.properties
...
[echo] database: filedb
[echo] pass: filepass
[echo] host: filehost
...
我发现这种机制比管理一长串命令行选项要方便得多。但是,您需要注意不要将您的属性文件签入到您的版本控制系统中!
您可以使用目标来确保填充属性。比方说,我的项目需要一个dbpass
属性。我希望用户在命令行上设置dbpass
(这总是优先于其他属性赋值方法)。如果失败,我应该寻找一个环境变量。最后,我应该放弃使用默认值:
// listing 19.12
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main" basedir=".">
<target name="setenvpass" if="env.DBPASS" unless="dbpass">
<property name="dbpass" override="yes" value="${env.DBPASS}" />
</target>
<target name="setpass" unless="dbpass" depends="setenvpass">
<property name="dbpass" override="yes" value="default" />
</target>
<target name="main" depends="setpass">
<echo>pass: ${dbpass}</echo>
</target>
</project>
因此,像往常一样,首先调用默认目标main
。这有一个依赖集,所以 Phing 返回到setpass
目标。然而setpass
依赖于setenvpass
,所以我从那里开始。setenvpass
配置为仅在dbpass
未设置且env.DBPASS
存在时运行。如果满足这些条件,那么我使用property
元素设置dbpass
属性。在这个阶段,dbpass
要么由命令行参数填充,要么由环境变量填充。如果这两者都不存在,那么该属性在此阶段将保持未设置状态。现在执行setpass
目标,但仅当dbpass
尚未出现时。在这种情况下,它将属性设置为默认字符串:"default"
。
使用条件任务有条件地设置属性值
前面的例子建立了一个相当复杂的赋值逻辑。然而,更常见的情况是,您需要一个简单的默认值。condition
任务允许您根据可配置的条件设置属性值。这里有一个例子:
// listing 19.13
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main">
<condition property="dbpass" value="default">
<not>
<isset property="dbpass" />
</not>
</condition>
<target name="main">
<echo>pass: ${dbpass}</echo>
</target>
</project>
condition
任务需要一个property
属性。它还可选地接受一个value
属性,如果嵌套的测试子句解析为true
,则该属性被分配给该属性。如果没有提供 value 属性,那么如果嵌套测试解析为true
,该属性将被设置为true
。
test 子句是许多标记中的一个,其中一些标记,如本例中的not
,接受它们自己的嵌套元素。我使用了isset
元素,如果设置了引用的属性,它将返回true
。因为我想给属性dbpass
赋值,如果它是而不是的话,我需要通过将它包装在not
标签中来否定这个结果。这将反转它所包含的标签的分辨率。因此,就 PHP 语法而言,我的示例中的condition
任务与此类似:
if (! isset($dbpass)) {
$dbpass = "default";
}
类型
您可能认为已经查看了属性,现在就可以处理数据了。事实上,Phing 支持一组称为类型的特殊元素。这些封装了对构建过程有用的不同种类的信息。
文件集
假设您需要在构建文件中表示一个目录。正如你所想象的,这是一种常见的情况。当然,您可以使用一个属性来表示这个目录,但是如果您的开发人员使用支持不同目录分隔符的不同平台,您会马上遇到问题。答案是文件集数据类型。文件集是独立于平台的,所以如果您在路径中用正斜杠表示一个目录,当构建在 Windows 机器上运行时,它们将在后台自动转换成反斜杠。您可以像这样定义一个最小的fileset
元素:
<fileset dir="src/lib" />
如您所见,我使用了dir
属性来设置我希望表示的目录。您可以选择添加一个id
属性,这样您就可以在以后引用fileset
:
<fileset dir="src/lib" id="srclib">
FileSet
数据类型在指定要包含或排除的文档类型时特别有用。安装一组文件时,您可能不希望包含那些符合特定模式的文件。您可以在一个excludes
属性中处理这样的条件:
<fileset dir="src/lib" id="srclib"
excludes="**/*_test.php **/*Test.php" />
注意我在excludes
属性中使用的语法。双星号代表src/lib
内的任何目录或子目录。单个星号代表零个或多个字符。所以我指定我想排除在所有目录中以_test.php
或Test.php
结尾的文件,这些目录位于在dir
属性中定义的起点之下。excludes
属性接受由空格分隔的多种模式。
我可以将相同的语法应用于一个includes
属性。也许我的src/lib
目录包含许多对开发人员有用的非 PHP 文件,但是它们不应该出现在安装中。当然,我可以排除那些文件,但是定义我可以包括的文件种类可能更简单。在这种情况下,如果文件不是以。php,它不会被安装:
<fileset dir="src/lib" id="srclib"
excludes="**/*_test.php **/*Test.php"
includes="**/*.php" />
随着你建立起include
和exclude
规则,你的fileset
元素可能会变得过长。幸运的是,您可以提取出单独的exclude
规则,并将每个规则放入其自己的exclude
子元素中。对于include
规则也可以这样做。我现在可以像这样重写我的文件集:
<fileset dir="src/lib" id="srclib">
<exclude name="**/*_test.php" />
<exclude name="**/*Test.php" />
<include name="**/*.php" />
</fileset>
你可以在表 19-3 中看到fileset
元素的一些属性。
表 19-3
文件集元素的一些属性
|属性
|
需要
|
描述
|
| --- | --- | --- |
| refid
| 不 | 当前fileset
是给定 ID 的fileset
的参考 |
| dir
| 不 | 起始目录 |
| expandsymboliclinks
| 不 | 如果设置为'true'
,跟随符号链接 |
| includes
| 不 | 以逗号分隔的模式列表—匹配的模式将被包括在内 |
| excludes
| 不 | 以逗号分隔的模式列表—匹配的模式将被排除 |
图案集
当你在你的fileset
元素(和其他元素)中构建模式时,有一个危险是你将开始重复exclude
和include
元素的组合。在我之前的例子中,我为测试文件和常规代码文件定义了模式。随着时间的推移,我可能会添加这些内容(也许我希望在我的代码文件定义中包含.conf
和.inc
扩展)。如果我定义了其他也使用这些模式的fileset
元素,我将被迫对所有相关的fileset
元素进行调整。
您可以通过将模式分组到patternset
元素中来解决这个问题。patternset
元素对include
和exclude
元素进行分组,以便以后可以从其他类型中引用它们。这里,我从我的fileset
示例中提取了include
和exclude
元素,并将它们添加到patternset
元素中:
// listing 19.14
<patternset id="inc_code">
<include name="**/*.php" />
<include name="**/*.inc" />
<include name="**/*.conf" />
</patternset>
<patternset id="exc_test">
<exclude name="**/*_test.php" />
<exclude name="**/*Test.php" />
</patternset>
我创建了两个patternset
元素,将它们的id
属性分别设置为inc_code
和exc_test
。inc_code
包含用于包含代码文件的include
元素,exc_test
包含用于排除测试文件的exclude
文件。我现在可以在一个fileset
中引用这些patternset
元素:
// listing 19.15
<fileset dir="src/lib" id="srclib">
<patternset refid="inc_code" />
<patternset refid="exc_test" />
</fileset>
要引用现有的patternset
,必须使用另一个patternset
元素。第二个元素必须设置一个属性:refid
。refid
属性应该引用您希望在当前上下文中使用的patternset
元素的id
。这样,我可以跨不同的fileset
元素重用patternset
元素:
<fileset dir="src/views" id="srcviews">
<patternset refid="inc_code" />
</fileset>
我对inc_code patternset
所做的任何更改都会自动更新使用它的任何类型。与FileSet
一样,您可以将exclude
规则放在一个excludes
属性或一组exclude
子元素中。include
规则也是如此。
表 19-4 中总结了一些patternset
元素属性。
表 19-4
Patternset 元素的一些属性
|属性
|
需要
|
描述
|
| --- | --- | --- |
| id
| 不 | 引用元素的唯一句柄 |
| excludes
| 不 | 排除模式列表 |
| includes
| 不 | 包含的模式列表 |
| refid
| 不 | 当前patternset
是给定 ID 的patternset
的参考 |
过滤器链
到目前为止,我所遇到的类型已经提供了选择文件集的机制。相反,FilterChain 提供了一种灵活的机制来转换文本文件的内容。
与所有类型一样,定义一个filterchain
元素本身不会引起任何变化。元素及其子元素必须首先与一个任务相关联——也就是说,一个告诉 Phing 采取一系列行动的元素。稍后我将回到任务。
一个filterchain
元素将任意数量的过滤器组合在一起。过滤器像管道一样对文件进行操作——第一个过滤器改变其文件并将结果传递给第二个过滤器,第二个过滤器进行自己的改变,依此类推。通过在一个filterchain
元素中组合多个过滤器,您可以实现灵活的转换。
在这里,我直接进入并创建了一个filterchain
,它从传递给它的任何文本中删除 PHP 注释:
// listing 19.16
<filterchain>
<stripphpcomments />
</filterchain>
StripPhpComments 任务顾名思义就是这样做的。如果您在源代码中提供了详细的 API 文档,您可能会让开发人员的工作变得轻松,但是您也给项目增加了很多负担。因为所有重要的工作都发生在您的源目录中,所以没有理由不删除关于安装的注释。
Note
如果您在项目中使用构建工具,请确保没有人对已安装的代码进行更改。安装程序将复制任何更改过的文件,更改将会丢失。我目睹了这一切的发生。
让我们先看一下下一部分,并将filterchain
元素放在一个任务中:
// listing 19.17
<target name="main">
<copy todir="build/lib">
<fileset refid="srclib"/>
<filterchain>
<stripphpcomments />
</filterchain>
</copy>
</target>
复制任务可能是你用得最多的任务。它将文件从一个地方复制到另一个地方。如您所见,我在todir
属性中定义了目标目录。文件的源由我在上一节中创建的fileset
元素定义。然后是filterchain
元素。复制任务复制的任何文件都将应用此转换。
Phing 支持许多操作的过滤器,包括剥离新行(StripLineBreaks
)和用空格替换制表符(TabToSpaces
)。甚至还有一个 XsltFilter,用于对源文件应用 XSLT 转换!然而,也许最常用的过滤器是ReplaceTokens
。这允许您将源代码中的标记交换为构建文件中定义的属性,无论是从环境变量中提取还是在命令行中传递。这对于定制安装非常有用。将您的令牌集中到一个中央配置文件中是一个好主意,这样可以方便地查看项目的各个方面。
ReplaceTokens
可选地接受两个属性,begintoken
和endtoken
。您可以使用这些来定义描述令牌边界的字符。如果省略这些,Phing 将采用默认字符@
。为了识别和替换令牌,您必须将token
元素添加到replacetokens
元素中。现在我将在我的例子中添加一个replacetokens
元素:
// listing 19.18
<target name="main">
<copy todir="build/lib">
<fileset refid="srclib"/>
<filterchain>
<stripphpcomments />
<replacetokens>
<token key="dbname" value="${dbname}" />
<token key="dbhost" value="${dbhost}" />
<token key="dbpass" value="${dbpass}" />
</replacetokens>
</filterchain>
</copy>
</target>
如您所见,token
元素需要key
和value
属性。让我们来看看在我的项目中的一个文件上运行这个任务及其转换的效果。原始文件位于源目录中,src/lib/Config.php
:
// listing 19.19
/*
* Quick and dirty Conf class
*
*/
class Config
{
public string $dbname ="@dbname@";
public string $dbpass ="@dbpass@";
public string $dbhost ="@dbhost@";
}
运行包含先前定义的复制任务的主目标会产生以下输出:
$ phing
Buildfile: /home/bob/working/megaquiz/build.xml
megaquiz > main:
[copy] Copying 8 files to /home/bob/working/megaquiz/build/lib
BUILD FINISHED
Total time: 0.1413 seconds
当然,原始文件没有被改动,但是由于复制任务,它在build/lib/Config.php
被复制:
class Config {
public string $dbname ="megaquiz";
public string $dbpass ="default";
public string $dbhost ="localhost";
}
不仅注释被删除了,而且标记也被替换成了它们的等价属性。
任务
任务是构建文件中完成工作的元素。不使用一个任务你不会有很大的成就,这就是为什么我已经欺骗和使用了两个。我将重新介绍这些。
回声
Echo 任务非常适合强制性的“Hello World”示例。在现实世界中,你可以用它来告诉用户你将要做什么或者你已经做了什么。您还可以通过显示属性值来检查您的构建过程。如您所见,放置在echo
元素的开始和结束标签中的任何文本都将被打印到浏览器中:
<echo>The pass is '${dbpass}', shhh!</echo>
或者,您可以将输出消息添加到一个msg
属性中:
<echo msg="The pass is '${dbpass}', shhh!" />
这与将以下内容打印到标准输出具有相同的效果:
[echo] The pass is 'default', shhh!
复制
复制实际上就是安装的全部。通常,您将创建一个目标,该目标从源目录中复制文件,并将它们汇编到一个临时的构建目录中。然后,您将拥有另一个目标,它将组装(和转换)的文件复制到它们的输出位置。将安装分成单独的构建和安装阶段并不是绝对必要的,但这意味着您可以在提交覆盖生产代码之前检查初始构建的结果。您还可以更改属性并再次安装到不同的位置,而不需要再次运行可能非常昂贵的复制/替换阶段。
简单来说,复制任务允许您指定源文件和目标目录或文件:
<copy file="src/lib/Config.php" todir="build/conf" />
如您所见,我使用file
属性指定了源文件。您可能已经熟悉了todir
属性,它用于指定目标目录。如果目标目录不存在,Phing 会为您创建一个。
如果您需要指定一个目标文件,而不是包含目录,您可以使用tofile
属性来代替todir
:
<copy file="src/lib/Config.php" tofile="build/conf/myConfig.php" />
如果需要的话,再次创建build/conf
目录,但是这一次,Config.php
被重命名为myConfig.php
。
如您所见,要一次复制多个文件,您需要向copy
添加一个fileset
元素:
// listing 19.20
<copy todir="build/lib">
<fileset refid="srclib"/>
</copy>
源文件是由srclib fileset
元素定义的,所以您只需在copy
中设置todir
属性。
Phing 足够聪明,可以测试源文件在目标文件创建后是否被修改过。如果没有改变,那么 Phing 不会复制。这意味着您可以进行多次构建,并且只有在此期间发生更改的文件才会被安装。这很好,只要其他事情不太可能改变。例如,如果一个文件是根据一个replacetokens
元素的配置转换的,那么您可能希望确保每次调用复制任务时文件都被转换。您可以通过设置一个overwrite
属性来做到这一点:
// listing 19.21
<copy todir="build/lib" overwrite="yes">
<fileset refid="srclib"/>
<filterchain>
<stripphpcomments />
<replacetokens>
<token key="dbname" value="${dbname}" />
</replacetokens>
</filterchain>
</copy>
现在,无论何时运行 copy,由fileset
元素匹配的文件都会被替换,不管源文件最近是否被更新过。
你可以在表 19-5 中看到copy
元素和它的一些属性。
表 19-5
复制元素的一些属性
|属性
|
需要
|
描述
|
缺省值
|
| --- | --- | --- | --- |
| file
| 不 | 要复制的文件 | 没有人 |
| todir
| 是(如果tofile
不存在) | 要复制到的目录 | 没有人 |
| tofile
| 是(如果todir
不存在) | 要复制到的文件 | 没有人 |
| tstamp or preservelastmodified
| 不 | 匹配任何被覆盖文件的时间戳(它将显示为未更改) | false
|
| preservemode or preservepermissions
| 不 | 匹配任何被覆盖文件的权限 | false
|
| includeemptydirs
| 不 | 复制空目录 | false
|
| mode
| 不 | 设置(八进制)模式 | 755
|
| haltonerror
| 不 | 如果遇到错误,构建过程将停止 | true
|
| overwrite
| 不 | 如果目标已经存在,则覆盖它 | no
|
投入
您已经看到了echo
元素用于向用户发送输出。为了从用户那里收集输入,我使用了不同的方法,包括命令行和环境变量。然而,这些机制既没有很好的结构化,也没有很好的交互性。
Note
允许用户在构建时设置值的一个原因是考虑到构建环境之间的灵活性。在数据库密码的情况下,另一个好处是这些敏感数据不会保存在构建文件本身中。当然,一旦构建已经运行,密码将被保存到一个源文件中,因此由开发人员来确保他的系统的安全性!
元素允许您向用户显示一条提示消息。Phing 然后等待输入,并将其分配给一个属性。这里有一个例子:
// listing 19.22
<?xml version="1.0"?>
<!-- build xml -->
<project name="megaquiz"
default="main" >
<target name="setpass" unless="dbpass">
<input message="You don't seem to have set a db password"
propertyName="dbpass"
defaultValue="default"
promptChar=" >" />
</target>
<target name="main" depends="setpass">
<echo>pass: ${dbpass}</echo>
</target>
</project>
同样,我有一个默认目标:main
。这依赖于另一个目标setpass
,它负责确保填充dbpass
属性。为此,我使用了目标元素的unless
属性,这确保了如果已经设置了dbpass
,它将不会运行。
setpass
目标由一个单独的input
任务元素组成。一个input
元素需要一个message
属性,该属性应该包含一个用户提示。propertyName
属性是必需的,它定义了由用户输入填充的属性。如果用户在没有设置值的情况下在提示符下按 Enter 键,那么如果设置了defaultValue
属性,该属性将被赋予一个回退值。最后,您可以使用promptChar
属性定制提示字符——这为用户输入数据提供了视觉提示。让我们使用前面的目标运行 Phing:
$ phing
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > setpass:
You don't seem to have set a db password [default] > mypass
megaquiz > main:
[echo] pass: mypass
BUILD FINISHED
Total time: 3.8878 seconds
表 19-6 中总结了input
元素。
表 19-6
输入元素的属性
|属性
|
需要
|
描述
|
| --- | --- | --- |
| propertyName
| 是 | 要用用户输入填充的属性 |
| message
| 不 | 提示信息 |
| defaultValue
| 不 | 如果用户不提供输入,则为属性分配一个值 |
| validArgs
| 不 | 以逗号分隔的可接受输入值列表。如果用户输入一个不在这个列表中的值,Phing 将重新显示提示 |
| promptChar
| 不 | 用户应该提供输入的视觉提示 |
| hidden
| 不 | 如果设置,隐藏用户输入 |
删除
安装通常是关于创建、复制和转换文件。然而,删除也有它的位置。当您希望执行全新安装时尤其如此。正如我已经讨论过的,对于自上次构建以来发生变化的源文件,文件通常只从源文件复制到目标文件。通过删除构建目录,您可以确保完整的编译过程将会发生。
在这里,我删除一个目录:
// listing 19.23
<target name="clean">
<delete dir="build" />
</target>
当我使用参数clean
(目标的名称)运行phing
时,我的delete
任务元素被调用。以下是 Phing 的输出:
$ phing clean
Buildfile: /var/popp/src/ch19/build.xml
megaquiz > clean:
[delete] Deleting directory /var/popp/src/ch19/build
BUILD FINISHED
Total time: 0.1000 seconds
delete
元素接受一个属性file
,它可以用来指向一个特定的文件。或者,您可以通过向delete
添加一个fileset
子元素来微调您的删除。
摘要
真正的发展很少发生在一个地方。代码库需要从它的安装中分离出来,这样进行中的工作就不会污染需要一直保持功能的生产代码。版本控制允许开发人员签出一个项目,并在他们自己的空间中处理它。这要求他们应该能够为自己的环境轻松地配置项目。最后,也可能是最重要的,客户(即使客户是一年后的你,那时你已经忘记了代码的来龙去脉)应该能够在看一眼自述文件后安装你的项目。
在这一章中,我已经介绍了 Phing 的一些基础知识,Phing 是一个非常棒的工具,它将 Apache Ant 的许多功能带到了 PHP 世界。我只是触及了 Phing 能力的表面。然而,一旦您启动并运行了这里讨论的目标、任务、类型和属性,您会发现为高级特性添加新元素很容易,比如创建 tar/gzipped 发行版、自动生成 PEAR 包安装,以及直接从构建文件运行 PHP 代码。
如果 Phing 不能满足您所有的构建需求,您会发现,像 Ant 一样,它被设计成可扩展的——去构建您自己的任务吧!即使不添加 Phing,也应该花点时间检查一下源代码。Phing 完全是用面向对象的 PHP 编写的,它的代码中充满了设计示例。
二十、Vagrant
你在哪里运行你的代码?
也许你有一个开发环境,你已经用一个最喜欢的编辑器和许多有用的开发工具磨练到完美。当然,您编写代码的完美设置可能与运行代码的最佳系统大相径庭。这是一个挑战,流浪者可以帮助你。使用 vagger,您可以在本地机器上工作,并在与您的生产服务器完全相同的系统上运行您的代码。在这一章中,我将告诉你如何做。我们将涵盖以下内容:
-
基本设置:从安装到选择第一个盒子
-
登录:用 ssh 调查你的虚拟机
-
挂载主机目录:在你的主机上编辑代码,并让它透明地出现在你的流浪者盒子里
-
Provisioning :编写脚本安装软件包,配置 Apache 和 MySQL
-
设置主机名:配置您的机顶盒,以便您可以使用自定义主机名访问它
问题
像往常一样,让我们花一点时间来定义问题空间。如今,在大多数台式机或笔记本电脑上配置灯组相对容易。即便如此,个人电脑也不太可能与您的生产环境相匹配。它运行的是同一版本的 PHP 吗?Apache 和 MySQL 呢?如果你正在使用 Elasticsearch,你可能也需要考虑 Java。这个名单很快就变长了。如果您的产品堆栈非常不同,在特定平台上使用一组工具进行开发有时会有问题。
你可能会放弃,将你的开发转移到一台远程机器上——有很多云供应商可以让你快速运转机器。但是这不是一个免费的选项,而且,根据您选择的编辑器,远程系统可能无法与您希望使用的开发工具很好地集成。
因此,尽可能地将您计算机上的软件包与安装在生产系统上的软件包匹配起来可能是值得的。匹配不会是完美的,但也许会足够好,并且您可能会在登台服务器上发现大多数问题。
然而,当你开始进行另一个需求完全不同的项目时,会发生什么呢?我们已经看到 Composer 在分离依赖项方面做得很好,但是仍然有像 PHP、MySQL 和 Apache 这样的全局包需要保持一致。
Note
如果您决定在远程系统上开发,我建议您努力学习如何使用 vim 编辑器。尽管它有些古怪,但它非常强大,您可以 99%确定 vim 或它的更基本的祖先 vi 可以在您遇到的任何类 Unix 系统上使用。
虚拟化是一个潜在的解决方案,也是一个很好的解决方案。不过,安装操作系统可能是一件痛苦的事,而且可能会有相当多的配置问题。
要是有一种工具能让在本地机器上创建类似生产的开发环境变得非常简单就好了。好了,很明显,现在我要说的就是这样一个工具的存在。嗯,有一个是。它叫《流浪》,真的很神奇。
一个小陷阱
很有诱惑力的说法是,流浪者给你一个单一命令的开发环境。那个可能是真的——但是你必须先安装必要的软件。考虑到这一点,以及您可以从项目的版本控制存储库中签出的配置文件,启动一个新环境确实只需要一个命令。
让我们先开始设置。流浪者需要一个虚拟化平台。它支持几个,但我会使用 VirtualBox。我的主机运行 Fedora,但你可以在任何 Linux 发行版和 OSX 或 Windows 上安装 VirtualBox。您可以在 www.virtualbox.org/wiki/Downloads
找到下载页面,以及针对您的平台的说明。
当然,一旦你安装了 VirtualBox,你将需要一个流浪者。下载页面在 www.vagrantup.com/downloads.html
。一旦我们安装了这些应用,我们的下一个任务将是选择运行代码的机器。
选择和安装流浪盒
大概最简单的获得流浪盒子的方法就是使用 https://app.vagrantup.com/boxes/search
的搜索界面。由于许多生产系统运行 CentOS,这就是我要寻找的。你可以在图 20-1 中看到我的研究成果。
图 20-1
寻找一个流浪的盒子
CentOS 7 看起来很适合我的需求。我可以点击我感兴趣的框的列表来获取设置说明。这给了我足够的信息来运行一个流浪环境。通常当你运行 named 时,它会读取一个名为Vagrantfile
的配置文件——但由于我是从头开始,所以我需要让 named 生成一个:
$ vagrant init bento/centos-7
A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.
如您所见,我将我想要使用的机器的名称传递给了 vagger,它使用这些信息来生成一些最小的配置。
如果我打开生成的Vagrantfile
文档,我可以看到这个(在许多其他样板文件中):
# Every Vagrant development environment requires a box. You can search for
# boxes at https://vagrantcloud.com/search.
config.vm.box = "bento/centos-7"
至此,我只完成了生成配置的工作。接下来,我必须运行非常重要的vagrant up
命令。如果你经常和流浪者一起工作,你会很快发现这个命令非常熟悉。它通过下载和提供您的新盒子(如果需要)来启动您的漫游会话,然后启动它:
$ vagrant up
因为我是第一次用bento/centos-7
虚拟机运行这个命令,所以 vagger 从下载盒子开始:
==> default: Box 'bento/centos-7' could not be found. Attempting to find and install...
default: Box Provider: virtualbox
default: Box Version: >= 0
==> default: Loading metadata for box 'bento/centos-7'
default: URL: https://vagrantcloud.com/bento/centos-7
==> default: Adding box 'bento/centos-7' (v202008.16.0) for provider: virtualbox
default: Downloading:
https://vagrantcloud.com/bento/boxes/centos-7/versions/202008.16.0/providers/ virtualbox.box
default: Download redirected to host: vagrantcloud-files-production.s3.amazonaws.com
==> default: Successfully added box 'bento/centos-7' (v202008.16.0) for 'virtualbox'!
...
vagger 存储了这个盒子(如果你运行的是 Linux,在~/.vagrant.d/boxes/
下),这样你就不必在你的系统上再次下载它——即使你运行多个虚拟机。然后它配置并引导机器(它提供了很多细节)。一旦它运行完毕,我就可以登录到我的新机器上进行测试了:
$ vagrant ssh
$ pwd
/home/vagrant
$ cat /etc/redhat-release
CentOS Linux release 7.8.2009 (Core)
我们进去了。那么我们赢得了什么?嗯,我们可以使用一台有点像我们生产环境的机器。还有别的吗?事实上,相当多。我之前说过,我想在本地机器上编辑文件,但在类似生产的空间中运行它们。我们来设置一下。
是时候再次离开盒子,回到主机上了:
$ exit
在流浪者盒子上安装本地目录
让我们把一些样本文件放在一起。我在一个我命名为infrastructure
的目录中运行了我的第一个vagrant init
和vagrant up
命令。我将重新启用我在第十八章中使用的webwoo
项目(我为第十二章开发的系统的精简版)。综上所述,我的开发环境看起来有点像这样:
ch20/
infrastructure/
Vagrantfile
webwoo/
AddVenue.php
index.php
Main.php
AddSpace.php
我们面临的挑战是设置环境,以便我们可以在本地处理webwoo
文件,但使用 CentOS box 上安装的堆栈透明地运行它们。根据我们的配置,vagger 将尝试在来宾系统中的主机上安装目录。事实上,流浪者已经为我们安装了一个目录。让我们来看看:
$ vagrant ssh
Last login: Wed Sep 23 16:46:53 2020 from 10.0.2.2
$ ls -a /vagrant
. .. .vagrant Vagrantfile
所以流浪者把infrastructure
目录挂载为盒子上的/vagrant
。当我们编写一个脚本来配置机器时,这将派上用场。不过现在,让我们集中精力安装webwoo
目录。我们可以通过编辑Vagrantfile
来做到这一点。不过,首先,现在可能是再次退出虚拟机的好时机。一旦我这样做了,我打开Vagrantfile
,添加这一行:
config.vm.synced_folder "../webwoo", "/var/www/poppch20"
通过搜索带注释的样板文件中的字符串synced_folder
,我可以找到放置这一行的最佳位置。我发现一个示例配置行看起来很像我自己的。通过这个指令,我告诉流浪者在/var/www/poppch20
的访客箱上安装webwoo
目录。为了看到效果,我需要重启机器。为此有一个新命令(应该在主机系统上运行,而不是在虚拟机中运行):
$ vagrant reload
虚拟机会完全关闭并重新启动。流浪者挂载infrastructure
( /vagrant
)和webwoo
( /var/www/poppch20
)目录。以下是该命令输出的摘录:
==> default: Mounting shared folders...
default: /vagrant => /home/mattz/localwork/popp/ch20-vagrant/infrastructure
default: /var/www/poppch20 => /home/mattz/localwork/popp/ch20-vagrant/webwoo
我可以快速登录以确认/var/www/poppch20
已就位:
$ vagrant ssh
$ ls /var/www/poppch20/
AddSpace.php AddVenue.php index.php Main.php
所以现在我可以在我的本地机器上运行一个性感的 IDE,并让它所做的更改透明地出现在来宾操作系统上!
Note
来自技术评论者和 Windows 用户 Paul Tregoing 的注释:如果运行 Windows 主机,不要使用 VirtualBox 共享文件系统(在本例中,它支撑着 vagger 的同步文件夹)。如果这样做,您可能会遇到区分大小写和缺少符号链接支持的问题。在这种情况下,最好在客户操作系统上运行 Samba(大多数发行版都将其安装为smbd
)并在主机上映射一个网络驱动器,以获得更无缝的体验。这方面有很多在线指南。
当然,将文件放在 CentOS 虚拟机上并不等同于运行系统。一个典型的流浪者盒子没有太多的预装。假设开发者想要根据需要和环境定制环境。
下一步是配置我们的机器。
准备金提取
同样,调配由Vagrantfile
文档指导。游民支持几个为置备机器设计的工具,包括 Chef ( www.chef.io/chef/
)、Puppet ( https://puppet.com
)和 Ansible ( www.ansible.com
)。都值得调查。但是,出于这个示例的目的,我将使用一个很好的老式 shell 脚本。
我再一次从Vagrantfile
开始:
config.vm.provision "shell", path: "setup.sh"
这应该是相当清楚的。我告诉 vagger 使用一个 shell 脚本来提供我的机器,我指定setup.sh
作为应该执行的脚本。
当然,在 shell 脚本中放入什么取决于您的需求。我将从设置几个变量和安装一些包开始:
#!/bin/bash
VAGRANTDIR=/vagrant
SERVERDIR=/var/www/poppch20/
sudo yum -q -y install epel-release yum-utils
sudo yum -q -y install http://rpms.remirepo.net/enterprise/remi-release-7.rpm
yum-config-manager --enable remi-php80
sudo yum -q -y install mysql-server
sudo yum -q -y install httpd;
sudo yum -q -y install php
sudo yum -q -y install php-common
sudo yum -q -y install php-cli
sudo yum -q -y install php-mbstring
sudo yum -q -y install php-dom
sudo yum -q -y install php-mysql
sudo yum -q -y install php-xml
sudo yum -q -y install php-dom
PHP 8 在 CentOS 7 上默认不可用。然而,通过安装包remi-release-7.rpm
,我能够安装 PHP 的新版本。我把我的脚本写到一个名为setup.sh
的文件中,我把它放在基础设施目录中Vagrantfile
的旁边。
现在,我该如何开始调配流程呢?如果在我运行vagrant up
时config.vm.provision
指令和setup.sh
脚本都已经就绪,那么供应将是自动的。实际上,我现在需要手动运行它:
$ vagrant provision
当setup.sh
脚本在 travel box 中运行时,这将会在你的终端上产生大量的信息。让我们看看它是否有效:
$ vagrant ssh
$ php -v
PHP 8.0.0 (cli) (built: Nov 24 2020 17:04:03) ( NTS gcc x86_64 )
Copyright (c) The PHP Group
Zend Engine v4.0.0-dev, Copyright (c) Zend Technologies
设置 Web 服务器
当然,即使安装了 Apache,系统也不能运行。首先要配置 Apache。最简单的方法是创建一个可以复制到 Apache 的conf.d
目录中的配置文件。让我们调用文件poppch20.conf
并将其放入基础设施目录:
<VirtualHost *:80>
ServerAdmin matt@getinstance.com
DocumentRoot /var/www/poppch20
ServerName poppch20.vagrant.internal
ErrorLog logs/poppch20-error_log
CustomLog logs/poppch20-access_log common
</VirtualHost>
<Directory /var/popp/wwwch20>
AllowOverride all
</Directory>
稍后我将返回到该主机名。抛开那些诱人的细节,这足以告诉 Apache 我们的/var/www/poppch20
目录并设置日志记录。当然,我还必须更新setup.sh
,以便在供应时复制配置文件:
sudo cp $VAGRANTDIR/poppch20.conf /etc/httpd/conf.d/
systemctl start httpd
systemctl enable httpd
我将配置文件复制到适当的位置,并重新启动 web 服务器,这样就可以获得配置了。我还运行systemctl enable
来确保服务器将在引导时启动。
进行了这一更改后,我可以重新运行该脚本:
$ vagrant provision
需要注意的是,我们之前提到的安装脚本的那些部分也将被重新运行。创建配置脚本时,必须将其设计为可以重复执行而不会产生严重影响。幸运的是,Yum 检测到我指定的包已经被安装,并发出无害的抱怨,部分原因是我采取了预防措施,传递了它的-q
标志,这使得抱怨相对较少。
设置 MariaDB
对于许多应用,您需要确保数据库可用并准备好连接。下面是对我的设置脚本的一个简单补充:
sudo yum -q -y install mariadb-server
systemctl start mariadb
systemctl enable mariadb
/usr/bin/mysqladmin -s -u root password 'vagrant' || echo " -- unable to create pass - probably already done"
domysqldb vagrant poppch20_vagrant vagrant vagrant
ROOTPASS=vagrant
DBNAME=poppch20_vagrant
DBUSER=vagrant
DBPASS=vagrant
MYSQL=mysql
MYSQLROOTCMD="mysql -uroot -p$ROOTPASS"
echo "creating database $DBNAME..."
echo "CREATE DATABASE IF NOT EXISTS $DBNAME" | $MYSQLROOTCMD || die "unable to create db";
echo "grant all on $DBNAME.* to $DBUSER@'localhost' identified by \"$DBPASS\"" |
$MYSQLROOTCMD || die "unable to grand privs for user $DBUSER"
echo "FLUSH PRIVILEGES" | $MYSQL -uroot -p"$ROOTPASS" || die "unable to flush privs"
我安装了 MariaDB,它是 MySQL 的现代替代品(由 MySQL 开发人员创建,在实现熟悉的 MySQL 工具和命令方面是兼容的)。我运行mysqladmin
命令来创建一个 root 密码。这将在第一次运行后失败,因为密码已经设置好了,所以我使用-s
标志来隐藏错误消息,并在命令失败时打印我自己的消息。然后我创建一个数据库、一个用户和一个密码。
准备就绪后,我可以再次调配资源,然后测试我的数据库:
$ vagrant provision
# much output
$ vagrant ssh
$ mysql -uvagrant -pvagrant poppch20_vagrant
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 8
Server version: 5.5.65-MariaDB MariaDB Server
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [poppch20_vagrant]>
我们现在有了一个正在运行的数据库和一个 web 服务器。是时候看看代码是如何运行的了。
配置主机名
我们已经多次登录到我们新的类似生产的开发环境中,因此网络或多或少得到了关注。尽管我已经配置了一个 web 服务器,但我还没有使用过它。这是因为我们仍然需要为我们的虚拟机支持一个主机名。所以我们给Vagrantfile
加一:
config.vm.hostname = "poppch20.vagrant.internal"
config.vm.network :private_network, ip: "192.168.33.148"
我发明了一个主机名,并使用config.vm.hostname
指令添加它。我还用config.vm.network
配置了私有网络,分配了一个静态 IP 地址。您应该为此使用私有地址空间——以192.168
开头的未使用的 IP 地址应该可以。
因为这是一个虚构的主机名,所以我们必须配置我们的操作系统来处理这个解析。在类 Unix 系统上,这意味着编辑系统文件/etc/hosts
。在这种情况下,我要补充以下内容:
192.168.33.148 poppch20.vagrant.internal
Note
Windows 上的 hosts 文件可以在%windir%\system32\drivers\etc\hosts
找到。
不太麻烦,但是我们正在为我们的团队进行一个命令安装,所以有一个自动化这个步骤的方法是很好的。幸运的是,Vagrant 支持插件,hostmanager
插件正是我们所需要的。要添加插件,只需运行vagrant plugin install
命令:
$ vagrant plugin install vagrant-hostmanager
Installing the 'vagrant-hostmanager' plugin. This can take a few minutes...
Installed the plugin 'vagrant-hostmanager (1.8.9)'!
然后你可以显式的告诉插件更新/etc/hosts
,像这样:
$ vagrant hostmanager
[default] Updating /etc/hosts file...
为了让团队成员自动完成这个过程,我们应该在Vagrantfile
中显式启用 hostmanager:
config.hostmanager.enabled = true
配置更改就绪后,我们应该运行vagrant reload
来应用它们。那就是真相大白的时刻了!我们的系统会在浏览器中运行吗?正如你在图 20-2 中看到的,这个系统应该可以正常工作。
图 20-2
访问流浪盒上的已配置系统
包装它
因此,我们已经从一无所有到一个完全工作的开发环境。考虑到花了整整一章的努力才到达这里,说流浪者又快又容易似乎有点欺骗。对此有两个答案。首先,一旦你这样做了几次,建立另一个浮动设置就变得非常简单了——当然比手动处理多个依赖栈要容易得多。
然而,更重要的是,真正的速度和效率的提高并不在于设置流浪者的人。想象一下,一个新的开发人员来到您的项目,期待几天的下载、配置文件编辑和 wiki 点击。想象一下,你告诉她,“安装流浪者和 VirtualBox。查看代码。从基础结构目录中,运行“向上漫游”。就这样!与你经历过或听到过的一些痛苦的入职流程相比。
当然,在这一章中我们仅仅触及了表面。由于您需要配置流浪者为您做更多的事情, www.vagrantup.com
的官方网站将为您提供所需的一切支持。
表 20-1 提供了本章中我们遇到的浮动命令的快速提示。
表 20-1
一些流浪的命令
|命令
|
描述
|
| --- | --- |
| vagrant up
| 启动虚拟机并进行配置(如果尚未配置) |
| vagrant reload
| 暂停系统并重新启动(除非使用--provision
标志运行,否则不会再次运行配置) |
| vagrant plugin list
| 列出已安装的插件 |
| vagrant plugin install <plugin-name>
| 安装插件 |
| vagrant provision
| 再次运行预配步骤(如果您已经更新了预配脚本,这将非常有用) |
| vagrant halt
| 正常关闭虚拟机 |
| vagrant suspend
| 停止虚拟机进程并保存状态 |
| vagrant resume
| 恢复先前挂起的虚拟机进程 |
| vagrant init
| 创建新的流浪者文件文档 |
| vagrant destroy
| 摧毁虚拟机。别担心,你随时可以用vagrant up
重新开始! |
摘要
在这一章中,我介绍了 vagger,这个应用可以让您在一个类似生产的开发环境中工作,而不会牺牲您的创作工具。我讲述了安装、发行版的选择和初始设置——包括安装您的开发目录。一旦我们有了一个可以使用的虚拟机,我就进入了配置过程——包括软件包安装以及数据库和 web 服务器配置。最后,我查看了主机名管理,并展示了我们的系统在浏览器中的工作情况!
二十一、持续集成
在前面的章节中,您已经看到了大量被设计来支持一个管理良好的项目的工具。单元测试、文档、构建和版本控制都非常有用。但是工具,尤其是测试,会很麻烦。
即使你的测试只需要几分钟就能运行,你也经常太专注于编码而不去理会它们。不仅如此,你还有客户和同事在等待新的特性。坚持编码的诱惑总是存在的。但是 bug 在接近孵化时更容易修复。这是因为您更有可能知道哪个变化导致了问题,并且更有能力提出快速解决方案。
在这一章中,我将介绍持续集成,这是一种自动化测试和构建的实践,它将您在最近几章中遇到的工具和技术结合在一起。
本章将涵盖以下主题:
-
定义持续集成
-
为 CI 准备项目
-
看 Jenkins:CI 服务器
-
用专门的插件为 PHP 项目定制 Jenkins
什么是持续集成?
在过去糟糕的日子里,集成是在你完成有趣的事情后做的事情。这也是你意识到还有多少工作要做的阶段。集成是这样一个过程,通过这个过程,您的项目的所有部分都被打包成可以运输和部署的包。这并不迷人,而且实际上很难。
集成也与 QA 紧密相关。如果产品不符合用途,你就不能发货。这意味着测试。很多测试。如果您在集成阶段之前没有进行太多的测试,这可能也意味着令人讨厌的惊喜。很多人。
从第十八章你知道,最好的做法是尽早并经常进行测试。从第 15 和 19 章中你知道,你应该从一开始就在头脑中设计部署。我们大多数人都认为这是理想状态,但是现实和理想有多匹配呢?
如果你实践面向测试的开发(这个术语我更喜欢测试优先的开发,因为它更好地反映了我见过的大多数好项目的现实),那么编写测试没有你想象的那么难。毕竟,无论如何,你都是一边编码一边写测试。每次开发一个组件时,您都会创建代码片段,可能在类文件的底部,这些代码片段实例化对象并调用它们的方法。如果你收集那些一次性的代码碎片,在开发过程中测试你的组件,你就有了一个测试用例。将它们放入一个类中,并添加到您的套件中。
奇怪的是,人们通常会回避测试的运行。随着时间的推移,测试需要更长的时间来运行。与已知问题相关的故障不断出现,使得诊断新问题变得困难。此外,您怀疑其他人提交了破坏测试的代码,并且您没有时间停下自己的工作来修复其他人造成的问题。最好运行几个与您的工作相关的测试,而不是整个套件。
未能运行测试,因此未能修复它们可能揭示的问题,使得问题越来越难以解决。寻找 bug 的最大开销通常是诊断,而不是治疗。通常,修复可以在几分钟内完成,而不是花几个小时去寻找测试失败的原因。然而,如果测试在提交后的几分钟或几小时内失败,您更有可能知道在哪里查找问题。
软件构建也有类似的问题。如果您不经常安装您的项目,您可能会发现,尽管在您的开发机器上一切运行良好,但安装的实例会出现一个模糊的错误消息。构建间隔的时间越长,失败的原因对你来说就越模糊。
通常是一些简单的事情:对系统上的库的未声明的依赖,或者您未能签入的一些类文件。如果你手头有,这些很容易解决。但是,如果您不在办公室时发生构建失败,该怎么办呢?无论哪个不幸的团队成员得到了构建和发布项目的工作,都不会知道您的设置,也不会容易地访问那些丢失的文件。
项目中涉及的人数越多,集成问题就越严重。你可能喜欢并尊重你所有的团队成员,但是我们都知道他们比你更有可能不进行测试。然后,他们在周五下午 4 点提交一周的开发工作,就在你准备宣布项目可以发布的时候。
持续集成(CI)通过自动化构建和测试过程来减少这些问题。
CI 既是一套实践,也是一套工具。作为一种实践,它需要频繁地提交项目代码(至少每天一次)。每次提交时,都应该运行测试并构建任何包。您已经看到了 CI 所需的一些工具,特别是 PHPUnit 和 Phing。然而,单独的工具是不够的。需要更高级别的系统来协调和自动化该过程。
如果没有更高的系统,CI 服务器,CI 实践很可能会屈服于我们跳过杂务的自然倾向。毕竟,我们宁愿编码。
拥有这样的系统有明显的好处。首先,您的项目经常被构建和测试。这是 CI 的最终目的和效益。然而,它的自动化又增加了两个维度。测试和构建发生在与开发不同的线程中。它发生在幕后,不需要您停止工作来运行测试。同样,和测试一样,CI 鼓励好的设计。为了能够在远程位置自动安装,您必须从一开始就考虑安装的方便性。
我不知道我遇到过多少次这样的项目,安装过程是只有少数开发人员知道的神秘秘密。"你是说你没有设置 URL 重写?"一个老手带着几乎不加掩饰的轻蔑问道。“老实说,重写规则在维基中是,你知道。只需将它们粘贴到 Apache 配置文件中。”开发时考虑 CI 意味着让系统更容易测试和安装。这可能意味着前期工作要多一点,但它让我们以后的生活更轻松。简单多了。
所以,首先,我要打下一些昂贵的基础。事实上,您会发现在接下来的大部分章节中,您已经遇到了这些准备步骤。
为 CI 准备项目
首先,当然我需要一个项目不断整合。现在,我是一个懒惰的人,所以我会寻找一些已经写好的测试附带的代码。显而易见的候选者是我在第十八章中创建的用来说明 PHPUnit 的项目。我要把它命名为userthing
,因为它是一个的东西,里面有一个User
的物体。
Note
本章中描述的一些工具要么是最近才发布的,要么在撰写本文时还处于测试阶段。这导致了一些怪癖和不兼容性。当你读到这篇文章时,这些问题很可能已经解决了。然而,为了构建一个可靠的 CI 系统,我不得不为这些例子回滚到 PHP 和 PHPUnit 的早期版本。这对显示的代码和配置应该没有影响。
首先,这里是我的项目目录的明细:
$ find src/ test/
src/
src/persist
src/persist/UserStore.php
src/util
src/util/Validator.php
src/domain
src/domain/User.php
test/
test/persist
test/persist/UserStoreTest.php
test/util
test/util/ValidatorTest.php
如您所见,我稍微整理了一下结构,添加了一些包目录。在代码中,我使用名称空间来支持包结构。
现在我有了一个项目,我应该将它添加到版本控制系统中。
CI 和版本控制
版本控制对 CI 至关重要。一个 CI 系统需要在没有人工干预的情况下获得一个项目的最新版本(至少在设置好之后)。
对于这个例子,我将使用我在 Bitbucket 上建立的存储库。我将在本地开发机器上配置代码,添加并提交它,然后推送到远程服务器:
$ cd path/to/userthing
$ git init
$ git remote add origin git@bitbucket.org:getinstance/userthing.git
$ git add build.xml composer.json src/ test/
$ git commit -m 'initial commit'
$ git push -u origin master
Note
在这些例子中,我已经创建并使用了一个位存储库。我可以很容易地使用 GitHub,它现在支持免费的私有库。
我导航到我的开发目录并初始化它。然后,我添加了origin
遥控器,并向其推送代码。我喜欢通过执行新的克隆来确认一切正常:
$ git clone git@bitbucket.org:getinstance/userthing.git
Cloning into 'userthing'...
X11 forwarding request failed on channel 0
remote: Counting objects: 16, done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 16 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (16/16), done.
Checking connectivity… done.
现在我有了一个userthing
存储库和一个本地克隆。自动化构建和测试的时间到了。
Phing
我们在第十九章遇到了 Phing。对于这一章,我将使用随 composer 一起安装的版本。
"require-dev": {
"phing/phing": "3.*"
},
Note
撰写本章时,Phing 3 仍在 alpha 测试中,composer 安装由于依赖问题而失败。phar 版本和 PHP 8 也有问题。因此,为了本章的目的,我们对 Phing 进行了分叉和修补,以解决各种代码和兼容性问题。希望,当你读到这篇文章时,Phing 又一次稳定了!你可以在首页找到 Phing 的版本信息和安装说明: www.phing.info/#install
。
我将使用这个构建工具作为我的项目 CI 环境的粘合剂,所以我在我打算用于测试的服务器上运行这个安装(或者,当然,您可以在使用 vagger 和 VirtualBox 的虚拟服务器上尝试这个)。我将定义构建和测试代码的目标,以及运行您将在本章中遇到的各种其他质量保证工具的目标。
让我们构建一个示例任务:
// listing 21.01
<project name="userthing" default="build" basedir=".">
<property name="build" value="./build" />
<property name="test" value="./test" />
<property name="src" value="./src" />
<property name="version" value="1.1.1" />
<target name="build">
<mkdir dir="${build}" />
<copy todir="${build}/src">
<fileset dir="${src}">
</fileset>
</copy>
<copy todir="${build}/test">
<fileset dir="${test}">
</fileset>
</copy>
</target>
<target name="clean">
<delete dir="${build}" />
</target>
</project>
我设置了四个属性。build
指的是在生成包之前,我可能会在其中组合我的文件的目录。test
指向测试目录。src
指来源目录。version
定义包的版本号。
build
目标将src
和test
目录复制到构建环境中。在一个更复杂的项目中,我可能还会执行转换,生成配置文件,并在这个阶段组装二进制资产。此目标是项目的默认目标。
clean
目标删除构建目录及其包含的任何内容。让我们运行一个构建:
$ ./vendor/bin/phing
Buildfile: /var/popp/src/ch21/build.xml
userthing > build:
[mkdir] Created dir: /var/popp/src/ch21/build
[copy] Created 4 empty directories in /var/popp/src/ch21/build/src
[copy] Copying 3 files to /var/popp/src/ch21/build/src
[copy] Created 3 empty directories in /var/popp/src/ch21/build/test
[copy] Copying 2 files to /var/popp/src/ch21/build/test
BUILD FINISHED
Total time: 1.8206 second
单元测试
单元测试是持续集成的关键。成功构建一个包含破碎代码的项目是没有好处的。我在第十八章中介绍了 PHPUnit 的单元测试。但是,如果您没有按顺序阅读,您会希望在继续阅读之前安装这个非常有用的工具。这里有一种全局安装 PHPUnit 的方法:
$ wget https://phar.phpunit.de/phpunit.phar
$ chmod 755 phpunit.phar
$ sudo mv phpunit.phar /usr/local/bin/phpunit
您也可以使用 Composer 安装 PHPUnit:
"require-dev": {
"phpunit/phpunit": "⁹"
}
这是我将在我的例子中采用的方法。因为 PHPUnit 将安装在vendor/
目录下,所以我的开发目录将保持独立于更广泛的系统。
我已经将我的测试目录与其余的源代码分开,所以我需要设置我的自动加载规则,以便 PHP 可以在测试期间定位所有的系统类。以下是我的完整composer.json
:
"require-dev": {
"phpunit/phpunit": "⁹"
},
"autoload": {
"psr-4": {
"userthing\\": ["src/", "test/"]
}
}
在对composer.json
进行任何更改后,不要忘记运行composer update
。
同样在第十八章中,我为我将在本章中使用的userthing
代码版本写了测试。在这里,我再次运行它们(从src
目录中),以确保我的重组没有破坏任何新的东西:
$ vendor/bin/phpunit test/
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
....... 7 / 7 (100%)
Time: 00:00.002, Memory: 18.00 MB
OK (7 tests, 7 assertions)
这证实了我的测试有效。然而,我想用 Phing 来调用它们。
Phing 提供了一个 exec 任务,我们可以用它来调用 phpunit 命令。然而,如果有的话,最好使用专门的工具。此作业有一个内置任务:
// listing 21.02
<target name="test" depends="build">
<phpunit bootstrap="${phing.dir}/vendor/autoload.php" printsummary="true">
<formatter type="plain" usefile="false"/>
<batchtest>
<fileset dir="${test}">
<include name="**/*Test.php"/>
</fileset>
</batchtest>
</phpunit>
</target>
因为这些是单元测试而不是功能测试,所以我们可以在本地的src/
目录下运行它们,而不需要一个已安装的实例(带有一个正常运行的数据库或 web 服务器)。在许多其他属性中,phpunit
任务接受一个printsummary
属性,这将导致输出测试过程的概述。
该任务的大部分功能是使用嵌套元素配置的。元素管理测试信息的生成方式。在这种情况下,我选择输出基本的人类可读数据。batchtest
允许您使用嵌套的fileset
元素定义多个测试文件。
Note
任务是高度可配置的。Phing 手册在 www.phing.info/guide/chunkhtml/PHPUnitTask.html
提供了完整的文档。
在这里,我用 Phing 运行测试:
$ ./vendor/bin/phing test
Buildfile: /vagrant/poppch21/build.xml
userthing > build:
userthing > test:
[phpunit] Testsuite: userthing\persist\UserStoreTest
[phpunit] Tests run: 4, Warnings: 0, Failures: 0, Errors: 0, Incomplete: 0, Skipped: 0, Time elapsed: 0.00684 s
[phpunit] Testsuite: userthing\util\ValidatorTest
[phpunit] Tests run: 3, Warnings: 0, Failures: 0, Errors: 0, Incomplete: 0, Skipped: 0, Time elapsed: 0.01188 s
[phpunit] Total tests run: 7, Warnings: 0, Failures: 0, Errors: 0, Incomplete: 0, Skipped: 0, Time elapsed: 0.02169 s
BUILD FINISHED
Total time: 0.1457 seconds
文件
透明是 CI 的原则之一。因此,当您在持续集成环境中查看构建时,能够检查文档是否是最新的并涵盖了最新的类和方法是很重要的。因为 PHPDocumentor 的最新版本正在开发中,所以目前不鼓励安装 composer。出于同样的原因,PHPDocumentor GitHub 页面可能是获取最新信息的最佳位置。
$ wget -O phpDocumentor https://github.com/phpDocumentor/phpDocumentor/releases/download/v3.0.0-rc/
phpDocumentor.phar
$ chmod 755 ./phpDocumentor
$ mv phpDocumentor /usr/local/bin/phpDocumentor
以 root 用户身份运行,我下载了phpDocumentor v3
,使其可执行,并将其移动到我的系统的中央位置。
为了确保万无一失,我最好调用这个工具,这次是从build
目录:
$ cd ./build
$ phpDocumentor --directory=src --target=docs --title=userthing
这会生成一些非常简单的文档。一旦它在 CI 服务器上发布,我肯定会羞愧地写一些真正的内联文档。
我想再一次把这个添加到我的build.xml
文档中。有一个名为phpdoc2
的任务被设计用来与 PHPDocumentor 集成。然而,由于它不能很好地与 PHPDocumentor 的 phar 版本集成,我将使用 cruder exec
任务调用该工具。
// listing 21.03
<target name="doc" depends="build">
<mkdir dir="reports/docs" />
<exec executable="/usr/local/bin/phpDocumentor" dir="${phing.dir}">
<arg line=" --directory=build/src --target=reports/docs --title=userthing" />
</exec>
</target>
同样,我的doc
目标依赖于build
目标。我创建了reports/docs
输出目录,然后使用exec
任务调用PHPDocumentor
。exec
接受一个嵌套的arg
元素,我用它来指定我的命令参数。
代码覆盖率
如果测试不适用于你写的代码,那么依靠测试是没有用的。PHPUnit 包括报告代码覆盖率的能力。以下是 PHPUnit 使用信息的摘录:
--coverage-clover <file> Generate code coverage report in Clover XML format
...
--coverage-html <dir> Generate code coverage report in HTML format
为了使用此功能,您必须安装代码覆盖率扩展,如 Xdebug 或 pcov。我将演示如何用 Xdebug 生成覆盖率报告。使用 pcov 是一个非常相似的过程。您可以在 http://pecl.php.net/package/Xdebug
和 https://pecl.php.net/package/pcov
找到关于这些扩展的更多信息(安装信息可在 http://xdebug.org/docs/install
和 https://github.com/krakjoe/pcov/blob/develop/INSTALL.md
找到)。您也可以使用您的 Linux 发行版的软件包管理系统直接安装这些软件包。在 Fedora 中,这应该对您有用,例如:
$ sudo yum install php-xdebug
在这里,我从src/
目录运行 PHPUnit,并启用代码覆盖率:
$ export XDEBUG_MODE=coverage
$ ./vendor/bin/phpunit --whitelist src/ --coverage-html coverage test
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
....... 7 / 7 (100%)
Time: 00:00.121, Memory: 12.00 MB
OK (7 tests, 7 assertions)
Generating code coverage report in HTML format ... done [00:00.047]
现在您可以在浏览器中看到该报告(参见图 21-1 )。
图 21-1
代码覆盖率报告
需要注意的是,实现完全覆盖并不等同于充分测试一个系统。另一方面,了解您的测试中的任何差距是很好的。如你所见,我还有一些工作要做。
确认我可以从命令行检查覆盖率之后,我需要将这个功能添加到我的构建文档中:
// listing 21.04
<target name="citest" depends="build">
<mkdir dir="reports/coverage" />
<coverage-setup database="reports/coverage.db">
<fileset dir="${src}">
<include name="**/*.php"/>
</fileset>
</coverage-setup>
<phpunit haltonfailure="true" codecoverage="true" bootstrap="${phing.dir}/vendor/autoload.php" printsummary="true">
<formatter type="plain" usefile="false"/>
<formatter type="xml" outfile="testreport.xml" todir="reports" />
<formatter type="clover" outfile="cloverreport.xml" todir="reports" />
<batchtest>
<fileset dir="${test}">
<include name="**/*Test.php"/>
</fileset>
</batchtest>
</phpunit>
<coverage-report outfile="reports/coverage.xml">
<report todir="reports/coverage" />
</coverage-report>
</target>
我创建了一个名为citest
的新任务。大部分都是你已经看过的test
任务的翻版。
我首先创建一个reports
目录和一个coverage
子目录。
我使用coverage-setup
任务为coverage
特性提供配置信息。我使用database
属性指定原始覆盖率数据应该存储在哪里。嵌套的fileset
元素定义了应该接受覆盖率分析的文件。
我已经向phpunit
任务添加了两个formatter
元素。类型为xml
的formatter
将生成一个名为testreport.xml
的文件,该文件将包含测试结果。clover formatter
将生成覆盖信息,也是 XML 格式的。最后,在citest
目标中,我部署了coverage-report
任务。这将获取现有的覆盖率信息,生成一个新的 XML 文件,然后输出一个 HTML 报告。
Note
CoverageReportTask
元素记录在 www.phing.info/guide/chunkhtml/CoverageReportTask.html
。
编码标准
我在第十五章中详细讨论了编码标准。虽然你的个人风格被一个共同的标准所束缚是令人讨厌的,但是它可以使一个项目更容易被更广泛的团队所使用。由于这个原因,许多团队强制执行一个标准。然而,这很难用肉眼来执行,所以自动化这个过程是有意义的。
我将再次使用 Composer。这一次,我将配置它来安装 PHP_CodeSniffer:
"require-dev": {
"phpunit/phpunit": "⁹",
"squizlabs/php_codesniffer": "3.*"
},
现在,我将把 PSR-12 编码标准应用到我的代码中:
$ vendor/bin/phpcs --standard=PSR12 src/util/Validator.php
FILE: /vagrant/poppch21/src/util/Validator.php
-----------------------------------------------------------------
FOUND 8 ERRORS AFFECTING 2 LINES
------------------------------------------------------------------
7 | ERROR | [x] Header blocks must be separated by a single blank line
22 | ERROR | [ ] Visibility must be declared on method "validateUser"
22 | ERROR | [ ] Expected "function abc(...)"; found "function abc (...)"
22 | ERROR | [x] Expected 1 space after FUNCTION keyword; 4 found
22 | ERROR | [x] Expected 0 spaces after opening parenthesis; 1 found
22 | ERROR | [x] Expected 0 spaces before opening parenthesis; 3 found
22 | ERROR | [x] Expected 1 space between comma and argument "$pass"; 0 found
22 | ERROR | [x] Opening brace should be on a new line
-----------------------------------------------------------------
PHPCBF CAN FIX THE 6 MARKED SNIFF VIOLATIONS AUTOMATICALLY
------------------------------------------------------------------
显然,我需要稍微清理一下我的代码!
自动化工具的一个好处是它的非个人性质。如果你的团队决定强加一套编码惯例,那么有一个没有幽默感的脚本来纠正你的风格可能比一个没有幽默感的同事做同样的事情要好。
如您所料,我想在我的构建文件中添加一个CodeSniffer
目标。
// listing 21.05
<target name="sniff" depends="build">
<exec executable="vendor/bin/phpcs" passthru="true" dir="${phing.dir}">
<arg line="--report-checkstyle=reports/checkstyle.xml --standard=PSR12 build/src"
/>
</exec>
</target>
虽然 Phing 提供了一个phpcodesniffer
任务,但是它与最近版本的 PHP_CodeSniffer 不兼容。因此,我再次使用exec
来运行工具。我用--report-checkstyle
标志调用phpcs
,这样它将在reports
目录中生成一个 XML 文件。
所以我有很多有用的工具可以用来监控我的项目。当然,如果让我自己去做,我很快就会对运行它们失去兴趣,即使是使用我有用的 Phing 构建文件。事实上,我可能会回到集成阶段的旧思想,只有在接近发布时才拿出工具,到那时,它们作为早期预警系统的有效性就无关紧要了。我需要的是一个 CI 服务器来为我运行这些工具。
Jenkins(以前叫 Hudson)是一个开源的持续集成服务器。虽然是用 Java 写的,但是 Jenkins 用 PHP 工具很好用。这是因为持续集成服务器位于它构建的项目之外,启动并监控各种命令的结果。Jenkins 还可以很好地与 PHP 集成,因为它被设计为支持插件,并且有一个非常活跃的开发人员社区正在努力扩展服务器的核心功能。
Note
为什么是詹金斯?Jenkins 非常易于使用和扩展。它已经很好地建立起来,并且有一个活跃的用户社区。它是免费和开源的。支持与 PHP 集成的插件(包括您可能想到的大多数构建和测试工具)是可用的。然而,有许多 CI 服务器解决方案。这本书的前一个版本侧重于 CruiseControl ( http://cruisecontrol.sourceforge.net/
),这仍然是一个不错的选择。
安装 Jenkins
Jenkins 是一个 Java 系统,所以你需要安装 Java。如何做到这一点将因系统而异。詹金斯网站在 www.jenkins.io/doc/book/installing/
提供了良好的安装说明。
多亏了 vagger,我现在正在 CentOS 7 系统上安装,以下是我的安装步骤:
$ sudo wget -O /etc/yum.repos.d/jenkins.repo \
https://pkg.jenkins.io/redhat-stable/jenkins.repo
$ sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key
$ sudo yum update
$ sudo yum install jenkins java-1.8.0-openjdk-devel
$ sudo systemctl daemon-reload
你可以这样开始詹金斯:
$ sudo systemctl start jenkins
默认情况下,Jenkins 在端口 8080 上运行,因此您可以通过启动浏览器并访问http://yourhost:8080/
来确定您是否准备好继续。你应该会看到类似图 21-2 中的屏幕。
图 21-2
安装屏幕
图 21-2 中的说明一目了然。我从/var/lib/jenkins/secrets/initialAdminPassword
获取密码(使用sudo
是因为受限的读取权限),并将其输入到提供的框中。然后我面临一个选择:用流行的插件安装还是选择我自己的?我选择最流行的插件,我知道它会让我获得对 Git 的支持。如果您想要一个苗条的系统,您可以选择只选择那些您需要的插件。之后,是时候在完成安装之前创建用户名和密码了。
安装 Jenkins 插件
Jenkins 是高度可定制的,我将需要相当多的插件来集成我在本章中描述的特性。在 Jenkins web 界面中,我点击Manage Jenkins
,然后点击Manage Plugins
。在Available
标签下,我发现了一个长长的列表。我在Install
列中选择我希望添加到 Jenkins 的所有插件的复选框。
表 21-1 描述了我将要使用的插件。
表 21-1
一些 Jenkins 插件
|插件
|
描述
|
| --- | --- |
| Git 插件 | 允许与 Git 存储库交互 |
| jUnit 插件 | 与包括 PHPUnit 在内的 xUnit 工具系列集成 |
| Phing 插件 | 调用 Phing 目标 |
| Clover PHP 插件 | 访问 clover XML 文件和由 PHPUnit 生成的 HTML 文件并生成报告 |
| HTML Publisher 插件 | 集成 HTML 报告。用于 PHPDocumentor 输出 |
| 警告下一代插件 | 访问 PHPCodeSniffer 生成的 XML 文件并生成报告 |
可以看到图 21-3 中的 Jenkins 插件页面。
图 21-3
詹金斯插件屏幕
安装完这些插件后,我就可以创建和配置我的项目了。
设置 Git 公钥
在使用 Git 插件之前,我需要确保能够访问 Git 存储库。在第十七章中,我描述了为了访问远程 Git 库而生成公钥的过程。我们需要在这里重复这个过程。但是詹金斯把哪里叫做家呢?
这个位置是可配置的,但自然詹金斯会提示你。我点击Manage Jenkins
,然后点击Configure System
。我发现詹金斯的主目录在那里。当然,我也可以检查/etc/ passwd
文件中与jenkins
用户相关的信息。在我的例子中,目录是/var/lib/jenkins
。
现在我需要配置一个SSH
目录:
$ sudo su jenkins -s /bin/bash
$ cd ~
$ mkdir .ssh
$ chmod 0700 .ssh
$ ssh-keygen
我切换到jenkins
用户,指定要使用的 shell(因为 shell 访问在默认情况下可能被禁用)。我切换到这个用户的主目录。ssh-keygen
命令生成 SSH 密钥。当提示输入密码时,我只需按回车键,这样 Jenkins 将只通过其密钥进行身份验证。我确保在.ssh/id_rsa
生成的文件既不是全球可读的,也不是群体可读的:
$ chmod 0600 .ssh/id_rsa
我可以从.ssh/id_rsa.pub
获取公钥,并将其添加到我的远程 Git 存储库中。详见第十七章。
我还没到那一步。我需要确保我的 Git 服务器是一个已知的 SSH 主机。我可以将这种设置与我的 Git 配置的命令行测试结合起来。当我这样做时,我确保我仍然以jenkins
用户的身份登录:
$ cd /tmp
$ git clone git@bitbucket.org:getinstance/userthing.git
我被提示确认我的 Git 主机,然后它被添加到用户的文件中。这可以防止 Jenkins 在稍后建立 Git 连接时被绊倒。
Note
还有各种插件可以用来管理 Git 凭证,包括 SSH 代理、 OAuth 凭证和 Kubernetes 凭证。
安装项目
在 Jenkins 仪表板页面,我点击New Item
。在这个新屏幕上,我终于可以创建我的userthing
项目了。在图 21-4 中可以看到设置画面。
图 21-4
项目设置屏幕
我选了Freestyle project
,打了OK
。这将我带到项目配置屏幕。我的首要任务是链接远程 Git 存储库。我在Source Code Manager section
中选择 Git 单选按钮并添加我的存储库。你可以在图 21-5 中看到这一点。
图 21-5
设置版本控制存储库
如果一切顺利,我应该可以访问我的源代码。我可以通过保存并从仪表板页面选择Build Now
来检查。然而,为了看到一些有意义的行动,我也应该建立 Phing。这很简单,因为我已经集中安装了 Phing。然而,如果你像我一样使用 Composer,事情就有点复杂了。你必须告诉詹金斯在哪里可以找到 Phing 可执行文件。您可以通过从主菜单中选择Manage Jenkins
,然后选择Global Tool Configuration
来完成此操作。因为我已经安装了 Phing 插件,所以我将在那里为该工具找到一个配置区域。我点击Add Phing
进入表格。在图 21-6 中,我展示了你用来引用 Phing 本地版本的配置区域。
图 21-6
指定 Phing 的位置
我给这个配置起了一个名字,并将路径添加到我的项目中的vendor
目录,Phing 就在这个目录中。
一旦我确信 Jenkins 可以找到 Phing,我就可以为我的项目配置它。我返回到userthing
项目区和Configure
菜单。我滚动到Build
部分,从Add build step
下拉菜单中选择两个项目。首先,我选择Execute shell
。我需要确保 Jenkins 运行composer install
,否则我的项目所依赖的工具都不会被安装。图 21-7 为其结构示意图。
图 21-7
设置外壳执行
我从Add build step
下拉菜单中选择的下一个项目是Invoke Phing targets
。我从下拉菜单中选择我之前配置的 Phing 实例(composer phing
)并将我的目标添加到文本字段中。你可以在图 21-8 中看到这个步骤。
图 21-8
配置 Phing
运行第一次构建
我保存配置屏幕并点击Build Now
来运行构建和测试过程。这是关键时刻!构建链接应该出现在屏幕的Build History
区域。我点击它,然后点击Console Output
来确认构建按预期进行。您可以在图 21-9 中看到输出。
图 21-9
控制台输出
Jenkins 从 Git 服务器检查出userthing
代码,并运行所有的构建和测试目标。
配置报告
多亏了我的构建文件,Phing 将报告保存在build/reports
目录中,将文档保存在build/docs
中。我激活的插件可以在项目配置界面的Add post-build action
下拉菜单中进行配置。
图 21-10 显示了其中一些配置项目。
图 21-10
配置报告插件项目
而不是让你一个接一个的截图,把配置项压缩成一个表格会更清晰。表 21-2 显示了我的 Phing 构建文件中的一些后期构建动作字段和相应的目标。
表 21-2
报告配置
|配置项
|
Phing 目标
|
田
|
价值
|
| --- | --- | --- | --- |
| 记录编译器警告和静态分析结果 | sniff
| 工具 | PHP_CodeSniffer
|
| | | 报告文件模式 | reports/checkstyle.xml
|
| 发布 Clover PHP 覆盖率报告 | citest
| Clover XML 位置 | reports/cloverreport.xml
|
| | | Clover HTML 报告目录 | reports/clovercoverage/
|
| 发布 HTML 报告 | doc
| HTML 目录到 | reports/docs
|
| | | 存档索引页 | index.html
|
| 发布 Junit 测试结果报告 | citest
| 测试报告 XML | reports/testreport.xml
|
| 电子邮件通知 | | 收件人 | someone@somemail.com
|
当我构建项目的构建文件时,你会遇到表 21-2 中的所有配置值。全部,也就是除了最后一个。E-mail Notification
字段允许您定义一个开发人员列表,当一个构建失败时,他们都会收到通知。
在检查覆盖率之前,我必须创建一个名为XDEBUG_MODE
的环境变量,其值为coverage
。我可以去Manage Jenkins
然后去Configure System
屏幕。如图 21-11 所示,我可以在Global Properties
部分设置环境变量。
图 21-11
设置环境变量
完成所有设置后,我可以返回到项目屏幕并运行另一个构建。图 21-12 显示了我新增强的输出。
图 21-12
显示趋势信息的项目屏幕
随着时间的推移,项目屏幕将绘制测试性能、覆盖率和风格符合性的趋势。还有到最新 API 文档、详细测试结果和全覆盖信息的链接。
触发构件
如果团队中的某个人必须记得通过手动点击来启动每个构建,那么所有这些丰富的信息几乎都是无用的。当然,Jenkins 提供了自动触发构建的机制。
您可以设置 Jenkins 以固定的时间间隔进行构建,或者以指定的时间间隔轮询版本控制存储库。可以使用 cron 格式设置时间间隔,这提供了对调度的精细控制,尽管有些神秘。幸运的是,Jenkins 为该格式提供了很好的在线帮助,如果您不需要精确调度,还有简单的别名。别名有@hourly
、@midnight
、@daily
、@weekly
、@monthly
。在图 21-13 中,我将构建配置为每天运行一次,或者每当存储库发生变化时运行,基于每小时一次的变更轮询。
图 21-13
调度构建
测试失败
到目前为止,一切似乎进展顺利,即使userthing
不会很快赢得任何法规遵从徽章。但是测试失败时就会成功,所以我最好打破一些东西,以确保 Jenkins 报告它。
下面是命名空间userthing\util
中名为Validate
的类的一部分:
// listing 21.06
public function validateUser(string $mail, string $pass): bool
{
// make it always fail
// return false;
$user = $this->store->getUser($mail);
if (is_null($user)) {
return false;
}
$testpass = $user->getPass();
if ($testpass == $pass) {
return true;
}
$this->store->notifyPasswordFailure($mail);
return false;
}
看看我会在哪里破坏这个方法?如果我取消了方法中第二行的注释,validateUser()
将总是返回false
。
下面的测试应该会让你窒息。就在test/util/ValidatorTest.php
里:
// listing 21.07
public function testValidateCorrectPass(): void
{
$this->assertTrue(
$this->validator->validateUser("bob@example.com", "12345"),
"Expecting successful validation"
);
}
做出改变后,我需要做的就是提交并等待。果不其然,不久之后,项目状态显示了一个由黄色图标标记的构建(表示整个项目的健康状况已经恶化)。单击构建链接后,我会找到更多详细信息。您可以看到图 21-14 中的屏幕。
图 21-14
失败的构建
记住,如果你需要更多关于构建错误的信息,你可以点击构建界面中的Console Output
链接。您通常会在那里找到比构建摘要屏幕本身更有用的信息。
摘要
在这一章中,我把你在前面章节中看到的许多工具放在一起,并用 Jenkins 把它们粘在一起。我为 CI 准备了一个小项目,应用了一系列工具,包括 PHPUnit(用于测试和代码覆盖)、PHP_CodeSniffer、phpDocumentor 和 Git。然后,我设置了 Jenkins,并向您展示了如何向系统添加项目。我测试了系统的速度,最后,向您展示了如何扩展 Jenkins,以便它可以用电子邮件来调试您,并测试构建和安装。
二十二、对象、模式、实践
从对象基础到设计模式原则,再到工具和技术,这本书只关注一个目标:成功的 PHP 项目。
在这一章中,我回顾了我在整本书中涉及的一些主题和观点:
-
PHP 和 objects:PHP 如何继续增加对面向对象编程的支持,以及如何利用这些特性
-
对象和设计:总结一些面向对象的设计原则
-
模式:是什么让他们变得酷
-
模式原则(Pattern principles):概述了许多模式背后的面向对象的指导原则
-
工作所需的工具:重温我描述过的工具,并检查一些我没有用过的工具
目标
正如你在第二章中看到的,在很长一段时间里,对象在 PHP 世界里是一种事后的想法。至少可以说,在 PHP 3 中,支持是初级的,对象只不过是穿了漂亮衣服的关联数组。尽管对于 PHP 4 的对象爱好者来说,事情有了根本性的改善,但是仍然存在一些严重的问题。最重要的是,默认情况下,对象是通过引用来分配和传递的。
PHP 5 的引入最终将对象拖到了舞台中央。你仍然可以不用声明一个类就用 PHP 编程,但是这种语言最终为面向对象的设计进行了优化。PHP 7 完善了这一点,引入了期待已久的特性,如标量和返回类型声明。可能出于向后兼容的原因,一些流行的框架本质上仍然是过程化的(特别是 WordPress);然而,总的来说,今天大多数新的 PHP 项目都是面向对象的。
在第 3 、 4 和 5 章中,我详细考察了 PHP 的面向对象支持。以下是 PHP 自版本 5 以来引入的一些新特性:反射、异常、私有和受保护的方法和属性、__toString()
方法、static
修饰符、抽象类和方法、最终方法和属性、接口、迭代器、拦截器方法、类型声明、const
修饰符、通过引用传递、__clone()
、__construct()
方法、后期静态绑定、名称空间和匿名类。这个不完整列表的长度揭示了 PHP 的未来与面向对象编程的紧密程度。
Zend Engine 2 和 PHP 5 使面向对象设计成为 PHP 项目的核心,向一批新的开发人员开放了这种语言,并为现有的爱好者开辟了新的可能性。
在第六章中,我看到了对象可以给你的项目设计带来的好处。因为对象和设计是本书的中心主题之一,所以有必要详细概括一些结论。
选择
没有法律规定你必须只用类和对象来开发。设计良好的面向对象代码提供了一个清晰的接口,可以从任何客户端代码访问,无论是面向过程的还是面向对象的。即使您对编写对象不感兴趣(如果您仍在阅读这本书,这种可能性不大),您也可能会发现自己在使用它们,即使只是作为 Composer 软件包的客户。
封装和委托
对象们关心自己的事情,关起门来继续完成分配给他们的任务。它们提供了一个接口,通过这个接口可以传递请求和结果。任何不需要暴露的数据,以及实现的肮脏细节,都隐藏在这种正面的背后。
这给了面向对象和过程化项目不同的形状。面向对象项目中的控制器通常出人意料地稀少,由少数几个获取对象的实例化和从一个集合中调用数据并将其传递给另一个集合的调用组成。
另一方面,程序性项目更倾向于干涉主义。控制逻辑在更大程度上下降到实现,引用变量,测量返回值,并根据情况沿着不同的操作路径轮流进行。
退耦
解耦就是消除组件之间的相互依赖,这样对一个组件进行更改就不需要对其他组件进行更改。设计良好的对象是自我封闭的。也就是说,他们不需要参考自身之外的东西来回忆他们在之前的调用中学习到的细节。
通过维护状态的内部表示,对象减少了对全局变量的需求——这是紧耦合的一个众所周知的原因。在使用全局变量时,你将系统的一部分绑定到另一部分。如果一个组件(无论是函数、类还是代码块)引用了一个全局变量,那么另一个组件可能会意外地使用相同的变量名,并用它的值替换第一个变量。第三个组件可能会依赖于第一个组件设置的变量中的值。改变第一个组件的工作方式,可能会导致第三个组件停止工作。面向对象设计的目标是减少这种相互依赖,使每个组件尽可能自给自足。
紧密耦合的另一个原因是代码重复。当您必须在项目的不同部分重复一个算法时,您会发现紧密耦合。你来改算法会怎么样?显然,您必须记住在它出现的任何地方进行更改。忘记这样做,你的系统就有麻烦了。
代码重复的一个常见原因是并行条件。如果您的项目需要根据特定的环境以一种方式做事(例如,在 Linux 上运行),而根据另一种环境以另一种方式做事(例如,在 Windows 上运行),您会经常发现相同的if
/ else
子句出现在系统的不同部分。如果你添加了一个新的环境和处理它的策略(MacOS),你必须确保所有的条件都被更新。
面向对象编程提供了处理这个问题的技术。可以用多态代替条件句。多态性,也称为类切换,是根据情况透明地使用不同的子类。因为每个子类都支持与公共超类相同的接口,所以客户端代码既不知道也不关心它使用的是哪个特定的实现。
条件代码没有从面向对象的系统中消失;它只是被最小化和集中化。必须使用某种条件代码来确定将向客户端提供哪些特定的子类型。不过,这种测试通常只在一个地方进行一次,从而减少了耦合。
复用性
封装促进了解耦,从而促进了重用。自给自足且仅通过公共接口与更广泛的系统进行通信的组件通常可以从一个系统转移到另一个系统中使用,而无需更改。
事实上,这比你想象的要罕见。即使是完美的正交码也可能是特定于项目的。例如,当创建一组用于管理特定网站内容的类时,值得在规划阶段花一些时间来查看那些特定于您的客户的功能,以及那些可能形成以内容管理为核心的未来项目的基础的功能。
重用的另一个技巧是:集中那些可能在多个项目中使用的类。换句话说,不要将一个非常好的可重用类复制到一个新项目中。这将导致宏观上的紧密耦合,因为您将不可避免地在一个项目中改变类,而在另一个项目中忘记这样做。在一个可以被项目共享的中央存储库中管理公共类会更好。
美学
这不会说服任何还没有被说服的人,但是对我来说,面向对象的代码在美学上是令人愉悦的。实现的混乱被隐藏在干净的接口后面,使得对象对其客户来说是一件明显简单的事情。
我喜欢多态性的整洁和优雅,因此 API 允许您操作非常不同的对象,但仍然可以互换和透明地执行——对象可以像儿童积木一样整齐地堆叠或插入另一个对象。
当然,有人认为反之亦然。面向对象的代码可以表现为类的爆炸式增长,这些类彼此之间是如此的解耦,以至于拼凑它们之间的关系是一件令人头疼的事情。这本身就是一种代码味道。建立生产工厂的工厂,生产工厂的工厂,这通常是很诱人的,直到你的代码看起来像一个镜子大厅。有时候,做最简单的工作,然后为了测试和灵活性而进行足够优雅的重构是有意义的。让问题空间决定您的解决方案,而不是最佳实践列表。
Note
严格应用所谓的最佳实践也经常是项目管理中的一个问题。每当一项技术或一个过程的使用开始变得像仪式一样,被自动地、不灵活地应用,就值得花一点时间来研究你当前方法背后的推理。有可能你正从工具领域转向货物崇拜领域。
同样值得一提的是,一个漂亮的解决方案并不总是最好或最有效的。使用成熟的面向对象解决方案是很诱人的,在这种情况下,一个快速脚本或几个系统调用就可以完成工作。
模式
最近,一个 Java 程序员申请了一份工作,这家公司和我有一些关系。在他的求职信中,他为几年来只使用模式而道歉。设计模式是最近的发现——一个变革性的进步——这一假设证明了它们所带来的兴奋。事实上,这位经验丰富的程序员使用模式的时间可能比他想象的要长。
模式描述了常见的问题和经过测试的解决方案。模式命名、编纂和组织真实世界的最佳实践。它们不是发明的组成部分,也不是教义中的条款。如果一个模式没有描述孵化时已经很普遍的实践,那么它将是无效的。
记住,模式语言的概念起源于建筑领域。在模式被提出作为描述空间和功能问题的解决方案之前,人们建造庭院和拱门已经有几千年了。
话虽如此,设计模式确实经常会激起与宗教或政治争议相关的情绪。信徒们在走廊里漫步,眼里闪着福音的光芒,胳膊下夹着一本《四人帮》的书。他们搭讪门外汉,像信仰文章一样一口气说出模式名称。难怪一些批评家认为设计模式是炒作。
在 Perl 和 PHP 等语言中,模式也是有争议的,因为它们与面向对象编程有着紧密的联系。在对象是设计决策而不是给定的情况下,将自己与设计模式联系起来相当于偏好声明,尤其是因为模式产生更多的模式,而对象产生更多的对象。
什么样的模式买给我们
我在第七章中介绍了模式。让我们重申一下模式可以给我们带来的一些好处。
屡试不爽
首先,正如我所提到的,模式是特定问题的成熟解决方案。在模式和配方之间进行类比是危险的:配方可以被盲目地遵循,而模式本质上是“半生不熟的”(马丁·福勒),需要更深思熟虑的处理。尽管如此,食谱和图案都有一个重要的特点:它们在铭刻之前都经过了彻底的试验和测试。
模式暗示了其他模式
图案有相互吻合的凹槽和曲线。某些模式会随着令人满意的咔哒声一起出现。使用模式解决问题将不可避免地产生后果。这些后果可能成为暗示互补模式的条件。当然,当您选择相关模式时,一定要注意解决实际的需求和问题,而不仅仅是构建优雅但无用的互锁代码塔。构建相当于建筑上愚蠢的编程是很有诱惑力的。
常用词汇
模式是开发描述问题和解决方案的通用词汇的一种方式。命名很重要——它代表描述,因此能让我们很快覆盖很多领域。当然,命名也模糊了那些还没有共享词汇的人的意思,这也是为什么模式有时会如此令人愤怒的原因之一。
模式促进设计
正如下一节所讨论的,如果使用得当,模式可以鼓励好的设计。当然,有一个重要的警告。模式不是仙尘。
设计的模式和原则
设计模式本质上与良好的设计有关。如果使用得当,它们可以帮助您构建松散耦合且灵活的代码。然而,当模式批评家说模式可能被新感染者过度使用时,他们说得有道理。因为模式实现形成了漂亮优雅的结构,所以很容易忘记好的设计总是在于符合目的。记住模式的存在是为了解决问题。
当我第一次开始使用模式时,我发现自己在代码中创建了抽象工厂。我需要生成对象,抽象工厂无疑帮助了我。
但事实上,我在懒散地思考,给自己做不必要的工作。我需要产生的对象集确实是相关的,但是它们还没有替代的实现。经典的抽象工厂模式非常适合根据环境生成不同的对象集的情况。要使抽象工厂工作,您需要为每种类型的对象创建工厂类,并创建一个类来提供工厂类。光是描述过程就让人精疲力尽。
如果我创建了一个基本的工厂类,我的代码会干净得多,如果我发现自己需要生成一组并行的对象,只需要重构来实现抽象工厂。
使用模式的事实并不能保证好的设计。在开发时,最好记住同一原则的两种表达方式:KISS(“保持简单,笨蛋”)和“做最简单的工作。”极限程序员还给出了另一个相关的缩写:YAGNI。“你不需要它”,这意味着除非真的需要,否则你不应该实现一个特性。
随着警告的消失,我可以继续我那令人窒息的热情。正如我在第九章中所阐述的,模式倾向于体现一套可以推广并应用于所有代码的原则。
偏爱合成而非遗传
继承关系是强大的。我们使用继承来支持运行时类切换(多态性),这是我在本书中探索的许多模式和技术的核心。但是,通过在设计中仅仅依赖继承,您可能会产生易于重复的不灵活的结构。
避免紧密耦合
我在本章已经谈到了这个问题,但为了完整起见,这里值得一提。您永远无法回避这样一个事实,即一个组件的变更可能需要项目其他部分的变更。但是,您可以通过避免重复(在我们的示例中以并行条件为代表)和过度使用全局变量(或单例)来最小化这种情况。当抽象类型可以用来促进多态性时,也应该尽量减少具体子类的使用。这最后一点引导我们到另一个原则。
接口的代码,而不是实现
用清晰定义的公共接口设计你的软件组件,使每个组件的职责透明。如果你在一个抽象超类中定义你的接口,并且让客户端类请求并使用这个抽象类型,那么你就可以将客户端从具体的实现中分离出来。
说到这里,请记住 YAGNI 原则。如果你开始时只需要一个类型的实现,没有直接的理由去创建一个抽象超类。你也可以在一个具体的类中定义一个清晰的接口。一旦您发现您的单个实现试图同时做多件事情,您可以将您的具体类重新指定为两个子类的抽象父类。客户端代码不会变得更聪明,因为它继续使用单一类型。
您可能需要拆分实现并将结果类隐藏在抽象父类之后的一个典型标志是实现中出现了条件语句。
概括不同的概念
如果你发现你淹没在子类中,也许你应该把所有这些子类化的原因提取到它自己的类型中。如果原因是为了达到某种目的,而这种目的是你的类型的主要目的所附带的,那就更是如此了。
例如,给定一个类型UpdatableThing
,您可能会发现自己创建了FtpUpdatableThing
、HttpUpdatableThing
和FileSystemUpdatableThing
子类型。然而,你的类型的责任是成为一个可更新的东西——存储和检索的机制是附带的。Ftp
、Http
和FileSystem
是这里变化的东西,它们属于自己的类型——姑且称之为UpdateMechanism
。UpdateMechanism
将有不同实现的子类。然后,您可以添加尽可能多的更新机制,而不会干扰UpdatableThing
类型,后者仍然专注于其核心职责。顺便提一下,注意UpdateMechanism
也可以被命名为UpdateStrategy
。我已经描述了策略模式的一个实现。有关更多信息,请参见第十一章。
还要注意,我在这里用动态运行时安排替换了静态编译时结构,让我们(好像是偶然地)回到了我们的第一个原则:“优先组合而不是继承。”
实践
我在本书的这一部分提到的问题(以及在第十四章中介绍的问题)经常被教科书和编码人员忽略。在我自己作为程序员的生活中,我发现这些工具和技术至少和设计一样与项目的成功相关。毫无疑问,像文档和自动化构建这样的问题在本质上不如组合模式这样的奇迹有启示性。
Note
让我们提醒一下 Composite 的美妙之处:一个简单的继承树,它的对象可以在运行时连接起来,形成同样是树的结构,但是要灵活和复杂得多。多个对象共享一个接口,通过这个接口向外界展示它们。简单与复杂、多重与单一之间的相互作用,一定会让你的脉搏加速——这不仅仅是软件设计,而是诗歌。
即使像文档和构建、测试和版本控制这样的问题比模式更加平淡无奇,它们也同样重要。在现实世界中,如果多个开发人员不能轻松地参与其中或理解其来源,那么一个出色的设计将无法存活。没有自动化测试,系统变得难以维护和扩展。没有构建工具,没有人会费心部署您的工作。随着 PHP 用户群的扩大,我们作为开发人员确保质量和易于部署的责任也在增加。
项目以两种模式存在。项目是其代码和功能的结构,它也是一组文件和目录,一个合作的基础,一组源和目标,以及一个转换的主题。从这个意义上说,一个项目从外部看是一个系统,就像它在代码中一样。构建、测试、文档和版本控制的机制需要像这些机制支持的代码一样关注细节。像关注系统本身一样关注元系统。
测试
尽管测试是从外部应用于项目的框架的一部分,但它与代码本身紧密地集成在一起。因为完全解耦是不可能的,甚至是不可取的,所以测试框架是监控变更结果的强大方法。改变方法的返回类型可能会影响其他地方的客户端代码,导致错误在更改后几周或几个月出现。测试框架让您有一半的机会捕捉到这种错误(测试越好,这里的机会就越大)。
测试也是改进面向对象设计的工具。首先测试(或者至少同时测试)有助于你关注一个类的接口,并仔细考虑每个方法的责任和行为。我在第十八章介绍了用于测试的 PHPUnit。
标准
我天生是个反向投资者。我讨厌别人告诉我该做什么。像合规这样的词会立刻引起我的战斗或逃跑反应。但是,尽管看起来有悖常理,标准推动创新。这是因为它们推动了互操作性。互联网的兴起在一定程度上是因为开放标准已经成为其核心。网站可以相互链接,web 服务器可以在任何域中重用,因为协议是众所周知和受尊重的。筒仓中的解决方案可能比广泛接受和应用的标准更好,但是如果筒仓烧毁了怎么办?如果买了,新主人决定收取访问费怎么办?当一些人决定隔壁的筒仓更好时会发生什么?在第十五章中,我讨论了 PSR,PHP 标准建议。我特别关注了自动加载的标准,它在清理 PHP 开发人员包含类的方式方面做了很多工作。我也看了 PSR-12,编码风格的标准。程序员对大括号的放置和参数列表的部署有强烈的感觉,但同意遵守一组公共规则有助于代码的可读性和一致性,并允许我们使用工具来检查和重新格式化源文件。本着这种精神,我已经将这个版本中的所有代码示例重新格式化为符合PSR-12。
版本控制
合作很难。面对现实吧:人是尴尬的。程序员更惨。一旦你理清了团队中的角色和任务,你最不想处理的就是源代码本身的冲突。正如你在第十七章中看到的,Git(以及类似的工具,如 CVS 和 Subversion)使你能够将多个程序员的工作合并到一个单一的存储库中。在冲突不可避免的地方,Git 会标记出事实并指出问题的根源。
即使你是单飞程序员,版本控制也是必须的。Git 支持分支,因此您可以同时维护一个软件版本和开发下一个版本,将稳定版本中的 bug 修复合并到开发分支中。
Git 还提供了对项目的每次提交的记录。这意味着您可以按日期或标记回滚到任何时刻。相信我,总有一天这会拯救你的项目。
自动化构建
没有自动构建的版本控制是有限的。任何复杂的项目都需要部署工作。需要将各种文件移动到系统的不同位置,需要转换配置文件以获得适合当前平台和数据库的正确值,并且需要设置或转换数据库表。我介绍了两个为安装而设计的工具。第一个是 Composer(参见第十六章),是独立软件包和小型应用的理想选择。我介绍的第二个构建工具是 Phing(参见第十九章),这是一个具有足够能力和灵活性的工具,可以自动安装最大最复杂的项目。
自动化构建将部署从繁琐的工作转变为命令行中的一两行代码。只需很少的努力,您就可以从构建工具中调用您的测试框架和文档输出。如果您的开发人员的需求没有动摇您,请记住当您的用户发现他们不再需要在每次您发布项目的新版本时花费整个下午来复制文件和更改配置字段时,他们可怜的感激叫声。
持续集成
能够测试和构建一个项目是不够的;你必须一直做这件事。随着项目变得越来越复杂,并且您管理着多个分支,这变得越来越重要。您应该构建并测试一个稳定的分支,从这个分支您可以发布较小的 bug 修复版本,一个或两个实验性的开发分支,以及您的主干。如果您试图手动完成所有这些工作,即使有构建和测试工具的帮助,您也永远无法进行任何编码。当然,所有的程序员都讨厌这样,所以构建和测试不可避免地会被忽略。
在第二十一章中,我谈到了持续集成,一种尽可能自动化构建和测试过程的实践和一套工具。
我错过了什么
由于时间和空间的限制,我不得不从本书中省略一些工具类别,尽管如此,它们对任何项目都非常有用。在大多数情况下,对于手头的工作,有不止一个好的工具,所以,尽管我会推荐一两个,但在做出选择之前,您可能需要花一些时间与其他开发人员交流,并使用您最喜欢的搜索引擎进行搜索。
如果您的项目有不止一个开发人员,甚至只有一个活动的客户,那么您将需要一个工具来跟踪 bug 和任务。像版本控制一样,bug 追踪器是一种生产力工具,一旦你在项目中尝试过,你就无法想象不使用它。追踪器允许用户报告项目中的问题,但是它们也经常被用作描述所需特性和将它们的实现分配给团队成员的手段。
您可以随时获得打开任务的快照,根据产品、任务所有者、版本号和优先级缩小搜索范围。每个任务都有自己的页面,您可以在其中讨论任何正在进行的问题。讨论条目和任务状态的更改可以通过邮件复制给团队成员,这样就可以很容易地关注事情,而不用一直去跟踪 URL。
外面有很多工具。尽管过了这么久,我通常还是会回到令人尊敬的 Bugzilla ( www.bugzilla.org
)。Bugzilla 是免费的开源软件,拥有大多数开发者可能需要的所有特性。它是一个可下载的产品,所以你必须在你自己的服务器上运行它。它看起来仍然有点 Web 1.0 的味道,但并没有因此而变得更糟。如果你不想拥有自己的追踪器,并且你有或者喜欢你的界面漂亮一点(并且有更深的口袋),你可以看看 Atlassian 的 SAAS 解决方案,吉拉( www.atlassian.com/software/jira
)。
对于高层次的任务跟踪和项目规划(尤其是如果你对使用看板系统感兴趣),你可能还会考虑 Trello ( www.trello.com
)。
追踪器通常只是您想要用来共享项目信息的一套协作工具中的一个。你可以付费使用 Basecamp ( https://basecamp.com/
)或 Atlassian tools ( www.atlassian.com/
)等集成解决方案。或者您可以选择使用各种工具将工具生态系统缝合在一起。例如,为了促进团队内部的交流,您可能需要一种聊天或消息传递的机制。也许在撰写本文时,最流行的工具是 Slack ( www.slack.com
)。Slack 是一个基于网络的多房间聊天环境。如果你像我一样是老派,你可能会立即想到 IRC(互联网中继聊天)——你可能是对的:除了 Slack 基于浏览器,易于使用,并与其他内置服务集成之外,你几乎可以用 Slack 做 IRC 做不到的事情。Slack 是免费的,除非你需要高级功能。
说到老学校,你也可以考虑为你的项目使用邮件列表。我最喜欢的邮件列表软件是 Mailman ( www.gnu.org/software/mailman/
),免费,相对容易安装,可配置性强。
对于可协同编辑的文本文档和电子表格,Google Docs ( https://docs.google.com/
)可能是最简单的解决方案。
你的代码没有你想象的那么清晰。第一次访问代码库的陌生人可能会面临一项艰巨的任务。即使是你,作为代码的作者,最终也会忘记这一切是如何联系在一起的。对于内联文档,您应该看看 phpDocumentor ( www.phpdoc.org/
),它允许您随时记录文档,并自动生成超链接输出。phpDocumentor 的输出在面向对象的上下文中特别有用,因为它允许用户从一个类点击到另一个类。由于类通常包含在它们自己的文件中,直接读取源代码可能涉及到从一个源文件到另一个源文件的复杂过程。
虽然内联文档很重要,但是项目也会产生大量的书面材料。这包括使用说明、关于未来方向的咨询、客户资产、会议记录和聚会公告。在一个项目的生命周期中,这样的材料是非常易变的,并且经常需要一种机制来允许人们在他们的发展过程中进行协作。
wiki (wiki 显然源自夏威夷语 wikiwiki 的意思是“非常快”)是创建超链接文档协作网络的完美工具。只需点击一个按钮,就可以创建或编辑页面,并且为匹配页面名称的单词自动生成超链接。wiki 是另一种工具,它看起来如此简单、重要和明显,以至于你确信你可能首先有了这个想法,但只是没有着手做任何事情。有许多维基可供选择。PhpWiki 我用的很好,可以从 https://phpwiki.sourceforge.io/
下载,DokuWiki 你可以在 www.dokuwiki.org/dokuwiki
找到。
但是,对于文档(以及一般的写作),我越来越倾向于削减到简单的文本文档和版本控制。对于格式,我使用 Markdown,一种轻量级标记语言。它在渲染之前易于阅读,并且通常在渲染之后是干净和平衡的(尽管,和所有渲染一样,你是在渲染器的支配下)。降价的最佳起点是 https://commonmark.org/
。经过多年与 Word 和 Word 兼容的文字处理器的斗争,我非常感谢 Apress 让我对这本书的这个版本使用 Markdown!
Note
虽然我没有省略这个工具(参见第十七章),但值得一提的是,转换到纯文本格式使我们有可能在本书的开发中广泛使用 Git。
本期的技术评论员(Paul Tregoing)希望看到 Docker ( https://docs.docker.com/
)被添加到持续集成的章节中,但是时间限制阻止了这一点。Jenkins 构建作业在 Docker 容器中运行意味着您可以完全自由地调整构建环境,而不受托管 Jenkins 的系统的限制。您可以使用不同版本的包甚至不同的 Linux 发行版来构建。
摘要
在这一章中,我总结了一下,重温了构成这本书的核心主题。虽然我在这里还没有解决任何具体的问题,比如单个模式或对象函数,但这一章应该是本书关注点的合理总结。
永远没有足够的空间或时间来涵盖一个人想要的所有材料。尽管如此,我还是希望这本书能够证明一个观点:PHP 已经完全成熟了。它现在是世界上最流行的编程语言之一。我希望 PHP 仍然是业余爱好者最喜欢的语言,并且许多新的 PHP 程序员会很高兴地发现他们只用一点代码就能走多远。然而与此同时,越来越多的专业团队正在用 PHP 构建大型系统。这样的项目不应该只有简单的方法。通过它的扩展层,PHP 一直是一种通用语言,提供了数百个应用和库的入口。另一方面,它的面向对象的支持使您可以访问一组不同的工具。一旦你开始用对象来思考,你就可以把其他程序员来之不易的经验绘制成图表。您不仅可以导航和部署参考 PHP 开发的模式语言,还可以参考 Smalltalk、C++、C#或 Java。我们有责任通过精心设计和良好实践来迎接这一挑战。未来是可重复使用的。