Может войдёшь?
Черновики Написать статью Профиль

Eloquent и Blade: советы по повышению производительности

перевод Eloquent blade

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

Сценарий 1. Загрузка отношения belongsTo(): не забудьте про «жадную загрузку»

Типичный случай — вы перебираете записи через @foreach, и, в каком-то столбце, вам нужно показать родительскую запись с определенным полем.

@foreach ($sessions as $session)
<tr>
  <td>{{ $session->created_at }}</td>
  <td>{{ $session->user->name }}</td>
</tr>
@endforeach

И, конечно, Session принадлежит User, в app/Session.php :

public function user()
{
    return $this->belongsTo(User::class);
}

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

Неправильный Контроллер:

public function index()
{
    $sessions = Session::all();
    return view('sessions.index', compact('sessions');
}

Правильный:

public function index()
{
    $sessions = Session::with('user')->get();
    return view('sessions.index', compact('sessions');
}

Видите разницу? Мы загружаем отношения с основным запросом Eloquent. Это и называется «жадная загрузка».

Если мы этого не сделаем, то в нашем шаблоне Blade в цикле foreach будет делаться SQL запрос для каждого сессии, запрашивая пользователя непосредственно из базы данных. И, если у вас есть таблица со 100 сессиями, то получится 101 запрос — 1 для списка сессий и 100 для получения пользователей.

Так что, не забывайте про «Жадную Загрузку»!

Сценарий 2. Загрузка отношений hasMany()

Другая типичная ситуация — вам нужно вывести все дочерние записи в родительском цикле

@foreach ($posts as $post)
<tr>
  <td>{{ $post->title }}</td>
  <td>
    @foreach ($post->tags as $tag)
      <span class="tag">{{ $tag->name }}</span>
    @endforeach
  </td>
</tr>
@endforeach

Догадайтесь, что будем применять здесь? Правильно, «жадную загрузку»! Без неё, для каждого Post, будет отдельный запрос к базе данных.

Поэтому в контроллере делаем так:

public function index()
{
    $posts = Post::with('tags')->get(); // а не просто Post::all()!
    return view('posts.index', compact('posts'));
}

Сценарий 3. НЕ использовать скобки в отношениях hasMany()

Представим, что у вас есть голосование и нужно показать его пункты с количеством голосов.

Разумеется вы делаете «жадную загрузку» в контроллере:

public function index()
{
    $polls = Poll::with('votes')->get();
    return view('polls', compact('polls'));
}

А затем, в файле Blade, выводите:

@foreach ($polls as $poll)
    <b>{{ $poll->question }}</b>
    ({{ $poll->votes()->count() }})
    <br />
@endforeach

Выглядит вроде неплохо, да? Но, обратите внимание на скобки в ->votes(). Если вы оставите это так, то для каждого пункта голосования по прежнему будет делаться отдельный запрос к базе. Потому что это не получение уже загруженных данных отношения, а вызов метода Eloquent.

Лучше сделайте так: {{ $poll->votes->count() }}. Без скобок.

И, кстати, это же относится и к отношениям belongsTo. Не используйте скобки при загрузке отношений в Blade.

Оффтоп : просматривая StackOverflow, я видел примеры и похуже. Например:

{{ $poll->votes()->get()->count() }}
//или
@foreach ($poll->votes()->get() as $vote)...

Протестируйте это через Laravel Debugbar и посмотрите количество SQL запросов.

Сценарий 4. А если отношения пустые?

Одна из самых распространенных ошибок в Laravel - «trying to get property of non-object», получали такую в своих проектах? (да ладно, не обманывайте)

Обычно это происходит примерно так:

<td>{{ $payment->user->name }}</td>

Нет никакой гарантии, что User этого Payment все еще существует. Быть может он был подвергнут «мягкому удалению». Или в базе данных отсутствует внешний ключ, что позволило кому-то удалить пользователя безвозвратно.

Решение этой проблемы зависит от вашей версии Laravel. До Laravel 5.7 показ дефолтного значения был таким:

{{ $payment->user->name or 'Anonymous' }}

Начиная с Laravel 5.7 синтаксис пришёл в соответствие с оператором PHP 7:

{{ $payment->user->name ?? 'Anonymous' }}

А вы знаете, что можно назначить дефолтное значение на уровне Eloquent?

public function user()
{
    return $this->belongsTo(User::class)->withDefault();
}

Метод withDefault() вернет пустую модель класса User, если отношение не существует.

Более того, вы можете заполнить эту дефолтную модель нужными значениями!

public function user()
{
    return $this->belongsTo(User::class)
      ->withDefault(['name' => 'Anonymous']);
}

Сценарий 5. Как избежать запросов с дополнительными отношениями в шаблонах Blade

Знаком такой код в Blade?

@foreach ($posts as $post)
    @foreach ($post->comments->where('approved', 1) as $comment)
        {{ $comment->comment_text }}
    @endforeach
@endforeach

Итак, вы фильтруете комментарии (конечно «жадная загрузка», так ведь? так?) через условие where(‘approved’, 1).

И это работает и даже не вызывает проблем с производительностью, но мои личные принципы (а также принципы MVC) говорят о том, что логика должна быть вне шаблонов, а где-то в «логическом» слое. Это может быть сама модель Eloquent, где вы можете указать отдельное отношение для утвержденных комментариев в app/Post.php.

public function comments()
{
    return $this->hasMany(Comment::class);
}

public function approved_comments()
{
    return $this->hasMany(Comment::class)->where('approved', 1);
}

И уже после этого вы загружаете эти конкретные отношения в Контроллере/Blade:

$posts = Post::with(‘approved_comments’)->get();

Сценарий 6. Как избежать сложных условий с помощью метода Читателя (Accessor)

Недавно в одном проекте у меня была задача: перечислить вакансии, со значком конверта для сообщений и с указание оплаты, которую следует взять из ПОСЛЕДНЕГО сообщения, содержащего эту сумму. Звучит сложно, и это так есть. Вообще жизнь штука сложная!

Для начала я сделал так:

@foreach ($jobs as $job)
    ...
    @if ($job->messages->where('price is not null')->count())
        {{ $job->messages->where('price is not null')->sortByDesc('id')->first()->price }}
    @endif
@endforeach

Ужас! Нужно проверить, существует ли оплата, затем взять последнее сообщение с этой оплатой, но ... Чёрт, этому не место в шаблонах.

В итоге я использовал метод Читателя (Accessor) в Eloquent и сделал это в app/Job.php:

public function getPriceAttribute()
{
    $price = $this->messages
        ->where('price is not null')
        ->sortByDesc('id')
        ->first();
    if (!$price) return 0;

    return $price->price;
}

Конечно, в таких сложных ситуациях легче сразу перейти к проблеме запроса N+1 или просто делать запросы несколько раз. Поэтому не забывайте использовать Laravel Debugbar для поиска проблем.

Также могу порекомендовать пакет Laravel N+1 Query Detector.

Бонус

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

@foreach($user->payments()->get() as $payment)
<tr>
    <td>{{$payment->type}}</td>
    <td>{{$payment->amount}}$</td>
    <td>{{$payment->created_at}}</td>
    <td>
        @if($payment->method()->first()->type == 'PayPal')
            <div><strong>Paypal: </strong>
            {{ $payment->method()->first()->paypal_email }}</div>
        @else
            <div><strong>Card: </strong>
            {{ $payment->payment_method()->first()->card_brand }} **** **** ****
            {{ $payment->payment_method()->first()->card_last_four }}</div>
        @endif
    </td>
</tr>
@foreach

Как вы считаете, полезен ли этот материал? Да Нет

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

OlegD

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

Если говорить про представление или генерацию шаблонов, то в идеале там вообще не должно быть никакой обработки данных (манипуляции с данными), только ее отображение, на то оно и представление и не должно знать о данных.

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

Работая с моделями на выходе всегда стоит коллекция и объект(ы). Мы обращаемся через →. Но если по каким-то причинам мы изменим их на другой вид, например массив, или наоборот, то придется переделывать логику отображение данных, точнее ее работу.

Если ли разница в производительности при работе с коллекциями и объектами и и работе с массивами?

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

Slavik

Что ты за чушь написал

SeaSnake

По поводу collection vs array, например — https://www.reddit.com/r/laravel/comments/44a2p2/laravel_collection_vs_array_performance/

Логично, что коллекция будет отъедать перфоманс — за удобство приходится платить. Но опять таки, как правильно пишут люди, если вы перебираете коллекцию на миллион записей, у вас что-то неправильно в sql.

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

Да и в целом, многие понимают паттерн MVC слишком буквально как по мне. Холивары «тонкий контроллер-толстая модель» и наоборот гремят с неослабевающей силой. Но как по мне, тонкими должны быть оба — single responsibility. А контроллер распределяет логику между сервисами (компонентами), ивентами и пр. частями приложения и отдает уже полученные от них данные в виде переменных во вьюху. Напрямую контроллер у меня не часто «знает» про модель.

Что касается «жадных загрузок», вьюх, Eloquent — вообще если данные будут использоваться в цикле (не только вьюха), то конечно только «жадная». Миллион не миллион, но даже например 500 обращений к БД в цикле — уже караул какой-то (а 500 ведь не предел). Но это понимание наверное придет с практикой, некоторые например запихивают какой-то select в цикл, вместо того чтоб выбрать нужные данные перед циклом и потом копаться уже в массиве или коллекции. Хотя ситуации бывают разные как говорится =)

Slavik

Видно что разбираешься, хоть кто то тут понимает паттерны классов

artoodetoo

ИМХО, совершенно недопустимо изменять данные в Представлении. У кого-то может возникнуть такой соблазн, типа, для "оптимизации". Счётчик какой-нибудь посчитать и сохранить. Но не надо так делать! Потому что это портит логику и усложняет сопровождение.

Что же до "обращения к базе" в смысле операций чтения, в идеале да, они должны все происходить до обращения к Представлению. Но мы же сами добиваемся абстракции, когда чтение свойства объекта может неявно сопровождаться каким-то действием. Представление понятия не имеет делали мы eager loading или нет. Оно просто использует объект. Короче, врядли удастся избежать select в 100% случаев. По причине именно "объектности", "принципа чёрного ящика". Мы не всегда знаем как что-то работает внутри. Мы знаем только внешний эффект.

Недавно автор статьи Povilas опубликовал свой приём по внедрению данных в шаблон: через инъекцию сервиса (@inject), который рендерит свою вьюху и возвращает её как текст для вставки. Внутри такого сервиса очевидно будут операции с базой и как по мне это не очень страшно, если они не изменяют состояние.

Calculations in Blade, without violating MVC? Use Service Injection

Slavik

что? Это полная херня! Это уже не программирование это еретизм. У тебя разные слои приложения смешиваются.

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

Написать комментарий

Разметка: ? ?

Авторизуйся, чтобы прокомментировать.