Null Object - поведінка за замовчуванням
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
Це перша стаття з циклу про те, як влаштована продуктивність laravelukraine.com. І почнемо з найпростішої, але найважливішої думки: найдешевший запит - той, якого немає. Перш ніж кешувати навантаження, варто спитати, чи створюємо ми його даремно. Найпоширеніше джерело зайвих запитів - N+1 у зв'язках моделей, від якого рятує класичний eager loading. Здавалося б, тема з першого туторіала по Eloquent, та на реальному проєкті вона має пастки, про які там не пишуть.
Список постів у блозі показує для кожного посту автора, категорію, теги й обкладинку. Якщо не подбати про завантаження зв'язків, Eloquent зробить окремий запит на кожен зв'язок кожного рядка: 20 постів × (автор + категорія + теги + медіа) - це десятки запитів там, де мало бути кілька. Ліки відомі - ->with():
$query = Post::query()
->published()
->with(['author', 'author.media', 'category', 'tags', 'media', 'series', 'seriesTopic.series'])
->withCount('comments');
Один запит на пости, і по одному додатковому - на кожен тип зв'язку для всієї сторінки одразу, а не для кожного рядка. Це нецікаво рівно доти, доки не натрапиш на два неочевидні місця.
Найпідступніший зв'язок у цьому списку - author.media. Аватар автора в нас зберігається через Spatie MediaLibrary, а вона тримає файли в окремій таблиці media з поліморфним зв'язком. Тонкість у тім, що звернення до аватара виглядає як звичайне читання властивості - $post->author->getFirstMediaUrl('avatar'), - і не схоже на запит. Але за цим викликом ховається звернення до зв'язку media моделі автора.
Тобто мало завантажити author - треба завантажити ще й author.media. Забудеш вкладене .media - і отримаєш N+1, який ще й важче помітити, ніж звичайний: author завантажений, IDE не свариться, автор рендериться - а на кожен аватар усе одно летить прихований запит у media. Саме тому в списку явно стоїть author.media, а не просто author. Той самий подвійний зв'язок бачимо всюди, де показуємо людей з аватарами - у коментарях навіть на два рівні вглиб:
// Коментарі: автор і його аватар, плюс автори відповідей та їхні аватари
->with(['author', 'author.media', 'reactions', 'replies.author', 'replies.author.media', 'replies.reactions'])
Урок: eager-load'ити треба не «модель», а весь ланцюг звернень, які зробить в'юха - включно з тими зв'язками, що ховаються за зручними хелперами пакетів.
Друга поширена помилка - рахувати пов'язані сутності «вручну» в циклі або через окремий запит на рядок. Скільки в цієї компанії підписників? Скільки вакансій? Наївно - $company->subscribers()->count() на кожну картку, знову N+1. Правильно - попросити базу порахувати все одним проходом через withCount:
$query = Company::query()
->published()
->with(['media', 'tags'])
->withCount(['subscribers', 'publishedVacancies', 'comments']);
withCount додає до основного запиту підзапити-лічильники, і кожна модель отримує готові subscribers_count, published_vacancies_count, comments_count без жодного зайвого походу в базу. А коли модель уже завантажена (детальна сторінка, де об'єкт прийшов роутером), той самий трюк робить loadCount пост-фактум:
// У mount() сторінки компанії, коли її вже розв'язано роутером:
$company->loadCount(['subscribers', 'comments']);
Це прибирає два окремі COUNT-запити в хедері, які інакше виконувалися б на кожен рендер, - і, що важливо, без жодного кешу й потреби щось інвалідувати. Часто найдешевша оптимізація - не «закешувати важке», а «порахувати за один прохід замість трьох».
І тут ми забігаємо наперед - до кешування, яким займемося далі в серії. Розібравшись, що запит дешевий (eager-loaded зв'язки, withCount замість ручних лічильників), починаєш бачити випадки, де кешувати теж не варто. Показовий - блок із п'ятьма свіжими вакансіями на сторінці компанії:
'vacancies' => $this->company->publishedVacancies()
->with(['tags', 'company.media'])
->latest('published_at')
->take(5)
->get(),
Спокусливо обгорнути це в кеш. Але порахуймо. Для гостя - а це майже весь трафік - сторінка віддається з edge Cloudflare, тож PHP до цього запиту навіть не доходить. Сам запит дешевий: take(5) по індексу. Ключ кешу був би на кожну компанію окремо - багато дрібних записів у Redis заради рідкісних рендерів. А інвалідація тега vacancies часта, тож такий кеш протухав би частіше, ніж читався.
Дешевий запит + рідкісний рендер (для гостя - взагалі 0) + часта інвалідація = кеш тут лише додасть навантаження на Redis і складності, майже нічого не заощадивши. Кожен шар кешу - це запис, який треба десь тримати й вчасно скидати; він виправданий, лише коли економить більше, ніж коштує. Тому вакансії компанії ми свідомо не кешуємо: дешевше зробити дешевий запит, ніж тримати й інвалідувати ще один запис у кеші.
with(['author']) замало, якщо в'юха бере аватар: зв'язок media схований за хелпером пакета. Явно вкладайте author.media (і глибше, якщо потрібно).withCount/loadCount, не в циклі. База порахує все одним проходом; ручний count() на рядок - той самий N+1 в іншій обгортці.Цей перший вид зайвого - N+1 у зв'язках - лікується eager loading'ом просто, бо він на видноті. Але є підступніший різновид, який ховається в самій архітектурі фронтенду. У наступній частині - прихований N+1, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.
1 / 3
Увійдіть, щоб залишити коментар
В проєктах, де дуже багато звязків, я зазвичай блокую lazyloading на рівні сервіспровайдера, Model::preventLazyLoading(! $this->app->isProduction());. Після цього, якщо забуду про eager loading, ріспонс видасть 500. Це також дисциплінує, не забувати про eager loading, там де це потрібно.
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Пропускає дані через серію кроків, де кожен трансформує вхід і передає далі. Готовий Illuminate\Pipeline\Pipeline та його зв'язок із middleware.
Middle PHP Developer для міжнародного B2B-продукту у телекомунікаціях. Розробка нового функціоналу на PHP 8.x/Laravel, підтримка кодової бази, робота з REST API, платіжними інтеграціями та MySQL. Вимоги: 2+ років комерційного досвіду PHP, впевнене знання Laravel, ООП, SOLID, тестування (PHPUnit/Pest), Git, Docker.
Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.
Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.
Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.