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
@foreach ($sessions as $session) <tr> <td>{{ $session->created_at }}</td> <td>{{ $session->user->name }}</td> </tr> @endforeach
@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 user() { return $this->belongsTo(User::class); }
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::all(); return view('sessions.index', compact('sessions'); }
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');
}
public function index() { $sessions = Session::with('user')->get(); 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
@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
@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'));
}
public function index() { $posts = Post::with('tags')->get(); // а не просто Post::all()! return view('posts.index', compact('posts')); }
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'));
}
public function index() { $polls = Poll::with('votes')->get(); return view('polls', compact('polls')); }
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
@foreach ($polls as $poll) <b>{{ $poll->question }}</b> ({{ $poll->votes()->count() }}) <br /> @endforeach
@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)...
{{ $poll->votes()->get()->count() }} //или @foreach ($poll->votes()->get() as $vote)...
{{ $poll->votes()->get()->count() }}
//или
@foreach ($poll->votes()->get() as $vote)...

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

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

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

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

{{ $payment->user->name }}
{{ $payment->user->name }}
{{ $payment->user->name }}

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

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

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

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

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

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

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

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

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

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

 

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

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

@foreach ($posts as $post)
@foreach ($post->comments->where('approved', 1) as $comment)
{{ $comment->comment_text }}
@endforeach
@endforeach
@foreach ($posts as $post) @foreach ($post->comments->where('approved', 1) as $comment) {{ $comment->comment_text }} @endforeach @endforeach
@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);
}
public function comments() { return $this->hasMany(Comment::class); } public function approved_comments() { return $this->hasMany(Comment::class)->where('approved', 1); }
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();
$posts = Post::with(‘approved_comments’)->get();
$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
@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
@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;
}
public function getPriceAttribute() { $price = $this->messages ->where('price is not null') ->sortByDesc('id') ->first(); if (!$price) return 0; return $price->price; }
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
@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
@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

 

Автор: Povilas Korop
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.