编程

非正统 Eloquent

621 2023-01-14 00:55:00

Eloquent 是一款利器,广受人们的欢迎。它允许您轻松地执行数据库操作,同时维护一个易于使用的 API。正如 Fowler 在 PoEAA 中所描述的,实现主动记录(AR)模式是当今可用的最好的 AR 实现之一。

在这篇文章中,我想回顾一下我在尝试使用不同选项的过程中学到的一些技巧和窍门。例如,你有没有考虑过以这样或那样的方式分享你的 eager load?没有吗?那么我敢肯定你至少会学到一点点东西,所以一定要坚持到最后!

就像现存的每一种工具一样,Eloquent 也有一套自己的权衡。作为负责任的开发人员,我们应该始终意识到我们一直都在权衡取舍。如果你想了解更多关于 AR 及其设计理念的信息,我强烈推荐 Shawn McCool 的这篇文章。

可插入的查询范围(Tappable scope)

传统上,可重用的查询范围总是使用在目标模型本身中定义的 scopeXXX 语法、宏或专用的 Builder 类。前两种方法的问题在于,它们都依赖于不透明的运行时魔法,这使得(几乎)不可能获得 IDE 帮助。更糟糕的是,在宏注册的情况下,可能会发生命名冲突。然而,还有第四种方法——在我看来是更好的方法:可插入的查询范围(Tappable scope)。Tappable scope 是一种非常有价值的隐藏宝藏,但同时也完全不为大众所知,因为它在任何地方都没有记录。

使用例子解释

以以下控制器方法为例:

public function index(): Collection
{
    return Company::query()
        ->oldest()
        ->whereNull('user_id')
        ->whereNull('verified_at')
        ->get();
}

我们可以看到,它在 Builder 实例上应用了一些条件,并在不进行任何转换的情况下按原样返回结果。虽然这是一种非常有效的编写查询的方法,但它会泄露内部信息,where 子句不会告诉我们任何关于域语言的信息。或许该需求是:“创建一个端点,返回最老的孤立的(orphaned)并且未验证的(unverified)公司”。在这种情况下,孤立意味着一家公司在注册过程中被放弃,这是我们的领域专家使用的概念。这样我就能有所改进:

public function index(): Collection
{
    return Company::query()
        ->oldest()
        ->tap(new Orphan())
        ->tap(new Unverified())
        ->get();
}

这使得读起来就像需求本身,是吧。现在,如果我们迅速看看其中一个 Tappable scope:

final readonly class Orphan
{
    public function __invoke(Builder $builder): void
    {
        $builder->whereNull('user_id');
    }
}

就是这样!这种简单性允许我们以任何方式、形状或形式编写查询,而不限于使用特定的 trait 或污染 Eloquent 模型的东西。

现在想象一下,一个新的需求出现了,它需要一个全新的端点,该端点应该列出属于某个公司的孤立成员。在这一点上,我们可能会开始出汗,因为  Company  和 Member 之间没有共同点,但不要担心!Tappable scope 来救场了!让我们重新使用这个查询范围:

public function index(Request $request): Collection
{
    return Member::query()
        ->whereBelongsTo($request->company)
        ->tap(new Orphan())
        ->get();
}

这就是可插入查询范围(tappable scope)的能力。我认为还应该提到的是,这个例子要求两个模型都具有“孤立”的概念,这是启动注册过程后的放弃状态,由  nullable 的 user_id 字段表示。一旦用户链接到所有模型,注册就完成了。不用说,你不能只是去使用任何模型的任何范围。它必须得到支持数据库表的支持。

规范模式说明

您是否听说过规范模式并尝试过教条地应用它?正如你所知,教条是一切邪恶的根源。这种应用查询约束的方式提供了多种世界中最好的。

你是包作者吗?

可插入查询范围(tappable scope)对于那些希望与包一起共享可重用作用域的包作者来说尤其有用。让我们以 laravel-adjacency-list 为例。scopeIsRoot  可以按如下方式重构:

final readonly class IsRoot
{
    public function __invoke(Builder $builder): void
    {
        $builder->whereNull('parent_id');
    }
}

这种方法还解决了方法和 scope 名称冲突的问题,这要归功于简单地避免了早期框架中仍然可用的古老魔法。总之,可插入查询范围(tappable scope)的使用为 90% 的用例带来了正面效果。

不太全局的全局范围设置

