PHP8 对象、模式和实践(四)
七、什么是设计模式?为什么使用它们?
我们作为程序员遇到的大多数问题已经被我们社区中的其他人一次又一次地处理过了。设计模式可以为我们提供挖掘智慧的方法。一旦一个模式成为一种通用货币,它就丰富了我们的语言,使得分享设计思想及其结果变得容易。设计模式只是提取常见问题,定义经过测试的解决方案,并描述可能的结果。许多书籍和文章关注计算机语言的细节,比如可用的函数、类和方法等等。相反,模式目录关注的是如何从这些基础(“什么”)转移到对项目中的问题和潜在解决方案的理解(“为什么”和“如何”)。
在这一章中,我将向你介绍设计模式,并看看它们流行的一些原因。本章将涵盖以下内容:
-
模式基础:什么是设计模式?
-
模式结构:一个设计模式的关键元素是什么?
-
模式的好处:为什么模式值得你花时间?
什么是设计模式?
在软件世界中,模式是组织部落记忆的有形表现。
——格雷迪·布奇在核心 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 类。我还查看了原型模式,了解了对象克隆如何允许组合用于对象生成。最后,我研究了对象创建的两种策略:服务定位器和依赖注入。