Pull to refresh
113.05
Рунити
Домены, хостинг, серверы, облака

Из легаси монолита в модульную архитектуру: проводим рефакторинг и наводим порядок в проекте

Level of difficultyMedium
Reading time10 min
Views1.6K

Привет, Хабр! Меня зовут Владимир Раду, я Backend-разработчик в Рунити. Однажды мы с командой встали перед дилеммой: как навести порядок внутри монолита. Админка одного из сайтов нашей группы компаний — большой и довольно возрастной проект. Он охватывает множество задач и сценариев: от управления ценами до редактирования контента. Со временем стало очевидно, что нужно снижать связанность компонентов и разводить бизнес-части. Так появилась идея перейти к модульной архитектуре.

Внутри о том, что у нас получилось из рефакторинга легаси, как мы выстраивали работу с модулями и объединяли в логичную структуру разрозненные части системы. Будет полезно разработчикам, которые работают с легаси и хотят встать на путь оптимизации проекта — без переписывания с нуля.

Навигация по тексту:

В начале были… папки, или почему появилась эта статья

Все мы считаем лучшим источником для изучения информации о фреймворке или библиотеке — документацию. На самом деле так и есть, но это хорошо работает только до определенного уровня развития проекта. С увеличением количества фич, контекстов и бизнес-требований становится всё сложнее поддерживать то, что из коробки предлагает нам фреймворк. Покажу на нашем примере: мы работаем с Laravel. Стандартная организация папок у нас выглядит так:

Как выглядит структура папок
Как выглядит структура папок

Эту структуру приложения разворачивает Laravel 12. В более старых версиях папок было больше. Сейчас выглядит компактнее, но классы проекта размазываются по разным папкам общего типа. Со временем проект станет выглядеть примерно так:

Как могла бы выглядеть структура папок
Как могла бы выглядеть структура папок

В каждой из этих папок будет n подпапок / n файлов. С масштабированием проекта вносить изменения и ориентироваться в проекте будет неудобно. Такая «открытая» архитектура может подталкивать разработчика переиспользовать некую логику между совершенно разными частями системы. Сегодня этот способ работает, а завтра требования к одному функционалу изменились — и вот, логика в другом месте ломается. Также в PHP нет приватных классов — каждый является публичным и скрыть его от «шаловливых» рук другого разработчика средствами языка невозможно.

Очевидно, что предложенная фреймворком документация — не единственный путь к организации проекта. Для этого необязательно переписывать его на микросервисы. В самом начале карьерного пути мне не хватало примера того, как можно работать «вне» документации. Более того, примеры там часто крайне упрощены (иначе ее совсем перестанут читать). Но для начинающих разработчиков это не так очевидно. В статье хочу показать пример нашего решения.

Как мы решили перейти на модули

Административная панель сайта — большой и сложный проект, который обращается к множеству внутренних сервисов. Само приложение работает как HTTP API для нашего фронта на React. Живя на монолите, оно затрагивает одновременно разные контексты, под капотом запросы уходят во множество наших внутренних сервисов. Проект создавался на классической структуре Laravel, и до определенного момента наше решение работало хорошо. Но со временем количество компонентов и связей между ними выросло, структура усложнилась — вносить изменения становилось сложнее.

Мы поняли, что нужно минимизировать хрупкость между разными разделами и UseCase. Так и пришли к идее использовать модули — внутри существующего монолита. Переход на модульный монолит позволяет ослабить связность между компонентами, не заставляет полностью менять архитектурную парадигму, а также допускает менять проект по частям.

На старте проекта
На старте проекта

Что мы называем модулем

Модуль — это выделенный бизнес-контекст. Например, «Услуги» — модуль Service. Он также может поддерживать вложенность, если это требуется. Модули не зависят друг от друга, поэтому импорт компонентов из одного в другой невозможен. Если функционал модуля всё же необходимо вызывать в другом, применяем событийно-ориентированный подход, либо завязываем коммуникацию между ними на интерфейс.