原谅我,标题在断章取义时没有太大意义。人们普遍认为“全局范围不好,局部范围好”。原因是 Laravel 文档中关于全局查询范围使之看起来像是应用全局查询范围是唯一的方式,这对它们来说是一个巨大的不利影响,但事实远非如此。

不久前,我在思考这个常见的抱怨,然后我突然想到:如果你无视这个惯例,会发生什么?我看了一眼全局范围查询 API,很快意识到事实上不需要在 Eloquent 模型的 booted 生命周期方法中声明查询范围。事实上,没有任何限制!它们可以应用于服务提供者、中间件、Job 等领域,可能性无穷。然而,在我看来,最好的用途是与中间件结合使用。让我们来看看一个例子。

使用案例解释:国家限制

象一下,你正在开发一个像 IMDb 这样的应用,它有一个面向前台的公共网站和一个内部管理面板。其中一个要求可能是,只有当用户的国家/地区在某个白名单中可用时,才应向用户播放某些电影,否则,就好像该电影根本不存在一样。长话短说,你必须根据来源国来划分数据。然而,这一限制应仅适用于公共网站,而不适用于内部管理小组。实现这一要求的一种简单方法是利用不那么全局的全局查询范围。

首先,像往常一样创建全局查询范围:

final readonly class CountryRestrictionScope implements Scope
{
    public function __construct(private Country $country) {}

    public function apply(Builder $builder, Model $model): void
    {
        // pseudocode: do the actual country-based filtering here
        $builder->isAvailableIn($this->country);
    }
}

接下来,创建一个 HTTP 中间件,负责将查询范围应用到相关模型:

final readonly class RestrictByCountry
{
    public const NAME = 'country.restrict';

    public function __construct(private Repository $geo) {}

    public function handle(Request $request, Closure $next): mixed
    {
        $scope = new CountryRestrictionScope($this->geo->get());

        Movie::addGlobalScope($scope);
        Rating::addGlobalScope($scope);
        Review::addGlobalScope($scope);

        return $next($request);
    }
}

注意: 本例中的 Repository 可以是任何返回用户国家的东西,比如 laravel-geo.

最后,打开 web.php 路由文件,并将中间件应用到相关的路由组中:

$router->group([
    'middleware' => ['web', RestrictByCountry::NAME],
], static function ($router) {
    $router->resource('movies', Site\MovieController::class);
    $router->resource('ratings', Site\RatingController::class);
    $router->resource('reviews', Site\ReviewController::class);
	
    // Front-facing public website routes...
});

$router->group([
    'middleware' => ['web', 'auth'],
    'prefix' => 'admin',
], static function ($router) {
    $router->resource('movies', Admin\MovieController::class);
    $router->resource('ratings', Admin\RatingController::class);
    $router->resource('reviews', Admin\ReviewController::class);
	
    // Admin routes...
});

请密切注意,中间件只应用于公共网站路由。这具有以下含义:

  • 每当用户访问任何公共网站路由时时,内容都会根据国家/地区自动过滤。这可能导致无害的 404 页。
  • 每当新的功能请求需要添加新路由时,开发人员不必记住每个模型都应该根据用户的国家/地区进行过滤。这已经得到了解决,除非故意,否则不可能绕过这一限制。
  • 每当开发人员使用 REPL 比如  tinker 时,他们也不会措手不及,因为一个隐藏的、令人讨厌的全局查询范围正在改变查询。请记住,我们的全局查询范围并不那么全局。
  • 每当管理员访问内部管理面板时,他们总是会看到所有内容,无论其来源如何。这正是我们想要的。
  • 简单地说,如果我们接受全局查询范围的全局性质,同时思考精确的位置及其影响区域,我们实际上可以创造快乐的开发体验,同时消除未来潜在的挫折。这不一定令人恼火!

额外收获:与可插入查询范围

我们可以这么做:

final readonly class FileScope implements Scope
{
    public function __invoke(Builder $builder): void
    {
        $this->apply($builder, File::make());
    }

    /** @param File $model */
    public function apply(Builder $builder, Model $model): void
    {
        $builder
            ->where($model->qualifyColumn('model_type'), 'directory')
            ->where($model->qualifyColumn('collection_name'), 'file');
    }
}

__invoke 意在使 Scope 可插入,apply 是为了遵守 Scope 约束,这是(不那么全局的)全局查询范围的要求。

  • 你想在特定上下文中将其作为真正的全局查询范围吗?没问题。
  • 你想使用可插入方式将查询范围应该到特定的查询中吗?也没问题。

