Null Object - поведінка за замовчуванням
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
У попередній частині ми прибирали N+1 у зв'язках моделей - той, що на видноті й лікується за допомогою eager loading. Але є другий різновид, куди підступніший, бо ховається не в запиті, а в самій структурі фронтенду. Продовжуємо ту саму лінію: найдешевший запит - той, якого немає, тільки цього разу зайві запити породжує не забутий with(), а десятки однакових компонентів на одній сторінці.
Найпідступніше джерело зайвого навантаження в нашому стеку - навіть не важкий запит, а тихий N+1: коли рендер сторінки непомітно робить по одному дрібному запиту на кожен елемент списку. Кожен окремо - копійчаний, разом - десятки зайвих походів у базу на одну сторінку. І найгірше, що його майже не видно: тести зелені, локально швидко, а на бойовій сторінці зі списком - раптово 100+ запитів.
Наш фронтенд - Livewire, тож інтерактивні елементи (кнопка «в обране», «підписатися», лічильник голосів) - це окремі компоненти. Список пакетів рендерить по картці на пакет, у кожній картці - кнопка-закладка, а кожна кнопка на mount() питає: «а користувач уже додав цей пакет в обране?». Наївна відповідь - окремий запит:
// BookmarkButton, наївна версія
$this->favorited = auth()->user()->favorites()
->where('favoritable_type', $this->model->getMorphClass())
->where('favoritable_id', $this->model->getKey())
->exists();
Один SELECT EXISTS на кнопку. На сторінці з 30 картками - 30 запитів, кожен повертає один біт «так/ні». Причому запит цілком коректний: логіка правильна, індекси на місці. Проблема не в самому запиті, а в тому, що їх тридцять там, де мало бути один.
Класична порада - eager loading - тут не рятує напряму: кнопка не знає про сусідні кнопки, а favoritable_type/id пари розкидані по різних моделях (пакети, проєкти, компанії). Потрібен не eager load зв'язку, а спільна пам'ять на весь запит.
Рішення - маленький сервіс, який один раз вантажить усі обрані поточного користувача й відповідає на будь-яку перевірку членства з пам'яті. Ось FavoriteRegistry цілком:
class FavoriteRegistry
{
/** @var array<string, true>|null Набір "{morphClass}:{id}" ключів. */
private ?array $keys = null;
private ?int $loadedForUserId = null;
public function has(Model $model): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false; // гість нічого не обирав
}
$this->ensureLoaded($user);
return isset($this->keys[$this->key($model->getMorphClass(), $model->getKey())]);
}
private function ensureLoaded(User $user): void
{
if ($this->keys !== null && $this->loadedForUserId === $user->getKey()) {
return; // уже завантажено в цьому запиті
}
$this->keys = [];
$this->loadedForUserId = $user->getKey();
$user->favorites()
->get(['favoritable_type', 'favoritable_id']) // ОДИН запит на всі перевірки
->each(function ($favorite): void {
$this->keys[$this->key($favorite->favoritable_type, $favorite->favoritable_id)] = true;
});
}
public function flush(): void
{
$this->keys = null;
$this->loadedForUserId = null;
}
private function key(string $type, int|string $id): string
{
return $type . ':' . $id;
}
}
Ідея проста: перша перевірка вантажить усі пари (type, id) обраного одним запитом і будує з них хеш-набір; кожна наступна кнопка на сторінці відповідає звіркою по ключу в пам'яті. 30 запитів згортаються в 1. Кнопка тепер лише питає реєстр:
$this->favorited = app(FavoriteRegistry::class)->has($this->model);
scoped(), а не статичний кешРеєстр зареєстрований у контейнері як scoped, а не singleton:
// AppServiceProvider::register()
$this->app->scoped(FavoriteRegistry::class);
$this->app->scoped(EdgeCacheTags::class);
scoped означає «один екземпляр на життєвий цикл запиту (або джоби), і свіжий - на наступний». Це рівно та тривалість життя, яка нам потрібна: у межах одного рендера набір обраного незмінний, тож його можна тримати; але наступний запит - інший користувач, і тягти його стан крізь запити було б помилкою. singleton жив би між запитами (у чергових воркерах - катастрофа: чуже обране), а статична властивість мала б ту саму ваду в довгоживучих процесах на кшталт Octane. scoped - правильна за замовчуванням тривалість життя для «пам'яті на один запит».
Один нюанс. Якщо на тій самій сторінці користувач клікнув кнопку - додав чи прибрав обране, - набір у реєстрі застарів. Тому кожна дія, що змінює обране, скидає реєстр:
public function toggleFavorite(): void
{
$added = auth()->user()->toggleFavorite($this->model);
// Набір змінився; скидаємо кеш, щоб інші кнопки й перевірки в цьому
// ж запиті побачили новий стан.
app(FavoriteRegistry::class)->flush();
// ...
}
Дешево (наступна перевірка просто перевантажить набір одним запитом) і рятує від неузгодженості, коли на сторінці кілька кнопок для однієї сутності.
Одне правило робить цей патерн надійним: усі перевірки членства йдуть одним спільним шляхом. Кожна кнопка - закладка, голос, підписка - питає реєстр, а не пише власний exists(). Варто одному компоненту піти в обхід - і N+1 тихо повертається саме там, де його найважче помітити. Тому корисний тест-вартовий, що рахує запити на список: він ловить такий регрес раніше за профайлер на проді.
Реєстр на час запиту - це не про базу, а про будь-яку дорогу операцію, що повторюється з однаковим входом. Приклад без жодного SQL. Наш компонент <x-tag> використовує TailwindMerge, щоб класи, передані при виклику через атрибут class, коректно перекривали дефолтні класи тега. Це зручно, але коштовно - близько 0.1 мс на виклик, а сторінка-список рендерить сотні тегів. Причому входів насправді жменька: усі теги діляться на кілька комбінацій (розмір, варіант, додатковий клас).
Тож запам'ятовуємо результат для кожного унікального входу:
class TagClassCache
{
/** @var array<string, string> */
private static array $cache = [];
public static function merge(string $key, Closure $resolver): string
{
return self::$cache[$key] ??= $resolver();
}
}
А в компоненті:
$mergeKey = $sizeClasses . '|' . $variantClasses . '|' . (string) $attributes->get('class');
$classes = TagClassCache::merge($mergeKey, fn () => TailwindMerge::merge(
$sizeClasses, $variantClasses, 'group', (string) $attributes->get('class'),
));
Сотні викликів TailwindMerge згортаються в кілька реальних злиттів; решта - звірка по ключу в пам'яті. Той самий принцип, що й у FavoriteRegistry: порахуй один раз на унікальний вхід, а не щоразу. (Тут кеш статичний, а не scoped, бо результат злиття класів не залежить від користувача чи запиту - однакові входи завжди дають однаковий вихід, тож його безпечно ділити.)
scoped() - правильна тривалість життя для пам'яті на запит. Не singleton (протече між запитами у воркерах) і не статика (та сама вада в Octane). Свіжий екземпляр на кожен запит - саме те, що треба.exists() - і N+1 тихо повертається. Прикрийте це тестом, що рахує запити на список.Ми прибрали зайві запити - і в зв'язках, і в списках. Але частину запитів прибрати не можна: важкі блоки на кшталт «ТОП компаній» доведеться таки виконати. Ось із цього місця й починається кеш. У наступній частині - перший його рівень: кеш результатів Eloquent-запитів у Redis із автоматичною інвалідацією за тегами.
2 / 3
Увійдіть, щоб залишити коментар
Ну по суті ви придумали LRU cache через статичні властивості класа, ця штука заслуговує на життя але я б все ж таки зробив окремий шар кешування базових наборів, кешувати фільтри згоден, не має сенсу.
Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.
Пропускає дані через серію кроків, де кожен трансформує вхід і передає далі. Готовий Illuminate\Pipeline\Pipeline та його зв'язок із middleware.
Fullstack розробник для роботи над живими клієнтськими проєктами на Laravel API з React Native та Filament. Потрібна глибока експертиза в PHP/Laravel, компетенція в TypeScript/React, досвід роботи з PostgreSQL, AWS. Вимагається англійська B2+, тестова дисципліна, вміння швидко адаптуватися до різних стеків.
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 разробника.
Найпопулярніша адмін-панель для Laravel: ресурси, таблиці, форми та дашборди описуються PHP-класами поверх Livewire, Alpine і Tailwind. Замінює рутину CRUD-інтерфейсів декларативною конфігурацією.