在前面的章节中,我已经讨论了每个应用程序的三个核心构建块中的两个:DTO 和行动—即数据和功能。在本章中,我们将看一下我认为是这个核心部分的最后一部分:公开数据存储中持久化的数据;也就是说:模型。

现在,模型是一个棘手的主题。 Laravel 通过其 Eloquent 模型类提供了许多功能,这意味着它们不仅代表数据存储中的数据,它们还允许你构建查询,加载和保存数据,它们还具有内置的事件系统等等。

在本章中,我不会告诉你抛弃 Laravel 提供的所有模型功能——它确实非常有用。然而,我将列举一些你需要注意的陷阱和解决方案;因此,即使在大型项目中,模型也不会变得难以维护的原因。

我的观点是,我们应该拥抱这个框架,而不是试图对抗它;不过我们应该以这样一种使大型项目保持可维护性的方式来拥抱它。让我们一探究竟吧。

模型≠业务逻辑

许多开发人员遇到的第一个陷阱是,他们将模型视为涉及业务逻辑的地方。我已经列出了 Laravel 内置模型的一些职责,并且我建议不要增加任何其他内容。

能够执行 $invoiceLine->price_include_vat$invoice->total_price 之类的操作,一开始听起来很吸引人,而且确实如此。我实际上确实相信发票和发票行应具有这些方法。但是有一个重要的区别:这些方法不应该计算任何东西。让我们看看不应该做什么:

下面是我们的发票模型上的 total_price 访问器,循环遍历所有发票行并生成它们的总价格。

class Invoice extends Model
{
    public function getTotalPriceAttribute(): int
    {
        return $this->invoiceLines
            ->reduce(function (int $totalPrice, InvoiceLine $invoiceLine) {
                return $totalPrice + $invoiceLine->total_price;
            }, 0);
    }
}

下面是每行总价的计算方法。

class InvoiceLine extends Model
{
    public function getTotalPriceAttribute(): int
    {
        $vatCalculator = app(VatCalculator::class);
    
        $price = $this->item_amount * $this->item_price;

        if ($this->price_excluding_vat) {
            $price = $vatCalculator->totalPrice(
                $price, 
                $this->vat_percentage
            );
        }
    
        return $price;
    }
}

既然你已经阅读了关于上一章的行动,那么你可能会猜到我会怎么做:计算发票的总价是一个用户故事,应该用一个行动来表示。

Invoice InvoiceLine 模型可以具有简单的 total_priceprice_including _vat 属性,但它们首先通过行动计算,然后存储在数据库中。使用 $invoice->total_price 时,只需简单地读取之前已经计算过的数据。

这种方式有几个优点。首先,最明显的一点是:性能,你只需要计算一次,而不是每次需要数据时都要计算。第二,可以直接查询计算数据。第三:你不必担心副作用。

现在,我们可以开始一场纯粹的辩论,讨论单一职责如何帮助让你的类变得更小、更易于维护和更容易测试;以及依赖注入如何优于服务位置;但是,我更倾向于陈述显而易见的问题,而不是长时间的理论辩论,因为我知道只有两个方面不同意。

因此,显而易见的是:尽管你可能希望能够执行 $invoice->send()$invoice->toPdf(),但模型代码仍在不断增长。这是随着时间的推移而发生的事情,一开始似乎没什么大不了的。$invoice->toPdf() 实际上可能只有一两行代码。

但从经验来看,这一两行加起来。一行或两行不是问题,但一百倍于一行或两行就是个问题啦。实际情况是,模型类会随着时间的推移而增长,并且确实会变得相当大。

即使你不同意我对单一职责和依赖注入所带来的好处的看法,也没有什么可以反对的:一个有数百行代码的模型类不能保持可维护性。

所有这些都是说:考虑模型和它们的目的只是为你提供数据,让其他的东西来确保数据被正确计算。

缩小模型

如果我们的目标是使模型类保持合理的小—小到只要打开它们的文件就能理解它们—我们需要移动一些东西。理想情况下,我们只希望保留 getter 和 setter、简单的访问器和变式(mutators)、强制转换(casts)和关系定义。

其他的职责应该转移到其他类。查询作用域就是一个例子:我们可以很容易地将它们转移到专用的查询构建器类中。