虚幻属性

在我最近参与的一个项目中,我不得不在谷歌地图、  LeafletMapbox 等交互式地图上显示大量标记。这些交互式地图根据 GeoJSON 规范接受一个几何类型列表。点(Point)类型正是我所需要的,它必须提供一个坐标属性,其中一个元组作为其值,分别表示 (lat,lon)。这里的问题是,坐标(coordinates )表示一个复合值,而数据在数据库中被扁平化为 addresses:id,latitude,longitude。表格是这样设计的,因为选择了管理面板为:Laravel Nova。如果你保持结构尽可能平坦,那么在 Nova 中处理记录创建会容易得多。我本可以在 Eloquent Resource(又名transformer)中简单地处理这个问题,但我内心好奇的程序员告诉我,应该有更好的方法。内心的我绝对是对的:有一种更好的方式,这要归功于——我称之为——虚幻属性。

示例解释: Coordinates

要解决这个问题,我们首先要创建一个表示地址坐标的(Coordinates)的 ValueObject:

final readonly class Coordinates implements JsonSerializable
{
    private const LATITUDE_MIN  = -90;
    private const LATITUDE_MAX = 90;
    private const LONGITUDE_MIN = -180;
    private const LONGITUDE_MAX = 180;

    public float $latitude;

    public float $longitude;

    public function __construct(float $latitude, float $longitude)
    {
        Assert::greaterThanEq($latitude, self::LATITUDE_MIN);
        Assert::lessThanEq($latitude, self::LATITUDE_MAX);
        Assert::greaterThanEq($longitude, self::LONGITUDE_MIN);
        Assert::lessThanEq($longitude, self::LONGITUDE_MAX);

        $this->latitude  = $latitude;
        $this->longitude = $longitude;
    }

    public function jsonSerialize(): array
    {
        return [$this->latitude, $this->longitude];
    }

    public function __toString(): string
    {
        return "({$this->latitude},{$this->longitude})";
    }
}

接下来,我们该定义 AsCoordinates 对象强制转换:

final readonly class AsCoordinates implements CastsAttributes
{
    public function get(
        $model, 
        string $key,
        $value, 
        array $attributes,
    ): Coordinates {
        return new Coordinates(
            (float) $attributes['latitude'], 
            (float) $attributes['longitude'],
        );
    }

    public function set(
        $model, 
        string $key,
        $value, 
        array $attributes,
    ): array {
        return $value instanceof Coordinates ? [
            'latitude' => $value->latitude,
            'longitude' => $value->longitude,
        ] : throw new InvalidArgumentException('Invalid value.');
    }
}

最后,我们在 Address 模型中将其强制转换赋值:

final class Address extends Model
{
    protected $casts = [
        'coordinates' => AsCoordinates::class,
    ];
}

现在,我们可以在 Eloquent Resource 使用它了:

/** @mixin \App\Models\Address */
final class FeatureResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'geometry' => [
                'type' => 'Point',
                'coordinates' => $this->coordinates,
            ],
            'properties' => $this->only('name', 'phone'),
            'type' => 'Feature',
        ];
    }
}
  • Coordinates 现在完全负责坐标概念(表示地球上的二维坐标点)。
  • 因为接口的实现,我们不用自己调用 jsonSerialize()。由于 Larave l在调用栈中的某个位置调用 json_encode,因此它已帮我们处理了。
  • 如果坐标(Coordinates)有变,找到 Coordinates 的概念在哪里被使用就没那么必要了。

这就是我们的预期结果:

{
    "geometry": {
        "type": "Point",
        "coordinates": [4.5, 51.5]
    },
    "properties": {
        "name": "Acme Ltd.",
        "phone": "123 456 789 0"
    },
    "type": "Feature"
}

另一个示例说明:渲染地址行

使用虚幻属性的另一种方便方法是使用模板渲染。通常,如果您想将地址渲染为 HTML,则必须执行以下操作:

<address>
  <span>{{ $address->line_one }}</span>
  @if($two = $address->line_two)<span>{{ $two }}</span>@endif
  @if($three = $address->line_three)<span>{{ $three }}</span>@endif
</address>

