撸了一个简易的配置中心,顺带整合到了SpringCloud

大家好,我是三友~~

最近突然心血来潮(就是闲的)就想着撸一个简单的配置中心,顺便也照葫芦画瓢给整合到SpringCloud。

本文大纲

配置中心的概述

随着历史的车轮不断的前进,技术不断的进步,单体架构的系统都逐渐转向微服务架构。虽然微服务架构有诸多优点,但是随着越来越多的服务实例的数量,配置的不断增多,传统的配置文件方式不能再继续适用业务的发展,所以急需一种可以统一管理配置文件应用,在此之下配置中心就诞生了。

所以配置中心就是用来统一管理各种服务配置的一个组件,本质上就是一个web应用。

配置中心的核心功能

一个配置中心的核心功能其实主要包括两个:

  • 配置的存取
  • 配置变更的通知

配置的存取是配置中心不可缺失的功能,配置中心需要能够将配置进行保存,存在磁盘文件也好,又或是数据库也罢,总之需要持久化,同时配置中心也得提供配置查询的功能。

配置变化的通知也是一个很重要的功能,一旦配置中心的配置有变动的话,那么使用到这个配置的客户端就需要知道这个配置有变动,从而可以做到相应的变动的操作。

手撸一个简易的配置中心

上文分析了一个配置中心的核心功能,接下来就实现这两个核心的功能。

一、文件工程整体分析

文件工程整体分为客户端与服务端

  • 服务端:单独部署的一个web应用,端口是8888,提供了对于配置增删改查的http接口
  • 客户端(SDK):业务系统需要引用对应的依赖,封装了跟服务端交互的代码

二、服务端实现详解

1、配置文件的数据存储模型ConfigFile

在配置中心存储配置的时候,需要指明以下信息

public class ConfigFile {

    private String fileId;

    private String name;

    private String extension;

    private String content;

    private Long lastUpdateTimestamp;

}
  • fileId: 文件的唯一id,由配置中心服务端在新增配置文件存储的时候自动生成,全局唯一
  • name: 就是文件的名字,没有什么要求,见名知意就行
  • extension: 文件后缀名,指的是该配置是什么类型的文件,比如是properties、yml等
  • content: 就是配置文件的内容,不同的后缀名有不同的格式要求
  • lastUpdateTimestamp: 上一次文件更新的时间戳。当文件存储或者更新的时候,需要更新时间戳,这个字段是用来判断文件是否有改动
2、文件存储层ConfigFileStorage

对于文件存储层,我提供了一个ConfigFileStorage接口,

public interface ConfigFileStorage {

    void save(ConfigFile configFile);

    void update(ConfigFile configFile);

    void delete(String fileId);

    ConfigFile selectByFileId(String fileId);

    List<ConfigFile> selectAll();

}

这个接口提供了对于配置存储的crud操作,目前我已经实现了基于内存和磁盘文件的存储的代码

可以在项目启动的时候,在配置文件指定是基于磁盘文件存储还是基于内存存储,默认是基于磁盘文件存储。

当然,如果想把配置信息存储到数据库,只要新增一个存储到数据的实现就行。

3、ConfigController

ConfigController提供了对于配置文件的crud的http接口

ConfigController是通过调用ConfigManager来完成配置文件的crud

4、ConfigManager

其实就是一个service层,就是简单的参数封装,最终是调用ConfigFileStorage存储层的实现来完成配置的存储功能。

这样配置中心的配的存取的功能就实现了。

所以,服务端还是比较简单的。其实就是跟平时写的业务系统的crud没什么区别,就是将数据库存储替换成了磁盘文件的存储。

至于前面说的配置文件变更通知的功能,我是基于客户端来实现的。

三、客户端的实现

客户端工程代码如下

1、ConfigFileChangedListener
ConfigFileChangedListener
ConfigFileChangedListener

配置变动的监听器,当客户端对某个配置监听的时候,如果这个配置的内容有变化的话,客户端就会回调这个监听器,传入最新的配置

2、ConfigService

封装了客户端的核心功能,可以添加对某个文件的监听器和获取某个文件的配置内容。

使用示例:

// 创建一个ConfigService,传入配置中心服务端的地址
ConfigService configService = new ConfigService("localhost:8888");

// 从服务端获取配置文件的内容,文件的id是新增配置文件时候自动生成
ConfigFile config = configService.getConfig("69af6110-31e4-4cb4-8c03-8687cf012b77");

