Продолжим описание нашего кейса и перейдем к настройкам сервера. Первую часть статьи с настройками клиента вы можете посмотреть здесь
Это будет самый обычный Spring Cloud Config Server, поэтому начнем с зависимостей:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
остальные зависимости специфично зависят от вашего приложения.
Сервер необходимо подключить в стартовом классе:
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
Далее мы планируем, что сервер будет забирать конфигурации клиентов, в которую мы добавим параметр для версионирования конфига, проверять версию на сервере и на клиенте, и если версия изменилась - отправлять новую конфигурацию клиенту принудительно с помощью встроенного в клиент Spring Actuator. Подход сильно упрощенный, при желании его можно всячески усовершенствовать. Сейчас для нас главное - показать, как это можно сделать без дополнительных посредников.
Для этого мы добавим в конфигурацию самого сервера отдельный bean для этого, который будет создавать простейший однопоточный экзекьютор, работающий по расписанию:
@Configuration
public class ApplicationConfig {
.....
@Bean
public Executor forceRefreshExecutor() {
return Executors.newSingleThreadScheduledExecutor();
}
}
Опять, здесь вы видите точку для возможных в будущем усовершенствований - никто не мешает использовать более совершенный экзекьютор сообразно задачам вашего приложения. Однако, вполне достаточно и однопоточного, это упрощает разработку, да я как-то и не вижу потребности именно в этом месте кода использовать что-то более продвинутое.
Экзекьютор будет запускать по расписанию единственный метод из класса RefereshService, который и будет выполнять для нас всю полезную работу:
@Service
@EnableScheduling
@Slf4j
public class SchedulerService implements SchedulingConfigurer {
private final RefreshService refreshService;
@Value("${application.refreshDelayInMs}")
private int delay;
private final Executor taskExecutor;
@Autowired
public SchedulerService(RefreshService refreshService, @Qualifier("forceRefreshExecutor") Executor taskExecutor) {
this.refreshService = refreshService;
this.taskExecutor = taskExecutor;
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskExecutor);
// триггер отсчитывает время следующего запуска обновления клиентов config-server через delay ms после
// окончания предыдущего успешного обновления или от текущего времени при указанной ошибке, простая аннотация
// @Scheduled в методе refreshOnCheckConfigVersion в RefreshService считала бы время от начала предыдущего
// запуска независимо от успешности его завершения,что может привести к утечке памяти при постоянной
// ошибке запуска задачи по расписанию и медленной обработке обновления по какой-то причине
taskRegistrar.addTriggerTask(
refreshService::refreshOnCheckConfigVersion,
triggerContext -> {
Optional<Date> lastCompletionTime = Optional.ofNullable(triggerContext.lastCompletionTime());
if (lastCompletionTime.equals(Optional.empty())) {
log.info("Не удалось получить предыдущее время запуска обновления клиентов config-server.\n" +
"Новый запуск обновления будет выполнен через {} ms от текущего времени.", delay);
}
Instant nextExecutionTime =
lastCompletionTime.orElseGet(Date::new).toInstant()
.plusMillis(delay);
Date nextRefreshDate = Date.from(nextExecutionTime);
log.info("Следующее обновление клиентов config-server запланировано на дату {}", nextRefreshDate);
return nextRefreshDate;
}
);
}
}
Прошу также обратить особое внимание на комментарий в коде - он объясняет, почему мне потребовалось кастомизировать триггер для запуска задачи. Очень не рекомендую заменять кастомный триггер задачи на простой запуск по аннотации @Scheduled - можно наступить на очень неудачно кем-то брошенные в травке грабельки.
Теперь перейдем непосредственно к самому полезному методу в классе RefereshService:
@Service
@Slf4j
public class RefreshService {
private final Clients clients;
private final ExternalConfigService externalConfigService;
private final InternalConfigService internalConfigService;
@Autowired
public RefreshService(Clients clients, ExternalConfigService externalConfigService, InternalConfigService internalConfigService) {
this.clients = clients;
this.externalConfigService = externalConfigService;
this.internalConfigService = internalConfigService;
}
public void refreshOnCheckConfigVersion() {
for (Clients.Client client : clients.getClients()) {
try {
InternalConfigEntity internalConfigEntity = internalConfigService.getInternalConfigByClient(client);
ExternalConfigEntity externalConfigEntity = externalConfigService.getExternalConfigByClient(client);
if (internalConfigEntity.isNotValid()) {
log.info("Значение версии клиента {} на сервере не задано, будет выполнено принудительное " +
"обновление конфигурации клиента", client.getName());
externalConfigService.forceRefresh(client);
} else if (externalConfigEntity.isNotValid()) {
log.info("Значение версии клиента {} на клиенте не задано, будет выполнено принудительное " +
"обновление конфигурации клиента", client.getName());
externalConfigService.forceRefresh(client);
} else if (!internalConfigEntity.getConfigVersion().equals(externalConfigEntity.getExternalConfigProperty().getValue())) {
log.info("Значение версии клиента {} на клиенте и на сервере не совпадают, будет выполнено " +
"принудительное обновление конфигурации клиента", client.getName());
externalConfigService.forceRefresh(client);
} else {
log.info("Значение версии клиента {} на клиенте и на сервере совпадают, " +
"конфигурация клиента не нуждается в обновлении", client.getName());
}
} catch (InternalConfigServiceGenericException e) {
log.error(e.getMessage());
//TODO подключить кастомную метрику
//На самом деле последующие catch не будут просить схлопываться с предыдущим, когда будут добавлены
//различные метрики в каждом блоке catch
} catch (ExternalConfigServiceGenericException e) {
log.error(e.getMessage());
//TODO подключить кастомную метрику
} catch (Throwable e) {
log.error(e.getMessage());
//TODO подключить кастомную метрику, здесь отлавливаются только неучтенные в сервисах остальные ошибки
}
}
}
}
Здесь описана вся главная логика - считываем версию конфигурации с очередного клиента, сравниваем ее с версией для него, хранящейся на сервере, и если они совпадают, запускаем обновление конфигурации на клиенте + еще несколько дополнительных вариантов, менее значимых, но тоже полезных. Разумеется, если вам нужна другая бизнес-логика реакции на версионность, просто сделайте собственную.
Дополнительные сервисы, использованные в этом классе, как раз и служат для всяких вспомогательных целей в рамках этой логики. Вы можете посмотреть их код на github по ссылке, приведенной в конце статьи.
Хранение конфигураций клиентов на сервере вы можете настроить самостоятельно где угодно в приложении, я сделал это в отдельном каталоге в корне приложения:
Соответственно, нам понадобится также файл applicaton.properties, где мы будем хранить в том числе и список клиентов централизованно (можно было сделать и посложнее, и хранить их где-то в enum-ах, но для демо приложения вполне достаточно и этого), а также класс для описания дополнительных проперти:
@Component
@ConfigurationProperties(prefix = "application")
@Data
public class Clients {
String refreshDelayInMs;
List<Client> clients;
@Data
public static class Client {
private String name;
private String protocol;
private String url;
private String user;
private String password;
.....
}
}
spring:
application:
name: config-server
profiles:
active: native
cloud:
config:
server:
native:
searchLocations: file:./cfg/
#здесь нельзя менять searchLocations на другое имя, это часть спецификации config server
security:
user:
name: configserver
password: configserver
roles: ACTUATOR_ADMIN
application:
refreshDelayInMs: 60000
clients:
-
name: pyramid-local.yml
protocol: http
url: localhost:8081
user: actuator
password: actuator
.....
Обратите внимание, что параметры clients здесь - это список, и перечислять их нужно именно в таком виде - с дефисами. Имя конфига клиента, хранимого на сервере, должно совпадать с именем, под которым вы его поместили в каталог cfg, авторизация на клиенте описывалась в предыдущей статье. Авторизация на сервере также присутствует в упрощенном виде, ее можно посмотреть в файлах проекта на github.
В конце каждого конфигурационного файла (в данном случае это pyramid-local.xml) добавьте такой параметр:
В демо приложении не поддерживается необходимость в последовательной нумерации версии и возможности хранения и отката на старые версии конфигураций клиентов, всего лишь сравнивается совпадение текущей версии на клиенте и текущей версии на сервере, то есть все упрощено до предела. Версия конфигурации на клиенте получается путем обращения и парсинга ответа от Spring Actuator клиента по адресу "/actuator/env/configVersion"
, а само обновление конфигурации, обращением по адресу "/actuator/refresh"
для соответствующего клиента. Клиентов может быть сколько угодно, главное - не забыть добавить их в список в файле application.properties. Однако, если вы не добавите его туда, обновление конфигурации, разумеется, все равно будет работать - но только не по факту изменения версии на сервере, а при очередном рестарте клиента или если вы вручную обновите конфигурацию с помощью актуатора, автоматического обновления не будет. Существенным недостатком демо реализации является примитивное версионирование - а именно, если забыть вручную поставить новую версию в новом конфиге, то автообновления не произойдет. Это тоже можно решить, если реализовать версионирование не по кастомному параметру, а просто сохранять где-нибудь в хранилище хэш нового конфига при любом его изменении и редеплое. Но это уже существенное усовершенствование, которое выходит за рамки поставленной задачи.
Код сервера можно посмотреть в github по адресу https://github.com/yamangulov/project14-config-server В нем вы также увидите код, не относящийся непосредственно к теме статьи, но все, что я здесь описал, в ней тоже имеется.
Если вам необходимо посмотреть что-то в коде клиента из предыдущей статьи, напомню, что он находится здесь.
Также хочу пригласить всех желающих на бесплатный урок от OTUS, в рамках которого будет рассмотрено, что такое REST, как пишутся REST-сервисы с использованием Spring MVC. Также будут рассмотрены вопросы применения Spring Session.
Регистрация доступна по ссылке.