Неортодоксальный Eloquent

Источник: «Unorthodox Eloquent»
Я работаю с Eloquent уже более пяти лет, и пришло время поделиться своим опытом. Пристегните ремни, это будет хорошая поездка!

Eloquent — это хорошо отточенный инструмент, полюбившийся многим. Он позволяет с лёгкостью выполнять операции с базами данных, сохраняя при этом простой в использовании API. Реализуя паттерн Active Record (AR), описанный Fowler в книге PoEAA, он является одной из лучших реализаций AR, доступных на сегодня.

В этой статье я хотел бы рассказать несколько советов и рекомендаций, которые я узнал, экспериментируя с различными опциями. Например, задумывались ли вы когда-нибудь о том, чтобы тем или иным образом разделить свои жадные загрузки? Нет? Тогда я уверен, что вы узнаете как минимум кое-что новое, поэтому обязательно дочитайте до конца!

Как и все существующие инструменты, Eloquent имеет свой набор компромиссов. Как ответственные разработчики, мы должны всегда помнить о том, на что мы идём. Если вы хотите узнать больше об AR и философии её разработки, я очень рекомендую статью Shawn McCool.

Переключаемые диапазоны

Традиционно многократно используемые диапазоны запросов всегда определялись в самой целевой модели с помощью магического синтаксиса scopeXXX, макросов или выделенного класса Builder. Проблема с первыми двумя подходами заключается в том, что они оба опираются на непрозрачную магию времени выполнения, что делает (почти) невозможным получение подсказок от IDE. Ещё хуже то, что в случае регистрации макросов может возникнуть конфликт имён. Однако существует и четвёртый, на мой взгляд, более эффективный, подход: использование переключаемых диапазонов. Переключаемые диапазоны — это одна из тех скрытых жемчужин, которые чрезвычайно ценны, но в то же время совершенно неизвестны широким массам, поскольку нигде не документированы.

Пояснение на примере

В качестве примера рассмотрим следующий метод Controller:

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

Мы видим, что он применяет некоторые условия к экземпляру Builder и возвращает результат как есть, без каких-либо преобразований. Хотя такой способ написания запроса вполне допустим, он приводит к утечке внутренних данных, а условия where ничего не говорят о языке домена. Возможно, требование было таким: Создать конечную точку, возвращающую самые старые осиротевшие и неверифицированные компании. Осиротевшая компания в данном случае означает, что компания была брошена во время регистрации, и именно этим понятием пользуются наши доменные эксперты. Таким образом, мы можем сделать гораздо лучше:

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

Это почти похоже на само требование, верно? Теперь, если мы быстро взглянем на один из этих переключаемых диапазонов:

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

Вот и всё! Такое упрощение позволяет нам составлять запросы в любой форме и не ограничиваться использованием какого-либо конкретного трейта или того, что загрязняет наши модели Eloquent.

Теперь представьте, что появилось новое требование, требующее создания совершенно новой конечной точки, которая должна перечислять осиротевших участников, принадлежащих к определённой компании. В этот момент мы можем начать потеть, потому что нет никакой общности между Company и Member, но не стоит расстраиваться! На помощь приходят переключаемые диапазоны! Давайте просто повторно используем диапазон и на этом закончим:

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

В этом и заключается мощь переключаемых диапазонов. Следует также отметить, что в данном примере обе модели должны обладать концепцией "сиротства", представляющей собой состояние покинутости после начатого процесса регистрации, о чем сигнализирует нулевая колонка user_id. Регистрация завершается, когда пользователь связан со всеми моделями. Само собой разумеется, что нельзя просто взять и использовать любой диапазон с любой моделью. Он должен поддерживаться таблицей базы данных.

Примечание к шаблону Specification

Слышали ли вы когда-нибудь о шаблоне Specification и пытались ли догматически применить его? Как известно, догма — корень всех зол. Этот способ применения ограничений запроса предлагает лучшее из многих миров.

Вы автор пакета