// 对某个配置文件进行监听
configService.addListener("69af6110-31e4-4cb4-8c03-8687cf012b77"new ConfigFileChangedListener() {
    @Override
    public void onFileChanged(ConfigFile configFile) {
        System.out.printf("fileId=%s配置文件有变动,最新内容为:%s%n", configFile.getFileId(), configFile.getContent());
    }
});

这里说一下配置变更通知的实现原理。

首先对于客户端来说,要想知道哪个配置文件进行了改动,有两种方式

第一种是通过push的方式来实现。当配置文件发生变动的时候,服务端主动将变动的配置文件push给客户端。这种方式实现起来比较麻烦,一方面是服务端还得存储客户端的服务的信息,因为服务端得知道push到哪台服务器上;另一方面,客户端需要提供一个接口来接收服务端push的请求,所以这种方式整体实现起来比较麻烦。但是这种push方式时实性比较好,一旦配置文件有变动,第一时间客户端就能够知道配置有变动。

第二种方式就是基于pull模式来实现。客户端定时主动去服务端拉取配置文件,判断文件内容是否有变动,一旦有变动就进行监听器的回调。这种实现相比push来说简单不少,因为服务端不需要关心客户端的信息,所有的操作都由客户端来完成。但是这个定时的时间间隔不好控制,太长可能会导致时实性差,太短会导致可能无效请求过多,因为配置压根可能没有变化。

但是这里我选择了第二种方式,因为实现起来简单。。

变动通知代码实现
变动通知代码实现

到这,一个简单的配置中心的服务端的和客户端就完成了,这里画张图来总结一下配置中心的核心原理。

接下来就把这个简易的配置中心整合到SpringCloud中。

SpringCloud配置中心的原理

1、项目启动是如何从配置中心加载数据的?

在SpringCloud环境下,当项目启动的时候,在SpringBoot应用容器创建之前,会先创建一个容器,这个容器非常重要,这个容器是用来跟配置中心交互,拉取配置的。

这个容器在启动的时候会干两件事:

  • 加载bootstrap配置文件,这就是为什么配置中心的配置信息需要写在bootstrap配置文件的重要原因

  • 加载所有spring.factories文件中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration对应的配置类,将这些配置类注入到这个容器中,注意这里是不会加载@EnbaleAutoConfiguration自动装配的类

当这两件事都做好之后,会从这个容器中获取到所有的PropertySourceLocator这个接口的实现类对象,依次调用locate方法。

PropertySourceLocator
PropertySourceLocator

这个类很重要,先来看看注释

Strategy for locating (possibly remote) property sources for the Environment. Implementations should not fail unless they intend to prevent the application from starting.

扔到有道翻译如下:

为环境定位(可能是远程)属性源的策略。实现不应该失败,除非它们打算阻止应用程序启动。

说的简单点就是用来定位到(也就是获取的意思)项目启动所需要的属性信息。同时要注意到括号内的 可能是远程 告诉我们一个很重要的信息,那就是获取的配置信息不仅仅可以存在本地,而且还可以存在远程。

远程?作者这里就差直接告诉你可以从配置中心获取了。。

所以从这个注释就可以发现,原来PropertySourceLocator就是起到在SpringCloud环境下从配置中心获取配置的作用。

PropertySourceLocator是一个接口,所以只要不同的配置中心实现这个接口,那么不同的配置中心就可以整合到了SpringCloud,从而实现从配置中心加载配置属性到Spring环境中了。

2、如何实现注入到Bean中的属性动态刷新?

上面讲了在项目启动的时候SpringCloud是如何从配置中心加载数据的,主要是通过新建一个容器,加载bootstrap配置文件和一些配置类,最后会调用PropertySourceLocator来从配置中心获取到配置信息。

那么在SpringCloud环境下,是如何实现注入到Bean中的属性动态刷新的呢?

举个例子

UserService
UserService

当在类上加一个@RefreshScope注解之后,那么当配置中心sanyou.username的属性有变化的时候,那么此时注入的username也会跟着变化。

这种变化是如何实现的呢?

SpringCloud中规定,当配置中心客户端一旦感知到服务端的某个配置有变化的时候,需要发布一个RefreshEvent事件来告诉SpringCloud配置有变动。

