Laravel 5
Отношения и жадная загрузка в Eloquent
На основе статьи https://bosnadev.com (англ)
Предисловие
Прежде чем перейти к основной теме статьи, я хотел бы привести случай из практики. Один из моих клиентов пожаловался, что некоторые страницы его сайта загружаются слишком медленно. Я был в шоке, когда узнал причину: на каждой проблемной странице было 16500+ запросов к базе данных. Код состоял из 3 циклов foreach с запросами данных, связанных друг с другом в разных таблицах. Все работало более менее нормально до тех пор, пока количество записей не стало около 5500. Вот пример кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
$main_object = MainObject::all(); foreach($main_object as $object) { echo $object->some_property; foreach($object->related_object as $related) { echo $related->some_property; echo $related->another_property; } foreach($object->another_related as $another) { echo $another->some_property; echo $another->another_property; } } |
Очень часто программисты пишут такие неоптимальные запросы и при этом использование ORM лишь усложняет проблему, известную как проблема N+1. Думаю, предыдущий разработчик моего клиента не знал о том, что решение заключается в жадной загрузке (eager loading).
Что такое “жадная загрузка” (eager loading)?
Есть три подхода: жадная загрузка (eager loading), ленивая загрузка (lazy loading) и сверхжадная загрузка (over-eager loading).
Жадная загрузка – загружаем все, что может понадобиться, ленивая загрузка – загружаем только то, что требуется, сверхжадная загрузка – загружаем то, что требуется и то, что скорее всего понадобится. Поясним на конкретном примере: страница с выпадающим меню, в каждом пункте меню есть изображение, которое показывается при раскрытии. Отображение страницы может происходит в трех вариантах:
- Загрузка всех изображений до показа страницы (жадная загрузка)
- Загрузка только тех изображений, который будут сразу показаны, остальные будут загружены при необходимости (ленивая загрузка)
- Загрузка только тех изображений, который будут сразу показаны, затем будут подгружаться изображения, которые могут понадобиться (сверхжадная загрузка)
Другими словами, жадная загрузка делает все, что просят, в противоположность ленивой загрузке, которая делает только то, что необходимо. Давайте разбираться на примере в Laravel. Рассмотрим схему:
Это EER-модель (Enhanced entity–relationship model) с тремя взаимосвязанными сущностями. У каждого владельца (member) может быть много магазинов (store), но каждый магазин может принадлежать только одному владельцу. Каждый магазин может иметь несколько товаров (product), но каждый товар может принадлежать только одному магазину.
Cоздадим Eloquent модели для этих сущностей.
Владелец
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Member extends Model { /** * The attributes that are mass assignable. * * @var array */ protected $fillable = ['username', 'email', 'first_name', 'last_name']; public function stores() { return $this->hasMany('App\\Store'); } } |
Магазин
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Store extends Model { /** * The attributes that are mass assignable. * * @var array */ protected $fillable = ['name', 'slug', 'site', 'member_id']; public function member() { return $this->belongsTo('App\\Member'); } public function products() { return $this->hasMany('App\\Product'); } } |
Товар
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Product extends Model { /** * The attributes that are mass assignable. * * @var array */ protected $fillable = ['name', 'short_desc', 'long_desc', 'price', 'store_id', 'member_id']; public function store() { return $this->belongsTo('App\\Store'); } } |
Представьте, что пишите приложение, которая позволяет пользователям создавать свои собственные магазины. Конечно, они могут добавлять и товары. И пусть от нас требуется создать страницу со списком всех магазинов, для каждого из которых показан владелец и лучшие товары. Что-то вроде такого:
Первая попытка решения данной задачи может выглядеть следующим образом. Контроллер:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php namespace App\Http\Controllers; use App\Repositories\StoreRepository; class StoresController extends Controller { protected $stores; function __construct(StoreRepository $stores) { $this->stores = $stores; } public function index() { $stores = $this->stores->all(); return \View::make('stores.index')->with('stores', $stores); } } |
И вид (view):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@foreach($stores as $store) <h1>{{ $store->name }}</h1> <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br> <h2>Products:</h2> @foreach($store->products as $product) <h3>{{$product->name}}</h3> <span>{{$product->short_desc}}</span><br/><br/> <span>Price: {{$product->price}}</span> <br/> <?php Debugbar::info('Product displayed'); ?> @endforeach <br/> ======================== <br/> @endforeach |
Результат (в базе 5 владельцев, 3 магазина и 4 товара):
Сначала мы запрашиваем все магазины и это “+1” из “проблема N+1”. А “N” – это количество магазинов. Далее мы много раз обращаемся select * from к таблицам products и members. Так как 3 магазина, то будет 3 запроса к members и 3 дополнительных запроса к products. Всего 3+3+1 запрос.
Если у нас 5000 или 10000 магазинов, то при каждом посещении страницы будет выполняться 10 000 – 20 000 запросов. И если посещений 10 000 в сутки? Понятно, что такой код неприемлем. И неважно какую базу вы используете, какой мощности у вас сервер. И даже кеширование запросов (например, Redis) будет лишь временным решением.
Применять жадную загрузку в Laravel довольно просто: указываем необходимые отношения в методе with(). Пример: $stores = Store::with(‘member’,’products’)->get().
И получаем всего три запроса даже при 10000 магазинах. Чтобы еще улучшить производительность, мы должны добавить индекс к id-полям в таблицах members и products, так как запрос с in(‘1′,’2’,…) может занять длительное время без индексации.
Расширение класс репозитория
Покажем, как можно работать с отношениями в репозиториях. Вот что должно получиться:
1 2 3 4 5 6 7 8 9 |
function __construct(StoreRepository $stores) { $this->stores = $stores; } public function index() { $stores = $this->stores->with('member', 'products')->all(); .... } |
Как видите, у нас есть метод with(), который очень похож на метод with() стандартного Query Builder-а в Laravel.
1 2 3 4 5 6 7 |
public function with($relations) { if (is_string($relations)) $relations = func_get_args(); $this->with = $relations; return $this; } |
Давайте привяжем отношения к модели:
1 2 3 4 5 6 7 8 9 |
protected function eagerLoadRelations() { if(!is_null($this->with)) { foreach ($this->with as $relation) { $this->model->with($relation); } } return $this; } |
И обновим метод all() в репозитории для жадной загрузки:
1 2 3 4 5 |
public function all($columns = array('*')) { $this->applyCriteria(); $this->newQuery()->eagerLoadRelations(); return $this->model->get($columns); } |
Как я уже говорил, вы можете добавить несколько отношений в with(). Вот пример для StoresController:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php namespace App\Http\Controllers; use App\Repositories\StoreRepository; class StoresController extends Controller { protected $stores; function __construct(StoreRepository $stores) { $this->stores = $stores; } public function index() { $stores = $this->stores->with('member', 'products')->all(); return \View::make('stores.index')->with('stores', $stores); } } |
Вид:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@foreach($stores as $store) <h1>{{ $store->name }}</h1> <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br> <h2>Products:</h2> @foreach($store->products as $product) <h3>{{$product->name}}</h3> <span>{{$product->short_desc}}</span><br/><br/> <span>Price: {{$product->price}}</span> <br/> <?php Debugbar::info('Product displayed'); ?> @endforeach <br/> ======================== <br/> @endforeach |
В результате, как и ожидалось, всего 3 запроса:
Вывод
С помощью жадной загрузки вы можете повысить производительность вашего приложения. Хотя в некоторых случаях такого подхода недостаточно и приходится использовать кеширование запросов и другие приемы.