特殊说明 :由于 action 一词有诸多含义,例如操作、行为、行动等,大家对它的认知也有些许差异,本文觉得将其译为 “行动” 更为贴切,所以在看到 “行动” 的时候即指 action(s) 这个单词。


既然我们可以以一种类型安全和透明的方式处理数据,那我们需要开始用这种方式做些什么。

就像我们不想使用充满数据的随机数组一样,我们也不想项目中最关键的部分——业务功能——分散在随机函数和类中。

这是一个示例:你项目中的用户故事之一可能是“管理员创建发票”。这意味着要在数据库中保存发票信息,但还需要更多:

  • 第一:计算每个单独的发票行和总价格
  • 保存发票到数据库
  • 通过支付提供者创建支付
  • 创建一个 PDF 与所有相关信息
  • 发送此 PDF 给客户

在 Laravel 中,一个常见的实践是创建 “臃肿的模型” 来处理所有这些功能。在本章中,我们将研究另一种将此行为添加到代码库中的方法。

我们将把这些用户故事作为项目的一级公民来对待,而不是在模型或控制器中混合功能。我倾向于称这些为“行动”。

专用词

在研究它们的使用之前,我们需要讨论行动是怎样构成的。首先,他们存在于领域中。

其次,它们是没有任何抽象或接口的简单类。行动是一个接受输入,执行某些操作并给出输出的类。这就是为什么一个行动通常只有一个公共方法,有时只有一个构造函数的原因。

作为我们项目的惯例,我们决定为所有类加后缀。当然,CreateInvoice 听起来不错,但是一旦你要处理数百或数千个类,就需要确保不会发生命名冲突。你会看到,CreateInvoice 也很可能是可调用控制器、命令、任务或请求的名称。我们希望尽可能避免混淆,因此,将以 CreateInvoiceAction 为名。

显然,这意味着类名变得更长。现实情况是,如果你正在处理较大的项目,为了确保不会造成混淆,则无法避免选择更长的名称。这是我们其中一个项目的一个极端示例,我不是在开玩笑:CreateOrUpdateHabitantContractUnitPackageAction

一开始我们讨厌这个名字,我们拼命想想出一个更短的。但最后,我们必须承认,最重要的是清楚类的含义。总之,我们 IDE 的自动完成功能将能够很好的解决长名称带来的不便。

当我们确定类名时,要克服的下一个障碍是为使用我们的行动而命名公共方法。一种方法是使其可调用,如下所示:

class CreateInvoiceAction
{
    public function __invoke(InvoiceData $invoiceData): Invoice
    {
        // …
    }
}

不过,这种方法有一个实际问题。在本章后面,我们将讨论如何从其他行动中组合行动,以及它是如何成为一个强大的模式。它看起来像这样:

class CreateInvoiceAction
{
    private $createInvoiceLineAction;

    public function __construct(
        CreateInvoiceLineAction $createInvoiceLineAction
    ) { /* … */ }

    public function __invoke(InvoiceData $invoiceData): Invoice
    {
        foreach ($invoiceData->lines as $lineData) {
            $invoice->addLine(
                ($this->createInvoiceLineAction)($lineData)
            );
        }
    }
}

你能发现问题所在吗? 当可调用是类属性时,PHP 不允许直接调用它,因为 PHP 要寻找的是类方法。这就是为什么在调用操作之前必须将其封装在括号内的原因。

虽然这只是一个小小的不便,但是 PhpStorm 还有一个额外的问题:当以这种方式调用操作时,它无法提供参数自动完成功能。就我个人而言,我认为正确使用 IDE 是项目开发的一个组成部分,不应被忽视。这就是为什么在这个时候,我们的团队决定不让行动可调用。

另一种选择是使用 handle, Laravel 经常在这些情况下使用它作为默认名称。这又有一个问题,特别是因为 Laravel 使用它。

