Spring Cloud Config и обновление компонентов в рантайме

Появилась потребность в том, чтобы определенные компоненты сервисов умели подтягивать обновленную конфигурацию и работать на основе этой конфигурации, т.е. конфигурация приложения меняется уже после запуска сервиса. Я проведу небольшой обзор подходов, которые нашел применительно к такой задаче и то, на чем остановился.

На проекте используется 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/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *