Laravel: Эффективный Eloquent

Источник: «Effective Eloquent»
Приготовьтесь повысить уровень Laravel навыков с помощью этого руководства по запросам Eloquent! Вы узнаете всё, что вам нужно знать, от начальных до продвинутых техник.

Для начала давайте сделаем шаг назад и подумаем, что такое Eloquent. Eloquent — это Объектно-Реляционное Сопоставление (Object Relational Mapper) для Laravel и Построитель Запросов (Query Builder). Вы можете использовать ORM для работы с Моделями Eloquent для быстрых и эффективных запросов к вашей базе данных. Вы также можете использовать Построитель Запросов (Query Builder) через фасад базы данных и создавать запросы вручную.

Что мы будем говорить сегодня? У меня есть приложение, над которым я работал для своего доклада Laracon EU, это банковское приложение — я знаю, это интересно. Но оно предоставляет некоторые интересные возможности, когда дело доходит до запроса данных.

Данные, с которыми мы работаем; Пользователь (User) может иметь много Аккаунтов (Accounts). На каждом Аккаунте хранится текущий баланс. У каждого Аккаунта есть много Транзакций (Transaction), Транзакции связаны между Аккаунтом и Продавцом (Vendor), а также сумма, на которую была совершена сделка. Вы можете посмотреть SQL диаграмму для визуального представления.

SQL диаграмма

В моём выступлении для Laracon я делаю что-то конкретное с запросами, поскольку оно фокусируется на самом API. Однако есть множество других запросов, которые мы могли бы использовать, поэтому давайте пройдёмся по ним.

Получение всех учётных записей пользователя, вошедшего в систему, мы можем написать довольно просто:

use App\Models\Account;

$accounts = Account::where('user_id', auth()->id())->get();

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

Для этого мы сделаем первую часть нашего запроса статическим вызовом query.

use App\Models\Account;

$accounts = Account::query()->where('user_id', auth()->id())->get();

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

Вместо использования ORM мы могли бы использовать фасад БД для запуска этого запроса, что, конечно, требует меньше памяти и будет возвращаться на долю секунды быстрей. Вероятность того, что вы увидите разницу в скорости, очень мала, если только у вас нет больших наборов данных. Однако давайте посмотрим на этот запрос.

use Illuminate\Support\Facades\DB;

$accounts = DB::table('accounts')->where('user_id', auth()->id())->get();

В моих тестах фасад БД использовал гораздо меньше памяти, но это потому, что он возвращает коллекцию объектов. Принимая во внимание, что запрос ORM вернёт коллекцию Моделей, которые необходимо построить и сохранить в памяти. Таким образом, мы платим за удобство наличия Модели Eloquent.

Давайте двигаться дальше. В моём примере у меня есть контроллер, который запускает запрос и возвращает результат. Я уже упоминал, что этот запрос можно повторно использовать в других областях приложений. Так что же мне нужно сделать, чтобы убедиться, что я могу контролировать эти запросы более глобально? Классы запросов спешат на помощь!

Это шаблон, который я использую довольно часто, и вам следует хотя бы развлечься, если вы не собираетесь его применять. Этому трюку я научился в мире CQRS, где операции чтения классифицируются как Queries, а операции записи — как Commands. Что мне нравится в CQRS, так это его способность разделять логику между тем, о чём должен знать контроллер, и классом, предназначенным для простого запроса данных. Давайте посмотрим на этот класс.

final class FetchAccountsForUser implements FetchAccountsForUserContract
{
public function handle(string $user): Collection
{
return Account::query()
->where('user_id', $user)
->get();
}
}

Это типичный класс запроса, выполняющий только одну вещь, и в типично манере Стива он использует контракт/интерфейс, так что я могу переместить его в контейнер и разрешить запрос там, где нужно. Итак, теперь в нашем контроллере нужно запустить только следующее:

$accounts = $this->query->handle(
user: auth()->id(),
);

Какие преимущества мы получаем, делая так? Во-первых, мы выделяем логику в отдельный класс. Если объём того, как мы извлекаем Аккаунты для пользователя, изменится, мы легко можем обновить его в нашей кодовой базе.

