Масштабування проєкту на Laravel: реорганізація та виклики

Привіт! Я Павло Бездверний, 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 досягнутий. Пишіть, з якими ще складнощами ви стикалися, а я розберу їх в наступній статті. Від корисних інсайтів у коментарях теж ніхто не відмовиться)

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Щось подібне, але з нуля, я робив на проєкті Medimops.de. Там структура тек мала корінь API, далі так само, по фічах: Product, Customer, Category, Genre, etc. В середині кожної фічи — Domain, Application та Implementation.

— Domain — переважно інтерфейси та дефініції, а також структури даних об’єктів на рівні бізнес-логіки.
— Application — методи, присутні в API
— Implementation — реалізація високорівневих класів та інтерфейсів.

Бекенд було зроблено для мобільних застосунків (iOS, Android) та фронтенду на NextJS/React.

Підхід цікавий, має достатньо спростити роботу зі складною бізнес-логікою.

Чи не дивились в сторону Laravel-Actions? Аж занадто схожі інтерфейси у вас. :)

дивились, десь в коментах я вже дав посилання на літаратурку :)

Интересно, в реализации функци transaction как образатываются ошибки при потери соединения с узлом БД. Конкуретность запросов ограничивается выставлением блокировки — но не обнаружил условия для снятия блокировки и почему не указывается ttl, что произойдет если фиксация данных транзакии успешно не выполнится и блок будет существовать вчено?Другими словами интресны кейсы обработки ошибок соединения по сети, недоступности узлов, конкуретного доступа к изменению данных...

хороший вопрос, но он не является темой текущей статьи :)
ЗЫ
обработка ошибок стандартная для фреймворка
блокировка снимается коммитом транзакции или ее ролбэком
проблемы с сетью не возникают, потому что монолит
изза больших нагрузок есть проблема с дедлоками

Почему не является темой статьи «Масштабирование проекта на Laravel: реорганизация и вызовы»? По-моему мнению, масштабирование системы и приводит к вызовам, где нужно погружаться детальнее в сетевую коммуникацию (ведь сеть нестабильна априори), если мы не говорим, что масштабирование вертикальное и по ресурсам только. Насчёт контрмеры Deadlock — или изменяем порядок фиксации данных конкурирующими транзакциями, чтобы всегда был один и тот же. Но или обрабатываем контекст ошибок (deadlock) и статус-коды перед и пробуем повторить фиксацию с небольшой задержкой (usleep (400..500)).

Класна стаття, дякую, на dou досить мало толкових дискусій по Laravel

Маю декілька запитань:

Велика кількість файлів у папках Controllers,

Ну велика, а в чому проблема? Якщо є багато entities та багатий фронт — то буде і багато контроллерів. Не розумію тут фундаментальної проблеми

GetUserSettingsQuery

В чому перевага над методами моделі та/чи використанні eloquent scopes? Ну або над переносом запитів кудись в service layer. Не знаю вашу специфіку, але мені здається що підхід «окремий клас на query» має породити багато десятків файлів. Тим паче, якщо єдиний public метод це execute

1 — контроллери то контроллери, тут не дуже влучний приклад наведений, точніш булу б написати папка Моделей )) ось там би вже було що поскролили.
2 — Спробую пояснити, взагалі зараз бачу що треба було в статті зазначити, що всі прклади вигадані та неймінг підібран штучно.
Тепер по класах кверей — звісно ми використовуєм і скоупи моделей і самі методи в моделях. Але є такі запити які треба використовувати в декількох місцях, та вони мають деяку бізнес логіку: вибрати останні успишні платежі користувача. Якщо саме цей запит виділити в окремий клас, то у всьому проєкті виборка буде однакова, ну а якщо змінеться параметр *успішності* платежу то скрізь буде нова логіка. Такий фокус не раз рятував нас. Але звісно кожну практику що тут описана треба використовувати з розумом. Наприклад сценрії дуже не підходять для КРУД операцій.

Якщо саме цей запит виділити в окремий клас, то у всьому проєкті виборка буде однакова

А скоупи не вирішують цієї проблеми?

скажу так: скоупи більш про технічну складову видорку, а класи квеері більш про продуктове