正如你所见,它很快就会失控。虽然我确实认识到这是一个相当人为的例子,因为地址通常因为国家的不同而不同,但这有助于描绘一幅画面,即它可能很快就会变得一团糟。要是我们这样做呢:

<address>
  @foreach($address->lines as $line)
  <span>{{ $line }}</span>
  @endforeach
</address>

好多了,对吧?我们的模板不再因为于不同国家的不同规则,而突然变得多么复杂。它做它最擅长的事情:渲染。对此负责的虚拟属性可能如下所示:

final readonly class AsLines implements CastsAttributes
{
    public function get(
        $model, 
        string $key,
        $value, 
        array $attributes,
    ): array {
        return array_filter([
            $attributes['line_one'],
            $attributes['line_two'],
            $attributes['line_three'],
        ]);
    }

    public function set(
        $model, 
        string $key,
        $value, 
        array $attributes,
    ): never {
        throw new RuntimeException('Set the lines explicitly.');
    }
}

例如,如果我们必须将 line_two 和 line_three 换成南极洲,我们可以在 AsLines 中进行调整,而不必调整 blade 模板。开箱即用的思维可以极大地简化我们如何渲染 UI,并防止我们创建过于智能的 UI,而这些 UI 通常被认为是反模式的。

关于文档中可用内容的说明

这些属性不直接映射到数据库字段,与访问器和修改器(Accessors & Mutators)的组合相当。文档称之为 Value Object Casting,但我认为这是一种严重的误导,因为使用这种方法不需要将其强制转换为 ValueObject。原因是,除了我上面给出的例子之外,另一个用例可能是生成由段组成的产品编号。您可能希望生成并持久化 CA‑01‑00001 这样的值,但实际上将其保存在三个不同的字段中(number_country - number_department - number_sequence),以便更轻松地进行查询:

final readonly class AsProductNumber implements CastsAttributes
{
    public function get(
        $model, 
        string $key,
        $value, 
        array $attributes
    ): string {
        return implode('-', [
            $attributes['number_country'],
            Str::padLeft($attributes['number_department'], 2, '0'),
            Str::padLeft($attributes['number_sequence'], 5, '0'),
        ])
    }

    public function set(
        $model, 
        string $key,
        $value, 
        array $attributes,
    ): array {
        [$country, $dept, $sequence] = explode('-', $value, 2);

        return [
            'number_country' => $country,
            'number_department' => (int) $dept,
            'number_sequence' => (int) $sequence,
        ];
    }
}

不用说,您还应该创建一个跨越这三个字段的唯一的复合索引。负责此操作的虚幻属性将创建组合字符串值 CA‑01‑00001 ,而不是 ValueObject,因此它具有误导性。

Fluent 查询对象

我已经在第一部分中可插入查询范围中提到过自定义 Builder 类。虽然它们是朝着更具可读性和可维护性的查询迈出的第一步,但我认为,当需要向自定义 Builder 类添加大量自定义约束时,它们会很快崩溃。它们只是倾向于成为另一种类型的上帝对象。让 IDE 开始帮助您提供建议也是一件很麻烦的事情。您也可以为模型创建一个专用的存储库,但我非常不喜欢这个选项。在我看来,Repository  和 Eloquent 是相互排斥的——当然,我也知道这不是绝对的。但是,如果你知道为什么存在 ActiveRecord 和为什么存在 Repository,那么您就会理解我所说的。

另一种方案是使用 QueryObject。它是一个组合和执行单个查询类型的对象。虽然它与 Martin Fowler 在  PoEAA 中的定义不完全相符,但也很接近了

案例解释:通知中心

假设我们有一个 SPA,由 HTTP JSON API 提供支持,它的顶部有一个通知栏。后端公开了一个端点,我们可以使用它来检索登录用户的未读通知。负责检索未读通知的 Controller 方法可能如下所示:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::query()
        ->whereBelongsTo($request->user())
        ->latest()
        ->whereNull('read_at')
        ->get();

    return NotificationResource::collection($notifications);
}

这一切都很简单,直到收到一个新的功能请求,要求我们创建一个专用页面来管理所有通知:已读、未读、通知类型等。为了让前端开发人员的生活更轻松,我们决定为每个视图类型创建一个专用端点。其中一个负责检索读取通知,可能如下所示

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::with('notifiable')
        ->whereBelongsTo($request->user())
        ->latest()
        ->whereNotNull('read_at')
        ->get();

    return NotificationResource::collection($notifications);
}

