Появилась потребность в том, чтобы определенные компоненты сервисов умели подтягивать обновленную конфигурацию и работать на основе этой конфигурации, т.е. конфигурация приложения меняется уже после запуска сервиса. Я проведу небольшой обзор подходов, которые нашел применительно к такой задаче и то, на чем остановился.
На проекте используется 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 можно включить в стартер и использовать в сервисах для автообновления и реинициализации бинов.