Дисклеймер
Эта статья описывает архитектуру построения приложений на Java с помощью фреймворка Spring. Если вы используете другие языки программирования или фреймворки, возможно, вы также сможете найти соответствующие инструменты, подходящие именно вам.
Введение
Какое‑то время назад, проработав 3 года на позиции Java Middle+‑ разработчика на крупном монолитном легаси‑проекте (естественно, модульном), я столкнулся с тем, что не имел практики в создании приложений на микросервисной архитектуре. При этом я понимал, что работа с микросервисами — это работа с актуальным стеком технологий в мире Java. И если ты не работаешь с микросервисами, то ты вроде как не имеешь необходимой квалификации для большинства вакансий на рынке труда. По крайней мере, если судить по самим вакансиям.
Поэтому я решил, что мне нужен какой‑то достаточно крупный проект, который бы дал мне основные практические навыки работы с микросервисами. Это должен быть проект, выходящий за рамки пет‑проекта с животными или чего‑то подобного. При этом, отработав на Java более трех лет, я уже хотел сделать что‑то свое, разработать архитектуру с самого начала. Думаю, это нормальное желание для мидл‑программиста, который хочет развиваться в своей сфере.
Я какое‑то время размышлял над тем, что это будет за проект, были различные варианты, некоторые я даже начинал реализовывать, но чувствовал, что это все не то. Нет той самой необходимости, того, что решало бы мою боль, ну или хотя бы закрывало мои некоторые потребности.
В конце концов, в очередной раз пытаясь прочитать книгу по микросервисам на английском языке в виде PDF, я понял, что это должен быть за проект. Это должен быть сервис, который бы переводил PDF, но не просто его переводил, а выдавал в качестве результата такой же PDF, сохраняя при этом оригинальное форматирование и весь оригинальный контент в виде кривых, изображений, рисунков, таблиц, колонтитулов и всего остального, что является неотъемлемой частью любой нормальной книги или статьи. И я сделал это приложение. И сделал я его не на микросервисах…
Модульный монолит — классика энтерпрайз‑решений
Так как вся статья основана на рассказе про теоретические аспекты через практическую реализацию моего приложения, то мне просто необходимо рассказать, как и почему принималось то или иное решение по архитектуре. Поэтому сначала пара слов о самой архитектуре.
Сначала я честно хотел построить приложение на микросервисах, завел соответствующую инфраструктуру, скачал необходимые докер контейнеры (Kafka, ZooKeeper, Eureka, Redis и тому подобное). С чего еще начинать разработку нового приложения, как не со скачивания всех нужных и ненужных на данный момент инструментов? Однако, поскольку я начинал разработку с самого простого, с проработки Proof of Concept, делать это сразу на микросервисах было довольно странно и затратно по времени. Поэтому я начал стандартно — с того, что умел и чему учит любое руководство по Spring: с создания приложения на Spring Boot с горизонтальной архитектурой, то есть построенной на разделении по техническим слоям (package‑by‑layer): контроллеры, сервисы, репозитории, сущности, DTO, конфигурация.
Сразу перечислю основные недостатки слоистой архитектуры в одном модуле:
Одна фича может быть размазана по всем пакетам. Чтобы что‑то изменить, нужно править в разных местах.
Нет границ между функциональностями. Пакеты разделяют слои, но никак не разделяют домены. Граф вызовов превращается в клубок, и неизбежны циклические зависимости.
Все становится public. Нет нормальной инкапсуляции. Владение данными размывается, любой сервис может напрямую взять чужой репозиторий и пойти в чужие таблицы. Нарушается консистентность данных.
Сервис превращается в свалку. Десятки классов в одном пакете без группировки.
Проблемы с рефакторингом, границы нельзя проверить, сложности в тестировании.
Структура не рассказывает о домене.
Правда, стоит оговориться, что сейчас никто в здравом уме не строит чисто монолитное приложение (шутка), а разбивают функциональность по модулям, где отдельный модуль — это подпроект Gradle или Maven, в данной статье речь везде будет идти про Gradle. В модульной архитектуре нарезка становится двухуровневой: по доменам — модулям Gradle (вертикальная архитектура) и по слоям внутри модуля (горизонтальная архитектура). Слоистость никуда не девается, но перестает быть верхним уровнем: так мы избавляемся от главной проблемы монолитного приложения — отсутствия границ между его частями. Любой класс может обратиться к любому другому классу. Со временем связи разрастаются в разные стороны, появляются круговые зависимости, и любое изменение уже перестает быть местным. Модули нужны, чтобы вернуть эти границы.
settings.gradle ├── app ← @SpringBootApplication, собирает все вместе ├── shared ← общий код, DTO, утилиты ├── auth │ └── src/main/java/.../auth/{controller,service,repository,domain} ├── document └── translation
Граница здесь — это граф зависимостей в build.gradle:
// document/build.gradle dependencies { implementation project(':shared') }
Модульный монолит — это одно приложение, разделенное внутри на модули с явными границами. Есть несколько способов собрать такое приложение, и различаются они двумя вещами: что получается на выходе сборки и как это запускается.
Первый вариант — все модули собираются в одно приложение. Каждый модуль компилируется в обычную библиотеку и на выходе мы получаем один запускаемый файл. Это самый распространенный вариант модульного монолита. Обычно сокрытие классов внутри бизнес‑модулей реализуется через разделение каждого модуля на два подпроекта: ‑api с контрактами и ‑impl с реализацией.
Второй вариант — каждый модуль выпускается отдельно. Модуль собирается, получает собственную версию и публикуется в хранилище библиотек. Приложение подключает его как внешнюю зависимость, наравне с другими библиотеками. Это имеет смысл, когда модуль нужен нескольким разным приложениям.
Таким образом, мы решаем основные проблемы монолитного приложения, а именно:
у нас есть границы, которые проверяет компилятор;
циклические зависимости устраняются построением модулей;
виден реальный граф связей, который записан явно, а не выводится из импортов;
тесты модуля можно запускать отдельно;
при необходимости модуль проще вынести в отдельный микросервис.
Практическим следствием является то, что контекст все равно один, вызов из модуля в модуль остается обычным вызовом метода, транзакция может проходить через границу, а отладчик идет по коду насквозь. То есть, когда приложение запускается, никаких модулей нет, Spring поднимает один общий контекст, все бины оказываются в общей куче, все работает в одной JVM.
Да, несомненно, модульный монолит является классикой энтерпрайз‑решений, и, может, стоило просто взять и пойти по известному пути. Тем более что на таком решении я проработал без малого три года. Но не стоит забывать, что изначальная цель была в том, чтобы попробовать что‑то новое. И раз микросервисы остались невостребованными в силу излишней сложности и неоправданности по затратам на данном этапе, то однозначно нужно было брать что‑то более свежее, модное, молодежное и, возможно, даже более простое и эффективное.
Spring Modulith — модульный монолит без модулей
Все знают, что Spring Modulith — это решение для создания структурированного приложения Spring Boot, которое помогает работать с модулями приложения, выделенными по предметной области.
Однако для кого‑то может стать откровением то, что, по сути, мы работаем не с модулями приложения в классическом энтерпрайз‑стиле, а с обычными пакетами. Таким образом, мы довольно сильно упрощаем саму структуру приложения, его настройку и деплой.
В основе лежит простое решение: границы описываются обычными пакетами внутри одного проекта, а следит за всем этим единственный тест. Каждый пакет верхнего уровня считается модулем. Все, что лежит в корне пакета, — это его публичное описание, то, чем модуль разрешает пользоваться снаружи. При этом все, что лежит внутри, во вложенных пакетах, — уже внутренняя реализация и обращаться к ней из других модулей нельзя.
io.github.receus.chatbot ├── document │ ├── SubmitPdfJobUseCase.java ← можно вызывать снаружи │ ├── JobCompletedEvent.java ← можно │ └── internal/ ← нельзя, это внутренности модуля │ ├── domain/ │ ├── service/ │ └── adapter/ └── translation └── ...
Проверяется все это одним тестом:
ApplicationModules.of(ChatbotApplication.class).verify();
Если нарушены границы, направление зависимостей или появилась циклическая зависимость, тест упадет. Ок, с границами разобрались. А что насчет архитектуры внутри модуля?
Гексагональная архитектура — еще одно модное веяние?
Гексагональная структура внутри модуля — на примере модуля document:
document/ ├── SubmitPdfJobUseCase.java ← что модуль умеет: принять PDF в работу ├── GetJobStatusUseCase.java ← узнать состояние задания ├── JobCompletedEvent.java ← о чем модуль сообщает наружу ├── PageSelection.java ← данные, которыми обмениваемся └── internal/ ├── domain/ ← ProcessingJob, ProcessingPage ├── port/out/ ← JobRepository, PageRepository — что модулю нужно снаружи ├── service/ ← DefaultDocumentService и остальная логика └── adapter/ ├── in/ ← кто модуль вызывает └── out/ ← чем закрыты потребности из port/out
В корне пакета модуля мы размещаем контракты с внешним миром. Интерфейсы с действиями, которые модуль разрешает выполнять, события, о которых он сообщает, и записи, которыми обменивается. И только это видят соседние модули.
domain — понятия предметной области. Задание, страница, их состояние и правила перехода между ними. Нет ни базы, ни Spring, ни внешних вызовов.
port/out — интерфейсы того, что модулю нужно снаружи. Например, сохранить задание или прочитать страницы. Это объявление потребности, а не ее исполнение.
service — реализация того, что обещано в корне модуля. Работает с понятиями из domain и с интерфейсами из port/out, ничего не зная о том, кто за ними стоит.
adapter — связь с внешним миром. Внутри in то, что вызывает модуль: обработчик команды, контроллер. Внутри out то, чем закрыты объявленные потребности: реализация репозитория к базе данных, клиента к внешнему сервису.
Вся суть в направлении зависимостей. Все зависимости смотрят внутрь: адаптеры зависят от сервисов, сервисы от понятий предметной области. Детали зависят от абстракций — тот самый принцип инверсии зависимостей.
Spring Modulith как бы подталкивает к использованию подобной архитектуры. Modulith требует, чтобы наружу торчал только корень пакета модуля. Гексагональный подход требует, чтобы наружу торчал только входной порт. В итоге, входные интерфейсы естественным образом оказываются в корне, а вся начинка — в internal. Одно правило, проверяемое тестом, работает сразу на обоих уровнях.
При этом, никто, конечно, не запрещает использование слоистой архитектуры. И в целом, это вполне может быть оправдано для модуля, нацеленного на работу с CRUD. Когда же основная работа модуля, это алгоритмы или работа с внешними взаимозаменяемыми сервисами, то слоистая архитектура как будто находится не в своей тарелке.
Простота тестирования
Хотелось бы упомянуть также простоту тестирования отдельных модулей или частей приложения. В примере ниже я прогоняю тестовую страницу с ограниченным контекстом через выборочные сервисы, минуя ненужные.
@SpringBootTest(classes = PageDevContext.class) @ActiveProfiles("pagedev") class PageDevRun { @Autowired AnalyzePdfPageUseCase analyzeUseCase; // модуль разбора страницы @Autowired FontPreparationUseCase fontPreparation; // модуль подбора шрифтов @Autowired TranslatePdfPageUseCase translateUseCase; // модуль перевода @Autowired RenderTranslatedPageUseCase renderUseCase; // модуль верстки
И сам прогон — всего четыре вызова:
@Test void dev() throws IOException { PageCase c = ...; // папка с исходной страницей PdfPage analyzed = analyzeUseCase.analyze(c.inputPdf(), 1, 0L); Map<String, Path> fonts = fontPreparation.prepareFonts( Files.readAllBytes(c.inputPdf()), "input.pdf", targetLang); PageTranslation translation = translateUseCase.translate(analyzed, targetLang, TranslationEngine.CLOUD); byte[] pdf = renderUseCase.render(translation.page(), fonts); TestOut.writeBytes(c.name() + "_dev.pdf", pdf); }
Каждый из этих модулей участвует в конвейере ровно одним действием: разобрать страницу, подготовить шрифты, перевести, отрисовать. Все остальное спрятано внутри и снаружи недоступно. Поэтому чтобы собрать конвейер, не нужно ничего знать про устройство модулей — достаточно взять их точки входа и вызвать по очереди. Оркестратор в приложении работает точно так же, только задания к нему приходят из очереди.
Общение между модулями
В моем приложении используются три способа: прямой вызов через публичный интерфейс; события; объявление потребности.
При прямом вызове модуль объявляет, что он умеет, другой модуль берет этот интерфейс и вызывает.
jobRepository.save(job); quotaUseCase.settle(job.getUserId(), totalPages(job), successCount);
Модуль заданий, завершая работу, сразу списывает израсходованные страницы у модуля учета. Обе операции происходят в одной транзакции: либо задание отмечено завершенным, и квота списана, либо не произошло ни того ни другого.
Прямой вызов выбирается, когда нужен ответ или когда результат обязан быть согласован с тем, что делает вызывающий.
События. Модуль сообщает, что что‑то произошло, при этом ему все равно, кто это слушает и слушает ли вообще.
// модуль заданий: работа закончена eventPublisher.publishEvent(new JobCompletedEvent(jobId, userId, total, success, failed)); // модуль Telegram: отправить человеку результат @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onJobCompleted(JobCompletedEvent event) { ... }
Модуль заданий ничего не знает про Телеграм. Он его не вызывает, не хранит на него ссылку и не подключает его к себе.
События разрывают круговые зависимости. Телеграм вызывает модуль заданий. Если бы модуль заданий в ответ вызывал Телеграм, получилась бы круговая зависимость. Такая зависимость не пройдет проверку Spring Modulith.
И третий способ: один модуль объявляет потребность, другой ее закрывает.
Модуль обработки хочет сообщать о ходе работы, но не должен зависеть от того, кто и где это хранит. Поэтому он объявляет у себя интерфейс:
// модуль обработки public interface PageProgressPort { ... }
А реализует его модуль заданий, который владеет этим хранением:
class PageProgressAdapter implements PageProgressPort { ... }
Здесь важно обратить внимание на направление зависимости. Зависимость идет от модуля заданий к модулю обработки. Модуль обработки не знает ни про базу данных, ни про то, что существует модуль заданий. Таким же способом реализован выбор способа отрисовки: обработка объявляет, что ей нужно получить готовую страницу, а реализуют это разные модули верстки, выбор реализации определяется настройкой.
Каким образом выбирается способ взаимодействия:
Нужен ответ или общая транзакция — прямой вызов.
Нужно сообщить о факте — событие.
Нужно, чтобы кто‑то что‑то сделал, но зависеть от него нельзя, — свой интерфейс, чужая реализация.
Возможно, вам не нужна и Kafka
В моем приложении все модули работают в одном процессе, поэтому событие — это обычный вызов внутри той же машины. Kafka или RabbitMQ здесь не решали бы никакой задачи, а добавили бы к приложению еще одну систему, которую нужно поднимать, настраивать и чинить.
Единственное, чего не хватает обычным событиям, — надежность. Если приложение упадет ровно между записью и отправкой сообщения клиенту, событие пропадет. Spring Modulith умеет это решать: у него есть возможность сохранять опубликованные события в базу и досылать необработанные после перезапуска.
Однако в данном приложении это реализовано немного иначе. Надежность обеспечена еще проще — состоянием в базе данных. У задания есть статус и отметка о том, отправлен ли результат. При старте приложение само находит задания, которые остались незавершенными или неотправленными, и доделывает их.
А что же приложение?
Мне бы хотелось детальнее познакомить всех со своим приложением, над которым я работал довольно долгое время. Однако, статья не об этом. Она про обоснованный выбор тех или иных архитектурных решений конкретного приложения. К сожалению, мне так и не удалось на практике изучить все прелести работы с микросервисами, но я все еще не теряю надежды. Мой путь как программиста начался не так давно, и я надеюсь, что мне предоставится такая возможность в будущем. Ну или это приложение когда‑нибудь вырастет до масштабов, когда микросервисы станут действительно правильным для него решением. Напоследок я все же покажу вам пару примеров того, как работает мое приложение на данном этапе.