Идея нашего решения заключается в том, что каждый модуль — это приватный пакет с выставленными «наружу» бизнес-сценариями. Если бы в PHP были приватные классы, то публичными в рамках модуля остались бы только UserStory и Event. Такой подход обеспечивает защиту от неожиданных багов: когда исправили в одном месте, а сломалось в другом. При этом он помогает четче структурировать проект в соответствии с бизнес-требованиями.

Еще приятный бонус: уходит необходимость создавать на каждый класс префикс для его контекста. Например, PriceGetAllRequest превращается в GetAllRequest — находясь в рамках модуля Price, уже понятно, о каком запросе идет речь.

Сложностью на старте также стало полное отсутствие типизации на уровне запросов и ответов. Тестов мало, все на массивах — понять, что там под капотом очень сложно. В рамках модуля начали постепенно типизировать все запросы и ответы. Благодаря этому код стал более документированным, а процессы ускорилась — win-win!

Выстраиваем структуру модуля

Посмотрим, как изнутри устроены наши модули. Они размещаются в проекте в namespace App\Module\Name. Структуру папки можно расширять подпапками — жестких ограничений нет. Выглядит она так:

Http
	Action
	Request
Console
Provider
Entity
Repository
ServiceNameFm
Transformer
UserStory

При проектировании у нас было две идеи: пойти по архитектуре Vertical Slices Architecture или разбить на UserStory с основной бизнес-логикой, а инфраструктуру разделять по типам файлов. Мы выбрали второй путь. Такой подход позволял нам не переусложнять там, где не надо. Также это давало слабую связность и понимание всех бизнес-сценариев. 

При этом модульный монолит хорош тем, что если одному из модулей потребуется другой подход, его можно реализовать локально, не затрагивая остальную систему. Главное правило — не допускать зависимостей между модулями и избегать наследования. И, конечно, не использовать постфикс Service для хранения бизнес-логики — это антипаттерн, который приводит к созданию классов-богов.

Техническая реализация

Из структуры выше можно сделать вывод, что какие-то файлы не попали в модуль. Например, роуты мы оставили в общем файле для удобной навигации. Миграции оставили в папке /migrations. Теперь разберем каждый компонент стандартной структуры модуля по отдельности.

HTTP

В папке HTTP мы складываем всё, что связано с запросами и ответами: валидация, DTO (Data Transfer Object), экшены. Это к вопросу о типизации — хочется, чтобы код был самодокументируемый, даже если пока нет описанной спецификации. Для сериализации используем SymfonySerializer. Вместо контроллеров — понятие Action. Условно его можно назвать контроллером с одним методом, который должен быть максимально тонким — в идеале вызов UserStory и сериализация ответа.

Для запросов и ответов рядом с Action храним DTO. Так код становится документированным, а мы всегда знаем, какой будет запрос и ответ — ведь всё типизировано.

Теперь давайте посмотрим на примеры структуры и реализации HTTP для разных методов. Для получения списка услуг HTTP-папки будут следующие:

Http
	Action
	    GetAll
		    RequestDto.php
		    ResponseDto.php
		    GetAllAction.php
	Request
	    GetAllRequest.php

Обратим внимание на DTO запроса и ответа:

final class RequestDto
{
   /**
    * @param array<int>|null $groups
    * @param array<?string>|null $paymentPeriod
    * @param array<?string>|null $clientType
    * @param array<?string>|null $contractType
    * @param array<?string>|null $residency
    */
   public function __construct(
       public readonly int     $page,
       public readonly ?string $status,
       public readonly ?string $viewMode,
       public readonly ?bool   $isPackage,
       public readonly ?string $order,
       public readonly ?string $lang,
       public readonly ?bool   $desc,
       public readonly ?int    $id,
       public readonly ?bool   $isSingle,
       public readonly ?string $searchQuery,
       public readonly ?array  $groups,
       public readonly ?array  $paymentPeriod,
       public readonly ?array  $clientType,
       public readonly ?array  $contractType,
       public readonly ?array  $residency,
   ) {}
}

