设计模式
设计模式是解决软件开发中经常出现的问题的常用方法。作为一名开发人员,如果你还没有接触过这个术语,那么迟早会接触到—这并不是没有原因的,因为这些模式是基于最佳实践的,并且已经证明了它们的实用性。
在本节中,我们将向你详细介绍不同类型的设计模式,以及为什么它们如此重要以至于成为本书的一部分。此外,我们还将向你介绍一些在 PHP 中广泛使用的常见设计模式。
了解设计模式
现在让我们来仔细了解一下设计模式。它们可以被视为解决特定问题的模板,并根据其提供的解决方案来命名。例如,在本章中,你将学习观察者模式,它可以帮助你实现一种观察对象变化的方法。这不仅在你编写代码时非常有用,而且在你与其它开发人员一起设计软件时也非常有用。使用一个简短的名称来命名一个概念比每次都要解释要容易得多。
不过,不要把设计模式与算法相混淆。算法定义了解决问题需要遵循的明确步骤,而设计模式则描述了如何在更高层次上实现解决方案。它们不受任何编程语言的约束。
你也不能像添加 Composer 包那样在代码中添加设计模式。你必须自己实现模式,而且在实现方式上有一定的自由度。
然而,设计模式并不是解决所有问题的唯一方法,它们也并不声称能提供最有效的解决方案。请务必慎重对待这些模式—通常情况下,开发人员想要实现某种模式只是因为他们知道这种模式。俗话说 "如果你只有一把锤子,一切看起来都像钉子"。
通常,设计模式分为三类:
-
创建模式处理如何高效创建对象,同时提供减少代码重复的解决方案
-
结构模式帮助你以灵活高效的结构组织实体(即类和对象)之间的关系
-
行为模式在保持高度灵活性的同时安排实体之间的通信
在接下来的几页中,我们将通过一些示例来解释设计模式背后的理念。
PHP 中的常见设计模式
现在,我们要介绍 PHP 世界中使用最广泛的一些设计模式。我们从上一节讨论过的创造性、结构性和行为性三个类别中各选择了一种模式。
工厂方法
想象一下下面的问题:你需要编写一个应用程序,该程序应能将数据写入使用不同格式的文件。在我们的示例中,我们希望支持 CSV 和 JSON 格式,但将来也可能支持其它格式。在写入数据之前,我们希望应用一些过滤功能,无论选择哪种输出格式,过滤功能都应始终进行。
解决这个问题的一个适用模式是工厂方法。这是一种创建模式,因为它涉及对象的创建。
这种模式的主要思想是,子类可以实现不同的方法来达到目标。值得注意的是,我们并没有在父类中使用 new
操作符来实例化任何子类,这一点在下面的类中可以看到:
abstract class AbstractWriter
{
public function write(array $data): void
{
$encoder = $this->createEncoder();
// Apply some filtering which should always happen,
// regardless of the output format.
array_walk(
$data,
function (&$value) {
$value = str_replace(‘data’, ‘’, $value);
}
);
// For demonstration purposes, we echo the result
// here, instead of writing it into a file
echo $encoder->encode($data);
}
abstract protected function createEncoder(): Encoder;
}
请注意 createEncoder
方法—这是给该模式命名的工厂方法,因为从某种意义上说,它就像一个新实例的工厂。它被定义为抽象函数,因此需要由一个或多个子类来实现。
为了使未来的格式足够灵活,我们打算为每种格式使用不同的 Encoder
类。但首先,我们要为这些类定义一个接口,以便于它们之间的交换:
interface Encoder
{
public function encode(array $data): string;
}
然后,我们为每种实现 Encoder
接口的格式创建一个 Encoder
类;首先,我们创建 JsonEncoder
:
class JsonEncoder implements Encoder
{
public function encode(array $data): string
{
// the actual encoding happens here
// ...
return $encodedString;
}
}
然后创建 CsvEncoder
:
class CsvEncoder implements Encoder
{
public function encode(array $data): string
{
// the actual encoding happens here
// ...
return $encodedString;
}
}
现在,我们需要为想要支持的每种格式创建一个 AbstractWriter
类的子类。在我们的例子中,首先是 CsvWriter
:
class CsvWriter extends AbstractWriter
{
public function createEncoder(): Encoder
{
$encoder = new CsvEncoder();
// here, more configuration work would take place
// e.g. setting the delimiter
return $encoder;
}
}
其次,它是 JsonWriter
:
class JsonWriter extends AbstractWriter
{
public function createEncoder(): Encoder
{
return new JsonEncoder();
}
}
请注意,这两个子类都只覆盖工厂方法 createEncoder
。此外,新的操作符只出现在子类中。write
方法保持不变,因为它继承自 AbstractWriter
。
最后,让我们在一个示例脚本中将这一切结合起来:
function factoryMethodExample(AbstractWriter $writer)
{
$exampleData = [
'set1' => ['data1', 'data2'],
'set2' => ['data3', 'data4'],
];
$writer->write($exampleData);
}
echo "Output using the CsvWriter: ";
factoryMethodExample(new CsvWriter());
echo "Output using the JsonWriter: ";
factoryMethodExample(new JsonWriter());
factoryMethodExample
函数首先接收 CsvWriter
作为参数,第二次运行时接收 JsonWriter
作为参数。输出结果如下
Output using the CsvWriter:
3,4
1,2
Output using the JsonWriter:
[["3","4"],["1","2"]]
工厂方法模式使我们能够将 Encoder
类的实例化从 AbstractWriter
父类移到子类中。这样,我们就避免了 Writer
和 Encoder
之间的紧密耦合,从而获得了更大的灵活性。但缺点是,代码会变得更加复杂,因为我们必须引入接口和子类来实现这种模式。
依赖注入
我们要介绍的下一种模式是一种名为 "依赖注入"(DI) 的结构模式。它通过在构造时将依赖关系插入类中,而不是在类中实例化依赖关系,来帮助我们实现松散耦合的架构。
下面的代码展示了如何在构造函数中实例化依赖关系,在本例中,依赖关系是一个经典的日志记录器:
class InstantiationExample
{
private Logger $logger;
public function __construct()
{
$this->logger = new FileLogger();
}
}
代码本身运行得非常好,但当你想用一个不同的类替换 FileLogger 时,问题就来了。虽然我们已经为 $logger 属性使用了日志程序接口,理论上可以很容易地与其它实现交换,但我们还是在构造函数中硬编码了 FileLogger。现在,想象一下你几乎在每个类中都使用了该日志记录器;使用不同的日志记录器实现来替换它需要花费一些精力,因为你必须接触使用该日志记录器的每个文件。
无法替换 FileLogger 也增加了为该类编写单元测试的难度。你不能用模拟日志来替换它,但你也不想在测试运行期间向实际日志中写入信息。如果要测试日志是否正常工作,就必须在生产中使用的代码中加入一些变通方法。
DI 迫使我们思考在一个类中应该使用哪些依赖关系以及依赖关系的数量。当构造函数的参数超过三到四个依赖项时,就会被认为是一种代码气味(即代码结构不良的标志),因为这表明类违反了单一责任原则(SOLID 中的 "S")。这也被称为 "范围蠕变":随着时间的推移,类的范围会缓慢而稳定地扩大。
现在让我们看看 DI 如何解决前面提到的问题:
class ConstructorInjection
{
private Logger $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
}
与之前的代码相比,差别似乎并不大。我们所做的只是将日志记录器实例作为参数传递给构造函数,而不是直接在构造函数中将其实例化。但这样做的好处是巨大的:我们现在可以改变注入的实例(如果它实现了日志记录器接口),而无需接触实际的类。
试想一下,你不再希望该类将日志记录到文件系统中,而是记录到一个日志管理系统(如 Graylog)中,该系统可将来自不同应用程序的所有日志集中管理。你只需创建 GraylogLogger,它也实现了日志程序接口,但会将日志写入该系统,而不是文件。然后,你只需将 GraylogLogger 而不是 FileLogger 注入到所有应该使用它的类中,恭喜你,你刚刚改变了应用程序记录信息的方式,而无需接触实际的类。
同样,我们也可以在单元测试中用模拟对象轻松交换依赖关系。这是对可测试性的巨大改进。
不过,无论你选择哪种实现,Logger
的实例化仍需在其它地方进行。我们只是将其移出了 InjectionExample
类。依赖关系会在类实例化时注入:
$constructorInjection = new ConstructorInjection(
new FileLogger()
);
通常,你会在工厂类中发现这种实例化。例如,这是一个实现简单工厂模式的类,其唯一的工作就是创建某个类的实例,并创建所有必要的依赖关系。
注入并不一定要通过构造函数进行。另一种可能的方法是所谓的设置器注入:
class SetterInjection
{
private Logger $logger;
public function __construct()
{
// ....
}
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
}
然后使用 setLogger
方法注入依赖关系。与构造函数注入相同,这很可能发生在工厂类中。
下面是这样一个工厂的示例:
class SetterInjectionFactory
{
public function createInstance(): SetterInjection
{
$setterInjection = new SetterInjection();
$setterInjection->setLogger(new FileLogger());
return $setterInjection;
}
}
依赖注入容器
你可能已经想过如何管理所有必要的工厂,尤其是在大型项目中。为此,我们发明了 DI 容器。它不是 DI 模式的一部分,但与 DI 模式密切相关,因此我们想在这里介绍一下它。
DI 容器是使用 DI 模式将所有对象引入目标类的中心存储空间。它还包含实例化对象的所有必要信息。
它还可以存储已创建的实例,这样就不必重复实例化它们。例如,你不会为每个使用 FileLogger
的类创建一个 FileLogger
实例,因为这样会产生大量相同的实例。你更希望只创建一次,然后通过引用将其传递给目标类。
如今,DI 容器的概念已被所有主要的 PHP 框架所采用,因此你很可能已经使用过这样的容器。不过你可能没有注意到它,因为它通常隐藏在应用程序的后端,有时也被称为服务容器。
Observer
我们要在本书中介绍的最后一种模式是观察者模式。作为一种行为模式,它的主要目的是实现对象之间的高效通信。实现观察者模式的一个常见任务是,当一个对象的状态发生变化时,触发另一个对象的某个动作。状态变化可以很简单,比如类属性值的变化。
让我们从另一个例子开始:每当客户取消订阅时,你必须向销售团队发送一封电子邮件,以便他们了解情况并采取对策留住客户。
怎样才能做到这一点呢?例如,你可以设置一个循环作业,在一定时间间隔内(如每 5 分钟)检查自检查以来是否有任何取消。这样做是可行的,但根据客户群的规模,这项工作可能在大多数时候都不会返回任何结果。另一方面,如果两次检查之间的间隔时间太长,在下一次检查之前,你可能会失去宝贵的时间。
现在,销售可能不是世界上时间最紧迫的事情(销售人员通常不同意这一点),但你肯定明白其中的含义。如果我们能在客户取消订阅后立即发送电子邮件,岂不更好?这样,我们就不用定期检查是否有变化,而只需在变化发生时才收到通知?
代码可以像下面这个简化示例一样:
class CustomerAccount
{
public function __construct(
private MailService $mailService
) {}
public function cancelSubscription(): void
{
// Required code for the actual cancellation
// ...
$this->mailService->sendEmail(
'sales@example.com',
'Account xy has cancelled the subscription'
);
}
}
这种方法肯定行得通,但它有一个缺点:MailService 的调用是直接编码到类中的,因此与类紧密耦合。现在,CustomerAccount 类必须关心另一个依赖关系,这就增加了维护工作量,例如,测试必须扩展。如果我们以后不想再发送这封电子邮件,甚至不想向其它部门发送额外的电子邮件,CustomerAccount 类就必须再次更改。
使用松散耦合方法,CustomerAccount 对象将只存储一个在发生变化时应通知的其它对象的列表。该列表不是硬编码的,需要通知的对象必须在引导阶段附加到该列表中。
我们要观察的对象(在前面的例子中为 CustomerAccount)被称为主体。主体负责通知观察者。添加或删除观察者时,主体无需修改代码,因此这种方法非常灵活。
下面的代码举例说明了 CustomerAccount 类如何实现观察者模式:
use SplSubject;
use SplObjectStorage;
use SplObserver;
class CustomerAccount implements SplSubject
{
private SplObjectStorage $observers;
public function __construct()
{
$this->observers = new SplObjectStorage();
}
public function attach(SplObserver $observer): void
{
$this->observers->attach($observer);
}
public function detach(SplObserver $observer): void
{
$this->observers->detach($observer);
}
public function notify(): void
{
foreach ($this->observers as $observer) {
$observer->update($this);
}
}
public function cancelSubscription(): void
{
// Required code for the actual cancellation
// ...
$this->notify();
}
}
这里发生了很多事情,让我们一点一点地看。首先值得注意的是,该类使用了 SplSubject 和 SplObserver 接口以及 SplObjectStorage 类。由于 CustomerAccount 类实现了 SplSubject 接口,因此必须提供 attach、detach 和 notify 方法。
我们还使用构造函数将 $observers 属性初始化为 SplObjectStorage,它将存储 CustomerAccount 类的所有观察者。幸运的是,SPL 已经提供了该存储的实现,因此我们不需要再做这项工作。
SplSubject 接口需要 attach 和 detach 方法。它们用于添加或删除观察者。它们的实现非常简单—我们只需在这两种情况下将 SplObserver 对象转发到 SplObjectStorage,SplObjectStorage 会替我们完成必要的工作。
通知方法必须调用存储在 SplObjectStorage 中的所有 SplObserver 对象的 update 方法。这就像使用一个foreach 循环遍历所有 SplObserver 条目并调用它们的更新方法一样简单,同时使用 $this 传递对主题的引用。
下面的代码显示了这样一个观察者的样子:
class CustomerAccountObserver implements SplObserver
{
public function __construct(
private MailService $mailService
) {}
public function update(CustomerAccount|SplSubject
$splSubject): void
{
$this->mailService->sendEmail(
'sales@example.com',
'Account ' . $splSubject->id . ' has cancelled the subscription'
);
}
}
观察者实现了 SplObserver 接口,这并不奇怪。唯一需要的方法是 update,它会在 notify 方法中被主体调用。由于该接口需要 $splSubject 参数来实现 SplSubject 接口,因此我们必须使用该参数类型提示。否则会导致 PHP 错误。
由于我们知道在这种情况下,对象实际上是一个 CustomerAccount 对象,因此我们也可以添加这个类型提示。这样我们的集成开发环境就能帮助我们完成正确的代码补全;但这并不是必须的。
正如你所看到的,所有关于电子邮件发送的逻辑现在都转移到了 CustomerAccountObserver 中。换句话说,我们成功地消除了 CustomerAccount 和 MailService 之间的紧密耦合。
我们需要做的最后一件事就是附加 CustomerAccountObserver:
$mailService = new MailService();
$observer = new CustomerAccountObserver($mailService);
$customerAccount = new CustomerAccount();
$customerAccount->attach($observer);
本代码示例再次进行了简化。在实际应用中,所有三个对象都将在专用工厂中实例化,并由 DI 容器汇集在一起。
观察者模式可以帮助你以相对较少的工作量解耦对象。但它也有一些缺点。观察者更新的顺序无法控制;因此,你无法使用它来实现顺序至关重要的功能。其次,通过对类进行解耦,光看代码已经看不出哪些观察者附加到了类上。
在总结设计模式这一主题时,我们将看看那些如今仍很常见,但已被证明存在重大缺陷而不值得推荐的模式。为反模式拉开帷幕!
反模式
并非每一种设计模式都经得起时间的考验。任何事物都在发展,软件开发和 PHP 也是如此。一些过去很成功的模式已经被更新和/或更好的版本所取代。
几年前解决问题的标准方法可能不再是正确的解决方案。PHP 社区一直在学习和改进,但这些知识的分布并不均衡。因此,为了让人们更清楚地了解哪些模式应该避免使用,这些模式通常被称为反模式(Anti-Patterns)--这听起来显然是你不希望在代码中出现的东西,对吗?
反模式是什么样的呢?让我们看看第一个例子。
单例
在 DI 在 PHP 世界越来越流行之前,我们已经不得不处理如何有效地创建实例以及如何使它们在其它类的作用域中可用的问题。单例模式(Singleton pattern)提供了一种快速而简单的解决方案,通常看起来是这样的:
$instance = Singleton::getInstance();
静态 getInstance
方法非常简单:
class Singleton
{
private static ?Singleton $instance = null;
public static function getInstance(): Singleton
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
}
如果执行了该方法,就会检查是否已经创建了该类的实例。如果是,则返回实例;如果不是,则事先创建实例。这种方法也被称为懒初始化。在这里,lazy 是件好事,因为只有在需要时才会初始化,这样可以节省资源。
此外,该方法还将新实例存储在静态 $instance 属性中。这种方法之所以能够实现,是因为静态属性无需类实例即可具有值。换句话说,我们可以将一个类的实例存储在它自己的类定义中。此外,在 PHP 中,所有对象都是以引用的形式传递的,即指向内存中对象的指针。这两个特点都有助于我们确保返回的总是同一个实例。
实际上,Singleton 非常优雅;由于它也使用静态方法,因此也不需要 Singleton 类的实例。这样,它就可以在代码中的任何地方执行,而无需做任何进一步的准备。
这种易用性是 Singleton 最终成为反模式(Anti-Pattern)的主要原因之一,因为它会导致范围蠕变。我们在有关 DI 的章节中解释过这个问题。
另一个问题是可测试性:很难用模拟对象替换实例,因此为使用 Singleton 模式的代码编写单元测试变得更加复杂。
如今,你应该将 DI 与 DI 容器结合起来使用。虽然它不像 Singleton 那么容易使用,但这也有助于我们在类中使用另一个依赖关系之前三思而后行。
不过,这并不意味着完全不能使用单例模式。可能有正当的理由实现它,或者至少在遗留项目中保留它。只是要注意其中的风险。
服务定位器
第二种可能有问题的模式是服务定位器:
class ServiceLocatorExample
{
public function __construct(
private ServiceLocator $serviceLocator
) {}
public function fooBar(): void
{
$someService = $this->serviceLocator
->get(SomeService::class);
$someService->doSomething();
}
}
在此示例类中,我们在对象的构造期间注入 ServiceLocator。然后在整个类中使用它来获取所需的依赖项。 在这些方面,DI 和服务定位器都是依赖倒置原则(SOLID 中的“D”)的实现:它们将依赖关系的控制移出类范围,帮助我们实现松散耦合的架构。
但是,如果我们只需要注入一个依赖项而不是多个依赖项,这不是一个好主意吗? 那么,Service Locator 模式的缺点是它隐藏了 ServiceLocator 实例背后的类的依赖关系。 虽然使用 DI,你可以通过查看构造函数清楚地看到使用了哪些依赖项,但仅注入 ServiceLocator 时则无法做到这一点。
与 DI 不同,它不会迫使我们询问类中应该使用哪些依赖项,因为对于较大的类,你可能很快就会失去对类中使用哪些依赖项的概述。 这基本上是我们发现的单例模式的主要缺点之一。
再次强调,在使用服务定位器模式时,我们不想教条主义。 在某些情况下可能适合使用它——只需小心处理即可。
总结
在本章中,我们讨论了标准和指南的重要性。编码标准可以帮助你与其它开发人员在代码格式上保持一致,你也了解了值得采用的现有标准。
编码指南可以帮助你的团队就如何编写软件达成一致。尽管这些准则对每个团队来说都是高度个性化的,但我们还是为你提供了一套很好的示例和最佳实践,以帮助你建立自己团队的准则。通过代码审查,你还可以了解如何保证质量。
最后,我们向你介绍了设计模式的世界。我们相信,至少了解这些模式的一部分,将有助于你与团队成员一起设计和编写高质量的代码。关于这个主题,还有很多内容值得探索,你可以在本章末尾找到一些精彩资料的链接。
这几乎结束了我们对 PHP 中简洁代码的多方面了解的精彩旅程。我们相信,你现在一定想尽快在日常工作中使用所有新知识。不过,在此之前,请耐心看完最后一章,我们将讨论文档的重要性。