Масштабування проєкту на Laravel: реоганізація та виклики
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привіт! Я Павло Бездверний, Lead Back-End Engineer в компанії MOJAM. Наш продукт — це платформа для кіберспортсменів і гравців у CS2, яка налічує понад 4 мільйони користувачів зі 168 країн світу. На першому місці інтерактивність, реактивність і відмовостійкість системи.
З розширенням команди, а також зі збільшенням обсягу та складності функціоналу, перед нами постало завдання переглянути традиційні підходи до розробки.
У статті розповім, як ми адаптували процеси та методології для ефективного контролю зростальної складності проєкту. Поділюся ключовими стратегіями, які допомогли залишатися на плаву та рухатися вперед у швидкоплинному світі розробки ПЗ.
Вступ
Наш проєкт — це досить навантажена система (більше 250rps), яка складається з фронтенду, написаного на Vue, та API-бекенду, написаного на Laravel.
Проєкт орієнтований на кінцевих користувачів і зосереджений на високій швидкості відгуку інтерфейсу, надійності та зручності використання. Основу складають різноманітні механіки взаємодії користувача із застосунком, а також існує багато комунікацій із зовнішніми системами.
Давайте детально розглянемо бекенд нашого застосунку та сфокусуємось на його особливостях.
Крім HTTP точок входу (портів), застосунок також включає консольні команди, крон-задачі та черги.
Обробники запитів через всі ці порти надає Laravel «з коробки». Наше завдання — максимально структурувати та стандартизувати роботу з ними усіма.
Проблеми стандартного підходу
У стандартній структурі, що надається фреймворком, є певні незручності в роботі з великою кількістю окремих смислових функціональних частин.
- Велика кількість файлів у папках Controllers, Models...
- Змішування файлів з різних логічних частин застосунку.
- Змішування файлів з різних шарів застосунку (бізнес і інфраструктурний).
Реорганізація структури застосунку
Для розв’язання вищезгаданих проблем ми оновили структуру папок. У папці app тепер є нові Domain, Integrations, MessageBus, Support.
Папка Support
У ній зібрані класи, які відносяться до інфраструктурних або загальних для всіх доменів (фічей) функцій — пагінація, фільтрація тощо.
Також тут є загальні для всього застосунку константи та єнами (enumeration). Системні слухачі, які відповідають за логування і збір метрик. Можна побачити й дві стандартні Lavarel-папки Exceptions та Providers. Ми вирішили перенести їх сюди як загальну частину для всього застосунку.
Папка MessageBus
Тут знаходяться службові файли для роботи з kafka. У нашій інфраструктурі основним асинхронним способом спілкування сервісів є відправка повідомлень саме через kafka.
Папка Integrations
Інтеграція з будь-яким зовнішнім сервісом виноситься в цю папку. Зазвичай вона складається з основного файлу, наприклад, GAIntegration.php, в якому реалізовані методи відправки HTTP-запитів до Google Analytics. Файлів Data, які являють собою класи DTO, та слухача подій. Іноді також присутній інтерфейс, який виступає маркером для доменних подій.
Папка HTTP
Тут знаходиться все, що пов’язане з HTTP-портом нашого застосунку, це стандартна папка фреймворка.
Папка Console
Стандартна папка фреймворка, яка відповідає за розклад крон-команд.
Папка Domain
Серце нашого застосунку. Тут розкладені по своїх папках всі фічі (домени), які існують у нашому застосунку.
Фіча (субдомен)
Бізнес-фіча у вебзастосунку — це функціональність чи можливість, що належить до бізнес-процесів або потреб користувачів і призначена для покращення або оптимізації певних аспектів бізнесу або користувацького досвіду.
Це може бути що завгодно від базових елементів, як-то реєстрація користувача або додавання продукту в кошик в інтернет-магазині до складніших функцій — системи управління замовленнями або підписки на розсилку новин.
Кожна бізнес-фіча зазвичай розробляється з метою додати цінність для бізнесу або покращити взаємодію користувачів із застосунком.
Зазвичай структура фічі виглядає ось так:
Розглянемо основні компоненти кожної фічі. Фіча з погляду API — це певний набір сценаріїв взаємодії користувача й системи.
У результаті запуску сценарію необхідно виконати бізнес-перевірки (checks). У системі мають відбутися деякі зміни (updates), результати яких повинні бути записані в базу даних, а також мають відбутися певні події (events).
І тепер ми маємо зрозумілі зони відповідальності.
Сценарій (Scenario) — завершена бізнес-дія користувача. З технічного погляду це клас із суфіксом Scenario, у якого є єдиний публічний метод run. Запускається сценарій найчастіше з HTTP-контролера, але також може робити це з консольних або крон-команд.
EmailController public function save( SaveMainEmailRequest $request, SaveMainEmailScenario $scenario ): JsonResponse { $user = $request->user(); $scenario->run( $user, $request->validated('email'), $request->getClientIp(), ); return $this->respond([ 'message' => UserMsg::EMAIL_CONFIRMATION_SENT, 'values' => EmailInfoData::from($user), ]); }
Всередині цього методу в сценарії можуть запускатися класи перевірок (Check), запитів (Query) та дій (Action).
class SaveMainEmailScenario class SaveMainEmailScenario { public function __construct( private readonly GetUserSettingsQuery $settingsQuery, private readonly UserSettingsEmailCheck $checker, private readonly LockUserProfileAction $lockUserProfile, private readonly SaveEmailAction $saveEmail, ) { } public function run(User $user, string $email, string $ip): void { .... }
Найчастіше при запуску сценарію потрібна транзакція та блокування, щоб уникнути стану перегонів запитів. У цьому випадку метод виглядає таким чином:
public function run(User $user, string $email, string $ip): void { transaction(function () use ($user, $email, $ip): void { $this->lockUserProfile->execute($user->id); $userSettings = $this->settingsQuery->execute($user->id); $this->checker->execute($userSettings, $email); $this->saveEmail->execute($email, $ip); event(new EmailAdded($user, $email)); dispatch(new SendConfirmationEmailJob($user->id, $email, $ip)); }); }
Запити (Query) — це класи запитів, призначені для отримання даних із бази даних або обчислення певних параметрів. Найчастіше концепцію такого класу можна розглядати як один із методів сервісного класу, наприклад, UserService::getFullUserInfo, або як вільну інтерпретацію патерну репозиторію.
Запити зазвичай використовуються в сценаріях, командах, джобах і контролерах як мінімальні логічні елементи для отримання даних. Такий клас повинен мати єдиний публічний метод — execute.
class GetUserSettingsQuery { public function execute(int $userId): UserSettings { return UserSettings::query() ->where('user_id', $userId) ->active() ->firstOrFail() ; } }
Чекер (Checker) — класи перевірок, призначені для запуску складних, часто не одноразових бізнес-перевірок. Такі перевірки відрізняються від стандартної валідації реквесту тим, що відбуваються всередині транзакції та блокування ресурсу.
Чекери найчастіше використовуються в сценаріях. Клас повинен мати єдиний публічний метод — execute, який зазвичай кидає виключення в разі невдалої перевірки або нічого не повертає.
class UserSettingsEmailCheck { public function execute(UserSettings $userSettings, string $email): void { $used = $userSettings->usedEmails() ->where('email', $email) ->get() ; Assertion::null( $used, 'This email is already in use. Please choose another one.' ); } }
Шар представлення
Для представлення даних у форматі JSON (оскільки в нас API) ми використовуємо пакет від компанії Spatie Laravel Data. Він надає чудову можливість не лише створити корисні DTO для передачі даних, а й при деяких маніпуляціях і потрібних анотаціях згенерувати інтерфейси на мові TypeScript.
Далі в нашому CI/CD пайплайні ці інтерфейси публікуються як npm-пакет, і команда фронтенду завжди має актуальні дані.
Файли Data ми розміщуємо в однойменній папці всередині домену. Іноді, навіть якщо дані зберігаються в одній моделі (таблиці), зручно винести набір значень в окремий файл Data.
#[TypeScript] class EmailInfoData extends Data { public function __construct( public string $email, public bool $is_confirmed, public bool $is_subscribed, ) { } public static function fromModel(User $user): self { return new self( email: $user->settings->email, is_confirmed: $user->settings->email_confirmed_at !== null, is_subscribed: $user->settings->is_email_subscribed ?? false, ); } }
І отриманий інтерфейс TypeScript:
export type EmailInfoData = { email: string; is_confirmed: boolean; is_subscribed: boolean; };
Саме перетворення моделі у файл Data відбувається в контролерах застосунку:
EmailInfoData::from($user)
EmailController public function save( SaveMainEmailRequest $request, SaveMainEmailScenario $scenario ): JsonResponse { $user = $request->user(); $scenario->run( $user, $request->validated('email'), $request->getClientIp(), ); return $this->respond([ 'message' => UserMsg::EMAIL_CONFIRMATION_SENT, 'values' => EmailInfoData::from($user), ]); }
Які переваги
Використовуючи таку архітектуру, команда з шести спеціалістів отримала значний буст в організації роботи та управлінні проєктом.
По-перше, архітектура забезпечує чіткий розподіл обов’язків, дозволяючи кожному члену команди зосередитися на своїй зоні відповідальності. Це знижує ймовірність конфліктів і покращує координацію між учасниками, якщо виникає необхідність повторного використання певних компонентів із сусідньої фічі.
По-друге, модульність архітектури полегшує внесення змін і покращень в окремі шари без ризику порушення роботи інших частин системи, що прискорює процес розробки та тестування.
Головне — наявна структура дозволяє ефективно масштабувати проєкт, додаючи нові функції та компоненти в темпі зростання застосунку, зберігаючи при цьому його стабільність і продуктивність.
Виклики, які ще потрібно вирішити
Публічні інтерфейси доменів
Логіка всередині доменної області може бути викликана через:
- HTTP-контролер;
- запуск консольної команди;
- через слухача якоїсь події.
Хотілося б якось явно підсвічувати ці точки входу.
Повне розділення доменів за контекстами
Зараз завдяки тому, що в нас монолітний застосунок, досить легко викликати клас екшена з іншого домену в сценарії. Оскільки є багато сценаріїв, які потребують транзакційних змін, це поки допустимо. Але час, коли доведеться переглянути архітектуру в цьому аспекті, вже наближається.
Ось так пройшло наше масштабування на Laravel. Є над чим працювати, але черговий milestone досягнутий. Пишіть, з якими ще складнощами ви стикалися, а я розберу їх в наступній статті. Від корисних інсайтів у коментарях теж ніхто не відмовиться)
25 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів