
Много лет мы обсуждали, как разбить монолит на микросервисы. Микросервисная архитектура стала стандартом для создания сложных систем. Однако что делать, если растущее число сервисов начинает тормозить разработку, усложнять сопровождение и порождать избыточность? Забавно, что спустя столько времени я пишу статью о том, как вернуться к монолиту. Это история о том, как микросервисная архитектура сыграла с нами злую шутку, а монолит оказался спасением. Данный подход, хотя и кажется шагом назад, открыл нам возможность упростить код, снизить эксплуатационные затраты и навести порядок в хаосе микросервисов. В этой статье я поделюсь тем, как я переосмыслила процессы и нашла баланс между гибкостью микросервисов и преимуществами модульного подхода.
Итак, ситуация:
Я попала в свежую команду разработки внутренних продуктов, и перед нами стояла непростая задача: управлять 15 микросервисами, написанными в разное время разными командами, и относящимися к совершенно различным доменным областям. Среди них админки для тестировщиков и разработчиков, планировщики для топ-менеджмента, онбординг-боты для HR, админки для архитекторов, помощники для автотестеров и прочие.
Проблема усугублялась тем, что продакт постоянно приносил множество небольших проектов, предполагающих проверку гипотез, удаление устаревших или несостоявшихся сервисов, расширение функционала и добавление новых решений. Все эти проекты объединяла одна база пользователей – сотрудники компании, единая авторизация, логика прав доступа и общий аудит действий в админках.
Техническое состояние:
-
Большинство сервисов в различных бд на разных хостах, некоторые не обновлялись 5 лет
-
Логика выкладки у каждого сервиса своя, окружение различается, а обновление инфраструктуры долгое время игнорировалось
-
Все сервисы существуют в единственном экземпляре, без перспективы на высокую нагрузку
-
Логи присутствуют лишь местами, а метрики отсутствуют полностью
-
У каждого сервиса свой инфраструктурный код, который частично вынесен в библиотеки команд, но всё ещё требует отдельной выкладки, регистрации в сервис-дискавери, своих походов в хранилище секретов, коннектов к кролю и пр.
Принимаем ситуацию как есть и вырабатываем решение

Первой задачей было пройти 5 стадий принятия неизбежного. Сервисов было слишком много, нас слишком мало. У большинства сервисов сложность поддержки инфраструктуры превышала сложность самого сервиса. Помним, что мы все еще говорим о CRUD-админках и мелких ботах.
И вот, однажды утром, собравшись на еженедельном нытинге было принято решение: делаем монолит.
Классический монолит

В классическом монолите приложение выглядит как бутерброд: слой контроллеров, слой бизнес логики, слой базы данных, общие обработчики и модели. Много моделей. Желательно по пачке моделей на реквесты, респонсы, бизнесовые и dto. И пачка мапперов на каждый слой сущностей.
Честно сказать, вариант классического монолита даже не рассматривался. Все наши сервисы имели разные доменные области, моделей и контроллеров бесконечное количество. Размазывание логики тонким слоем по слоям классического монолита не входило в наши планы. Тем более у нас на руках уже имелась пачка микросервисов с определенными доменными областями. Поэтому перейдем сразу к вкусному.
Модульный монолит

Модульный монолит — это архитектурный подход, при котором приложение строится как единая кодовая база (монолит), но с чётким разделением на независимые модули. Этот вариант отлично подошел для решения нашей задачи. И инфраструктура общая, и есть место для общей логики, нет смешивания доменных областей, но все еще монолит и монореп.
Популярность модульных монолитов
Модульные монолиты — идея не только не новая, но еще и трендовая. Сейчас монолиты проживают свою вторую жизнь.

История сделала оборот и уставшие от микросервисов разработчики начинают подстраивать архитектуру под новую реальность. На мой взгляд, это связано с усложнением и повышением качества инфраструктурного кода: если 10 лет назад наши сервисы могли разворачиваться руками, ходить друг к другу по прямому имени и брать connection string к базе из конфиг-файла, то теперь времена изменились: мы стали умнее, а наши сервисы — более безопасными, отказоустойчивыми, гибкими и масштабируемыми. Однако одновременно с этим они стали более требовательными к инфраструктуре.
Паттерн SUFA

