Решение проблем общего кода в микросервисах Spring Boot и не только
Привет, Хабр! Меня зовут Александр Митин, я работаю в Холдинге Т1 на проектах одного крупного банка. Занимаюсь развитием продуктовых сервисов компании (проект по обслуживанию и проведению ЧДП/ПДП клиентов). Опыт разработки 13 лет, последние 5 лет — в финтехе.
Сегодня микросервисная архитектура используется во многих проектах. Зачастую в таких системах применяют журналирование, авторизацию и прочие служебные сервисы, общие для всех приложений. А при разработке возникает огромное желание не дублировать одну и ту же логику, а вынести её в отдельную библиотеку, тем самым переиспользовав код. Что мы имеем в итоге? В лучшем случае одну библиотеку «common», которая разрастается до огромных размеров и становится ядром распределённого монолита. В дальнейшем новые версии этой библиотеки теряют обратную совместимость, а каждое её обновление в проектах сильно осложняет поддержку. Более того, становится невозможным разобраться, где и какие классы используются, что делает архитектуру хрупкой и уязвимой.
Казалось бы, сама идея микросервисной архитектуры не должна предполагать возможности использования общего кода, или крайне ограничивать его. Почему так происходит? Одна или несколько продуктовых команд с Java-разработчиками пишут код системы, как правило, в сжатые сроки. Упор всегда делается на требование бизнеса, и разработка или копирование одного и того же кода выглядит избыточной и затратной по времени. Более того, при обнаружении ошибки потребуется не забыть исправить её во всех микросервисах. А это превратит поддержку системы в кошмар. Поэтому в реальной жизни не уйти от общего кода в микросервисах. В этой статье я постарался собрать весь свой опыт ведения общего кода в проектах. Эти рекомендации легко реализовать с помощью рефакторинга, чтобы улучшить понимание проекта.
Как правило, общим кодом библиотеки «common» оказываются:
DTO‑классы, модели и даже сущности, используемые в нескольких сервисах одновременно или для интеграции с несколькими системами;
абстракции, фабрики, стратегии и прочие общие поведенческие классы;
классы с логикой, которыми могут быть клиенты к смежным системам, или даже с запросами в БД;
утилитарные классы со статическими методами;
компоненты настроек, например общие бины Spring для настройки безопасности во всех сервисах;
обёртки для сторонних библиотек и стартеров.
Также такая библиотека может «подтягивать» в проекты общие зависимости. Например, стартер другой команды для обеспечения аудита событий.
Приведу свои рекомендации по рефакторингу систем, которые позволят облегчить жизнь и переиспользовать код.
Выносим API в отдельную библиотеку
Так выглядит типовая структура проекта на Java:
my-service-api/
├── src/
├──main/
├──java/
└──resources/
└──test/
├──java/
└──resources/
└──pom.xml
Если мы пишем приложение со своим API, то полезно вынести этот API в отдельную библиотеку. Структура проекта становится многомодульной:
my-service/
├── my-service-api/
├──src/
└──pom.xml
├── my-service-backend/
├──src/
└──pom.xml
└── pom.xml
При этом библиотека API подключается как зависимость к проекту backend
.
Преимущества такого подхода:
Появляется контракт, который лежит отдельно. При желании, можно выкинуть весь
backend
и написать заново.Есть «защита от дурака». Лишний раз подумаешь, а действительно ли стоит менять библиотеку API.
Версионирование API сервиса. Так как API меняется гораздо реже, чем логика, лучше использовать отдельные версии для
api
иbackend
.Возможность переиспользовать библиотеку API. Например, мы можем создать стартер с клиентом к нашему сервису и передать его другой команде.
Очень легко перейти из парадигмы CodeFirst в API First и обратно.
Количество пакетов и классов в backend уменьшается, это позволяет сосредоточиться на логике.
К самой библиотеке API предъявляются особые требования:
Максимально возможная обратная совместимость Очевидно, это необходимо для потребителей. Если же она невозможна, и мы точно знаем, что это ничего не сломает, то повышаем мажорную версию.
Для описания методов используем интерфейсы, реализации будем писать в проекте backend Помним, что наша задача — описать контракт взаимодействия с нашей системой. И не факт, что это будут RestControllers, возможно, завтра мы захотим реализовать этот контракт через Kafka или любой другой протокол.
Используем минимальный набор библиотек Это избавит нас от проблем с зависимостями. В наших проектах используется только Lombok, Springdoc (для описания Swagger) и spring‑validation (для объявления правил валидации полей).
По желанию, можно вынести интерфейсы асинхронного взаимодействия.
Имея библиотеку API, мы можем её переиспользовать в других микросервисах и создать на её основе клиенты, просто реализовав интерфейсы. Это избавит нас от необходимости включать в библиотеку «common» DTO-классы описания запросов и ответов, а также явно подсветит, с какими микросервисами происходит взаимодействие. Недостатком такого подхода является зависимость сборки микросервисов друг от друга в случаях изменений API и обновления версии библиотеки. Если не требуется синхронно изменять API и повышать версию клиента (помним про обратную совместимость), то проблем вообще не возникнет.
Итак, когда мы выделяем библиотеку API, у нас есть варианты описания API через CodeFirst или API First. Рассмотрим эти реализации.
Реализация API, подход Code First
Сначала мы пишем код, а Swagger-документация генерируется автоматически. При таком подходе я рекомендую придерживаться следующих правил:
Выносим в библиотеку DTO‑классы, описывающие запросы и ответы. Я рекомендую давать имена с использованием постфиксов «request» и «response» — это лучше отражает суть классов (
UserResponse
выглядит лучше, чемUserDTO
). Более того, эти названия перекочуют в документацию Swagger, а на её основе потребители могут генерировать клиенты.Не стоит плодить классы моделей, для описания запросов и ответов лучше использовать вложенные статические классы. И старайтесь не допускать иерархии — лучше, чтобы все вложенные классы были одного уровня:
public class UserCreateRequest { private final Integer id; private final City city; public static class City { private final Country country; } public static class Country { /*...*/ } }
Описываем интерфейсы без привязки к HTTP‑методам. Уже в самой реализации определимся, это будет POST или GET. Если на вход метода приходит объект, а нам нужно принимать его поля как параметры GET‑запроса, то в реализации используем аннотацию
@ParameterObject
.
Реализация API, подход API First
В своих проектах для описания документации я предпочитаю использовать OpenAPI (Swagger), поэтому далее будем говорить в контексте этой технологии. Прекрасным инструментом кодогенерации на основе OpenAPI зарекомендовал себя OpenAPI Generator.
Итак, создать наш API проще всего с помощью библиотечки с одним файлом swagger.yaml, в котором будут описаны все HTTP-методы.
my-service/
└──my-service-api/
├── src/
└──resources/
└──swagger.yaml
└──pom.xml
Остаётся только настроить OpenAPI Generator для создания Java-классов и указать ему файл swagger.yml. Конечно, можно не включать swagger.yaml в проект и скачивать его при сборке, например, из dev-среды. Но тогда сборка проекта будет очень часто разваливаться. Для описания асинхронных взаимодействий можно использовать стандарт AsyncAPI, принцип работы будет аналогичным.
Реализация проекта с документацией
В случаях, когда наши микросервисы активно взаимодействуют друг с другом и имеют общие модели, а сборка микросервисов должна быть независимой, следует рассмотреть вариант с созданием отдельного проекта со Swagger-документацией. Тогда все микросервисы должны использовать подход API First. При этом отпадает необходимость использовать общий код DTO-классов, описывающих запросы и ответы, такие классы будут генерироваться автоматически из YAML-файлов. Ещё одним преимуществом такого подхода может стать независимое развёртывание, отдельное ветвление, версионирование документации и т. д. А главный недостаток — более сложный процесс CI/CD.
Из коробки интерфейс Swagger позволяет отображать несколько API. Для этого достаточно прописать пути к ним в swagger-initializer.js:
window.ui = SwaggerUIBundle({
urls:[
{"name":"my-service1","url":"/api-doc/services/my-service1/swagger.yml"},
{"name":"my-service2","url":"/api-doc/services/my-service2/swagger.yml"},
{"name":"my-service3","url":"/api-doc/services/my-service3/swagger.yml"}
],
dom_id: '#swagger-ui',
deepLinking: true,
/* ... */
});
При этом структура проекта будет такая:
swagger-project/
├──services/
├──my-service1/
└── swagger.yml
├──my-service2/
└── swagger.yml
└──my-service3/
└── swagger.yml
├──swagger-ui/ # swagger ui html, js, css files
└── ... # Dockerfile and others
Такой проект можно развернуть в Docker-контейнере, используя, например, Nginx. Также можно запустить контейнер на локальной машине и при разработке документации на лету смотреть внесённые в YAML-описания изменения, просто обновив страницу Swagger-документации. Более того, можно писать документацию с помощью Swagger Editor. Однако такой подход не предполагает переиспользования моделей между микросервисами. Более того, файлы Swagger могут разрастаться до огромных размеров, что затрудняет поддержку. Эти проблемы можно решить, модифицировав структуру проекта следующим образом:
swagger-project/
├──services/
├──my-service1/ # Документация my-service1
├──components/ # Описание всех компонентов (объектов)
├──model/ # В этом каталоге хранятся модели данных
├──User.yml
└── ...
├──request/ # Описание запросов (могут использовать модели данных)
├──UserRequest.yml
└── ...
├──response/ # Описание ответов (могут использовать модели данных)
├──UserResponse.yml
└── ...
└──schemas/ # Описание схем
├──common.yml # импорт компонентов из common (для лучшего контроля)
├──request.yml # Описание схем запросов
└──response.yml # Описание схем ответов
├──operations/ # Каталог с описанием операций (используя схемы)
├──user-crud.yml
└── ...
└──openapi.yml # Главный файл, в котором перечислены ссылки на операции
└──my-service2/
└── ...
└──common/ # Каталог с общими компонентами
└──components/
└──schemas/
├──ErrorResponse.yml
└── ...
├──swagger-ui/ # Swagger UI HTML, JS, CSS files
└── ... # Dockerfile and build.gradle
Эта иерархия может показаться сложной, но она проистекает из самой структуры стандарта OpenAPI. При таком подходе в YAML-файлах мы можем ссылаться на другие файлы в различных каталогах, используя относительные пути.
Пример файла services/my-service1/components/schemas/response.yml:
components:
schemas:
UserResponse:
$ref: '../response/UserResponse.yml#/UserResponse'
Такой проект можно тоже развернуть в Docker-контейнере и использовать для локальной разработки. Тут не подойдёт Swagger Editor, проще всего будет писать YAML-файлы вручную. Для того, чтобы генерировать Java-код из такой иерархии, следует для каждого микросервиса собрать документацию в один файл, как в примере в начале главы. Это делается с помощью OpenAPI Generator и OpenAPI YAML Generator с указанием корневых файлов openapi.yml. На мой взгляд, удобнее всего использовать для этих целей Gradle с готовым плагином. Процесс CI/CD в этом случае может быть разным, например:
Собираем проект с документацией и развёртываем его в репозитории как Maven‑артефакт (ZIP‑архив). При сборке микросервиса скачиваем архив с документацией, распаковываем и создаём Java‑код в указанном Swagger файле.
Подключаем проект с документацией через.gitmodules. Первым шагом у нас идёт сборка проекта с документацией, затем нашего микросервиса.
Собираем и развёртываем проект с документацией в dev‑контуре, при сборке микросервисов указываем на него ссылку.
Лично я предпочитаю первый вариант, он наиболее прозрачный с точки зрения версионирования API. Другими словами, при сборке микросервиса он должен опираться на конкретную версию API, а не последнюю.
Общие зависимости
В некоторых проектах нужно использовать во всех микросервисах одну и ту же библиотеку или стартер. Например, у нас в компании есть команда, отвечающая за мониторинг систем, и она выпустила свой стартер с реализациями метрик. Может показаться хорошей идеей подключить такой стартер в библиотеку «common» как зависимость, чтобы она автоматически появилась во всех микросервисах. Но такой подход скрывает множество проблем:
Не факт, что эта зависимость завтра будет нужна, или нужна во всех микросервисах.
Очень сложно отследить, где конкретно используется эта зависимость.
Проблемы с версионированием и обратной совеместимостью таких завимостей. Однажды половина микросервисов может просто не собраться из‑за рекомендованного повышения версии. Это заставит нас единомоментно переписывать код всех микросервисов.
Таких стартеров может быть много, и при подключении библиотеки «common» к микросервису они могут вступить в конфликт.
Я описал лишь часть недостатков, но их уже достаточно, чтобы отказаться от такого подхода. Моя рекомендация: все зависимости необходимо подключать к проекту напрямую.
Общие классы для работы с БД
В некоторых случаях может показаться хорошей идеей вынести слой работы с базой данных в библиотеку. Как показывает практика, не стоит этого делать ни при каких обстоятельствах, каким бы ни был соблазн. Все классы @Entity
, JDBC или интерфейсы SpringData
— это внутренняя логика конкретной реализации. Даже если на текущий момент она общая или похожая для нескольких микросервисов, уже завтра она может различаться, поэтому её нельзя считать общим кодом.
Переписываем common на «микросервисы»
Как я говорил ранее, библиотека «common» может выродиться в ядро монолита. Чтобы этого не произошло, я предлагаю пересмотреть концепцию организации общего кода и использования такой библиотеки. Идея заключается в том, чтобы создать набор микроутилит, которые выполняют только одну функцию, и делают это хорошо (что-то вроде пакетов Linux). Для этого мы можем преобразовать нашу библиотеку в простой многомодульный проект:
common/
├──common-core
├──common-logging/
├──src/
├── README.md
└── ... # build and other files
├──common-monitoring/
├──src/
├── README.md
└── ... # build and other files
├──common-systemA-client/
├── ... # other modules
├── build.gradle # build files
└── README.md # other modules
Такой проект может иметь более сложную структуру:
common/
├── common-core
├── common-utils
├──common-logging/
├──common-monitoring/
└── ...
├── common-clients
├──common-systemA-client/
├──common-systemB-client/
└── ...
└── ... # other modules
В этом случае у нас есть множество хорошо протестированных и задокументированных модулей, которые мы можем при необходимости подключать. При написании такого проекта, я рекомендую соблюдать несложные правила:
Используем минимальный набор зависимостей, необходимый для написания функциональности. Все зависимости подключаются в каждом модуле отдельно. Версии общих зависимостей, таких как Spring, можно указывать в свойствах.
Утилиты независимы друг от друга. Ни в коем случае не стоит подключать одну утилиту как зависимость другой.
Все утилиты включают в себя библиотеку «common‑core». Она позволит вынести какие‑то общие классы для нескольких утилит и переиспользовать их (например, это могут быть исключения).
Прежде чем добавлять в «common‑core» новый код, несколько раз подумайте, нет ли других вариантов. Не стоит добавлять туда любую логику. Помните, что «core» всегда будет подключена к проекту, в отличие от того или иного модуля.
Внимательно следим за тем, чтобы утилиты не разрастались. Библиотеки должны выполнять функцию, которая интуитивно понятна из названия.
По возможности делайте библиотеки стартерами. Ведь приятно подключить модуль, который просто работает из коробки в вашем проекте.
Единое версионирование для всех библиотек. Это позволит менять версию в одном месте и документировать изменения в целом по проекту.
По возможности, версии зависимостей «common» не должны перезатирать версии зависимостей в микросервисе. В Gradle для этого прекрасно подходит указание зависимостей через конфигурацию
api()
.Максимально возможное покрытие модульными тестами. Помните, это общий код, который используется во всех микросервисах, а значит он требует больше внимания.
В каждой библиотеке используйте файл README.md для документирования функций, настроек и вариантов использования этой утилиты.
Хорошей практикой будет описывать список изменений для каждой версии в файле README.md корневого проекта.
Такой подход позволяет нам использовать в конкретном микросервисе только те куски общего кода, которые требуются. А в случае необходимости мы можем безболезненно удалить ненужный модуль и написать «свою» реализацию в микросервисе. Очевидным преимуществом является масштабируемость проекта: какая-то логика может добавляться, какая-то переписываться, а какая-то и вовсе умирать. Многомодульный проект позволяет нам при разработке, отладке и тестировании собирать только нужный модуль, без пересборки проекта целиком.