当 Laravel 允许你使用 handle 时,例如,任务或命令,它也会提供来自依赖项容器的方法注入。在我们的行动中,我们只希望构造函数具有 DI 功能。在本章的后面,我们将再次深入探究背后的原因。

所以 handle 也出局了。当我们开始使用行动时,我们实际上对这个命名难题进行了很多思考。最后,我们决定选用 excute。请记住,你可以自由地提出自己的命名约定:这里的要点更多地是关于使用行动的模式,而不是关于它们的名称。

付诸实践

排除了所有的专用词,让我们来谈谈为什么行动是有用的,以及如何实际使用它们。

首先,让我们谈谈可重用性。使用行动的诀窍是将它们分成足够小的块,以便某些东西可以重复使用,同时又要保持足够大的大小,以免最终导致过载。以我们的发票为例:从发票中生成 PDF 可能会在我们的应用程序的多个上下文中发生。当然,发票实际创建时会生成 PDF,但是管理员可能还希望在发票发送之前先查看预览或草稿。

这两个用户场景:“创建发票”和“预览发票”显然需要两个入口点,两个控制器。但另一方面,根据发票生成 PDF 是两种情况下都要做的事情。

当你开始花时间考虑应用程序实际要做什么时,会注意到有许多行动可以重用。当然,我们还需要注意不要将代码过于抽象。复制粘贴一小段代码通常比过早地进行抽象要好。

一个好的经验法则是在进行抽象时考虑功能,而不是代码的技术属性。当两个行动可能做类似的事情时,尽管它们是在完全不同的上下文中做的,你应该注意不要过早地开始对它们进行抽象。

另一方面,在某些情况下,抽象可能会有所帮助。再次以我们的发票 PDF 为例:你可能需要生成更多的 PDF 文件,而不仅仅是发票—至少在我们的项目中是这样的。有一个通用的 GeneratePdfAction 可能是有意义的,它可以与一个接口一起工作,然后由一个 Invoice 实现。

但是,说实话,我们的大多数行动很可能是针对他们的用户场景的,并且不可重用。在这些情况下,你可能认为行动是不必要的开销。但是请坚持住,因为可重用性不是使用它们的唯一原因。事实上,最重要的原因与技术上的好处完全无关:行动允许程序员以更接近真实世界的方式思考,而不是代码。

假设你需要更改发票的创建方式。一个典型的 Laravel 应用程序可能会将这个发票创建逻辑分布在控制器和模型上,可能是生成 PDF 的任务,最后是一个事件侦听器来发送发票邮件。你需要知道很多地方,我们的代码再一次,按其技术属性而不是其含义进行分组,分布在代码库中。

行动减少了由这样一个系统引入的认知负荷。如果你需要研究如何创建发票,可以直接转到行动类,并从那里开始。

不要搞错:行动可以很好地与异步任务和事件监听器一起工作;尽管这些任务和监听器只是为行动工作提供基础设施,而不是业务逻辑本身。这是一个很好的例子,说明为什么我们需要分割领域和应用层:每个都有自己的用途。

所以我们得到了可重用性和认知负荷的减少,但还有更多!

因为行动是几乎可以独立运行的小块软件,所以很容易对它们进行单元测试。在你的测试中,不必担心发送假的 HTTP 请求、设置假的外观等等。你可以简单地创建一个新的行动(可能提供一些模拟依赖项),向其传递所需的输入数据并对其输出做出断言。

例如,CreateInvoiceLineAction:它将获取有关将要用于开具发票的商品数据,以及金额和时期;它将计算总价格和含增值税及不含增值税的价格。你可以为这些东西编写健壮但简单的单元测试。

如果你的所有行动都经过了适当的单元测试,那么你可以非常自信地认为,应用程序需要提供的大部分功能实际上是按照预期工作的。现在只需以对最终用户有意义的方式使用这些行动,并为这些部分编写一些集成测试。

行动组合