Переключаемые диапазоны особенно полезны для авторов пакетов, которые хотели бы использовать многократно используемые диапазоны вместе со своими пакетами. В качестве примера возьмём laravel-adjacency-list. scopeIsRoot может быть отрефакторен следующим образом:

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

Такой подход также решает проблему несовпадения имён методов и диапазонов, благодаря простому отказу от древней магии, сохранившейся во фреймворке с первых дней его существования. В целом, использование переключаемых диапазонов даёт положительный результат в 90% случаев.

Не-совсем-глобальные глобальные диапазоны

Я знаю, что название не имеет большого смысла, если читать его вне контекста, но, пожалуйста, потерпите. Время от времени в моей ленте X (бывший Twitter) появляется несколько сообщений о глобальных диапазонах. Общий смысл всегда сводится к тому, что глобальные диапазоны — это плохо, локальные — хорошо. Причина в том, что документация Laravel по глобальным диапазонам делает им огромное одолжение, создавая впечатление, что задокументированный способ применения глобальных диапазонов является единственным, но на самом деле всё обстоит совсем не так.

Некоторое время назад я размышлял над этой распространённой жалобой, и тут меня осенило: а что будет, если пренебречь этим соглашением? Я взглянул на API глобальных диапазонов и быстро понял, что на самом деле объявлять диапазоны в методе booted жизненного цикла модели Eloquent необязательно. Более того, ограничений нет вообще! Они могут быть применены в ServiceProvider, Middleware, Job и т.д. Возможности безграничны. Однако наилучшее применение, на мой взгляд, — в сочетании с middleware. Итак, давайте рассмотрим пример.

Пояснение на примере: ограничение по странам

Представьте себе, что вы работаете над приложением, подобным IMDb, имеющим публичный сайт и внутреннюю панель администрирования. Одним из требований может быть то, что определённые фильмы должны показываться пользователям только в том случае, если страна пользователя есть в некотором белом списке, в противном случае фильм как будто бы вообще не существует. Короче говоря, необходимо разделить данные на основе страны происхождения. Однако это ограничение должно распространяться только на публичный сайт, а не на внутреннюю панель администрирования. Лёгкий способ реализовать это требование — использовать не-совсем-глобальные глобальные диапазоны.

Сначала создайте глобальный диапазон, как обычно:

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

public function apply(Builder $builder, Model $model): void
{
// псевдокод: здесь выполняется фактическая фильтрация по странам
$builder->isAvailableIn($this->country);
}
}

Далее создадим HTTP middleware, в обязанности которого будет входить применение диапазона к соответствующим моделям:

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 и примените middleware на соответствующей группе:

$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);

// Маршруты, проложенные через публичный сайт...
});

$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);

// Маршруты администрирования...
});

Обратите внимание на то, что middleware применяется только к публичным маршрутам сайта. Это имеет следующие последствия:

Говоря кратко, если принять глобальную природу глобальных диапазонов и подумать о точном размещении и области действия, то можно получить удовольствие от разработки и устранить возможные разочарования в будущем. Это не обязательно должно вызывать раздражение!

Бонус: комбинирование с переключаемыми диапазонами

Ничто не мешает делать подобные вещи:

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, что является обязательным условием для (не-совсем-глобальных) глобальных диапазонов.

Фантомные свойства/Phantom properties

В одном из недавних проектов, над которым я работал, требовалось отобразить огромное количество маркеров на интерактивной карте типа Google Maps, Leaflet или Mapbox. Эти интерактивные карты принимают список геометрических типов в соответствии со спецификацией GeoJSON. Тип Point, который мне и был нужен, должен предоставлять свойство coordinates, значением которого является кортеж, представляющий (lat,lon) соответственно. Проблема заключается в том, что coordinates представляют собой составное значение, в то время как в базе данных данные представлены в виде addresses:id,latitude,longitude. Таблица была спроектирована таким образом из-за выбранной панели администрирования: Laravel Nova. В Nova гораздо проще работать с созданием записей, если структура данных максимально плоская. Я мог бы просто решить эту проблему в Eloquent Resource (он же transformer), но любопытный программист подсказал мне, что должен быть лучший способ. Внутреннее "я", безусловно, было право: лучший способ существует благодаря тому, что я называю фантомными свойствами.