眼尖的读者可能已经注意到了,除了 whereNotNull 子句及渴求式加载 notifiable 关联之外,这段代码和前面的很像。现在,我也需要为其他类型重复该动作:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::query()
        ->whereBelongsTo($request->user())
        ->latest()
        ->where('data->type', '=', $request->type)
        ->get();

    return NotificationResource::collection($notifications);
}

我想你已经明白了要点。这里有太多重复了,必须采取一些措施。进入 fluent 查询对象。首先,我们将创建负责“获取我的通知”的查询类:

final readonly class GetMyNotifications
{
}

接下来,我们将会把基础查询(需要一直应用的条件)移动到全新对象的 constructor:

final readonly class GetMyNotifications
{
    private Builder $builder;

    private function __construct(User $user)
    {
        $this->builder = Notification::query()
            ->whereBelongsTo($user)
            ->latest();
    }

    public static function query(User $user): self
    {
        return new self($user);
    }
}

现在,我们需要通过 ForwardsCalls trait 踏入组合的艺术:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    use ForwardsCalls;

    // omitted for brevity

    public function __call(string $name, array $arguments): mixed
    {
        return $this->forwardDecoratedCallTo(
            $this->builder, 
            $name, 
            $arguments,
        );
    }
}

观察:

  • ForwardsCalls 允许你将该类视为基础类 \Illuminate\Database\Eloquent\Builder 的一部分,即使继承没有在场。这就是组合的魅力。
  • @mixin 注解使得 IDE 可以提供自动补全建议 
  • 你也可以选择加入 Conditionable,使之更加 fluent API,不过由于我们设计选择(每个视图类型不同端点),此处并不是必须的。
  • 剩下的就只有自定义查询约束了,那么我们来添加吧:
/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    // omitted for brevity
	
    public function ofType(NotificationType ...$types): self
    {
        return $this->whereIn('data->type', $types);
    }

    public function read(): self
    {
        return $this->whereNotNull('read_at');
    }

    public function unread(): self
    {
        return $this->whereNull('read_at');
    }
	
    // omitted for brevity
}

很好,我们现在有了正确实现“获取我的通知”功能(即通知中心)所需的一切。因此,将所有东西缝合在一起:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    use ForwardsCalls;

    private Builder $builder;

    private function __construct(User $user)
    {
        $this->builder = Notification::query()
            ->whereBelongsTo($user)
            ->latest();
    }

    public static function query(User $user): self
    {
        return new self($user);
    }

    public function ofType(NotificationType ...$types): self
    {
        return $this->whereIn('data->type', $types);
    }

    public function read(): self
    {
        return $this->whereNotNull('read_at');
    }

    public function unread(): self
    {
        return $this->whereNull('read_at');
    }

    public function __call(string $name, array $arguments): mixed
    {
        return $this->forwardDecoratedCallTo(
            $this->builder, 
            $name, 
            $arguments,
        );
    }
}

我们来重构一下之前的 Controller:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = GetMyNotifications::query($request->user())
        ->read()
        ->with('notifiable')
        ->get();

    return NotificationResource::collection($notifications);
}

我不知道你是怎么想的,但这段优雅的代码看起来很漂亮。你可以继续使用所有常规的 Illuminate\Database\Eloquent\Builder 方法,同时也可以单独调用专用于这个独特查询的增强的特定方法。要点:

  • 关键概念封装在有意义的接口后面。
  • 遵守 SRP
  • 摸索框架及其工具,而不是像使用 Repository 一样与之对抗
  • 可在多个地方轻松重复使用
  • 易于测试

额外收获:与可插入查询范围结合

我们可以这么做:

final readonly class GetMyNotifications
{
    // omitted for brevity
	
    public function ofType(NotificationType ...$types): self
    {
        return $this->tap(new InType(...$types));
    }

    public function read(): self
    {
        return $this->tap(new Read());
    }

    public function unread(): self
    {
        return $this->tap(new Unread());
    }
	
    // omitted for brevity
}

许我们需要为一个不那么全局的用例创建这些查询范围。为了保持一致性,在这里重用它们是非常有意义的。你的想象力是这里唯一的限制因素。

使用 Pipeline 的注意事项

