Появилась потребность в том, чтобы определенные компоненты сервисов умели подтягивать обновленную конфигурацию и работать на основе этой конфигурации, т.е. конфигурация приложения меняется уже после запуска сервиса. Я проведу небольшой обзор подходов, которые нашел применительно к такой задаче и то, на чем остановился.
На проекте используется spring boot 2.6.4 и kotlin 1.5.31. Также для конфигурации сервисов используется spring cloud config server, где в качестве backend используются Git + Vault.
Spring Cloud Config Server
Для тестирования подходов легче использовать в качестве backend Spring Cloud Config Server файловую систему:
#docker-compose.yml version: '2' services: config-server-env: container_name: config-server-env image: hyness/spring-cloud-config-server:2.2 ports: - "8888:8888" environment: SPRING_PROFILES_ACTIVE: native volumes: - ./config:/config
Поместим конфигурацию для приложения refresh_app с профилем dev в директорию ./config:
#config/refresh_app-dev.yml refresh: property1: 1
После запуска данной конфигурации можно проверить, какую конфигурацию отдает Spring Cloud Config Server для тестового сервиса refresh_app:
>curl http://localhost:8888/refresh_app/dev {"name":"refresh_app","profiles":["dev"],"label":null,"version":null,"state":null,"propertySources":[{"name":"file:config/refresh_app-dev.yml","source":{"refresh.property1":1}}]}
Конфигурация Spring Cloud Config Client (сервиса refresh_app)
//build.gradle .............. implementation "org.springframework.boot:spring-boot-starter" implementation "org.springframework.cloud:spring-cloud-starter-config" implementation "org.springframework.boot:spring-boot-starter-actuator" implementation "org.springframework.boot:spring-boot-starter-web" ..............
#application.properties spring.config.import=optional:configserver:http://localhost:8888 spring.application.name=refresh_app server.port=8080 management.endpoints.web.exposure.include=*
Обновление конфигурации и синхронизация с сервисом
Для обновления конфигурации refresh_app на стороне Spring Cloud Config Server в нашем примере достаточно просто обновить файл config/refresh_app-dev.yml. Чтобы убедиться в том, что наш сервер отдает уже обновленную конфигурации можно снова выполнить запрос GET /refresh_app/dev к spring cloud config server.
Далее будут использоваться actuator endpoints сервиса refresh_app:
-
POST /actuator/refresh
Вызов данного endpoint выполняет запрос текущей конфигурации у Spring Cloud Server. Сверяет полученную конфигурацию с текущей конфигурацией приложения и генерирует список properties, которые изменились, и обновляет Environment сервиса. Response body данного запроса содержит Set измененных properties. Также внутри приложения генерируется событие EnvironmentChangeEvent, которое тоже содержит Set обновлений, на него можно подписаться.
-
GET /actuator/env
Response body отображает текущий Environment сервиса. С помощью данного вызова можно убедиться, что обновленная конфигурация подтянулась.
Для того, чтобы включить actuator endpoints для сервиса необходимо:
-
в build.gradle зависимости spring-boot-starter-actuator и spring-boot-starter-web
-
в application.properties прописать «management.endpoints.web.exposure.include=*»
Как сделать так, чтобы компоненты приложения использовали обновленную конфигурацию
Рассмотрим самый простой пример: есть некий сервис, который в ответ на запрос формирует ответ на основе значения refresh.property1. Реализуем такую простую логику тремя различными способами.
1) Environment
@RestController class Rest1(val applicationContext: ApplicationContext) { @GetMapping("/test1") fun test(): ResponseEntity<String> = ResponseEntity.ok(applicationContext.environment["refresh.property1"]) }
2) @ConfigurationProperties
@ConfigurationProperties("refresh") class TestProperties { lateinit var property1: String } @RestController class Rest2(val testBean: TestProperties ) { @GetMapping("/test2") fun test(): ResponseEntity<String> = ResponseEntity.ok(testBean.property1) }
Хочу обратить внимание на особенность работы kotlin и spring. Следующие конструкции будут корректно инициализироваться во время старта приложения, но не смогут обновляться в runtime:
@ConstructorBinding @ConfigurationProperties("refresh") data class TestBean(val property1: String) @ConstructorBinding @ConfigurationProperties("refresh") data class TestBean(var property1: String)
3) @RefreshScope + @Value
@RefreshScope @RestController class Rest3(@Value("\${refresh.property1}") val property1: String) { @GetMapping("/test3") fun test(): ResponseEntity<String> = ResponseEntity.ok(property1) }
Все три приведенных выше варианта реализации в ответе будут отдавать актуальное значение refresh.property1 после синхронизации со Spring Cloud Config Server с помощью вызова POST /actuator/refresh.
В данном случае логика работы сервиса не требует выполнения какой-то логики по обновлению бинов, но в некоторых случаях требуется более комплексный подход.
Реинициализация существующих бинов
В предыдущем разделе рассмотрен самый простой случай по изменению логики работы приложения после обновления конфигурации. Но в некоторых случаях после обновления некоторых properties требуется реинициализация компонента приложения.
Например есть бин, который является оберткой над скажем KafkaConsumer. В @PostConstruct методе выполняется инициализация и запуск Consumer, в @PreDestroy методе выполняется остановка и деинициализация Consumer. В таком случае хотелось бы, чтобы данный бин реагировал на изменение Environment и ,если связанные с ним properties изменились, реинициализировался. Что значит реинициализация в данном контексте — например вызов @Predestroy метода и вызов @PostConstruct метода с учетом уже обновленной конфигурации.
Как было указано ранее результатом выполнения запроса к сервису POST /actuator/refresh является генерация события EnvironmentChangeEvent со списком изменившихся properties, на которое можно подписаться и вызвать определенную логику.
Т.е. можно реализовать свой Bean Post Processor вызывающий процесс реинициализации определенных компонентов сервиса, если определенные properties были обновлены. Я ввел новые аннотации @Refreshable и @Refresh:
@Target(ElementType.METHOD, ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) annotation class Refreshable(val property: String) @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) annotation class Refresh
@Refreshable задает property от которого зависит бин и при изменение которого, происходит его обновление. @Refresh — для метода, который будет выполнен, если связанные с бином property будут обновлены.
Изначально хотелось обойтись одной аннотацией @Refreshable и в случае обновления деинициализировать и инициализировать бин как это делает спринговый контекст, но пришло понимание, что нужно учитывать много факторов и лучше дать возможность явно указать какой метод будет выполняться при реинициализации с помощью @Refresh.
Реализация Bean Post Processor:
class RefreshBeanPostProcessor(private val applicationContext: GenericApplicationContext) : BeanPostProcessor { private val refreshPropertyTasks = mutableListOf<RefreshPropertyTask>() override fun postProcessBeforeInitialization(bean: Any, beanName: String): Any { (getRefreshablePropertyByBean(bean) ?: getRefreshablePropertyByBeanDefinition(beanName)) ?.let { property -> bean.javaClass.methods .firstOrNull { it.getAnnotation(Refresh::class.java) != null } ?.let { method -> refreshPropertyTasks.add(RefreshPropertyTask(property) { method.invoke(bean) }) } } return bean } @PostConstruct fun init() { applicationContext.addApplicationListener { event -> if (event is EnvironmentChangeEvent) { onApplicationEvent(event) } } } private fun onApplicationEvent(event: EnvironmentChangeEvent) { refreshPropertyTasks .filter { task -> event.keys.firstOrNull { key -> key.startsWith(task.property) } != null } .forEach { task -> try { log.info("Update task ${task.property}") task.action() } catch (ex: Exception) { log.error("Error for task ${task.property}", ex) } } } private data class RefreshPropertyTask(val property: String, val action: () -> Unit) private fun getRefreshablePropertyByBean(bean: Any): String? = bean.javaClass.getAnnotation(Refreshable::class.java)?.property private fun getRefreshablePropertyByBeanDefinition(beanName: String): String? = applicationContext.beanFactory .let { kotlin.runCatching { it.getBeanDefinition(beanName) }.getOrNull() } ?.source ?.takeIf { it is AnnotatedTypeMetadata } ?.let { it as AnnotatedTypeMetadata } ?.getAnnotationAttributes(Refreshable::class.java.name) ?.let { it[Refreshable::property.name] as String? } }
Важно упомянуть, что необходимо для данного постпроцессора добавить @DependsOn(«configurationPropertiesRebinder»). configurationPropertiesRebinder — это компонент, который собственно обновляет @ConfigurationProperties классы. Он точно также реализует интерфейс ApplicationListener<EnvironmentChangeEvent> как и реализованный Bean Post Processor, и, если при инициализации ApplicationListener он будет в списке контекста стоять после нашего, то на момент выполнения нашей логики реинициализации, классы @ConfigurationProperties будут еще не обновлены и наша реинициализация произойдет с неактуальными значениями, если она зависит от классов @ConfigurationProperties.
@DependsOn("configurationPropertiesRebinder") @Bean fun refreshBeanPostProcessor(ctx: GenericApplicationContext) = RefreshBeanPostProcessor(ctx)
Это гарантирует нам, что ApplicationListener реализованный внутри Bean Post Processor будет в списке после configurationPropertiesRebinder и соответственно будет выполняться после.
Примеры использования:
@ConfigurationProperties("refresh") class TestProperties{ lateinit var property1: String lateinit var property2: String } @Refreshable("refresh") @Service class Bean1(testProperties: TestProperties){ @PostConstruct fun init(){} @PreDestroy fun destroy(){} @Refresh fun refresh(){ init() destroy() } } class Bean2(testProperties: TestProperties){ @PostConstruct fun init(){} @PreDestroy fun destroy(){} @Refresh fun refresh(){ init() destroy() } } @Configuration class TestConfiguration{ @Refreshable("refresh") @Bean fun bean2(testProperties: TestProperties) = Bean2(testProperties) }
Автообновление Environment сервиса
Spring Cloud Config Server ничего не знает о клиентах и для обновления конфигурации на каждом инстансе нашего сервиса необходимо вызывать POST /actuator/refresh.
Одним из вариантов упростить обновление сервисов может стать использование Spring Cloud Bus. Необходимо настроить интеграцию на сервисах и Spring Cloud Config Server через добавление зависимости и настройки конфигурации. Для интеграции могут быть использованы Kafka, RabbitMQ и др. После настройки вызовом GET /monitor Spring Cloud Config Server будет вызвано обновление всех сервисов, через обмен сообщениями через topic Kafka. Если в качестве backend для Spring Cloud Config Server используется система, которая поддерживает webhook, то можно настроить вызов GET /monitor через webhook. Я пока решил не останавливаться на данном решение, т.к. пока кажется, что такая дополнительная интеграция усложняет поддержку и настройку системы.
На данный момент решил сделать автообновление внутри каждого сервиса. Выглядит так, что периодический запрос не должен добавить какую-то существенную нагрузку на систему, даже если всего всех инстансов всех сервисов суммарно около 100.
Вариант реализации периодического запроса на обновление Environment:
class RefreshEventPublisherScheduler( private val refreshSchedulerProperties: RefreshSchedulerProperties, private val applicationEventPublisher: ApplicationEventPublisher ) { companion object : Log() private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() @PostConstruct fun init() { schedule() } private fun schedule() { executor.schedule( { if (refreshSchedulerProperties.enabled) { publishRefreshEvent() } schedule() }, refreshSchedulerProperties.interval.toMillis(), TimeUnit.MILLISECONDS ) } private fun publishRefreshEvent() { applicationEventPublisher.publishEvent( RefreshEvent( this, "Refresh event", "Refresh scope" ) ) } }
Т.е. каждый клиент по расписанию вызывает метод publishRefreshEvent(), который соответствует вызову POST /actuator/refresh.
Реализованные Bean Post Processor и RefreshEventPublisherScheduler можно включить в стартер и использовать в сервисах для автообновления и реинициализации бинов.
ссылка на оригинал статьи https://habr.com/ru/post/712628/
Добавить комментарий