Skip to content

19配置更新:如何理解配置信息自动更新的工作原理?

在理解了 Spring Cloud Config 客户端与服务器端的交互流程之后,我们还剩下一个核心问题:一旦服务器端的配置信息发生变化,客户端如何进行及时更新?

我们知道 Zookeeper 实现分布式配置中心的方法就是在客户端与 Zookeeper 之间建立长连接,然后合理利用 Zookeeper 的临时节点和监听器机制。而 Spring Cloud Config 客户端与服务器端采用的是基于 HTTP 协议的 RESTful API,本质上是一种短连接技术,也就无法通过类似监听器的方案实现配置信息的自动获取和更新。但是 Spring Cloud Config 也没有采用像 Eureka 中纯粹轮询的方式获取增量数据,而是提供了一套完全不同的解决方案。今天我们就来深入分析这套解决方案,Spring Cloud Config 在这点上的设计和实现方法同样值得我们学习并加以应用。

Spring Cloud Config 客户端更新策略

通过上一课时内容的介绍,我们知道在 Spring Cloud Config 客户端启动时,会发送 HTTP 请求到服务器端获取配置信息。而在 Git 等配置仓库中更改了配置信息之后,客户端不会主动再次请求最新配置,而是使用缓存到本地的原有配置信息。那么问题就来了,如何能够实时获取更改后的配置呢?

针对这个问题,我们有如下三种处理方法:

  • 重启客户端。

因为客户端在启动时会重新获取服务器端配置,所以重启客户端能实现配置信息的及时更新,但显然这不是一种合理的方法。

  • 使用 Spring Boot Actuator。

Spring Boot 提供了一个专门的组件用来监测和管理系统运行时状态,这个组件就是 Spring Boot Actuator。Actuator 是一种集成化组件,可以获取应用系统的运行时数据和配置信息,并进行统计分析。

当在代码类路径中引入 spring-boot-starter-actuator 依赖并启动 Spring Boot 应用程序时,我们会在启动日志里发现自动添加了 autoconfig、dump、beans、actuator、health、refresh 等众多 HTTP 端点。通过调用 /actuator/refresh 端点就可以刷新客户端的配置信息,从而实现不重启 Spring Boot 应用的配置热更新。从实现原理上,这个操作实际上就是使用了一个 ContextRefresher 工具类完成了刷新操作,如下所示:

java
@Endpoint(id = "refresh")
public class RefreshEndpoint {
 
    private ContextRefresher contextRefresher;
 
    public RefreshEndpoint(ContextRefresher contextRefresher) {
        this.contextRefresher = contextRefresher;
    }
 
    @WriteOperation
    public Collection<String> refresh() {
        Set<String> keys = contextRefresher.refresh();
        return keys;
    }
}

这是一种简单有效的处理方式,但也不是最好的处理方式。因为针对每个微服务一般都会存在多个运行时实例,这样就需要把客户端的 /actuator/refresh 端点逐个进行调用,这点显然也不是很合理。而且,这种方案属于客户端自身行为,与配置服务端没有关系。

  • 集成 Spring Cloud Bus。

Spring Cloud Config 提供的是第三种方法,即集成 Spring Cloud Bus 组件。Spring Cloud Bus 是 Spring Cloud 中用于实现消息总线的专用组件,集成了 RabbitMQ、Kafka 等主流消息中间件。Spring Cloud Bus 不是本课程的重点,而关于消息通信相关的内容我们会放在下一个主题中进行详细阐述,这里只需要知道 Spring Cloud Config 集成 Spring Cloud Bus 的目的就是想借助于它的消息通信能力。

当我们在 Spring Cloud Config 服务器端工程的类路径中添加 Spring Cloud Bus 的引用并启动应用程序之后,Spring Boot Actuator 就为我们提供了 /actuator/bus-refresh 端点,通过访问该端点就可以达到对客户端所有服务实例的配置进行自动更新的效果。在这种方案中,服务端会主动通知所有客户端进行配置信息的更新,这样我们就不需要关注各个客户端,而只对服务端进行操作即可。

是不是听起来有点神奇?整个过程我们至少要搞清楚以下三大问题:

配置信息自动更新的三大问题

针对这三个问题,接下去我们将结合源码逐一展开讨论。

问题一:如何自动调用服务器端所暴露的 /actuator/bus-refresh 端点?

在现代软件开发过程中,开放式平台是一种常见的软件服务形态。我们可以把 Spring Cloud Config Server 所提供的 HTTP 端点视为一种开放式的接口,以供 Git 等第三方工具进行访问和集成。