网络上有很多教程,展示了如何使用管道(Pipeline)来逻辑地拆分操作链,或者进行一些复杂的过滤。有些读者可能会认为处理自己的QueryObject 是在浪费时间。问题是,我不认为 PipelineQueryObject 是互斥的。它们可以相互补充,相互帮助,更有效地完成任务。我们可以使用自定义 QueryObject 类型提示,而非在管道中使用 Builder 类型提示。本质上构建我们自己的 laravel-query-builder,但使用更具体的 API。

Pipeline 可能如下所示:

$orders = Pipeline::send(
    GetMyOrders::query($request->user())
)->through([
    Filter\Cancelled::class,
    Filter\Delayed::class,
    Filter\Shipped::class,
])->thenReturn()->get();

Pipe 可能如下:

final readonly class Cancelled
{
    public function __construct(private Request $request) {}

    public function handle(GetMyOrders $query, Closure $next): mixed
    {
        if ($this->request->boolean('cancelled')) {
            $query->cancelled();
        }

        return $next($query);
    }
}

合并不同的概念以达到最终目标并没有错。只要确保它对你当前的环境有意义,并且你没有引入意外的复杂性。

积极加载

这是一篇简明扼要但不可或缺的篇章(至少对我来说)。在某个时刻,你可能会发现自己在想,如何共享积极加载,尤其是那些进行了一些额外改进的负载,但最终仍然只是复制粘贴相关代码。虽然复制粘贴是一个非常有效的选择,但事实上有更好的方法来解决这个问题。由于应用了额外的查询约束,重复这些类型的积极加载可能会很快变得麻烦。例如,当使用 Spatie 的包 laravel-medialibrary 时,可能会出现这种情况。

假设你有 10 个不同模型。每个模型定义了多个、不同的 MediaCollection,同时每个模型也定义了一个 thumbnail,用以前端展示。出于多种原因,控制器代码等不能分享(无论如何也不应该分享)。该包有一个大的 media 关联,加载了所有的 Media 对象并且在后台使用 Collection 魔法对其进行分割,在索引页积极加载整个 Media 关联很快就会碰到问题,因其加载了很多的 MediaCollection. 毕竟,在索引页中我们只需要模型的缩略图(thumbnail)。为了解决此问题,我们可以像这样使用查询约束:

public function index(): View
{
    $products = Product::with([
        'categories',
        'media' => static function (MorphMany $query) {
            $query->where('collection_name', 'thumbnail')
        },
        'variant.media' => static function (MorphMany $query) {
            $query->where('collection_name', 'thumbnail')
        },
    ])->tap(new Available())->get();

    return $this->view->make('products.index', compact('products'));
}

虽然这确实解决了过度查询的问题,但看起来并不好看。现在再重复 9 次。好烦!事实上,正确解决这个问题非常非常简单的。首先,想想你想积极加载什么?缩略图。然后,创建一个表示此约束的类:

final readonly class LoadThumbnail implements Arrayable
{
    public function __invoke(MorphMany $query): void
    {
        $query->where('collection_name', 'thumbnail');
    }

    public function toArray(): array
    {
        return ['media' => $this];
    }
}

现在可以这样简单使用:

public function index(): View
{
    $products = Product::with([
        'categories',
        'media' => new LoadThumbnail(),
        'variant.media' => new LoadThumbnail(),
    ])->tap(new Available())->get();

    return $this->view->make('products.index', compact('products'));
}

太棒了,对吧?您可能还注意到底部的 toArray。如果您想通过连续的 with((new LoadThumbnail)->toArray()) 调用一次定义一个积极加载的关联,那么这将非常有用。这种技术执行起来如此简单,几乎是不公平的。请不要过度查询,并确保从数据库返回的数据最少。懒惰不是借口!

可调用(Invokable)访问器

我们已经讨论过虚幻属性等技术。如果你还没有读过那一节,请先读一遍,然后再回到这一节。无论如何,Phantom 属性最大的缺陷是,它要求我们定义一个 set(inbound) 强制转换,即使我们不会使用它,就像 $address->lines 示例一样;并且它没有自动记忆计算结果的机制。令人沮丧的是,没有 CastsOutboundAttributes,但这正是可调用访问器的优点。它的主要好处有:

  • 记忆化
  • 无模型杂乱
  • 可测试单元

可组合,就像任何其他对象一样

示例定义 :

final class File extends Model
{
    protected function stream(): Attribute
    {
        return Attribute::get(new StreamableUrl($this));
    }
}