我之前已经简要提及的行动的一个重要特征是它们如何使用依赖注入。由于我们使用构造函数来传递来自容器的数据,而使用 execute 方法来传递与上下文相关的数据;我们可以自由地不断地从行动之外的行动中组合行动…。

你懂的。让我们弄清楚一点,深度依赖链是你想要避免的东西——它让代码复杂并且彼此高度依赖——但在一些情况下,依赖注入是非常有益的。

再次以 CreateInvoiceLineAction 为例,它必须计算增值税价格。现在,根据上下文,发票行的价格可能包含或不包含增值税。计算增值税价格微不足道,但是我们不希望我们的 CreateInvoiceLineAction 关心它的细节。

所以想象我们有一个简单的 VatCalculator 类-它可能存在于 \Support 命名空间中-它可以像这样注入:

class CreateInvoiceLineAction
{
    private $vatCalculator;

    public function __construct(VatCalculator $vatCalculator)
    { 
        $this->vatCalculator = $vatCalculator;
    }
    
    public function execute(
        InvoiceLineData $invoiceLineData
    ): InvoiceLine {
        // …
    }
}

然后你会这样使用它:


public function execute(
    InvoiceLineData $invoiceLineData
): InvoiceLine {
    $item = $invoiceLineData->item;

    if ($item->vatIncluded()) {
        [$priceIncVat, $priceExclVat] = 
            $this->vatCalculator->vatIncluded(
                $item->getPrice(),
                $item->getVatPercentage()
            );
    } else {
        [$priceIncVat, $priceExclVat] = 
            $this->vatCalculator->vatExcluded(
                $item->getPrice(),
                $item->getVatPercentage()
            );
    }

    $amount = $invoiceLineData->item_amount;
    
    $invoiceLine = new InvoiceLine([
        'item_price' => $item->getPrice(),
        'total_price' => $amount * $priceIncVat,
        'total_price_excluding_vat' => $amount * $priceExclVat,
    ]);
}

依次将 CreateInvoiceLineAction 注入 CreateInvoiceAction,并且这个也有其他依赖项,例如,CreatePdfActionSendMailAction

你将看到组合如何帮助你使单个行动保持较小的规模,同时又允许以清晰且可维护的方式对复杂的业务功能进行编码。

行动的替代方案

在这一点上,我需要提到两个范例,两种方式你不需要像行动这样的概念。

第一个是熟悉 DDD 的人都知道的:命令和处理程序。行动是它们的简化版本。命令和处理程序区分需要发生什么和需要如何发生,而行动将这两种职责合并为一个。确实,命令总线比行动提供了更多的灵活性。另一方面,它也需要你编写更多的代码。

对于我们项目的范围来说,将行动拆分为命令和处理程序是一种过分的做法。我们几乎永远不需要附加的灵活性,但是编写代码需要更长的时间。

第二种选择值得一提的是事件驱动系统。如果你曾经在事件驱动的系统中工作过,你可能会认为行动与实际使用它们的地方太直接耦合了。同样的论点也适用于:事件驱动系统提供更多的灵活性,但是对于我们的项目来说,使用它们有点杀鸡用牛刀。此外,事件驱动的系统增加了一层间接性,这使得代码的推理更加复杂。虽然这种间接性确实提供了好处,但不会好过我们的维护成本。


我希望我并不是在暗示我们已经解决了所有问题,并为所有 Laravel 项目提供了完美的解决方案。我们没有。在继续阅读本系列文章时,务必关注项目的特定需求。虽然你可以使用这里提出的一些概念,但你可能还需要一些其他解决方案来解决某些方面的问题。

对我们而言,行动是正确的选择,因为它们提供了适量的灵活性,可重用性,并且认知负担也显著减少,它们封装了应用程序的本质。实际上,它们可以与 DTO 和模型一起被认为是项目的真正核心。

这就把我们带到了下一章,核心的最后一部分:模型。