在SpringCloud中RefreshEventListener类会去监听这个事件,一旦监听到这个事件,就会进行两步操作来刷新注入到对象的属性。

RefreshEventListener
RefreshEventListener
  • 从配置中心再次拉取属性值,而这个拉取的代码逻辑跟项目启动时拉取的属性值核心逻辑几乎是一样的,也是创建一个新的spring容器,加载配置文件和配置类,最后通过PropertySourceLocator获取属性,这一部分核心的代码逻辑是复用的。

  • 有了最新的属性之后,就开始刷新对象的属性。

刷新的逻辑实现的非常的巧妙,可不是你以为的简单地将新的属性重新注入对象中,而是通过动态代理的方式来实现的。

对于在类上加了@RefreshScope注解的Bean,Spring在生成这个Bean的时候,会进行动态代理。

这里我们就上面举个UserService例子来分析,在生成UserService有两步操作

  • 生成一个UserService对象,将从配置中心拉到的配置sanyou.username注入给UserService对象

  • 由于加了@RefreshScope,会给上一步骤生成的UserService对象进行代理,生成一个代理对象

最后真正暴露出去供我们使用的其实是就是这个代理对象,如图所示

由于暴露出去的是一个代理对象,所以当调用getUsername方法的时候,其实是调用UserService的代理对象的getUsername方法,从而就会找到UserService,调用UserService的getUsername获取到username的属性值。

当配置中心的配置有变动刷新属性的时候,Spring会把UserService这个对象(非代理对象)给销毁,重新创建一个UserService对象,注入最新的属性值。

当再次通过UserService代理对象获取username属性的时候,就会找最新创建的那个UserService对象,此时就能获取到最新的属性值。

配置每刷新一次,UserService对象就会先销毁再重新创建,但是暴露出去的UserService代理对象一直不会变。

这样,对于使用者来说,好像是UserService对象的属性自动刷新了,其实本质上是UserService代理对象最终找的UserService对象发生了变化。

到这应该就知道为什么加了@RefreshScope的对象能够实现配置的自动刷新了,其实依靠的是动态代理完成的。

3、源码执行流程图

由于上面并没有涉及整体执行流程的源码分析,所以我特地结合源码画了两张源码的执行流程图,有兴趣的小伙伴可以对照着图翻一翻具体的源码。

3.1启动时加载配置流程

最终从配置中心获取到的属性会放在项目启动时创建的 Environment 对象里面。

3.2配置刷新源码流程

这个图新增了对于加了@ConfigurationProperties数据绑定的对象原理的分析。

整合SpringCloud和测试

一、整合SpringCloud

1、ConfigCenterProperties

配置中心的配置信息,这里需要配置配置中心服务端的地址和使用的配置文件的id。当然这部分信息需要写在bootstrap配置文件中,前面也说过具体的原因。

2、ConfigCenterPropertySourceLocator

上面分析知道,项目启动和刷新的时候,SpringCloud是通过PropertySourceLocator的实现从配置中心加载配置信息,所以这里就得实现一下

核心的逻辑就是根据所配置的文件的id,从配置中心拉取配置信息,然后解析配置。

3、ConfigContextRefresher

这个是用来注册文件变动的监听器,来刷新文件的信息的。

因为上面提到,当配置发生变化的时候,需要发布一个RefreshEvent事件来触发刷新配置的功能。

核心的逻辑就是当项目启动的时候,对所使用的配置文件进行注册一个监听器,监听器的实现就是当发生配置改动的时候,就发布一个RefreshEvent事件。

4、两个配置类
4.1 ConfigCenterBootstrapConfiguration

配置了ConfigCenterPropertySourceLocator、ConfigCenterProperties、ConfigService

4.2 ConfigCenterAutoConfiguration

配置了ConfigContextRefresher、ConfigCenterProperties、ConfigService

最后需要将两个配置类在spring.factories配置一下。

这里有个需要注意,前面说过,SpringCloud会创建新的容器来加载配置,而这个容器只会加载spring.factories文件中键为@BootstrapConfiguration注解的配置类,所以需要将ConfigCenterBootstrapConfiguration跟BootstrapConfiguration配对,因为ConfigCenterBootstrapConfiguration配置了ConfigCenterPropertySourceLocator。

好了,到这里真的就完成了对SpringCloud整合了。

二、测试

1、新增一个配置文件

