Laravel 使用 CURD 之外-动作(Action)

735 阅读4分钟

原文链接:stitcher.io/blog/larave…

现在我们已经可以使用安全且透明的数据了,我们需要用它做点什么。

就像我们不喜欢装有各种数据的随机数组一样,我们也不想项目中最重要的部分-业务函数,随机分布在各种方法和类中

举个栗子 🌰 : 项目中的一个用户用例可能是『一个管理员创建发票』,这表示在数据库中保存发票,但是还有一些事情:

  • 首先, 计算每个要开票项目的金额和总和
  • 然后把发票保存到数据库中
  • 使用支付服务创建一笔付款
  • 创建一个包含所有相关信息的 PDF
  • 把 PDF 发给顾客

Laravel 里常用的方法是把这些功能都放在『Models』里(译者注:充血模型),在这一节,我们将尝试另一种方法用代码实现这些功能。

与其把这些功能混合在 『Models』和 『Controllers』里面,我们把这些用户用例看作为项目的第一公民(first class citizens),我试图称之为『Actions』。

0x01 术语 Terminology

在我们使用之前,我们先来讨论一下动作是怎么构成的。对于初学者来讲,动作在领域(domain)下面。

第二,动作是没有任何抽象或者接口的简单类。一个动作类是获得输入,做一些操作,然后返回输出。这是为什么动作类通常只有一个公共方法,有时只有一个构造函数。

作为我们项目的惯例,我们决定给所有的类加上后缀。当然,CreateInvoice听起来不错,但只要您处理的是数百或数千个类,就需要确保不会发生命名冲突。你看,CreateInvoice,也可以是一个Controllercommandjobrequest的名称。我们希望尽可能消除混淆,因此,名称将被命名为CreateInvoiceAction

显然,这意味着类名变长了。现实情况是,如果您正在处理较大的项目,您无法避免选择较长的名称,以确保不会产生混淆。这是我们项目中的一个极端例子,我不是在开玩笑:CreateOrUpdateHabitantContractUnitPackageAction

一开始我们讨厌这个名字。我们拼命想找一个更短的。最后,我们不得不承认,类名的明确性 是最重要的。我们IDE的自动补全功能将会处理长名称带来的不便。

当我们解决了类的名称,下一个要解决的问题是给公共方法起名。一个选项是让类可调用(make class invokable), 例如:

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中会有额外的问题:当这样调用动作类时无法对参数进行自动补全。个人认为,这种用法是日常开发的一部分,不应该被忽略。 因为这个原因,我们团队决定不把动作类作为可调用。

另一个选项是使用handle, Laravel经常使用handle作为这类情况下的默认名称。这又是一个问题,特别是因为Laravel使用它。

每当Laravel让你使用handle时,在 jobs 和commands中,它还可以在依赖容器中进行方法注入。在我们的动作类中,我们想只有构造函数拥有依赖注射的能力。后面我们会解释原因。

所以handle也是不合适的。当我们开始使用action时,我们实际上对这个命名难题进行了大量的思考。最后我们决定使用execute。请记住,您可以自由地提出自己的命名约定:这里的重点是使用action的模式,而不是它们的名称

0x02 付诸实践

抛开所有术语,我们来谈论一下为什么动作是有用的和怎么使用它。

首先我们说一下可复用性。使用动作的一个小技巧是把它分为很多小块以便其他地方重复使用,同时还要在不超范围的情况下使它足够大。用我们的发票栗子说明:通过发票生成 PDF 是一件可能发生在程序中很多情况下的事情。当然发票生成的时候回创建 PDF,不过管理员有可能想在发送之前,预览或者编辑它。

这里有两个用例: 『创建一个发票』和『预览一个发票』很显然需要两个入口,两个 controller。另一面来说,在两种情况下都需要根据发票创建 PDF 。

当您开始花时间考虑应用程序实际要做什么时,您会注意到有许多操作可以重用。当然,我们还需要注意不要过度抽象我们的代码。复制粘贴一些代码通常比进行不成熟的抽象要好。

一条好的经验是:根据业务功能进行抽象,而不是根据技术属性(think about the functionality when making abstractions, instead of the technical properties of code)。即使在不同的场景中两个动作做了同样的事情,你也要注意不要太早的对他们进行抽象。