Все поля DTO типизированы, свойства задаются через promoted с указанием модификаторов доступа и readonly — данные запроса и ответа должны быть неизменяемыми. Если в запросе есть вложенные объекты, они тоже типизируются и описываются в этой же папке.

final class ResponseDto
{
   /**
    * @param array<ResponseEntityDto> $entities
    */
   public function __construct(
       public readonly int   $allRecordsCount,
       public readonly int   $recordsOnPage,
       public readonly array $columns,
       public readonly array $entities,
   ) {}
}

Пример Action выглядит так:

/**
* Action получения списка услуг.
*/
final class ServiceGetAllAction extends Action
{
   public function execute(GetAllRequest $request, GetAllUserStory $userStory): JsonResponse
   {
       $result = $userStory->do(new GetAllInput($request->toDto()));

       return $this->makeResultResponse($result->value());
   }
}

В Action простая логика. Она сводится к вызову UserStory с передачей входных данных.

А вот таким мы пишем Request. Они основаны на Laravel FormRequest — используем их для валидации данных и сериализации DTO.

/**
* Запрос для получения списка услуг.
*/
final class GetAllRequest extends BaseFormRequest
{
   /**
    * @inheritDoc
    */
   public function rules(): array
   {
       return [
           'page'             => 'integer|required',
           'status'           => 'string|nullable',
           'view_mode'        => 'in:full,short',
           'is_package'       => 'string|in:true,false|nullable',
           'order'            => 'string|in:id,name,code,is_package',
           'lang'             => 'string',
           'desc'             => 'string|in:true,false',
           'id'               => 'sometimes|integer',
           'is_single'        => 'string|in:true,false|nullable',
           'search_query'     => 'string|nullable',
           'groups.*'         => 'integer',
           'payment_period.*' => 'string|nullable',
           'client_type.*'    => 'string|nullable',
           'contract_type.*'  => 'string|nullable',
           'residency.*'      => 'string|nullable',
       ];
   }


   public function toDto(): RequestDto
   {
       $validated = $this->validated();

       return $this->makeDto($this->modifyNotTypedFields($validated), RequestDto::class);
   }
}

Providers

Теперь о папке  Providers — в ней необходимо собирать классы для реализации зависимостей для DI. Провайдер для модуля регистрируем в App\Providers\ModulesServiceProvider.

Entity и Repository

Мы используем сущности без привязки к Eloquent и Active Record. Они представляют собой объекты с данными и могут содержать бизнес-логику — ту, которая опирается исключительно на информацию, доступную самой сущности. Остальная логика описывается в UserStory.

<ServiceName>Fm

В <ServiceName>Fm мы храним классы-обертки для клиентов, необходимых модулю сервисов. То есть это не одна конкретная папка, а множество — в зависимости от количества сервисов.

Transformer

В нашем проекте сосредоточено много логики, связанной с преобразованием данных и запросами к внешним сервисам. Раньше эта информация собиралась в классах-handler-ах. Однако префикс Handler оказался неочевидным — новым разработчикам приходилось дополнительно погружаться в особенности работы. Поэтому мы решили называть эти классы с постфиксом Transformer.

Для преобразования мы используем такие слова, как From / To, например. Логика преобразователей может варьироваться — от простого маппинга до более сложных случаев с зависимостями. И здесь нет необходимости «запихивать» всё в один класс. Если объект большой, удобнее разнести преобразование его частей по разным классам — так проще читать и поддерживать.

Ниже — пример структуры преобразователей для метода получения списка услуг:

Transformer
    GetAll
        ContentFmEntityDataToResponseTransformer.php
        GroupServiceFmToResponseTransformer.php

UserStory

UserStory — это конкретные пользовательские сценарии приложения. Внутри них сосредоточена вся логика, связанная с обработкой бизнес-сценария. Это важная часть модуля, где собирается весь функционал нашего метода.