启动配置中心的server端,然后打开ApiPost,新增一个配置文件

新增文件类型为properties一个配置,内容为sanyou.username=sanyou键值对,当然可以写很多键值对,我这里就写了一个,新增成功之后,返回了文件的id:79765c73-c1ef-4ea2-ba77-5d27a64c4685

2、测试客户端

这里我为了方便,就把测试代码跟客户端写在同一个服务了,正常情况肯定是把跟SpringCloud代码打成一个依赖引到项目中。

在bootstrap.yml文件中配置配置中心的相关信息

  • 配置中心服务端的地址是:localhost:8888
  • 使用的配置文件的id是刚才创建的:79765c73-c1ef-4ea2-ba77-5d27a64c4685

测试Controller

提供一个接口,注入上面提到的UserService

启动项目,调用接口

从断这里可以看出,实际注入的是一个UserService代理对象,并且最终找的是com.sanyou.configcenter.test.UserService@3a1e4fd3这个UserService对象

此时这次调用的返回值就是:sanyou

接下来测试一下自动刷新属性的功能

现在修改一下配置中心的sanyou.username为sanyou666

静静等待5秒钟。。

此时控制台打印出 Refresh keys changed: [sanyou.username] ,也就是sanyou.username属性变了

此时再次获取username

可以看出,UserService代理对象没变,但是UserService对象已经变成了com.sanyou.configcenter.test.UserService@4237b3cd

此时获取到的username就已经变成了sanyou666

所以,到这里就成功将我们自己写的那个简易版的配置中心整合到了SpringCloud中了。

不足和改进

虽然我们这里的配置中心有了配置中心基本的功能,但是其实还有很多的不足和可以改进的地方。

1、配置变更推送问题

问题前面也说过,在判断配置是否变更的时候,这里是每隔5s从服务端获取一次,这里就会可能5s之后才能感知到配置有变化,达不到真正时实的效果,并且由于这里是由客户端根据来判断,会导致无效的请求过多,因为可能配置压根没有变化,但是还是每隔5s获取一次配置信息,白白浪费资源

解决这个问题可以换成上面提到的push方式来做,或者将轮询方式改成长轮询的方式实现也是可以的,如果不清楚push、轮询、长轮询的,可以翻一下
RocketMQ的push消费方式实现的太聪明了这篇文章。

2、高可用问题

这里服务端的实例只有一个,不支持集群的方式,就会有单点故障的问题,不支持高可用。在实际项目中,肯定要支持集群的方式,保证即使有服务实例挂了,整个集群仍然可以继续对外提供服务,比如nacos就支持集群的方式,并且可以自由选择是使用AP模式还是CP模式。

3、通信协议和序列化协议

对于通信协议,这里为了方便,我选择了客户端和服务端的通信方式是基于http协议的,当然也可以自定义协议,或者使用其它的协议,比如gRPC协议。其实在nacos2.x的版本中,nacos开始全面拥抱gRPC协议了。

至于序列化协议,这里选择了json协议,因为很简单、常见、使用范围广、跨语言,当然也可以选择其它的,比如hessian序列化协议等等。

4、多租户隔离

一个合格的配置中心需要能支持不同应用的隔离,还有同一个应用不同环境的隔离,这里就图省事,直接就是有一个文件id来表示,虽然也可以做到隔离(不同系统用不同的文件id),但是这种方式比较low。像nacos会自动根据配置的名称和后缀名之类的,生成文件id(dataId),同时还有分组的概念,其实就是为了做到隔离的效果。

5、鉴权

鉴权是一个系统比较常见的东西,这里就不做过多赘述

6、控制页面

上面所有对于配置的crud都是基于ApiPost来的,但是实际怎么也得通过一个页面来操作吧,至于这里我为啥不自己写个页面,给你个眼神自己体会~~

最后,本文代码地址:

//github.com/sanyou3/sanyou-config-center

往期热门文章推荐

RocketMQ保姆级教程

三万字盘点Spring/Boot的那些常用扩展点

RocketMQ的push消费方式实现的太聪明了

一网打尽异步神器CompletableFuture

@Async注解的坑,小心

扫码或者搜索关注公众号 三友的java日记 ,及时干货不错过,公众号致力于通过画图加上通俗易懂的语言讲解技术,让技术更加容易学习,回复 面试 即可获得一套面试真题。