正如前面提到的,可以把服务器端 /actuator/bus-refresh 端点对外进行暴露。第三方工具可以通过这个暴露的端点进行集成。例如,在 Git 中设计了一种 Webhook 的机制,并提供了用户界面供我们配置所需要集成的端点以及对应的操作,操作方法如下图所示:

GitHub 的 Webhook 配置界面(来自 GitHub 官网)

我们可以在上图的 Payload URL 中设置 /actuator/bus-refresh 端点地址。所谓的 Webhook,实际上就是一种回调。通过 Webhook,当我们提交代码时,Git 就会自动调用所配置的 HTTP 端点。也就是说,可以根据配置项信息的更新情况自动实现对 /actuator/bus-refresh 端点的访问。基于 GitHub 的配置仓库实现方案,我们可以得到如下所示的系统结构图:

GitHub Webhook 机制执行效果图

现在,配置信息一旦有更新,Spring Cloud Config Server 就能从 Github 中获取最新的配置信息了。接下来我们关注第二个问题,即客户端如何得知服务器端的配置信息已经更新?

问题二:客户端如何得知服务器端的配置信息已经更新?

我们首先需要明确调用 /actuator/bus-refresh 端点之后,系统内部会发生什么事情。这里我们需要快速浏览 Spring Cloud Bus 中的代码工程,发现在 org.springframework.cloud.bus.endpoint 包下存在一个 RefreshBusEndpoint 端点类,如下所示:

java
@Endpoint(id = "bus-refresh") 
public class RefreshBusEndpoint extends AbstractBusEndpoint {
 
    public RefreshBusEndpoint(ApplicationEventPublisher context, String id) {
        super(context, id);
    }
 
    @WriteOperation
    public void busRefreshWithDestination(@Selector String destination) { 
        publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), destination));
    }
 
    @WriteOperation
    public void busRefresh() {
        publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), null));
    } 
}

显然,RefreshBusEndpoint 类对应于我们前面访问的 /bus-refresh 端点。可以看到,Spring Cloud Bus 在这里做的事情仅仅只是发布了一个新的 RefreshRemoteApplicationEvent 事件。

既然发送了事件,我们就需要寻找该事件的监听者。我们在 Spring Cloud Bus 的 org.springframework.cloud.bus.event 包下找到了 RefreshRemoteApplicationEvent 事件的监听者 RefreshListener,如下所示:

java
public class RefreshListener
        implements ApplicationListener<RefreshRemoteApplicationEvent> {
 
    private static Log log = LogFactory.getLog(RefreshListener.class);
 
    private ContextRefresher contextRefresher;
 
    public RefreshListener(ContextRefresher contextRefresher) {
        this.contextRefresher = contextRefresher;
    }
 
    @Override
    public void onApplicationEvent(RefreshRemoteApplicationEvent event) {
        Set<String> keys = contextRefresher.refresh();
        log.info("Received remote refresh request. Keys refreshed " + keys);
    }
}

从类的定义中,我们可以看到该监听器就是用来处理 RefreshRemoteApplicationEvent 事件,其中在 onApplicationEvent 函数中同样也是调用了前面介绍的 ContextRefresher 中的 refresh()方法进行配置属性的刷新。

请注意,RefreshRemoteApplicationEvent 是一个远程事件,将通过消息中间件进行发送,并被 Spring Cloud Config 客户端所监听,处理流程如下图所示:

基于 Spring Cloud Bus 的事件传播机制

最后需要明确的第三个问题是,客户端如何获取服务器端所更新的配置信息,这就需要梳理 Spring Cloud Config Server 与注册中心 Eureka 之间的关系。

问题三:客户端如何实时获取服务器端所更新的配置信息?

我们在分析配置中心的基本模型时提到,配置中心作为整个微服务架构运行所需的基础服务,需要确保其可用性。Spring Cloud Config 实现高可用的方式很简单,因为配置服务本身也是一个独立的微服务,与其他微服务一样,也可以注册到 Eureka 服务器上,让其他服务提供者或消费者通过注册中心进行服务发现和获取。

显然,在这种方式下,基于 Eureka 的服务治理机制同时提供了服务器端的负载均衡和客户端的配置功能,从而也就间接实现了高可用性。从另一个角度,我们也可以理解为可以通过 Eureka 获取所有 Spring Cloud Config 服务的实例,从而在分布式环境下为获取配置信息提供了一种简便的手段。