Каждая UserStory реализует интерфейс App\Packages\UserStory\UserStory и содержит только один публичный метод. Это помогает избежать перегрузки: в одном сценарии логика не разрастается в несколько разных. Интерфейс для UserStory:

/**
* Пользовательский сценарий.
*/
interface UserStory
{
   /**
    * Выполняет пользовательский сценарий.
    */
   public function do(Input $input): Result;
}

Так выглядит список всех, уже реализованных пользовательских сценариев для модуля услуг:

Пакеты

Выше я уже упоминал неймспейс App\Packages. Он объединяет базовую инфраструктурную логику. При этом, чтобы класс ушел в пакет, стоит несколько раз подумать. Иначе есть риск из модульного монолита вернуться обратно в старый добрый монолит. Логика Package не завязана на конкретной бизнес-функциональности.

Например, у нас много клиентов для вызова наших внутренних сервисов. К сожалению, не везде есть типизация. Можно переписать все наши клиенты, но это займет слишком много ресурсов. Поэтому в рамках пакетов мы добавляем типизацию в методы на месте рефакторинга или нового функционала. Также делаем клиенты-адаптеры с типизацией. В будущем будет проще перенести это в отдельные библиотеки.

Кроме того, мы поместили в модули наши UserInterfaceDto. В проекте есть интересный функционал, который позволяет нашему Frontend-приложению на React определенным образом рендерить компоненты в зависимости от ответа. Это удобный инструмент для настройки UI, но он осложняется отсутствием типизации. Так как мы используем некоторые UI-элементы в разных частях нашего проекта, типизировали всё это и вынесли в пакет. Выглядит примерно так:

Тесты

Тестами мы покрываем все публичные методы. С точки зрения модульного монолита папка tests находится на верхнем уровне. Далее tests/Functional и tests/Unit. А внутри них — разделения по модулям в соответствии с файлами в самом модуле.

Что-то пошло не так

Рефакторинг часто сопровождается созданием новых багов — часто не учитывается старое поведение в новом коде. В старом оно скрывается «магией»: документация отсутствует и никому неизвестно, как это должно работать. Поэтому всегда есть риск посадить на прод новые ошибки.

К сожалению, с такой участью столкнулись и мы. Активно добавляли типизацию и то, что в контракте было типизировано, с приведением типов сломалось у нас в самый неудачный момент. Вот несколько важных уроков, которые мы извлекли:

  • покрываем как можно больше старого функционала тестами для фиксации поведения. Таким образом мы спокойно сможем менять на новую реализацию и знать, что ничего не сломалось;

  • переносим на модули поэтапно и не пытаемся изменить всё здесь и сейчас. Движение итерациями и частые обновления мастера позволили нам следить за изменением прода постепенно;

  • фиксим баги сразу от мастер ветки. Никаких откатов на старые реализации. Иначе так мы никогда не перейдем на модули;

  • если не уверены в чем-то, просим тестировщика перепроверить функционал.

Что получилось в итоге

Перенести полностью большой проект на модульную архитектуру одним махом — задача со звездочкой. С каждым спринтом постепенно продолжаем миграцию и работаем над улучшением архитектуры системы.

Также для поддержания актуальности веток мы стараемся как можно чаще лить все изменения в мастер. Например, если метод переносится на модули и не полностью готов, но некие компоненты уже реализованы и покрыты тестами — отправляем в мастер.

Что мы уже реализовали:

  • изолировали контексты — изменения в модуле цен больше не затрагивают модуль услуг: у каждого раздела свои сущности;

  • четко типизировали запросы и ответы — теперь они строго и понятно описаны, а код стал более документированным;

  • прозрачную структуру и быстрый онбординг на проект — система стала легче восприниматься, а погружать нового человека в проект стало проще;

  • увеличили покрытие тестами — вносить изменения уже не так страшно.

Спасибо, что дочитали! Если у вас остались вопросы — давайте обсудим.

Tags:
Hubs:
+6
Comments1

Articles

Information

Website
runity.ru
Registered
Employees
501–1,000 employees
Location
Россия
Representative
Рунити