Или, иными словами, работа со сложными отношениями между базой данных и моделями 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
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.