Таким образом, при запросе данных в ваших приложениях в большинстве случаев вы заметите, что запросы не такие уж и динамичные. Да, значения, которые вы хотите передать, будут динамическими на основе ввода пользователей. Однако запросы будут меняться лишь иногда. Это верно лишь иногда; например, конечная точка API с параметрами включения отношений, фильтрации, сортировки результатом и т.д.

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

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

final class FetchTransactionForAccount implements FetchTransactionForAccountContract
{
public function handle(Builder $query, string $account): Builder
{
return $query->where('account_id', $account);
}
}

Затем мы вызовем это внутри нашего контроллера следующим образом:

public function __invoke(Request $request, string $account): JsonResponse
{
$transactions = $this->query->handle(
query: Transaction::query(),
account: $account,
)->get();
}

Мы можем добиться этого, передав Transaction::query() в контроллер и ссылку ID для Аккаунта. Класс запроса возвращает экземпляр построителя запросов, поэтому нам нужно вернуть результат. Этот упрощённый пример может не очень хорошо подчёркивать преимущества, поэтому я рассмотрю альтернативу.

Представьте, что у нас есть запрос, в котором мы всегда хотим возвращать набор отношений и применять области (scope). Например, мы хотим показать самые последние Аккаунты Пользователя с общим количеством транзакций.

$accounts = Account::query()
->withCount('transactions')
->whereHas('transactions', function (Builder $query) {
$query->where(
'created_at',
now()->subDays(7),
)
})->latest()->get();

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

Давайте посмотрим, как это работает в подходе с классом запроса.

final class RecentAccountsForUser implements RecentAccountsForUserContract
{
public function handle(Builder $query, int $days = 7): Builder
{
$query
->withCount('transactions')
->whereHas('transactions', function (Builder $query) {
$query->where(
'created_at',
now()->subDays($days),
)
});
}
}

Когда мы подошли к реализации этого:

public function __invoke(Request $request): JsonResponse
{
$accounts = $this->query->handle(
query: Account::query()->where('user_id', auth()->id()),
)->latest()->get();

// handle the return.
}

Немного чище, и, поскольку у нас есть специальный класс для основной части запроса — он легко повторяем.

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

Что если бы мы захотели перейти к использованию фасад БД во всём приложении? Внезапно у нас появляется много логики, которую нужно изменить во многих местах, и наши результаты становятся очень непредсказуемыми. Давайте посмотрим, как будет выглядеть этот запрос с использованием фасада БД.

$latestAccounts = DB::table(
'transactions'
)->join(
'accounts',
'transactions.account_id', '=', 'accounts.id'
)->select(
'accounts.*',
DB::raw(
'COUNT(transactions.id) as total_transactions')
)->groupBy(
'transactions.account_id'
)->orderBy('transactions.created_at', 'desc')->get();

Как насчёт того, чтобы разбить это на класс запроса?

final class RecentAccountsForUser implements RecentAccountsForUserContract
{
public function handle(Builder $query, int $days = 7): Builder
{
return $query->select(
'accounts.*',
DB::raw('COUNT(transactions.id) as total_transactions')
)->groupBy('transactions.account_id');
}
}

Тогда в нашей реализации это будет выглядеть так:

public function __invoke(Request $request): JsonResponse
{
$accounts = $this->query->handle(
query: DB::table(
'transactions'
)->join(
'accounts',
'transactions.account_id', '=', 'accounts.id'
)->where('accounts.user_id', auth()->id()),
)->orderBy('transactions.created_at', 'desc')->get();

// handle the return.
}

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

В целом, мы создали что-то похожее на области видимости (scope), но менее привязанное к самому Eloquent Builder.

Именно так мне нравится управлять запросами Eloquent в своих приложениях, поскольку это позволяет иметь повторяемы е части, которые можно тестировать изолированно от различных входящих параметров. Мне нравиться думать об этом как об эффективном подходе к составлению запросов, но он подходит не всем — моя недавняя статья с Мэттом Штауфером доказывает это! Всё, что я только что сделал, может быть достигнуто с помощью вспомогательных методов в моделях или даже областей запросов — но мне нравится, чтобы модели были лёгкими, а области — лёгкими и специфичными. Такое чувство, что этому здесь не место. Конечно, я могу ошибаться, и я всегда рад признать, что мой путь — не единственный способ приблизиться к этому.

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

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

Laravel: Рефакторинг контроллера

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

PHP: Редирект 301 и 302