State - поведінка залежно від стану
Змінює поведінку об'єкта залежно від стану, прибираючи каскади if. Замовлення, підписки, оплати та пакет spatie/laravel-model-states.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
У попередній частині ми прибирали 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 / 2
Увійдіть, щоб залишити коментар
Ну по суті ви придумали LRU cache через статичні властивості класа, ця штука заслуговує на життя але я б все ж таки зробив окремий шар кешування базових наборів, кешувати фільтри згоден, не має сенсу.
Змінює поведінку об'єкта залежно від стану, прибираючи каскади if. Замовлення, підписки, оплати та пакет spatie/laravel-model-states.
Організовує реакції на події без жорстких зв'язків. У Laravel це Events, Listeners і Subscribers - як додавати реакції, не торкаючись джерела події.
Backend розробник на PHP/Laravel для розробки та підтримки Core API для мобільних додатків і веб-сайтів. Потрібен досвід від 3 років з Laravel, Tailwind CSS, Livewire, Alpine.js, ООП та Git. Обов'язки включають вирішення задач back-end розробки, роботу з базами даних та контроль версій.
Розроблювати та керувати складними веб-застосунками на Laravel як технічний лідер. Відповідатимете за архітектуру, оцінку проектів, спілкування з клієнтами та повний цикл розробки. Вимоги: 5+ років з PHP/Laravel, B2 польською та англійською, повна технічна незалежність.
PHP Full-Stack розробник для внутрішньої операційної платформи цифрової реклами. Розробка функціоналу від серверів і БД до аналітичних дашбордів та UI. Потрібен досвід з Laravel 8+, MySQL, Redis, AWS, REST/webhooks, Blade/Livewire/Alpine.js, черги та фонова обробка. Мінімум 2+ років комерційного досвіду в PHP.
Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.