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