编程

Laravel 设计模式之装饰器模式

829 2024-01-02 17:34:00

在所有讨论的设计模式中,“四人”原著中的装饰器设计模式最吸引我。它的简单而强大(即广泛的使用/适用性)使它成为我个人的最爱。

然而,这种简单性和实用性经常被忽视,取而代之的是,焦点被转移到了“哦,过度工程化”或“过早的抽象是万恶之源!”(因为有一个接口在使用中)。虽然我并不是说从来都不是这样,但我认为可以公平地说,用同一把刷子对每个用例和实现进行标记是明显不公平的。此外,在我看来,关于服务装饰器的 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());
});

我希望这对你有意义。现在让我们开始工作吧!

设计包

🔗 Livewire Toaster

在开发新特性/软件包/功能时,你应该做的第一件事就是对设计/模型进行实验和思考。你可能会认为,从第 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 模式,我们还建立了一个自定义的中间件/管道。职责如下(最后一次注册成为“洋葱”的外层):

ToastTranslatingCollectorAccessibleCollectorQueuingCollector

Toast 对象在到达目的地 QueuingCollector 之前经过一系列层/管道。现在想象一下,新的功能请求出现了,或者我们看到了可改进的点。扩展功能/行为便非常简单:

ToastChatGptCollectorTranslatingCollectorBroadcastingCollectorQueuingCollector

AccessibleCollector 怎么了?.. 别担心。包用户决定将该特性关闭。 😏

最后

代码中的条件是不可避免的。然而,我们可以把它们放得很高,以至于它们几乎“脱落”。这些条件越早被应用,结果代码的复杂性就越低。组合非常适合这个。

设计模式没有单一的用例。你的创造力是这里唯一的限制因素。我仍然记得我的大学时代,他们举了一些愚蠢的咖啡例子来解释装饰器。现在回想那次演讲,我不得不为那个例子是多么做作而感到尴尬。将其与这样一个真实世界的例子进行对比…

  • 装饰器可以用作责任链。这可以通过使用 $next 参数使其显而易见。
  • $this->app->extend 值得更多的爱和关注。
  • SOLID 是很好的工具。
  • 行为组合总能会战胜以数据为中心的思维。我再怎么强调也不为过,在保持其他一切完整的同时,为包添加额外的功能是多么微不足道。
  • 恰当的设计是关键。