Кастомные отношения

Как сделать свое собственное отношение в Ларавел

Или, иными словами, работа со сложными отношениями между базой данных и моделями Laravel.

Недавно мне пришлось столкнуться с проблемой производительности на одном из наших крупных Laravel-проектов. Быстро расскажу о ситуации.

Нужно, чтобы администратор мог видеть всех пользователей системы в таблице. Один из столбцов этой таблицы должен отображать активные контракты (Contract) для каждого пользователя (Person).

Отношение между Contract и Person следующее:

Contract > HabitantContract > Habitant > Person

Не хочу тратить много времени на объяснение того, как мы пришли к этой иерархии отношений. Важно знать, как мы её используем: в Contract может быть несколько Habitants, связанных через сводную модель HabitantContract; и каждый Habitant имеет отношение к одному Person.

Поскольку мы показываем всех пользователей, то делаем в нашем контроллере:

class PeopleController
{
    public function index() 
    {
        $people = PersonResource::collection(Person::paginate());

        return view('people.index', compact('people'));
    }
}

Это упрощенный пример. Я просто хочу, что бы вы поняли суть. В идеале наш класс ресурсов должен выглядеть так:

/** @mixin \App\Domain\People\Models\Person */
class PersonResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'name' => $this->name,

            'active_contracts' => $this->activeContracts
                ->map(function (Contract $contract) {
                    return $contract->contract_number;
                })
                ->implode(', '),

            // …
        ];
    }
}

Обратите особое внимание на отношение Person::activeContracts. Как можно заставить это работать?

Сначала можно использовать отношение HasManyThrough, но помните, что в иерархии отношений мы находимся на 4 уровне. Кроме того, я считаю, что HasManyThrough сбивает с толку.

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

Последнее решение, которое мне пришло на ум — загрузить всех пользователей, все контракты и вручную замапить их. В итоге именно это я и сделал, но очень чистым способом: через кастомные отношения (custom relations).

Занырнём.

Настройка модели Person

Поскольку мы хотим, чтобы $person->activeContracts работало точно так же, как и любое другое отношение, то вот что нам нужно сделать: добавить метод отношений в нашу модель, как и любое другое отношение.

class Person extends Model
{
    public function activeContracts(): ActiveContractsRelation
    {
        return new ActiveContractsRelation($this);
    }
}

Больше здесь ничего делать не нужно. Мы, конечно, только начали, ведь нужно реализовать этот ActiveContractsRelation!

Кастомный класс отношений

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

Глядя на существующие классы отношений в Laravel, мы узнаем, что есть одно базовое отношение, которое ими всеми управляет: Illuminate\Database\Eloquent\Relations\Relation. Что означает — нам нужно реализовать некоторые абстрактные методы.

class ActiveContractsRelation extends Relation
{
    /**
     * Set the base constraints on the relation query.
     *
     * @return void
     */
    public function addConstraints() { /* … */ }

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param array $models
     *
     * @return void
     */
    public function addEagerConstraints(array $models) { /* … */ }

    /**
     * Initialize the relation on a set of models.
     *
     * @param array $models
     * @param string $relation
     *
     * @return array
     */
    public function initRelation(array $models, $relation) { /* … */ }

    /**
     * Match the eagerly loaded results to their parents.
     *
     * @param array $models
     * @param \Illuminate\Database\Eloquent\Collection $results
     * @param string $relation
     *
     * @return array
     */
    public function match(array $models, Collection $results, $relation) { /* … */ }

    /**
     * Get the results of the relationship.
     *
     * @return mixed
     */
    public function getResults() { /* … */ }
}

Doc-блоки помогают нам в нашем путешествии, хотя всё равно не всегда ясно, что происходит. Но нам снова повезло — у Laravel ведь уже есть классы отношений, которые мы можем рассмотреть.

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

Абстрактный конструктор Relation требуется как для eloquent-класса Builder, так и для родительской модели, к которой относится отношение. Builder предназначен для использования в качестве базового объекта запроса для связанной модели Contract, в нашем случае.

Поскольку мы создаем класс отношений специально под себя, то нет необходимости настраивать конструктор. Вот как он выглядит:

class ActiveContractsRelation extends Relation
{
    /** @var \App\Domain\Contract\Models\Contract|Illuminate\Database\Eloquent\Builder */
    protected $query;

    /** @var \App\Domain\People\Models\Person */
    protected $parent;

    public function __construct(Person $parent)
    {
        parent::__construct(Contract::query(), $parent);
    }

    // …
}

Обратите внимание, что добавляем $query как к Contract так и к классу Builder. Это позволяет IDE обеспечить лучший автокомплит, например кастомные скоупы, определенные в классе модели.

Мы сделали наше отношение: оно будет запрашивать модели Contract и использовать модель Person качестве родителя. Переходим к построению нашего запроса.