信不信由你:查询构造器类实际上是使用 Eloquent 的正常方式;作用域只是在它们之上的语法糖。这就是查询构建器类的样子。

namespace Domain\Invoices\QueryBuilders;

use Domain\Invoices\States\Paid;
use Illuminate\Database\Eloquent\Builder;

class InvoiceQueryBuilder extends Builder
{
    public function wherePaid(): self
    {
        return $this->whereState('status', Paid::class);
    }
}

接下来,我们在模型中覆盖 newEloquentBuilder 方法,并返回我们的自定义类。 Laravel 自此就会使用它。

namespace Domain\Invoices\Models;

use Domain\Invoices\QueryBuilders\InvoiceQueryBuilder;

class Invoice extends Model 
{
    public function newEloquentBuilder($query): InvoiceQueryBuilder
    {
        return new InvoiceQueryBuilder($query);
    }
}

这就是我所说的拥抱框架的意思:你不需要引入新的模式,比如存储库本身,你可以在 Laravel 提供的基础上进行构建。仔细考虑一下,我们在使用框架提供的用具和防止代码在特定位置增长过大之间取得了完美的平衡。

使用这种思维方式,我们还可以为关系提供自定义集合类。Laravel 有很好的集合支持,不过通常在模型或应用程序层中都会有很长的集合函数链。这同样不理想,幸运的是,Laravel 为我们提供了将集合逻辑绑定到专用类中所需的钩子。

下面是一个自定义集合类的示例,请注意,完全可以将几个方法组合成新的方法,避免在其他地方出现长函数链。

namespace Domain\Invoices\Collections;

use Domain\Invoices\Models\InvoiceLines;
use Illuminate\Database\Eloquent\Collection;

class InvoiceLineCollection extends Collection
{
    public function creditLines(): self
    {
        return $this->filter(function (InvoiceLine $invoiceLine) {
            return $invoiceLine->isCreditLine();
        });
    }
}

下面是如何将集合类链接到模型;在本例中为 InvoiceLine

namespace Domain\Invoices\Models;

use Domain\Invoices\Collection\InvoiceLineCollection;

class InvoiceLine extends Model 
{
    public function newCollection(array $models = []): InvoiceLineCollection
    {
        return new InvoiceLineCollection($models);
    }

    public function isCreditLine(): bool
    {
        return $this->price < 0.0;
    }
}

现在,与 InvoiceLine 具有 HasMany 关系的每个模型都将改用我们的集合类。

$invoice
    ->invoiceLines
    ->creditLines()
    ->map(function (InvoiceLine $invoiceLine) {
        // …
    });

尽量保持模型的整洁和面向数据,而不是让它们提供业务逻辑。业务逻辑有更好的地方来处理它。

空袋子的虚无

我很感谢 Taylor Otwell 关注这个博客系列。上周他问如何避免我们的对象变成只是装满数据的空袋子,一个 Martin Fowler 写到 的反模式。

因为 Taylor 花时间在 Twitter 上问我有关问题,所以我认为我也可以在本章中添加我的回答,所有人都可以阅读该内容。

答案——我的答案——是双重的。首先:我不认为模型是装着普通旧数据的空袋子。通过使用访问器、赋值器和强制转换,它们在数据库中的普通数据和开发人员希望使用的数据之间提供了一个丰富的层。在本章中,我主张将其他一些职责转移到单独的类中,这是事实,但我相信,由于 Laravel 提供的所有功能,模型在其“修剪”版中仍然提供了比简单的数据包更多的价值。

其次,我认为有必要提一下 Alan Kay 对这个话题的看法(OOP 这个术语就是他提出来的)。他自己在这次演讲中说,他后悔称范例为“面向对象”,而不是“面向过程”。Alan 认为,他实际上是拆分过程和数据的支持者。

你是否同意这个观点取决于你自己。我承认我受到了 Alan 的一些观点的影响,你可能会在这个系列的博客中注意到这一点。就像我之前说的:不要把这个系列看作是软件设计的圣杯。我的目标是挑战你现在写代码的方式,让你思考是否有更好的方法来解决你的一些问题。

所以让我们确保继续讨论,我们可以发邮件讨论这个问题,或者在 Twitter 上讨论。


相关文章:
Custom query builders in Laravel
Custom Eloquent collections
Martin Fowler on anemic domain models
Alan Kay about his vision of OOP