这就是定义可调用访问器所需的全部内容。注意构造函数参数,因为这是一个具有可调用访问器的重复模式。访问正在使用的模型是必须的,否则我们将无法收集执行任务所需的上下文信息。在这个例子中,StreamableUrl 负责——你猜对了——生成可流化的 URL。我们本可以内联逻辑并使用经典的闭包方式,但这会很快开始填充我们的模型。这段代码来自的实际模型有十四个其他访问器(!)。窥探这个特定的可调用访问器:

final readonly class StreamableUrl
{
    private const S3_PROTOCOL = 's3://';

    public function __construct(private File $file) {}

    public function __invoke(): string
    {
        $basePath = UuidPathGenerator::getInstance()
            ->getPath($this->file);

        if ($this->file->supports('s3')) {
            return self::S3_PROTOCOL 
                . $this->file->bucket 
                . DIRECTORY_SEPARATOR 
                . $basePath 
                . $this->file->basename;
        }

        return Storage::disk($this->file->disk)
            ->url($basePath . rawurlencode($this->file->basename));
    }
}

确切的细节并不那么重要,但它正确地封装了生成优化的可流式 URL 的逻辑。对于来自 s3 的流式文件,直接返回 s3:// 路径要高效得多。

要点是,想象一下,如果我们在模型本身的访问器中的传统闭包中定义了这段代码,并对其他 13 个访问器进行了类似的操作。我们的模型很快就会变得过于拥挤。使用可调用的访问器还允许我们提取出这样的代码,并保持我们的模型干净整洁。

同一个表多次读取模型

这是我真正理解 Laravel Nova 限制的罕见时刻之一,与其他管理面板解决方案相比,它通常很难定制。它让我发现了一个新颖的只读模型用例。

最近,我们收到了一个功能请求,要求我们创建一个成熟的文件导出器,以便与第三方和客户共享文件。推出我们自己的文件管理解决方案是不可能的,因为1)这是一个已解决的问题,2)这是一个复杂而边缘化的问题。我们决定选择 laravel medialibrary,但有一个巨大的障碍需要克服。我们必须在 Nova 的 Directory 资源下创建一个用户友好界面,该界面将容纳属于该特定目录的文件,并且必须是可排序的。虽然默认的 Media 模型做得很好,但它与 Nova 最流行的排序库(同样来自 Spatie)不兼容。我们不得不想出一个独创的解决方案。就在那时,我突然想到要为 media 创建一个只读模型,并对该理论进行测试:

final class File extends Model implements Sortable
{
    use SortableTrait;

    public array $sortable = [
        'order_column_name' => 'order_column';
    ];

    protected $table = 'media';

    public function buildSortQuery(): Builder
    {
        return $this->newQuery()->where($this->only('model_id'));
    }
}

虽然这看起来已经很有希望,但还有另一个障碍必须克服。此模型可用于查询 media 表中的任何内容,可能导致非预期的数据丢失。当然,这是不可接受的,因为这个模型专门表示一个  File,它实际上是一个满足两个标准的 Media 对象:

  • 它必须属于模型 Directory
  • collection_name 必须是 file

我决定创建一个真正的全局范围查询,并且在 ServicProvider 中强制配置这些规则(另外一个真正全局范围查询有意义的罕见时刻):

final class FileScope implements Scope
{
    /** @param File $model */
    public function apply(Builder $builder, Model $model): void
    {
        $builder
            ->where($model->qualifyColumn('model_type'), 'directory')
            ->where($model->qualifyColumn('collection_name'), 'file');
    }
}

不再返回不符合这些标准的模型,开始抛出 ModelNotFoundException。这正是我们想要的。完美,但我们还不能宣布胜利。Nova 接口需要一堆信息,这些信息根本无法从默认 Media 模型中提取。但后来我再次想到:既然这是我们的定制模型,我可以做任何我想做的事!我甚至可以在 Directory 模型中将其声明为一个关联:

public function files(): HasMany
{
    return $this->hasMany(File::class, 'model_id')->ordered();
}

你注意到什么“奇怪”的东西了吗?没有吗?看看关联类型。如果你知道 MediaLibrary 是如何工作的,你就会知道 media 表实际上使用了 MorphMany 关联。但是,由于我们定义了一个全局 FileScope,它总是微调 model_type 上的查询,所以我们可以简单地单独使用 HasMany 关联类型,一切都正常工作。这是我大吃一惊的时候。调用 $directory->files 现在将返回 File 对象的集合,而不是 Media 对象。长话短说,File 现在拥有了服务于 FileSharing 上下文所需的一切。我们不需要更改任何配置或其他东西——什么都不需要。只是一些聪明和新颖的方法。最终的结果非常良好。