另一方面,有一些情况下抽象会有帮助。再拿我们发票 PDF 的栗子来说,当不仅发票需要生成 PDF 时 - 我们项目中就是如此,有理由使用一个通用的 GeneratePdfAction 作为接口,然后 Invoice来实现。

但是,实话来说,更多的情况下动作是针对用例,而不是复用。你可能会想在这种情况下,动作是没必要开销。但是,可复用性不是使用动作的唯一原因。实际上,最重要的原因和技术优势没有关系:动作让开发者按照更贴近真实世界的方式去思考,而不是代码方式去思考。

假设您需要更改创建发票的方式。一个典型的Laravel应用程序可能会将这个发票创建逻辑扩展到一个控制器和一个模型,可能是一个生成PDF的job,最后是一个发送发票邮件的事件监听器。你需要了解很多地方。我们的代码按照技术特性而不是其意义分布在各个地方。

actions减少了这种系统带来的认知负荷。如果您需要研究如何创建发票,可以直接从 action 类中开始。

不要搞错了:actions很可能与其他组件诸如异步任务事件监听器配合得很好,尽管这些任务和侦听器仅仅为actions的正常工作提供基础设施,而不是业务逻辑本身。这是一个很好的例子为什么我们把 Domain 和 application 层分开: 他们有不同的目的。

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

因为actions是几乎独立存在的小块代码,所以很容易对它们进行单元测试。在您的测试中,您不必担心发送虚假的HTTP请求、设置虚包等等。您可以简单地创建一个新操作,或者提供一些模拟依赖项,将所需的输入数据传递给它,并对其输出进行断言。

例如,CreateInvoiceLineAction:它将获取关于它将开发票的商品的数据,以及金额和期限;它将计算总价格、包含和不包含增值税的价格。您可以为这些内容编写健壮而简单的单元测试。

如果您的所有actions都经过了适当的单元测试,那么您可以非常自信地认为,应用程序需要提供的大部分功能实际上是按预期工作的。现在只需要对影响用户使用到地方加上单元测试即可。

组合动作(Composing actions)

我在前面简要提到过的actions的一个重要特性是,它们如何使用依赖项注入。因为我们使用构造函数来传递来自容器的数据,而execute方法来传递与上下文相关的数据;我们可以自由的一层层嵌套 actions。

你明白了。首先澄清一点,尽管多重依赖是我们要避免的(它使代码复杂并且高度依赖彼此),在有些场景依赖注入是非常有用的。

再次用CreateInvoiceLineAction举例说明它需要计算增值税价格。根据上下文,一个开票行(invoice line)应该有一个含税和不含税价格。 计算带税价格不太重要,所以我们不想 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中。CreateInvoiceAction也有其他依赖项,例如CreatePdfActionSendMailAction

你会发现组合可以保持较小action的情况下,又能以一种清晰可维护的方式完成复杂的业务逻辑。

0x04 Action替代品

这里有两种设计模式可以不用考虑动作。

熟悉DDD的人会知道第一个:commandshandlersactions是它们的简化版本。当commandshandlers区分需要发生什么和如何发生时,actions将这两个职责合并为一个。命令总线确实比操作提供了更多的灵活性。另一方面,它也需要您编写更多的代码。

对于我们项目的范围来说,将actions拆分为commandshandlers太过了。我们可能不需要那点灵活性,在这上面会需要更多的编程时间。

第二种模式是事件驱动系统(event driven systems).如果你用过事件驱动,你可能会认为actions被直接调用增加了耦合性。再次说明一点:事件驱动系统有更多的灵活性,但是对于我们的项目有点大材小用。另外事件驱动添加了一层间接层,增加了阅读代码的复杂度。虽然这种间接性确实带来了好处,但带来更多的维护成本。


我希望大家明白,我并不是说我们已经把所有问题都解决了,并为所有Laravel项目找到了完美的解决方案。我们没有。当您继续阅读本系列文章时,务必注意项目的具体需求。虽然您可能能够使用这里提出的一些概念,但是您可能还需要一些其他解决方案来解决某些方面。

对我们来说,actions是正确的选择,因为它们提供了适当的灵活性、可重用性,并显著降低了认知负荷。它们封装了应用程序的本质。实际上,它们可以与DTOModels一起被认为是项目的真正核心。

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