Вот откуда метод addConstraints. Он будет использоваться для настройки базового запроса. Настроит наш запрос отношений по нашим потребностям. Это место, где будет основная бизнес-логика:

  • Нам нужны только активные контракты
  • Мы хотим загружать только активные контракты, которые принадлежат указанному лицу ($parent нашего отношения)
  • Возможно мы захотим использовать жадную загрузку для некоторых отношений, но об этом позже

Вот как выглядит addContraints на данный момент:

class ActiveContractsRelation extends Relation
{
    // …

    public function addConstraints()
    {
        $this->query
            ->whereActive() // Скоуп к модели Contract
            ->join(
                'contract_habitants', 
                'contract_habitants.contract_id', 
                '=', 
                'contracts.id'
            )
            ->join(
                'habitants', 
                'habitants.id', 
                '=', 
                'contract_habitants.habitant_id'
            );
    }
}

Я думаю, что вы знаете как работает команда join. Подытожу, что здесь происходит: мы создаем запрос, который загрузит все contracts и их habitants через сводную таблицу contract_habitants, отсюда и две команды join.

Еще одно ограничение заключается в том, что нужны только активные контракты, для этого мы можем просто использовать скоуп модели Contract.

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

Для этого используются addEagerConstraints, initRelation и match. Давайте посмотрим на них по очереди.

Сначала метод addEagerConstraints. Он позволяет нам модифицировать запрос для загрузки всех контрактах, связанных с нужными пользователями. Помните, что мы хотим сделать только два запроса, а потом связать их результаты.

class ActiveContractsRelation extends Relation
{
    // …

    public function addEagerConstraints(array $people)
    {
        $this->query->whereIn(
            'habitants.contact_id', 
            collect($people)->pluck('id')
        );
    }
}

Поскольку мы уже джойнили таблицу habitants, то этот метод довольно прост: мы загружаем только те контракты, которые принадлежат предоставленной группе людей.

Далее initRelation. Опять же, всё довольно просто: его цель — инициализировать пустое отношение activeContract в каждой модели Person, чтобы впоследствии его можно было заполнить.

class ActiveContractsRelation extends Relation
{
    // …

    public function initRelation(array $people, $relation)
    {
        foreach ($people as $person) {
            $person->setRelation(
                $relation, 
                $this->related->newCollection()
            );
        }

        return $people;
    }
}

Обратите внимание, что свойство $this->related устанавливается родительским классом Relation, это чистый экземпляр модели нашего базового запроса, другими словами пустая модель Contract:

abstract class Relation
{
    public function __construct(Builder $query, Model $parent)
    {
        $this->related = $query->getModel();
    
        // …
    }
    
    // …
}

Наконец, мы приходим к основной функции, которая решит нашу проблему: объединение всех пользователей и контрактов вместе.

class ActiveContractsRelation extends Relation
{
    // …

    public function match(array $people, Collection $contracts, $relation)
    {
        if ($contracts->isEmpty()) {
            return $people;
        }

        foreach ($people as $person) {
            $person->setRelation(
                $relation, 
                $contracts->filter(function (Contract $contract) use ($person) {
                    return $contract->habitants->pluck('person_id')->contains($person->id);
                })
            );    
        }

        return $people;
    }
}

Давайте посмотрим, что здесь происходит: с одной стороны, у нас есть массив родительских моделей, пользователей; с другой стороны, у нас есть коллекция контрактов, результат запроса, выполненного нашим классом отношений. Цель функции match — связать их вместе.

Как это сделать? Все не так уж и сложно: перебрать в цикле всех пользователей и найти все контракты, принадлежащие каждому из них, на основе habitants, связанных с этим контрактом.

Готово? Ну… есть еще одна проблема. Поскольку мы используем отношение $contract->habitants, то мы должны убедиться, что оно также загружено жадно, в противном случае мы просто передвинули проблему n+1, вместо ее решения. Итак, вернемся к методу addEagerConstraints.

class ActiveContractsRelation extends Relation
{
    // …

    public function addEagerConstraints(array $people)
    {
        $this->query
            ->whereIn(
                'habitants.contact_id', 
                collect($people)->pluck('id')
            )
            ->with('habitants')
            ->select('contracts.*');
    }
}

Добавляем вызов with для жадой загруки habitants и обратите внимание на select. Мы должны указать сборщику запросов Laravel, выбирать данные только из таблицы contracts, иначе связанные данные habitants будут объединены в модели Contract, что приведет к неправильным идентификаторам.

Наконец, нам нужно реализовать метод getResults, который просто выполняет запрос:

class ActiveContractsRelation extends Relation
{
    // …

    public function getResults()
    {
        return $this->query->get();
    }
}

Вот и всё! Наше кастомное отношение может использоваться, как и любое другое отношение в Laravel. Элегантное решение сложной проблемы в стиле Laravel.

Автор: Brent
Перевод: Demiurge Ash