Я стала злодейкой и теперь мои контроллеры лежат в библиотеках. Архитектурный паттерн SUFA в .net приложении

от автора

Много лет мы обсуждали, как разбить монолит на микросервисы. Микросервисная архитектура стала стандартом для создания сложных систем. Однако что делать, если растущее число сервисов начинает тормозить разработку, усложнять сопровождение и порождать избыточность? Забавно, что спустя столько времени я пишу статью о том, как вернуться к монолиту. Это история о том, как микросервисная архитектура сыграла с нами злую шутку, а монолит оказался спасением. Данный подход, хотя и кажется шагом назад, открыл нам возможность упростить код, снизить эксплуатационные затраты и навести порядок в хаосе микросервисов. В этой статье я поделюсь тем, как я переосмыслила процессы и нашла баланс между гибкостью микросервисов и преимуществами модульного подхода.

Итак, ситуация:

Я попала в свежую команду разработки внутренних продуктов, и перед нами стояла непростая задача: управлять 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/

Неочевидные плюсы и минусы модульного монолита

Плюсы:

  1. Хост модульного монолита берёт на себя управление инфраструктурой проекта, упрощая процессы

  2. Модуль обладает свойствами, схожими с микросервисом, но взаимодействие между модулями происходит через внутренние интерфейсы, а не через сеть, что повышает производительность

  3. Значительно уменьшается объём инфраструктурного кода, упрощая поддержку.

Минусы:

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

  2. Монолит может быть ограничен числом пользователей, особенно если нагрузка начинает расти

  3. Наличие большого числа проектов замедляет процесс сборки, что может сказаться на скорости разработки

Создаем модули и размазываем контроллеры по проекту.

Здесь и далее все примеры не принадлежат реальному сервису, это пустой проект созданный исключительно для демонстрации структуры кода.

Каждый микросервис — это отдельный модуль, в каждом модуле свой слой работы с бд, слой бизнес моделей и контроллеров. На практике некоторые микросервисы оказались слишком маленькими и в некоторых модулях пропущен слой бизнес моделей и мапперы гоняют данные из реквестов напрямую в 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. Пример что можно сюда вынести:

  1. Фабрика общих клиентов

  2. Коннект к базе

  3. Общение с брокером сообщений

  4. Подключение к хранилищу секретов

  5. Коннект к распределенному кэшу

Инициализируйте общую инфраструктуру в хосте монолита: 

  1. Единый DI контейнер

  2. Единый инициализатор логов и метрик

  3. Централизованный процесс CI/CD.

  4. Общая инициализация в service discovery

  5. Единый 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *