Дисклеймер

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

Однако в данном приложении это реализовано немного иначе. Надежность обеспечена еще проще — состоянием в базе данных. У задания есть статус и отметка о том, отправлен ли результат. При старте приложение само находит задания, которые остались незавершенными или неотправленными, и доделывает их.

А что же приложение?

Мне бы хотелось детальнее познакомить всех со своим приложением, над которым я работал довольно долгое время. Однако, статья не об этом. Она про обоснованный выбор тех или иных архитектурных решений конкретного приложения. К сожалению, мне так и не удалось на практике изучить все прелести работы с микросервисами, но я все еще не теряю надежды. Мой путь как программиста начался не так давно, и я надеюсь, что мне предоставится такая возможность в будущем. Ну или это приложение когда‑нибудь вырастет до масштабов, когда микросервисы станут действительно правильным для него решением. Напоследок я все же покажу вам пару примеров того, как работает мое приложение на данном этапе.

Среднее время получения подобной страницы в пределах 2 секунд
Среднее время получения подобной страницы в пределах 2 секунд