例如,我还可以添加一堆(可调用的)访问器来满足 UI 需求:

// other accessors ommitted, there's simply too many

protected function realpath(): Attribute
{
    return Attribute::get(new Realpath($this));
}

protected function stream(): Attribute
{
    return Attribute::get(new StreamableUrl($this));
}

protected function extension(): Attribute
{
    return Attribute::get(fn () => $this->type->extension());
}

protected function type(): Attribute
{
    return Attribute::get(fn () => FileType::from($this->mime));
}

要点:

  • 当 UI 方面的情况变得复杂时,应该使用只读模型。
  • 全局查询范围并不总是坏的。
  • 这些模型允许根据用例需要进行微调。

如果包不允许覆盖它所使用的“基本模型”,也可以使用此方法。只需创建自己的模型引用包中的表格,就能解决问题。

WithoutRelations 改进队列性能

最后,我想谈论的是神秘的 WithoutRelations 属性或 WithoutRelations 方法。热心和眼尖的 Laravel 包的源代码探索者在浏览源代码时可能已经注意到了一些用法。事实上,它确实被 Laravel Jetstream 用于 Livewire 组件。尽管这里使用它的原因是为了防止太多信息泄露到客户端,尽管这是完全有效的,但我不想谈论这个用例。

正如你可能已经知道的,如果您、你想将包含 Eloquent 模型的 Job 入队,应该使用 SerializesModels trait。(文档中简要描述了它的用途,所以我不重复了。)但有一个问题是很多开发人员都不知道的:SerializesModels 同时记住了在序列化时加载了哪些关联,并在模型反序列化时使用这些信息重新加载所有关联。有效载荷示例:

{
    "user": {
        "class": "App\\Models\\User",
        "id": 10269,
        "relations": ['company', 'orders', 'likes'],
        "connection": "mysql",
        "collectionClass": null
    }
}

如你所见,relationships 属性包含三个关联。这些将在该作业的反序列化时积极加载。点赞和订单这样的关联可能会带来数百甚至数千条记录,极大地损害 Job 的性能。更糟糕的是,我从中获取这个快照的 Job 甚至不需要任何这些关联来执行其主要任务。

使用方法

解决这个问题的一个简单方法是——在将 Eloquent 模型传递给 Job 造函数之前,使用 withoutRelations 方法。例如:

final class AccessIdentitySubscriber
{
    public function subscribe(Dispatcher $events): void
    {
        $events->listen(
            Registered::class, 
            $this->whenRegistered(...),
        );
    }

    private function whenRegistered(Registered $event): void
    {
        CreateProspect::dispatch($event->user->withoutRelations());
    }
}

每当新用户在我们的应用中注册时,此事件订阅者负责在不同的 CRM 系统中创建新的潜在客户。在调度 CreateProspect 之前,会调用withoutRelations,以确保没有无用的关联被序列化,从而确保最佳性能。如果我们现在检查序列化的有效负载,我们可以看到数组已经被清空:

{
    "user": {
        "class": "App\\Models\\User",
        "id": 10269,
        "relations": [],
        "connection": "mysql",
        "collectionClass": null
    }
}

使用属性

在准备这篇博客文章时,我发现 Laravel 的一位开发人员贡献了一个全新的 #[WithoutRelations] 属性,该属性在 Job 序列化时自动负责剥离所有模型关联:

#[WithoutRelations]
final class CreateProspect implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use SerializesModels;

    public function __construct(private User $user) {}

    public function handle(Gateway $crm): void
    {
        // omitted for brevity
    }
}

这肯定是我定义 Job 的新默认方式。我也不知道你的情况,但我没有任何用例对自己说“该死,我应该别管关联”。这种行为引入的隐藏 bug 比任何东西都多(根据我的经验)。大多数时候,懒惰加载可以很好地完成任务。记住,没有坏工具。只有在特定的环境中才是坏工具。这就是为什么我不太喜欢新的 Model::preventLazyLoading 功能。

总结

好奇驱使我们称为更好的程序员,因此走出教程,开始实践吧。

原文:https://muhammedsari.me/unorthodox-eloquent