Laravel 设计模式之装饰器模式
在所有讨论的设计模式中,“四人”原著中的装饰器设计模式最吸引我。它的简单而强大(即广泛的使用/适用性)使它成为我个人的最爱。
然而,这种简单性和实用性经常被忽视,取而代之的是,焦点被转移到了“哦,过度工程化”或“过早的抽象是万恶之源!”(因为有一个接口在使用中)。虽然我并不是说从来都不是这样,但我认为可以公平地说,用同一把刷子对每个用例和实现进行标记是明显不公平的。此外,在我看来,关于服务装饰器的 Laravel 文档过于简洁。
这就是为什么,在这篇文章中,我想更清楚地了解 Laravel 社区中的这个弱者,并提供一个实际的例子,而不是展示虚构的场景。唯一的先决条件是抛开你对装饰器的任何偏见,同时保持清醒和开放的心态。我并不是说这是库房里最锋利的工具,尽管我相信我有一个极好的、实际的例子等着你。
本文假定你至少对装饰器设计模式有一些了解。
案例研究
在开发一个项目时,我需要发送及展示 toast 消息的能力,即在屏幕角落显示的提示消息。像平常一样,由于这是个相当通用问题,我开始查看各种包,以寻找是否有人已经使用 Livewire 搭建过。毫不意外,有很多这样的包。查看完每一个包后,我得出一个不幸的结论,没有一个包完全符合我的需求:
- 不臃肿 — 流行的包存在巨大问题。
- 性能 — 无需额外的请求。有些总是在会话中闪现,因此会有受到轻微的性能损失。
- 智能 — 无需显式告知它是需要事件发送还是会话闪存:库应该自己解决。如果文本内容有点太多,例如 3 秒钟内无法阅读,它也应该让 toast 消息在屏幕上停留更长的时间。
- 使用起来毫不费力 — 无需记住显式的 send 调用。
- 顶级开发体验 — 许多应用都是多语言环境的,所以不需要显式调用 __ 或
Lang::get
。 - 最小化设计 — 没有侵入性的图标、动作按钮等。它应该做一件事,而且做得很好:显示 toast 消息。
由于不存在这样的包,我决定自己动手!该包为: Livewire Toaster。
我想详细谈谈第三和第五点:智能及开发体验。为了实现这些功能,我使用了时髦的装饰器。你一定听过开闭原则,对吧?就是 SOLID 中的 O。使用装饰器,可以以这种方式设计代码,以使后续扩展更为容易。
由于建模方法,这一特性只是一个额外的好处,因为它本身并不是目标。请使用食谱和工具作为客观的指南,引导你朝着正确的方向前进。指南本身不是目标,目的地才是目标!
Laravel 的 extend 方法
在使用代码示例进入实际实现之前,我们首先需要建立一个关于 Container 的 extend 方法及其指定角色的共识。我已经看到了很多使用此功能的示例,但不幸的是,原因是错误的(IMHO)。从文档中,逐字逐句:
比如,解析服务时,你可以运行额外的代码装饰或者配置服务。
不幸的是,所有那些都关注了后一点:
(...) 配置服务。
有一种更好的方法可以实现这一目标:容器事件(Container Event)。在 $this->app->resolving(…)
调用中进行一些额外的配置更有意义,因为它们实际上是为这个目的而设计的。以下是 Intertia 的示例,它使用 afterResolving
事件(callAfterResolving
是其别名):
$this->callAfterResolving('blade.compiler', function ($blade) {
$blade->directive('inertia', [Directive::class, 'compile']);
$blade->directive('inertiaHead', [Directive::class, 'compileHead']);
});
回到 $this->app->extend(...)
方法,通过将其视为“扩展行为”,更容易记住其真正目的。在一个解析过的对象上设置属性,”扩展行为“不是必需,它更多的是配置工作。
好的示例
比如,你是否曾经创建过包并需要“扩展(配置)” Laravel 的 ExceptionHandler
?你可以选择使用装饰器:
$this->app->extend(ExceptionHandler::class, fn ($next) => new Handler($next));
并添加类似如下内容:
final class Handler implements ExceptionHandler
{
private ExceptionHandler $handler;
public function __construct(
private ExceptionHandler $handler,
) {}
public function render($request, Throwable $e): mixed
{
if ($e instanceof ModelNotFoundException) {
// do something...
}
return $this->handler->render($request, $e);
}
// omitted for brevity
}
我认为这是一个较少数的选择,使用容器事(Container Event)是一个更好的选择:
$this->callAfterResolving(ExceptionHandler::class, function (ExceptionHandler $handler) {
$handler->ignore(SucceededException::class);
$handler->renderable(fn (SucceededException $e) => $this->app->make(Responder::class)->respond());
});
我希望这对你有意义。现在让我们开始工作吧!
设计包
在开发新特性/软件包/功能时,你应该做的第一件事就是对设计/模型进行实验和思考。你可能会认为,从第 0 天起,包的当前状态就在那里,但事实远非如此。我想我花了 2-3 周的时间来确定包的设计,这样我就可以实现所有(自我强加的)要求,并为未来的添加铺平道路。
思考过程
我坐下来,开始思考我需要如何解决各种问题。长话短说,在考虑了所有的要求并连接了所有的点(经过三次重写)后,我决定需要多个组件来解决这个难题:
- Toaster — 负责管理和发送 toast 消息的组件
- Toast — 我们需要发送的 toast
- Relay — 根据最适合当前上下文的内容,将 toast 消息中转发到正确通道的多种实现。
- Hub - 显式 toast 消息的前端组件
- Collector — 主要角色是收集 toast 信息,然后根据请求发布。这一点很重要,因为我们刚刚发现有多个 Relay 组件。我们将主要关注这一点。
还有其他细粒度的组件,但它们与我们在这里的观点并不真正相关。如果您想了解更多信息,可以随时查看 GitHub 库。
这些组件协同工作以实现更大的目标:调度和显示多个 toast 消息。
Collector
正如我们所知,这个组件的工作是收集 toast 信息,然后在需要时发布。让我们把这个概念变为现实:
interface Collector
{
public function collect(Toast $toast): void;
public function release(): array;
}
我们已经完成了 50%,不是玩笑。定义完抽象模型后,我们需要实体来表示。Collector
实际收集 Toast
消息的方式就是实现的细节。我能想到的最简单的设计是 QueuingCollector
:
final class QueuingCollector implements Collector
{
private array $toasts = [];
public function collect(Toast $toast): void
{
$this->toasts[] = $toast;
}
public function release(): array
{
$toasts = $this->toasts;
$this->toasts = [];
return $toasts;
}
}
之所以这样命名,那是因为:它使用类似于队列的数据结构来排队 toast 消息。这将是我们抽象模型的主要实体。接下来,我们需要在服务容器中对其进行注册:
public function register(): void
{
$this->app->scoped(Collector::class, QueuingCollector::class);
}
完成了吗?嗯,有点。我们仍然需要增强最低限度的功能,并在上面添加行为风格。
装饰器出场
智能
该包需要智能,因为如果消息更长,那么它应该在屏幕上保留更长的时间。我还没有遇到过一个人能在 3 秒内阅读 300 个单词。这牵涉 toast 消息的可访问性(accessibility )方面,因此将其称为 AccessibleCollector
将是完美的:
final readonly class AccessibleCollector implements Collector
{
private const AMOUNT_OF_WORDS = 100;
private const ONE_SECOND = 1000;
public function __construct(private Collector $next) {}
public function collect(Toast $toast): void
{
$addend = (int) floor(str_word_count($toast->message->value) / self::AMOUNT_OF_WORDS);
$addend = $addend * self::ONE_SECOND;
if ($addend > 0) {
$toast = ToastBuilder::proto($toast)->duration($toast->duration->value + $addend)->get();
}
$this->next->collect($toast);
}
public function release(): array
{
return $this->next->release();
}
}
代码本身并没有那么相关,但它所做的基本工作是为消息中的每 100 个单词增加额外的屏幕时间。这里有几点需要注意:
我们正在接受另一个 Collector
实例,因为这对于装饰器来说是非常典型的。最终,我们将获得一个“洋葱结构”。
装饰时并不是所有的东西都必须修改。在这种情况下,只使用 collect
方法进行装饰。我们不需要扩展 release
方法的行为。
- 没有继承。只有组合。
- final readonly 是我默认使用的。
开发体验
我不喜欢这种语法 (伪代码):
Toast::create()
->error(__('general.obvious.translation'))
->send();
我们可以对其改进:
Toaster::error('general.obvious.translation');
现在有了装饰器,实现起来很简单。由于这涉及到 toast 消息的翻译方面,因此 TranslationCollector
这个名称将是完美的:
final readonly class TranslatingCollector implements Collector
{
public function __construct(
private Collector $next,
private Translator $translator,
) {}
public function collect(Toast $toast): void
{
$replacement = $this->translator->get($original = $toast->message->value, $toast->message->replace);
if (is_string($replacement) && $replacement !== $original) {
$toast = ToastBuilder::proto($toast)->message($replacement)->get();
}
$this->next->collect($toast);
}
public function release(): array
{
return $this->next->release();
}
}
同样,代码本身并不那么相关,但它所做的是查询翻译器,并检查原始消息是否被新消息替换。如果它确实发生了更改,则原始 Toast 将被新 Toast 替换,然后转发到下一个对象。我喜欢组合。
附录:使用起来毫不费力
眼尖的读者可能已经注意到,这段代码并不能解决所需的显式 ->send()
调用。它与装饰无关,而是与对象的生命周期有关。为此,我创建了一个包装器 PendingToast
类,该类包装 ToastBuilder
,并在对象即将离开作用域时自动调度它:
public function __destruct()
{
if (! $this->dispatched) {
$this->dispatch();
}
}
把所有东西组装到一起
显然,除非我们使用 extend
方法将这些装饰器注册到服务容器中,否则它们不会做任何事情:
public function register(): void
{
$this->app->scoped(Collector::class, QueuingCollector::class);
$this->app->extend(Collector::class, static fn (Collector $next) => new AccessibleCollector($next));
$this->app->extend(Collector::class, fn (Collector $next) => new TranslatingCollector($next, $this->app['translator']));
}
等等。我们完了。。。但我们真的吗?为包用户提供切换某些功能的能力怎么样?那会很难,对吧?错了!
让我们在配置文件中定义一些 flag:
return [
/**
* Add an additional second for every 100th word of the toast messages.
*
* Supported: true | false
*/
'accessibility' => true,
/**
* Whether messages passed as translation keys should be translated automatically.
*
* Supported: true | false
*/
'translate' => true,
];
现在唯一剩下的就是添加简单的 if 检查(忽略稍微不规范的配置对象):
public function register(): void
{
$config = $this->configureService();
$this->app->scoped(Collector::class, QueuingCollector::class);
if ($config->wantsAccessibility) {
$this->app->extend(Collector::class, static fn (Collector $next) => new AccessibleCollector($next));
}
if ($config->wantsTranslation) {
$this->app->extend(Collector::class, fn (Collector $next) => new TranslatingCollector($next, $this->app['translator']));
}
}
啊,我们完成了。我希望你被这种方法的简单和灵活所震撼。更值得注意的是,除了使用 decorator 模式,我们还建立了一个自定义的中间件/管道。职责如下(最后一次注册成为“洋葱”的外层):
Toast → TranslatingCollector → AccessibleCollector → QueuingCollector
Toast 对象在到达目的地 QueuingCollector
之前经过一系列层/管道。现在想象一下,新的功能请求出现了,或者我们看到了可改进的点。扩展功能/行为便非常简单:
Toast → ChatGptCollector → TranslatingCollector → BroadcastingCollector → QueuingCollector
AccessibleCollector 怎么了?.. 别担心。包用户决定将该特性关闭。 😏
最后
代码中的条件是不可避免的。然而,我们可以把它们放得很高,以至于它们几乎“脱落”。这些条件越早被应用,结果代码的复杂性就越低。组合非常适合这个。
设计模式没有单一的用例。你的创造力是这里唯一的限制因素。我仍然记得我的大学时代,他们举了一些愚蠢的咖啡例子来解释装饰器。现在回想那次演讲,我不得不为那个例子是多么做作而感到尴尬。将其与这样一个真实世界的例子进行对比…
- 装饰器可以用作责任链。这可以通过使用
$next
参数使其显而易见。 - $this->app->extend 值得更多的爱和关注。
- SOLID 是很好的工具。
- 行为组合总能会战胜以数据为中心的思维。我再怎么强调也不为过,在保持其他一切完整的同时,为包添加额外的功能是多么微不足道。
- 恰当的设计是关键。