Введение
Каждый, кто работал с микросервисами, независимо от используемого ЯП и фреймоврка сталкивался с тем, что за время жизни проекта и написания новых микросервисов вырабатываются общие решения, зачастую вспомогательного характера, переиспользуемые в каждом или почти в каждом микросервисе. Подобные общие функции принято выносить в разделяемый модуль\библиотеку (подходящий для вашего случая термин подчеркнуть) между всеми микросервисами. В данной статье будет рассмотрен способ эльфийского "изящного" подключения разделяемого модуля и его функций в экосистеме Spring Framework.
Состав common модуля
Для примера предположим, что в вашем common модуле находятся Spring-компоненты, решающие следующие задачи:
- Конфигурация CORS
- Логирование и трассировка
- Обработка ошибок и унификация формата тела ответа сервера при возникновении ошибок
- Feature flags
Функции 2 и 3, очевидно, будут востребованы в каждом разрабатываемом микросервисе, тогда как функции 1 и 4 - нет. То есть нам также нужна возможность конфигурации используемых функций common-модуля, чтобы не притаскивать в runtime микросервиса ненужные вещи.
Данные функции оформлены в виде отдельного Kotlin-модуля, среди зависимостей которого отсутствует Spring Boot, а также application-сервер. Common модуль в своих зависимостях объявляет только конкретные версии отдельных библиотек экосистемы Spring, необходимых для успешной компиляции разделяемых функций.
Подключение common модуля и его конфигурация
Опять-таки опустим подробности подключения непосредственного разделяемой библиотеки на уровне системы сборки - это достаточно тривиальный процесс, отлично решаемый при помощи 5 минутного гугления документации любой системы сборки. Сосредоточимся именно на конфигурации ApplicationContext-а Spring-а нашими разделяемыми функциями. Для начала посмотрим на итоговый результат - то, как мы будем конфигурировать common модуль на стороне микросервисов.
@EnableGentlemanToolkit(
provideCorsConfig = true,
provideExceptionHandlers = true,
provideLoggingFilter = true,
provideFeatureFlags = false
)
class SomeServiceApplication
Вот таким вот лаконичным способом, одной лишь аннотацией мы можем подключить нужные нам разделяемые функции к микросервису. В примере выше каждый параметр provide* отвечает за добавление в ApplicationContext нужных Spring-компонент из common модуля, реализующих требуемую разделяемую функцию.
Уличная магия
Теперь посмотрим на то, как достигается такое удобство подключения разделяемых функций. С теоретической точки зрения нам необходимо решить следующие задачи:
- Добавление в ApplicationContext при старте микросервиса дополнительных бинов
- Добавление бинов по параметру-условию настраивающей аннотации.
Для решения первой задачи нужно немного "пореверсить" спринговскую реализацию конфигурации приложений такими аннотациями как EntityScan, EnableAsync и так далее. После такого реверс-инжиниринга вырабатывается следующее решение.
Во-первых, к объявлению нашей настраивающей аннотации EnableGentlemanToolkit добавим спринговскую аннотацию org.springframework.context.annotation.Import, аргументом которой укажем нашу собственную реализацию org.springframework.context.annotation.ImportBeanDefinitionRegistrar:
import org.springframework.context.annotation.Import
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Import(EnableGentlemanToolkitRegistrar::class)
annotation class EnableGentlemanToolkit(
val provideCorsConfig: Boolean = true,
val provideExceptionHandlers: Boolean = true,
val provideLoggingFilter: Boolean = false,
val provideFeatureFlags: Boolean = false
)
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar
import org.springframework.core.type.AnnotationMetadata
class EnableGentlemanToolkitRegistrar : ImportBeanDefinitionRegistrar {
override fun registerBeanDefinitions(importingClassMetadata: AnnotationMetadata, registry: BeanDefinitionRegistry) {
val config = importingClassMetadata.getAnnotationAttributes(EnableGentlemanToolkit::class.java.name)!!
val provideCorsConfig = config["provideCorsConfig"] as Boolean
val provideExceptionHandlers = config["provideExceptionHandlers"] as Boolean
val provideLoggingFilter = config["provideLoggingFilter"] as Boolean
val provideFeatureFlags = config["provideFeatureFlags"] as Boolean
if (provideCorsConfig) registerAllowedCorsConfigBeanDefinitions(registry)
if (provideExceptionHandlers) registerExceptionHandlerBeanDefinitions(registry)
if (provideLoggingFilter) registerLoggingFilterBeanDefinition(registry)
if (provideFeatureFlags) registerFeatureFlagsBeanDefinition(registry)
}
}
На примере выше также видно решение задачи номер 2 - в ImportBeanDefitionRegistrar-е мы можем получить доступ к атрибутам аннотации, импотирующей данный Registrar и далее по условию либо регистрировать в BeanDefinitionRegistry требуемые бины, либо нет.
Регистрация же самих бинов выглядит следующим образом.
private fun registerBeanDefinitionIfMissing(registry: BeanDefinitionRegistry, clazz: KClass<*>) {
if (!registry.containsBeanDefinition(clazz.java.name)) {
registry.registerBeanDefinition(clazz.java.name, RootBeanDefinition(clazz.java))
}
}
fun registerAllowedCorsConfigBeanDefinitions(registry: BeanDefinitionRegistry) {
registerBeanDefinitionIfMissing(registry, AllowedCorsConfigProvider::class)
}
fun registerExceptionHandlerBeanDefinitions(registry: BeanDefinitionRegistry) {
registerBeanDefinitionIfMissing(registry, ExceptionHandlerProvider::class)
}
fun registerLoggingFilterBeanDefinition(registry: BeanDefinitionRegistry) {
registerBeanDefinitionIfMissing(registry, LoggingFilterBeanProvider::class)
}
fun registerFeatureFlagsBeanDefinition(registry: BeanDefinitionRegistry) {
registerBeanDefinitionIfMissing(registry, FeatureFlagsProvider::class)
}
Обратите внимание, что в BeanDefinitionRegistry мы региструем не сами бины, реализующие необходимые функции (хотя так тоже можно), а бины-провайдеры, помеченные аннотацией Configration. Вот пример одного из них.
@Configuration
class FeatureFlagsProvider {
@Bean
@Profile("stage", "prod")
fun gitlabFeatureFlagsManager(
environment: Environment
): FeatureFlagsManager = GitLabFeatureFlagsManager(environment)
@Bean
@Profile("dev")
fun stubFeatureFlagsManager(
environment: Environment
): FeatureFlagsManager = StubFeatureFlagsManager(environment)
}
Подход с использованием бинов-провайдеров позволяет сохранить привычный способ конфигурации бинов-функций (с заданием приоритета бина, имени, зависимости на профиле запуска и т.д.)
Заключение
В данной статье мы рассмотрели немного эльфийский способ настройки разделяемых функций для микросервисов. Данный способ отличается простотой использования на стороне микросервиса, позволяет уменьшить количество дублирующегося кода в кодовой базе микросервисов, ну и просто он выглядит весьма элегантно :)
Из недостатков замечено следующее:
- Подобным способом не получится добавить в ApplicationContext бины-репозитории из common модуля, так как их сканированием занимается отдельный монструозный франкенштейн от Spring Data Jpa. По крайней мере автор статьи не смог найти способа настроить программно пути сканирования этого франкенштейна.
- IDE (по крайней мере Intellij IDEA) не может осознать такой способ подключения, поэтому внедряемые бины из common модуля она "подсвечивет красным" на стороне микросервисов.