Spring Cloud Config 提供一个工具类 ConfigServerInstanceProvider 来完成与 Eureka 之间的交互,如下所示:

java
public class ConfigServerInstanceProvider {
 
    private static Log logger = LogFactory.getLog(ConfigServerInstanceProvider.class);
    private final DiscoveryClient client;
 
    public ConfigServerInstanceProvider(DiscoveryClient client) {
        this.client = client;
    }
 
    @Retryable(interceptor = "configServerRetryInterceptor")
    public List<ServiceInstance> getConfigServerInstances(String serviceId) {
        logger.debug("Locating configserver (" + serviceId + ") via discovery");
        List<ServiceInstance> instances = this.client.getInstances(serviceId);
        if (instances.isEmpty()) {
            throw new IllegalStateException(
                    "No instances found of configserver (" + serviceId + ")");
        }
        logger.debug("Located configserver (" + serviceId
                + ") via discovery. No of instances found: " + instances.size());
        return instances;
    }
}

在这里,我们看到了熟悉的 DiscoveryClient,DiscoveryClient 通过同样熟悉的 getInstances() 方法从 Eureka 中获取 Spring Cloud Config 服务器实例,如下所示:

java
List<ServiceInstance> instances = this.client.getInstances(serviceId);

ConfigServerInstanceProvider 的调用者是 DiscoveryClientConfigServiceBootstrapConfiguration。如果系统中生成了 ContextRefreshedEvent 事件就会触发 startup() 方法,而该方法则直接调用了如下所示的 refresh() 方法(部分代码做了裁剪):

java
private void refresh() {
        try {
            String serviceId = this.config.getDiscovery().getServiceId();
            List<String> listOfUrls = new ArrayList<>();
            List<ServiceInstance> serviceInstances = this.instanceProvider
                    .getConfigServerInstances(serviceId);
 
            for (int i = 0; i < serviceInstances.size(); i++) {
 
                ServiceInstance server = serviceInstances.get(i);
                String url = getHomePage(server);
 
                if (server.getMetadata().containsKey("password")) {
                    String user = server.getMetadata().get("user");
                    user = user == null ? "user" : user;
                    this.config.setUsername(user);
                    String password = server.getMetadata().get("password");
                    this.config.setPassword(password);
                }
 
                if (server.getMetadata().containsKey("configPath")) {
                    String path = server.getMetadata().get("configPath");
                    if (url.endsWith("/") && path.startsWith("/")) {
                        url = url.substring(0, url.length() - 1);
                    }
                    url = url + path;
                }
 
                listOfUrls.add(url);
            }
 
            String[] uri = new String[listOfUrls.size()];
            uri = listOfUrls.toArray(uri);
            this.config.setUri(uri);
 
        }
}

在上述 refresh() 方法中可以看到,系统首先会获取配置文件中配置项 spring.cloud.config.discovery.serviceId 所指定的服务实例 Id,然后根据 serviceId 从 ConfigServerInstanceProvider 中获取注册服务的实例对象集合 serviceInstances,最后循环遍历 serviceInstances 来更新存储在内存中的配置属性值。

我们知道这些属性值都保存在 ConfigClientProperties 对象中,而在《配置服务:如何基于 Spring Cloud Config 构建配置中心服务器?》课时所介绍的 ConfigServicePropertySourceLocator 类中,通过分析 getRemoteEnvironment 方法,发现正是通过 ConfigClientProperties 对象中的这些属性值来对 Spring Cloud Config Server 进行远程调用。这样我们就可以把整个流程串联起来了。

小结与预告

本课程讲解的是基于 Spring Cloud Config 实现配置信息自动更新的工作原理。我们抛出了三个与这个主题相关的核心问题,然后基于源码对这些问问都做了一一解答。事实上,Spring Cloud Config 作为 Spring 自研的配置中心框架,其内部大量使用了 Spring 现有的功能特性,这点与我们学习 Netflix 旗下的 Eureka、Zuul 等框架不同。我们需要首先对 Spring 容器相关的知识体系有足够的了解,才能更好地理解 Spring Cloud Config 的设计和实现方式。

这里给你留一道思考题:在 Spring Cloud Config 中,当位于配置服务器中的配置信息发生变更时,如何让各个客户端保持同步更新呢?

我们在讨论配置中心时提到了可以基于事件发送和消费机制来实现配置信息的动态更新。而事件的发送和消费往往需要依赖于消息通信机制以及主流的一些消息中间件,从下课时开始,我们将进入"消息通信"这个主题,来学习 Spring Cloud 中提供的 Spring Cloud Stream 组件。