Кастомное автоматическое обновление конфигураций клиентов Spring Cloud Config Server. Часть 2: настройка сервера

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Продолжим описание нашего кейса и перейдем к настройкам сервера. Первую часть статьи с настройками клиента вы можете посмотреть здесь

Это будет самый обычный 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 В нем вы также увидите код, не относящийся непосредственно к теме статьи, но все, что я здесь описал, в ней тоже имеется.

Если вам необходимо посмотреть что-то в коде клиента из предыдущей статьи, напомню, что он находится здесь https://github.com/yamangulov/project14-adapter-pyramid

Также хочу пригласить всех желающих на бесплатный урок от OTUS, в рамках которого будет рассмотрено, что такое REST, как пишутся REST-сервисы с использованием Spring MVC. Также будут рассмотрены вопросы применения Spring Session.

Регистрация доступна по ссылке.

Источник: https://habr.com/ru/company/otus/blog/682594/


Интересные статьи

Интересные статьи

Продолжение цикла статей про asyncio. Продолжаем готовить asyncio. Теперь мы уже знаем достаточно много, чтобы написать настоящий асинхронный микросервис, реализующий паттерн "API-шлюз". И попутно поз...
Второй выпуск подкаста был разделен на несколько частей. Гость выпуска - Константин Сахнов. Мы говорим о том, что представляет собой профессия "Геймдизайнер", какие ключе...
Мониторинг очень важен для современных приложений, современные приложения по своей природе сильно распределены и имеют разные зависимости, такие как база данных, службы, ...
Привет, Хабр! Мы часто говорим о способах восстановления данных на магнитных и твердотельных накопителях, резервном копировании, создании RAID’ов и прочих ухищрениях, которые помогают не остаться...
Данная статья содержит решений заданий, направленных на криминалистику оперативной памяти, разбора пэйлоада для USB Rubber Duck, а так же расшифрования перехваченных паролей групповой политики ...