Или, иными словами, работа со сложными отношениями между базой данных и моделями 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.
