前一文章《SpringCloudConfigServer配置中心使用与刷新详解》 介绍了Spring Cloud原生配置中心的部署方案,以及配置变更时的刷新方案。
通过该文可以看到:
- 第一种方案无法同时刷新单个服务的所有实例
- 第二种方案依赖于消息中间件(RabbitMQ或kafka)比较重,我测试中还碰到客户端反序列化异常导致故障的情况
基于如上2个原因,希望有一个比较轻量的配置刷新方案,思考了一下,是否可以由Client定时轮询,如果配置有更新,就自己调用自己的/actuator/refresh
接口,完成配置刷新呢?
验证了一下可行性,是没有问题的:
- Config-Server支持添加
@RestController
接口 - Client调用自己的
/actuator/refresh
接口,就是http发请求给自己,调用localhost:端口就可以
根据/actuator/refresh
接口调用日志,在对应的类上打断点,跟踪了一下,发现该接口定义在org.springframework.cloud.endpoint.RefreshEndpoint
类里,调用的是org.springframework.cloud.context.refresh.ConfigDataContextRefresher
类的refresh()
方法,并且该类也注册为Bean了;
那就更简单了,直接定义一个Job,调用这个Bean的refresh()
就好了
自定义方案原理介绍
自定义实现的轮询机制,大致步骤:
- Config-Server端:
- 提供管理API,用于开发人员更改 最近配置刷新时间
- 提供客户端API,用于客户端定时拉取最近的配置刷新时间,并判断是否需要重新加载配置和刷新
- Config-Client端:
- 定时轮询Config-Server端的API,获取自己的最近的配置刷新时间;
- 如果比上一次刷新时间大,则进行配置刷新
整体的刷新流程图如下:
代码实现
Config-Server端
1、定义全局时间和每个应用的时间,并提供读写方法,核心代码如下:
@Service
public class RefreshService implements InitializingBean {
private LocalDateTime globalLastUpdateTime = LocalDateTime.now();
private Map<String, LocalDateTime> mapAppLastUpdateTimes = new HashMap<>();
private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-DD HH:mm:ss.SSS");
/**
* 客户端使用:获取指定app的配置更新时间
*/
public String getLastUpdateTime(String appName) {
appName = formatAppName(appName);
LocalDateTime time = null;
if (StringUtils.hasLength(appName)) {
time = mapAppLastUpdateTimes.get(appName);
}
if (time == null) {
time = globalLastUpdateTime;
}
return time.format(formatter);
}
/**
* 管理端使用:设置指定app的配置更新时间,以触发该app更新配置
*/
public void setLastUpdateTime(String appName, LocalDateTime time) {
appName = formatAppName(appName);
if (!StringUtils.hasLength(appName))
throw new RuntimeException("应用名不能为空");
if (time == null)
time = LocalDateTime.now();
mapAppLastUpdateTimes.put(appName, time);
log.info("{}最后配置更新时间修改为:{}", appName, time);
}
/**
* 管理端使用:设置全局配置更新时间,触发所有app的配置更新
*/
public void setGlobalLastUpdateTime(LocalDateTime time) {
if (time == null)
time = LocalDateTime.now();
globalLastUpdateTime = time;
mapAppLastUpdateTimes.clear();
log.info("全局最后配置更新时间修改为:{}", time);
}
private String formatAppName(String appName) {
if (appName == null)
return "";
return appName.trim().toLowerCase();
}
2、提供对外API
@RestController
public class DefaultController {
private final RefreshService refreshService;
public DefaultController(RefreshService refreshService) {
this.refreshService = refreshService;
}
/**
* 查看指定应用的的最近配置更新时间
*
* @param appName 应用
* @return 时间
*/
@GetMapping("lastTime")
public String getTime(@RequestParam(required = false) String appName) {
return refreshService.getLastUpdateTime(appName);
}
/**
* 修改指定应用的最近配置更新时间为now
*
* @param appName 应用
* @return 更新后时间
*/
@PostMapping("lastTime")
public String setTime(@RequestParam(required = false) String appName) {
refreshService.setLastUpdateTime(appName, LocalDateTime.now());
return getTime(appName);
}
/**
* 修改所有应用的最近配置更新时间为now
*
* @return 更新后时间
*/
@PostMapping("lastAllTime")
public String setGlobalTime() {
refreshService.setGlobalLastUpdateTime(LocalDateTime.now());
return getTime(null);
}
}
Config-Client端
1、定义feign类以获取配置更新时间
// url就是上面的Config-Server的url地址,可以写入yml配置
@FeignClient(name = "config-server", url = "http://localhost:8999")
public interface FeignConfigServer {
@GetMapping("/lastTime")
String getTime(@RequestParam String appName);
}
2、定时job轮询Config-Server端API,并判断是否刷新配置
@Component
@ConditionalOnProperty(value = "spring.cloud.config.refresh.enabled", havingValue = "true", matchIfMissing = true)
@RequiredArgsConstructor
@Slf4j
public class ConfigsRefresh implements InitializingBean {
private final FeignConfigServer feignConfigServer;
private final org.springframework.cloud.context.refresh.ConfigDataContextRefresher refresher;
@Value("${spring.application.name:}")
private String appName;
private LocalDateTime lastUpdateTime = LocalDateTime.now();
private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-DD HH:mm:ss.SSS");
private static final Random RANDOM = new Random();
/**
* 每分钟检查并刷新
*/
@Scheduled(cron = "0 * * * * *")
public void doRefresh() {
LocalDateTime serverUpdateTime = getServerLastUpdateTime(appName);
if (serverUpdateTime.compareTo(lastUpdateTime) <= 0) {
return;
}
log.info("本地更新时间较小:{} < {} 需要刷新配置", lastUpdateTime, serverUpdateTime);
sleep();
Set<String> keys = refresher.refresh();
lastUpdateTime = serverUpdateTime;
log.info("更新列表 {}", keys);
}
// 获取当前应用,配置中心的配置最近刷新时间
private LocalDateTime getServerLastUpdateTime(String appName) {
try {
String serverUpdateTime = feignConfigServer.getTime(appName);
// log.info("配置中心最近更新: {}", serverUpdateTime);
if (StringUtils.hasLength(serverUpdateTime)) {
return LocalDateTime.parse(serverUpdateTime, formatter);
}
} catch (Exception exp) {
log.error("取配置中心更新时间出错:", exp);
}
return LocalDateTime.MIN;
}
private void sleep() {
// 随机休眠,避免所有pod同时刷新配置
int selectSecond = RANDOM.nextInt(10);
try {
Thread.sleep(selectSecond * 1000L);
} catch (InterruptedException e) {
log.error("休眠出错", e);
}
}
@Override
public void afterPropertiesSet() {
lastUpdateTime = LocalDateTime.now();
log.info("配置本地更新时间:{}", lastUpdateTime);
}
}
Demo代码参考:
Config-Server端Demo | Config-Client端Demo
实际工作中使用步骤简述
假设Config-Server端url为 http://localhost:8999
单个应用刷新
假设需要刷新配置的应用,spring.application.name=cb-admin
1、修改配置,并提交到git(注意提交到正确的分支)
2、调用配置中心API,通知刷新,调用方式:
curl -X POST http://localhost:8999/lastTime?appName=cb-admin
3、OK,1分钟左右,该应用就会完成配置刷新
所有应用刷新
如果希望所有应用同时刷新,需要调用另一个API:
curl -X POST http://localhost:8999/lastAllTime
查看配置最近更新时间
浏览器里访问:
http://localhost:8999/lastAllTime
未解决的问题
在测试过程中,Config-Server使用了https,结果发现/actuator/refresh
无法刷新配置,因为启动能正常加载配置,只是无法刷新,搞了1,2天,后面新建了一个空项目才发现是刷新时报ssl证书无法验证的问题。
可是启动时也是同样的配置加载过程,就比较奇怪。
在跟踪过程中,发现Config-Client使用的是自己内部new的RestTemplate
,而不是Spring里的Bean,所以也无法通过重定义RestTemplate
的Bean方式来忽略ssl证书校验,
我知道在Config-Server里,可以添加配置spring.cloud.config.server.git.skip-ssl-validation: true
来忽略git的ssl证书认证,但是没找到client的类似配置,这个问题就先忽略了,没有进一步去跟踪代码。
后面有时间再研究吧,先用http方式处理了,内部调用也节约一些性能。