Если Вы ещё не читали первую часть, самое время это сделать.

Итак, продолжаем!

Оптимизация ленивой загрузки

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

Представьте себе, что у Вас есть 10 различных моделей. Каждая модель определяет несколько отдельных коллекций MediaCollections и каждая из которых также определяет превью для отображения на странице. По разным причинам код контроллера и т.д. не может быть общим (да и не должен быть в любом случае). Пакет работает с одним большим медиа-релейшеном, загружающим все вложенные медиа-объекты использующие магию Collection в фоновом режиме для их разделения. Жадная загрузка всех отношений может быстро превратиться в проблему на индексной странице, где указана модель с кучей MediaCollections. Ведь, единственное, что нам нужно на ней - это вывод превью. Для решения этой проблемы можно применить ограничение условий запроса. Например, такое:

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

Хотя это и решает проблему перегрузки, но выглядит не очень красиво. Теперь повторим это ещё несколько раз. Фу! На самом деле правильное решение этой проблемы до безумия простое. Подумайте, что именно Вы хотите "жадно" загружать. Поняли? Просто создайте класс реализации 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 в нижней части класса LoadThumbnail. Это пригодится Вам если захотите определить жадную загрузку одного отношения за раз с помощью последовательных вызовов with((new LoadThumbnail)->toArray()). Эта техника настолько проста в исполнении, что выглядит словно читерство. Пожалуйста, не перегружайте выборку и следите за тем, чтобы из БД по Pipeline возвращалось минимальное количество данных. Лень - не оправдание!

Invokable аксессоры

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

  • сохранение в памяти результата вычислений;

  • отсутствие толстых моделей;

  • тестируемые блоки;

  • возможность компоновки, как и любого другого объекта.

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

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.

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

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

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

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

  • Она должна принадлежать модели Directory;

  • collection_name должно быть файлом.

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

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

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

Применённые практики

  • Если пользовательский интерфейс становится сложным, следует использовать модель, доступную только для чтения;

  • Глобальные скоупы не всегда плохи;

  • Такие модели позволяют производить тонкую настройку в соответствии с теми случаями использования, которые они должны поддерживать;

  • Этот подход можно использовать и в том случае, если пакет не позволяет переопределить использую им "базовую модель". Достаточно создать собственную модель, ссылающуюся на таблицу пакета, и начать решать проблемы.

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

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

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

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

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

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

Простой способ решить эту проблему - использовать метод 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], который автоматически удалит все релейшены модели при сериализации джобы:

#[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
    }
}

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

Заключение

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

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

Комментарии (0)