Apollo配置中心迁移Consul落地技术方案(Spring项目)
1、前言
实习接到的第一个需求,网上找遍资料也没有Apollo迁移Consul的具体文章,所以还是自己动手来做。Apollo相对Consul提供了更多方便实用的api,所以在代码方面需要改动的地方比较多,这也是本文的重点。公司相关信息已做隐私处理。
2、依赖导入
//SpringCloud集成Consul服务注册发现
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
//SpringCloud集成Consul配置中心
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
//在单元测试中嵌入式运行Consul库
<dependency>
<groupId>com.pszymczyk.consul</groupId>
<artifactId>embedded-consul</artifactId>
</dependency>
3、Bootstrap配置
仅做参考,具体参数待定
spring:
profiles:
active: dev
cloud:
consul:
host: ${CONSUL_HOST:127.0.0.1}
port: ${CONSUL_PORT:8500}
config:
prefix: config
default-context: orderService
enabled: ${CONSUL_CONFIG_ENABLED:true}
format: YAML
data-key: ${SPRING_CONSUL_CONFIG_FILE:data}
failFast: true
profileSeparator: '-'
overrideNone: false
watch:
enabled: true
delay: 1000
wait-time: 30
这段配置表示读取配置中心下config/orderService-dev的配置,且如果没有指定读取的key就由data作为key。
对于环境的区分,Consul采用字符串切割的形式,默认是",“,如上配置我改成了”-“。假如config文件夹下分别由"orderService-dev”,“orderService-prod”,“orderService-test”,“orderService”,我们可以通过更改active配置项来选择不同的Consul文件夹,从而加载不同的配置,默认加载"orderService",即没有指定环境。
4、配置项的迁移
原Apollo的配置是依据应用、环境、集群进行了配置划分
app:
id: ${APOLLO_APP_ID:aaabbb123456789}
apollo:
meta: ${APOLLO_CLUSTER:http://zheshiyigelianjie.com}
cluster: ${APOLLO_META:default}
cacheDir: ./data/settings/
bootstrap:
enabled: true
而在Consul中,不同纬度的区分则是文件夹层级的区别,我们只需要按照原Apollo的纬度创建一一对应的文件夹,类似于"/应用-环境/集群"的格式即可。
需要注意的是,Consul不支持json格式的Value值输入,也就是,只要被"{}“或”[]"包起来的Value必须要用单引号圈起来。
jsonConfig2:
arrayList: '["zhangsan","lisi","wangwu"]'
关于json输入,Apollo可以直接读取,会在下面代码改造部分讲到。
5、代码改造
代码改造这部分,我将其划分为需要改造和不需要改造的部分
5.1、普通注入(不需要改造)
普通注入的代码主要分为三种,一种是在applica.yml文件中,如下代码:
SERVER_IP: ${spring.cloud.client.ip-address}
server:
port: ${SERVER_PORT:8080}
ip: ${SERVER_IP}
zone: ${ZONE:default}
spring:
cache:
redis:
key-prefix: ORDER-SERVER
redis:
sentinel:
master: ${REDIS_MASTER:mymaster}
nodes: ${REDIS_NODES}
也就是从配置中心读取到配置文件中,注意这部分配置必须写在application中,否则可能会出现读取不到配置的情况,因为bootsrap文件比application文件读取优先级高,我们必须保证配置中心能够成功连接。
这部分的代码不需要做改造,只需要保证配置迁移的过程中,对应的key-value不出现变化即可。
第二种代码则是在java文件中,如下代码:
@Data
@Component
@RefreshScope
public class consulConfig {
@Value("${TestConfig}")
private String testConfig;
}
这段代码通过@Value注解读取了了key为"TestConfig"的配置,并将其注入到该配置类的属性"testConfig"中,@RefreshScope注解表示该配置是动态更新的。
对于这种类型的配置注入,Apollo和Consul都是支持的,所以在保证key-value不出现变化的情况下不改动即可。
第三种则是通过配置文件实体类注入
@Configuration
@Data
@ConfigurationProperties(prefix = "test.Config")
public class testConfig2 {
private String signKey;
private String appId;
private String appKey;
}
这种配置大多数属于配置的嵌套,通过@ConfigurationProperties(prefix = “test.Config”)注解获取被嵌套的value。
5.2、配置注入的切面代码(需要改造)
与AOP的思想类似,在某个配置动态更新的时候,我们希望在更新的前后进行一些操作,比如记录下配置前后更新的值,这就是切面代码。在这个部分,Apollo和Consul的代码会有一点区别,最主要的就是如何捕获到配置的变化。
5.2.1、Apollo配置发生变化时执行切面操作
如下图所示,Apollo提供了@ApolloConfigChangeListener注解,该注解可以监听指定配置Key"testConfig"的变化,如果发生了变化,则执行该注解下的方法,如代码所示,执行了记录配置发生变化的前后值。
@ApolloConfigChangeListener(interestedKeys = {"testConfig"})
public void refresh(ConfigChangeEvent changeEvent) {
log.info("[testConfig] old value={}", refreshConfig.getTestConfig());
refreshScope.refresh("financialHashConfig");
log.info("[testConfig] new value={}", refreshConfig.getTestConfig());
}
5.2.2、Consul配置发生变化时执行切面操作
Consul并没有提供类似的注解进行监听,但是我们可以借助SpringFramework的事件监听机制来实现。
5.2.2.1、Consul集成SpringCloud源码分析
通过观察察看spring-cloud-consul-config这个包,可以看到ConfigWatch这个文件
打开源码,可以看到核心方法watchConfigKeyValues,关键代码如下
Response<List<GetValue>> response = this.consul.getKVValues(context, aclToken, new QueryParams((long)this.properties.getWatch().getWaitTime(), currentIndex));
if (response.getValue() != null && !((List)response.getValue()).isEmpty()) {
Long newIndex = response.getConsulIndex();
if (newIndex != null && !newIndex.equals(currentIndex)) {
if (!this.consulIndexes.containsValue(newIndex) && !currentIndex.equals(-1L)) {
log.trace("Context " + context + " has new index " + newIndex);
RefreshEventData data = new RefreshEventData(context, currentIndex, newIndex);
this.publisher.publishEvent(new RefreshEvent(this, data, data.toString()));
} else if (log.isTraceEnabled()) {
log.trace("Event for index already published for context " + context);
}
this.consulIndexes.put(context, newIndex);
} else if (log.isTraceEnabled()) {
log.trace("Same index for context " + context);
}
} else if (log.isTraceEnabled()) {
log.trace("No value for context " + context);
}
简单来说,这是Consul对配置的监视器,假如配置发生了变化,则执行下面的操作。可以看到这么一句代码:
this.publisher.publishEvent(new RefreshEvent(this, data, data.toString()));
这句代码意思为发布一个RefreshEvent事件,得益于Consul与Spring框架的集成,Consul可以利用到Spring的事件监听机制。
在发布事件的这段代码中,我们可以看到发布的事件会携带上"data"以及其字符串,再去看data的组成:
RefreshEventData data = new RefreshEventData(context, currentIndex, newIndex);
可以看到"data"是由发生改动的配置路径,当前配置的索引,更改后的配置索引组成。但是这个事件携带的数据并没有包含具体的key值,我们是检测不到的。观察上面代码,可以看到发布了一个RefreshEvent事件,很自然的,可以发现有一个RefreshEventListener类专门处理RefreshEvent事件。
public void handle(RefreshEvent event) {
if (this.ready.get()) {
log.debug("Event received " + event.getEventDesc());
Set<String> keys = this.refresh.refresh();
log.info("Refresh keys changed: " + keys);
}
这个类会在接收到事件之后进行判断,假如是RefreshEvent事件就执行上面的代码,关键是中间的refresh方法。被调用的refresh对象的元类是ContextRefresher,我们继续看ContextRefresher的refresh方法。
public synchronized Set<String> refresh() {
Set<String> keys = this.refreshEnvironment();
this.scope.refreshAll();
return keys;
}
public synchronized Set<String> refreshEnvironment() {
Map<String, Object> before = this.extract(this.context.getEnvironment().getPropertySources());
this.addConfigFilesToEnvironment();
Set<String> keys = this.changes(before, this.extract(this.context.getEnvironment().getPropertySources())).keySet();
this.context.publishEvent(new EnvironmentChangeEvent(this.context, keys));
return keys;
}
看到这里,已经很清晰了。篇幅原因就不继续往下看了,直接说结论,refreshEnvironment方法会对比RefreshEvent事件前后发生变化的配置,然后会把比对结果,也就是发生变化的key存在一个集合中,同时也会发布一个EnvironmentChangeEvent事件,那改动的代码就好说了,我们只需要获取这个集合,同时监听这个事件就可以实现对我们想要的配置做动态刷新的切面操作了
5.2.2.2、Consul集成SpringCloud实现配置监听
上面说到Consul发布了事件,基于Spring的事件监听框架,主要有三个角色:事件,发布者,监听者。前两者已经由SpringCloud-Consul帮我们处理好了,现在只需要编写监听者就可以实现对配置的监听,并且在配置发生变化的时候进行一些额外的操作了,具体代码我们可以使用@EventListener注解实现,以下是代码实现:
@EventListener
public void onRefreshEvent(EnvironmentChangeEvent event) {
Set<String> keys = event.getKeys();
log.info("Consul事件已捕获={}",event.getSource());
String propertyName = "zjmTestListener";
log.info("Consul中发生变化的配置为={}",keys);
if (keys.contains(propertyName)) {
log.info("[zjmTestListener] old value={}", refreshConfig.getZjmTestListener());
refreshScope.refresh("testConsulConfig");
log.info("[zjmTestListener] new value={}", refreshConfig.getZjmTestListener());
}
}
如上,SpringFramework发布事件时,会根据@EventListener和入参类型进行方法调用,再在内部对具体到某个配置去进行处理。在进入到事件中,会去获取发生变化的key集合,再去判断我们期望的配置是否在这个集合中,假如在的话就进行切面操作。
这里解释下为什么可以进行切面操作,关键是RefreshScope类的refresh方法,这个方法的作用是重新加载目标bean(方法参数填bean的名称,bean默认名称是原类名且首字母小写),以便第一时间获得最新的配置值;而假如只使用@RefreshScope是只有当再次被访问时才会刷新对应的配置。
5.2.2.3、存在的问题
在上面的代码中,确实存在一个潜在的问题。如果 onRefreshEvent()
方法被频繁调用,但大多数调用的事件源都不包含指定的属性名,那么在方法内部执行的条件检查会浪费一定的资源。更好的做法是在方法被调用之前,先检查事件源是否包含指定的属性名,如果不包含,就不再调用这个方法。
具体的改进方法是可以自己手动去编写事件发布类,只有在检测到指定配置的情况下才发布对应的配置,再由对应的监视者去处理,从而避免把判断写在监视者方法内部,导致监视者方法被反复调用。或者将所有需要监听的配置写在一个集合中,挨个遍历集合中的元素是否在发生改动的配置集合中,假如为true再进行切面操作,像日志记录这种切面方法就很适合这样做。
5.3、注入Value为Json格式的情况(需要改造)
Apollo提供的api中,有这么一个@ApolloJsonValue注解,这个注解会自动解析json格式的Value,并自动注入到对应的数据类型中。
@ApolloJsonValue("${test.list:[]}")
private List<String> testList;
但是Consul并没有这样一个注解,但是我们可以通过代码功能实现类似功能。
@Data
@Slf4j
@Component
@RefreshScope
public class TestConsulJsonListConfig {
@Value("${test.list}")
private String jsonListConfig;
private List<String> testConfigList=new ArrayList<>();
@PostConstruct
public void init() {
if (jsonListConfig != null && !jsonListConfig.isEmpty()) {
jsonListConfig=jsonListConfig.substring(1,jsonListConfig.length()-1);
jsonListConfig=jsonListConfig.replaceAll("\"","");
String[] arr=jsonListConfig.split(",");
for (String s : arr) {
testConfigList.add(s.trim());
}
}
}
}
具体改造如上,首先原来被注入的集合名字不要变,因为我们是做迁移,很有可能有别的代码是根据这个集合名字去调用的。接着把@Value注解去掉,我们用一个String接收,而原来的集合就变成了一个普通的属性。用String接收到json格式的字符串之后,我们通过@PostConstruct标记的init方法去把String类型的json格式集合转换成真正的集合,并且注入到原来的集合中。@PostConstruct是在bean加载完之后回调的方法,所以在配置注入到字符串之后,集合也会被注入,从而实现@ApolloJsonValue的效果。
除此之外,还有一点需要注意。假如该属性所在类有@RefreshScope注解,这表示该配置是动态刷新的,配置动态刷新的同时,我们也要保证集合属性也是最新值,意味着我们需要做同步操作。同步操作的解决思路也很简单,在上面讲到的切面代码中,我们实现了Consul对配置监听的切面操作,我们可以利用这一点编写代码,在json属性变化的同时,将新值注入到集合属性中。
@EventListener
public void onRefreshEvent(EnvironmentChangeEvent event) {
Set<String> keys = event.getKeys();
log.info("Consul事件已捕获={}",event.getSource());
String propertyName = "test.list";
log.info("Consul中发生变化的配置为={}",keys);
if (keys.contains(propertyName)) {
refreshScope.refresh("testConsulJsonListConfig");
}
}
如上代码,在json属性发生变化时,刷新该属性所在bean,从而使得被@PostConstruct注解标记的init方法再执行一遍,当然也可以在该属性所在类中单独写一个方法去调用,就不需要重新加载bean了。