когда-то работал с ларавелом, жирный перегруженный фреймворк. Походу выявились некоторые фатальные проблемы с реализацией очередей, хранением сессий, баги в eloquent, маппинг некоторых типов данных на реальные в базах. Когда смотрели организацию очереди там их чел тупо создавал в коре дистрибьетед мутекс на час в базе. Все открытые ишьюм (не только нами) Тейлор и его команда тупо позакрывала потому шо «так нада» и так правильно по их мнению. Некоторые классы типа Passport раздуты или не расширяемые. Я б на вашем месте дропнул ларавел и сделал на нормальных сервисах типа Го, чем заниматься мазохизмом с пыхой и упоротыми авторами ларавела которые не считаются ни с чьим мнением

Сам не пишу на ларі роки 4, але веду проект де є ларавель розробник, ніяких проблем з описаними речами не було за весь час і зараз немає, черги і мапінг працюють чудово, але там багато магії і треба знати як вона працює

делайте хорошо, хорошо будет. Конечно у ларика много кривых решений и магии, но задача хорошего разработчика в том и состоит, как извлечь максимальную выгоду из того что есть ))

Схоже на «я намагаюся ложкою їсти макарони, і воно невдобно. попросив автора нарізати в ній пропилів, щоб наштрикувати макарони — автор відмовився, сказав що все так як треба. дурна ложка»

Юзаю лару вже років 7, прямо зараз на навантаженому API проекті. Ніяких вищезгаданих проблем не зустрічав

Деякі приколи звісно є (типу відсутності Dependency Injection в деявих місцях лари, через що приколи типу роутингу прибиті гвіздками), але в кого їх нема?

Ну і да

на нормальных сервисах типа Го

покажіть мені той «нормальний го» на якому можна підняти проект з такою ж швидкістю як на ларі, або хоча б не в 2 рази довше

вот примера говнокода github.com/...​heSchedulingMutex.php#L45

они создают «мьютекс» с тупо вхардкоденным временем на 1 час и считают что джоба выполняется час и более чаще нельзя запускать джобы, и изменить это нельзя потому что они считают это абсолютно правильно, короче шедулер ларавела выбросили в мусорку то что спроектирован он ужасно

Когда я вижу что классы называются типа "

SaveMainEmailScenario

" мне становится плохо. Это подход когда в качестве архитектора взяли «молодое дарование которое заболтало всем уши» и наплодило спагетти обьектов Ну теперь расхлебывайте.Привет тем кто все это оплачивает А статья да, как тут пишут — хорошая

приходите к нам на собеседование, обсудим :) нам нужны «сформировавшиеся дарования» )) (вакансия реально открыта)

Концепція сценаріїв має право на життя, але неймінг

SaveMainEmailScenario

реально хріновий. Мені, як сторонньому розробнику, взагалі непонятно з цього імені:
— що таке Main email. є інші, не головні?
— що означає save email. було б send — окей. а куди його зберігають, і навіщо?

ви дуже класно підтвердили то для чого такий неймінг і існує:
у нас в проєкті у користувача є саме мейн емейл, до того ж сами цей сценарій по факту потрібен для його збереження :) саме збереження, там є ще окрема логіка яка не ввійшла в код семпли :)

Вам розробники в команду часом не потрібні? :))

Відправила вам запит в LinkedIn))

До статті зауважень немає, крім картинки з user -> controller interaction, де вона два рази дублюється ) Загалом як для моноліту все досить таки ок і дуже структурно.

Тільки от не завжди розумію чому народ прикручує над Eloquent(можу бути неправий стосовно використаної ORM) репозиторії, які по суті просто обмежують доступ до основної моделі. Чому наприклад було б не використати ту ж саму Doctrine ORM де вже все реалізовано і можна генерити як репозиторії так і сутності. Трохи звичайно складна у використанні і імплементації, але досить варіативна навіть для роботи з тим ж транзакціями.

Стосовно багатьох точок входу, тут вже правильно зазначили про Laravel Modules, розбивати на модулі з власними неймспейсами і відповідно ServiceProviders або ділити на мікро сервіси. Останній варіант мені особисто не дуже подобається, оскільки ваша красива структура стане великою кількістю мікро красивих структур )

Дякуємо, що помітили! Зайву картинку прибрала :)

А чим не підійшла структура з Laravel modules?

дуже слушне зауваження!
я б відповів так- ми на час коли будували свою не були знайомі з модулями.
Ось деякі варінти ще:
github.com/Mahmoudz/Porto Доволі прикольна моделька
а ось, стара книга що нас надихала github.com/adelf/acwa_book_ru

Підписатись на коментарі