SUFA (Simple, Unified, Function-based Applications) — это архитектурный паттерн, который объединяет лучшие элементы различных подходов, таких как монолиты, микросервисы и бессерверные функции. Он сочетает простоту разработки монолита с масштабируемостью микросервисов. Проще говоря, паттерн SUFA — это частный случай паттерна модульного монолита.
Вкратце — это монолит, позволяющий поднимать его и как одну цельную сущность и как группу независимых друг от друга функций. Например, мы можем определить набор функций “HR инструменты” и, при необходимости масштабирования, развернуть не второй инстанс монолита, а отдельную группу функций (ASG — Auto-Scaling Group). На мой вкус, эта функция сомнительна в использовании, однако она хорошо подчеркивает изолированность модулей друг от друга, тем временем, каждый модуль будет зависеть от модуля авторизации или общих функций, которые непосредственного будут входить в каждую группу ASG.
Более подробно об этом паттерне можно почитать здесь
https://blog.suborbital.dev/meshing-a-modern-monolith
и здесь https://habr.com/ru/companies/piter/articles/678484/
Неочевидные плюсы и минусы модульного монолита
Плюсы:
-
Хост модульного монолита берёт на себя управление инфраструктурой проекта, упрощая процессы
-
Модуль обладает свойствами, схожими с микросервисом, но взаимодействие между модулями происходит через внутренние интерфейсы, а не через сеть, что повышает производительность
-
Значительно уменьшается объём инфраструктурного кода, упрощая поддержку.
Минусы:
-
Все модули или группы функционала деплоятся одновременно, что ограничивает гибкость развертывания
-
Монолит может быть ограничен числом пользователей, особенно если нагрузка начинает расти
-
Наличие большого числа проектов замедляет процесс сборки, что может сказаться на скорости разработки
Создаем модули и размазываем контроллеры по проекту.
Здесь и далее все примеры не принадлежат реальному сервису, это пустой проект созданный исключительно для демонстрации структуры кода.

Каждый микросервис — это отдельный модуль, в каждом модуле свой слой работы с бд, слой бизнес моделей и контроллеров. На практике некоторые микросервисы оказались слишком маленькими и в некоторых модулях пропущен слой бизнес моделей и мапперы гоняют данные из реквестов напрямую в dto, модульность приложения позволяет пропускать слои логики не нанося вред основной архитектуре.
И вот они злосчастные контроллеры в библиотеке! Честно, они мало кому нравятся на первый взгляд. Открывая любой чужой сервис ты по привычке всегда ищешь папку Controllers в корневом проекте. А в моем корне только програм, да стартап.

Для архитектуры SUFA хранение контроллеров в модулях — обязательное условие, ведь модульный монолит должен иметь возможность разворачиваться группами функций ASG. Но все же где хранить контроллеры — дело вкуса, контроллеры всех библиотек инициализируются в корневом проекте, корень имеет в зависимостях все модули, так что принципиально ничего не изменится, если вы не ставите себе цель поддержку выкладки набором функций. В моем варианте контроллеры ушли в модули из-за валидации, респонс/реквест моделей и обработчиков ошибок в апи методах. Это часть бизнес логики и мне не хотелось выносить её из модуля.
В стартапе инициализация инфрастуктуры и модулей
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddHealthChecks(); services.AddSwagger(); services.AddMetrics(); services.AddOnboardingAdminExtensions(); services.AddAccountAdminExtensions(); services.AddCommonManagerExtensions(); services.AddGeoAdminManagerExtensions(); }
В каждом модуле инициализируется свой набор необходимых функций в общем DI контейнере. Здесь же свой кэш и набор клиентов не вошедших в Common.
public static void AddSupportAdminExtensions(this IServiceCollection services) { services.AddControllers(); services.AddScoped<UserCalls>(); services.AddSingleton<UserSpammer>(); services.AddSingleton<SupportSomeFunction>(); services.AddSingleton<OtherFunctions>(); services.AddSingleton<IRepository<Calls>, CallsRepository>(); services.AddMemoryCache(); services.AddClients(); }
В Common общие для всех модулей хелперы

Главное в модульности — вовремя остановиться
Теперь мы живём в мире, где для добавления нового модуля не нужно подключать множество библиотек, выполнять выкладку и настраивать новый сервис. Все модули используют одну базу данных, а взаимодействие между ними представляет собой цепочку вызовов функций, а не сетевых запросов. В какой-то момент может показаться, что нужно создавать настоящий Service as a Function. Именно тогда стоит вспомнить весь накопленный опыт разработки микросервисов, принципы DDD и использование общих библиотек. Не дробите слишком сильно, используйте в модулях деление на доменные области и следите за их связанностью.
Используйте в монолите общий модуль Common. Пример что можно сюда вынести:
-
Фабрика общих клиентов
-
Коннект к базе
-
Общение с брокером сообщений
-
Подключение к хранилищу секретов
-
Коннект к распределенному кэшу
Инициализируйте общую инфраструктуру в хосте монолита:
-
Единый DI контейнер
-
Единый инициализатор логов и метрик
-
Централизованный процесс CI/CD.
-
Общая инициализация в service discovery
-
Единый Swagger для документации API
Маленькие Unit тесты
Если к этому моменту вы еще не потеряли сознание, у меня для вас еще новости: юнит тесты в библиотеках модулей.