Пояснение на примере: coordinates

Для решения поставленной задачи сначала необходимо создать объект ValueObject, который будет представлять Coordinates адреса:

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',
];
}
}

Вот что мы получили в итоге, как и ожидалось:

{
"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. Нестандартное мышление может значительно упростить процесс рендеринга пользовательских интерфейсов и предотвратить создание слишком "умных" интерфейсов, что, как правило, не приветствуется и считается антипаттерном.

Примечание, что доступно в документации

Эти свойства не связаны напрямую с колонками базы данных и сравнимы с комбинацией 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,
];
}
}

Само собой разумеется, что необходимо также создать уникальный составной индекс, охватывающий эти три столбца. Фантомное свойство, отвечающее за это, создаст составное значение stringCA-01-00001, а не ValueObject, поэтому оно вводит в заблуждение.

Объекты свободного запроса

Я уже упоминал о пользовательских классах Builder в первом разделе о переключаемых диапазонах. Хотя они и являются первым хорошим шагом на пути к более читаемым и удобным в обслуживании запросам, я считаю, что они быстро разваливаются, когда в пользовательские классы Builder необходимо добавить большое количество пользовательских ограничений. Они просто превращаются в ещё один тип Божественного объекта. Кроме того, очень сложно довести IDE до такого состояния, чтобы она начала помогать вам с подсказками. Можно также создать специальный Repository для своих моделей, но мне этот вариант сильно не нравится. На мой взгляд, Repository и Eloquent — это взаимоисключающие понятия. Прежде чем вы возьмёте вилы, я знаю, что это не совсем так. Однако если вы знаете, почему существует ActiveRecord и почему существует Repository, то вы поймёте, к чему я веду. — Подробнее об этом можно прочитать здесь.

Другой альтернативой является использование так называемого QueryObject. Это объект, отвечающий за составление и выполнение одного вида запроса. Хотя это не полностью соответствует определению Martin Fowler в PoEAA, оно достаточно близко, и я считаю, что заимствование этого понятия для данной конкретной цели вполне допустимо. Если у вас есть подписка на Laracasts, то вы, возможно, уже видели доступный урок на эту тему. Несмотря на идентичность философии и образа мышления, я хотел бы представить альтернативный API, который намного, намного приятнее в использовании: объекты свободного запроса.

Пояснение на примере: центр уведомлений

Представьте себе, что у нас есть SPA, работающий с HTTP JSON API, в верхней части которого находится колокольчик уведомлений. На backend открыта конечная точка, которую мы можем использовать для получения непрочитанных уведомлений вошедшего в систему пользователя. Метод 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);
}

Думаю, вы поняли суть. Здесь слишком много повторений, и с этим надо что-то делать. На помощь приходят объекты свободного запроса. Сначала мы создадим класс запроса, который будет отвечать за "получение моих уведомлений":

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:

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

// опущено для краткости

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

Наблюдения:

Остались только пользовательские ограничения запроса, добавим их:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
// опущено для краткости

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');
}

// опущено для краткости
}

Замечательно. Теперь у нас есть всё, что нужно для правильной реализации функции "получать мои уведомления", она же центр уведомлений. Итак, сшиваем всё воедино:

/** @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 и в то же время иметь возможность вызывать расширенные, специфические методы, предназначенные только для этого отдельного запроса. Выводы:

Бонус: комбинирование с переключаемыми диапазонами

Нам ничто не мешает делать подобные вещи:

final readonly class GetMyNotifications
{
// опущено для краткости

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());
}

// опущено для краткости
}

Возможно, нам нужно было создать эти диапазоны для не-совсем-глобального случая использования. Тогда имеет смысл повторно использовать их здесь, чтобы сохранить согласованность. Единственным ограничивающим фактором здесь является ваше воображение.

Примечание по использованию Pipeline

На YouTube можно найти множество руководств, показывающих, как можно использовать Pipeline для логического разделения цепочки операций или выполнения сложной фильтрации. Некоторые читатели могут подумать, что работа с собственным QueryObject — это пустая трата времени. Однако я не считаю, что Pipeline и QueryObject — это взаимоисключающие понятия. Они могут дополнять друг друга и помогать друг другу выполнять задачу более эффективно. Вместо того чтобы указывать тип Builder в пайпах, мы можем указывать тип наших пользовательских QueryObject. По сути, мы создаём собственный 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);
}
}

Нет ничего плохого в объединении различных концепций для достижения конечной цели. Просто убедитесь, что это имеет смысл в текущем контексте и что вы не вносите непреднамеренных сложностей.

Разделение нетерпеливых загрузок

Это краткая, но незаменимая (для меня, по крайней мере) статья. В какой-то момент вы, вероятно, задались вопросом, как можно разделить нетерпеливые загрузки, особенно выполняющие дополнительную доработку, но, тем не менее, в итоге просто скопировать соответствующий код. Хотя копи-паст — это вполне приемлемый вариант, на самом деле существуют более эффективные способы решения этой проблемы. Повторение подобных нетерпеливых загрузок может быстро стать громоздким из-за применения дополнительных ограничений запроса. Это может произойти, например, при использовании фантасмагорического пакета laravel-medialibrary от Spatie.

Представьте себе, что у вас есть 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 раз. Фу! На самом деле правильное решение этой проблемы очень и очень простое. Сначала подумайте, что именно вы хотите подвергнуть нетерпеливой загрузке. Поняли? LoadThumbnail. Затем создайте класс, представляющий это ограничение:

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 accessors

Мы уже говорили о таких приёмах, как Фантомные свойства. Если вы ещё не читали тот раздел, то, пожалуйста, сначала прочитайте его, а затем вернитесь к этому. В любом случае, самый большой недостаток Фантомных свойств заключается в том, что они требуют от нас определения set (входящего) каста, даже если мы не будем его использовать, как в примере $address->lines; а также в отсутствии механизма, автоматической мемоизации. Неприятно, что нет CastsOutboundAttributes, но именно здесь и проявляются преимущества вызываемых аксессоров. Основными преимуществами являются:

Пример определения (кстати, Attribute::get существует):

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

Это всё, что требуется для определения вызываемого аксессора. Обратите внимание на аргумент constructor, так как это повторяющийся паттерн для вызываемых аксессоров. Необходимо получить доступ к используемой модели, иначе мы не сможем собрать контекстную информацию, необходимую для выполнения наших задач. В данном примере StreamableUrl отвечает, как вы уже догадались, за генерацию потоковых URL-адресов. Мы могли бы встроить эту логику и использовать классический способ Closure, но это привело бы к быстрому заполнению модели. В реальной модели, из которой взят этот фрагмент, есть ещё четырнадцать аксессоров (!). Наглядный пример этого конкретного вызываемого аксессора:

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.

Главное, представьте себе, если бы мы определили этот код внутри традиционного Closure в аксессоре самой модели, а также сделали бы нечто подобное для остальных тринадцати аксессоров. Наша модель очень быстро стала бы переполненной. Использование вызываемых аксессоров также позволяет удалить такой код и сохранить чистоту и порядок в модели.

Несколько моделей чтения одной таблицы

Это один из тех редких моментов, когда я действительно оценил ограничения Laravel Nova, которую, как правило, сложно настроить по сравнению с другими решениями для панелей администрирования. Это позволило мне открыть для себя новый вариант использования моделей, доступных только для чтения.

Недавно поступил запрос на создание полноценного файлового эксплорера для обмена файлами с третьими лицами и клиентами. О создании собственного решения для управления файлами не могло быть и речи, так как 1. это уже решённая задача и 2. это сложная и небезупречная задача. Мы решили использовать laravel-medialibrary (как и любой другой здравомыслящий человек, спасибо Spatie!), но при этом нам предстояло преодолеть огромное препятствие. Нам нужно было создать в Nova удобный UX-интерфейс под ресурсом Directory, в котором будут храниться File, принадлежащие данному 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, удовлетворяющим двум критериям:

Я решил создать настоящий глобальный диапазон и зарегистрировать его в ServiceProvider для постоянного применения этих правил (ещё один редкий случай, когда действительно глобальный диапазон имеет смысл):

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 теперь возвращал Collection File, а не Media. Короче говоря, File теперь обладал всем необходимым для обслуживания контекста FileSharing. Нам не нужно было изменять ни конфигурацию, ни что-то ещё — ничего. Просто немного смекалки и новых подходов. Конечный результат был просто превосходным.

Например, я мог бы также добавить кучу (вызываемых) аксессоров, чтобы удовлетворить потребности пользовательского интерфейса:

// другие аксессоры опущены, их просто слишком много

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));
}

Выводы

WithoutRelations для производительности очереди

И последняя, но не менее важная тема, о которой я хотел бы поговорить, — это загадочный атрибут WithoutRelations или метод WithoutRelations. Заядлые и зоркие исследователи исходных текстов пакетов Laravel, возможно, уже заметили его использование при просмотре исходного кода. Действительно, он используется в компоненте Livewire в Laravel Jetstream. Правда, здесь он используется для того, чтобы предотвратить утечку слишком большого количества информации на сторону клиента, что, хотя и вполне оправданно, не является тем случаем, о котором я хотел бы рассказать.

Как вы, возможно, уже знаете, трейт SerializesModels следует использовать, если вы хотите вызывать Job, содержащий модели Eloquent. (Его назначение кратко описано в документации, поэтому я не буду повторяться). Но есть одна загвоздка, о которой многие разработчики не знают: SerializesModels также запоминает, какие отношения были загружены во время сериализации, и использует эту информацию для повторной загрузки всех отношений при десериализации моделей. Пример полезной нагрузки:

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

Как видно, свойство relations содержит три отношения. Они будут нетерпеливо загружены при десериализации данного Job. Такие отношения, как likes и orders, потенциально могут потянуть за собой сотни или даже тысячи записей, что сильно снизит производительность Job. Ещё хуже то, что Job, из которого я взял этот снапшот, даже не нуждался ни в одном из этих отношений для выполнения своей основной задачи.

Вариант метода

Простой способ решить эту проблему — использовать метод withoutRelations перед передачей моделей Eloquent в конструктор Jobs. Пример:

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
{
// опущено для краткости
}
}

Это определённо будет моим новым стандартным способом определения Job. Не знаю, как у вас, а у меня не было ни одного случая, когда бы я сказал себе: Черт возьми, надо было оставить отношения в покое. Такое поведение вносит больше скрытых ошибок, чем что-либо ещё (по моему опыту). В большинстве случаев ленивая загрузка прекрасно справляется со своей задачей. Помните, что плохих инструментов не бывает. Плохие инструменты бывают только в определённом контексте. Именно поэтому я не являюсь большим поклонником новой функции Model::preventLazyLoading. Извини, тёзка.

Подведение итогов

На данный момент мои пальцы онемели, но я думаю, что это того стоило. Любопытство делает вас лучшим программистом, так что выбирайтесь из ада учебников и начинайте экспериментировать. Поверьте, самое худшее, что может случиться, — это то, что вы научитесь. И, пожалуйста…. не забудьте прочитать о компромиссах Active Record. Самое худшее, что может случиться, — это опять же ваше обучение.

Присоединяйтесь к обсуждению на сайте X (бывший Twitter)! Я буду рад узнать, что вы думаете об этой статье.

Спасибо за внимание!

Дополнительные материалы

Предыдущая Статья

Новое в Symfony 6.4: Улучшения Serializer

Следующая Статья

Обновление синтаксиса CSS вложенности