Конечно, никто в этом мире не помешает вам сделать отдельный csproj с тестами. Во время инициализации вы сможете объединить юнит тесты с их проектом в один ASG, однако на уровне кода юнит тесты будут находиться отдельно от модуля с логикой. В моем случае все модули — это маленькие CRUD-сервисы, основная масса которых не нуждается в юнит тестах, но есть исключения. В примере показаны тесты на алгоритм глубокого сравнения bson документов, это один файл в модуле аудита. Может показаться что подобную логику лучше вынести в Common, вообще запросто. Однако я не выношу в Common алгоритмы, имеющие меньше одного потребителя, насколько бы общими они не казались.
Маленькие readme
Снабдим каждый модуль описанием проекта. Так мы будем знать чем занимается каждый модуль. В результате получаем следующую структуру проекта.

Архитектурные тесты на ссылки между проектами
Чтобы такая архитектура не превратилась в лапшу, необходимо ограничить использование ссылок на проекты. Мы же не хотим чтобы админка девопсов пользовалась функциями HR отдела и наоборот. Но хотим чтобы все пользовались общей авторизацией, плюс явно были определены наборы ASG функций. В этом нам помогут архитектурные тесты. Они станут блокировщиком ошибочных решений и послужат своего рода документацией.
Тест может быть любым, это тема для другой статьи. Оставлю здесь пример, проверяющий что все проекты используют только Common:
[Test] public void AllProjects_ShouldDependOnlyOn_AdminHubCommon() { // Укажите пути к папкам с проектами string[] projectPaths = new[] { @"../../../../AdminHub/AdminHub.csproj", @"../../../../AdminHub.AccountAdmin/AdminHub.AccountAdmin.csproj", @"../../../../AdminHub.GeoAdmin/AdminHub.GeoAdmin.csproj", @"../../../../AdminHub.SupportAdmin/AdminHub.SupportAdmin.csproj", @"../../../../AdminHub.OnboardingAdmin/AdminHub.OnboardingAdmin.csproj", @"../../../../AdminHub.Audit/AdminHub.Audit.csproj" }; string allowedDependency = "AdminHub.Common"; foreach (var path in projectPaths) { // Читаем файл .csproj var csprojContent = XElement.Load(path); // Извлекаем ссылки на другие проекты var projectReferences = csprojContent.Descendants("ProjectReference") .Select(pr => Path.GetFileNameWithoutExtension(pr.Attribute("Include")?.Value)) .ToList(); // Проверяем, что ссылки только на разрешённые проекты Assert.That(projectReferences.All(r => r == allowedDependency), Is.True, $"{Path.GetFileName(path)} содержит запрещённые зависимости: {string.Join(", ", projectReferences.Where(r => r != allowedDependency))}"); } }
Выводы
В итоге мы получили один большой модульный монолит. Вся информация внутренних админок теперь хранится в единой базе, и мы всегда знаем, где искать нужные данные. Скорость разработки значительно увеличилась: нет необходимости делать запросы к нескольким сервисам, а мелкие задачи можно решать пачкой. Каждый новый инструмент создаётся легко, словно добавление набора API-методов. Мы полностью уверены в актуальности инфраструктурного кода и используемых библиотек. Больше не нужно беспокоиться о старых микросервисах, забытых и покрытых пылью: все модули обновляются одновременно.
Можно ли было решить задачу другим способом? Конечно! Любая задача имеет минимум два решения. Можно продолжать развивать микросервисы: внедрять общие библиотеки, создавать шаблоны для выкладки, выделять отдельный микросервис для авторизации, автоматизировать обновление хостов через инструменты DevOps. Однако в данном случае я выбрала путь монолита.
Все трюки выполнены профессионалами, не пытайтесь повторить их в домашних условиях. Паттерн модульного монолита — не волшебное средство, а инструмент для решения специфической задачи. Прежде чем внедрять подобный подход, тщательно взвесьте плюсы и минусы. Действуйте, если вы уверены в своих силах и никто не пострадает.
ссылка на оригинал статьи https://habr.com/ru/articles/900628/
Добавить комментарий