背景
这几年使用nacos作为配置中心的团队越来越多,掌握nacos成了我们工作、面试中不可缺少的一道硬菜,同样具有五年配置中心使用的笔者来说也有必要学习下。本文主要就nacos客户端进行分析,涉及到服务端相关的会在后面文章展开。
介绍
Nacos 是阿里巴巴推出来的一个新开源项目,这是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台,Nacos 提供配置统一管理功能,能够帮助我们将配置以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置。
使用
可本地安装单节点测试,也可以安装nacos集群,安装方式,mvn 导入nacos client、如果使用spring可以引入nacos-spring的一些扩展功能jar包,在项目中就可以使用了
使用就介绍到这里,不清楚的可以通过官方介绍 nacos 使用
总览
红色框框便是客户端所在角色,而他的功能也很明确拉取Actor配置的最新值
核心介绍
下面分析下客户端是怎么感知服务器值变更的,并且又是怎么通知到我们spring管理的bean,让业务代码感知
拉取方案
NacosConfigService 是程序启动nacos客户端的入口,比如ofc项目中通过引入spring-cloud-starter-alibaba-nacos-config 包,间接通过 NacosConfigManager 启动它
//com.alibaba.cloud.nacos.NacosConfigAutoConfiguration
@Bean
public NacosConfigManager nacosConfigManager(
NacosConfigProperties nacosConfigProperties) {
return new NacosConfigManager(nacosConfigProperties);
}
NacosConfigManager中引用了ClientWorker,clientWorker会初始2个线程池,executor和executorService,executorService主要负责轮询nacosServer获取最新的配置变更,executor是进行拉取任务分配,比如你的应用引用了几十万个配置文件,而且会动态删减 那么executor会检测然后将任务拆分为3000个文件一次,这样以达到多线程同时拉取的效果缩短延迟。
this.executor = Executors.newScheduledThreadPool(1, new ThreadFactory() {
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("com.alibaba.nacos.client.Worker." + agent.getName());
t.setDaemon(true);
return t;
}
});
this.executorService = Executors
.newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("com.alibaba.nacos.client.Worker.longPolling." + agent.getName());
t.setDaemon(true);
return t;
}
});
this.executor.scheduleWithFixedDelay(new Runnable() {
public void run() {
try {
checkConfigInfo();
} catch (Throwable e) {
}
}
}, 1L, 10L, TimeUnit.MILLISECONDS);//这里10毫秒一次
executor是单线程线程池个人感觉 executor 10毫秒的刷新频次太高了,executorService 线程个数和cpu相匹配。
public void checkConfigInfo() {
int listenerSize = cacheMap.get().size();
int longingTaskCount = (int) Math.ceil(listenerSize / 3000);
if (longingTaskCount > currentLongingTaskCount) {
for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {
executorService.execute(new LongPollingRunnable(i));
}
currentLongingTaskCount = longingTaskCount;
}
}
按照3000分组提交给executorService线程池,接下去我们看看核心拉取逻辑。提交到executorService的任务是 LongPollingRunnable 类,我们看看run方法都干了些什么
public void run() {
List<CacheData> cacheDatas = new ArrayList<CacheData>();
List<String> inInitializingCacheList = new ArrayList<String>();
try {//第一部分
for (CacheData cacheData : cacheMap.get().values()) {
if (cacheData.getTaskId() == taskId) {
cacheDatas.add(cacheData);
checkLocalConfig(cacheData);
if (cacheData.isUseLocalConfigInfo()) {
cacheData.checkListenerMd5();
}
}
}//第二部分
List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);
for (String groupKey : changedGroupKeys) {
String[] key = GroupKey.parseKey(groupKey);
String dataId = key[0];
String group = key[1];
String tenant = null;
if (key.length == 3) {
tenant = key[2];
}
String[] ct = getServerConfig(dataId, group, tenant, 3000L);
CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant));
cache.setContent(ct[0]);
if (null != ct[1]) {
cache.setType(ct[1]);
}
}
for (CacheData cacheData : cacheDatas) {
if (!cacheData.isInitializing() || inInitializingCacheList
.contains(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant))) {
cacheData.checkListenerMd5();//第四部分
cacheData.setInitializing(false);
}
}
inInitializingCacheList.clear();
executorService.execute(this);//第五部分
} catch (Throwable e) {
executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);
}
}
List<String> checkUpdateDataIds(List<CacheData> cacheDatas, List<String> inInitializingCacheList) throws Exception {
boolean isInitializingCacheList = !inInitializingCacheList.isEmpty();
return checkUpdateConfigStr(sb.toString(), isInitializingCacheList);
}//第三步
List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws Exception {
Map<String, String> params = new HashMap<String, String>(2);
params.put(Constants.PROBE_MODIFY_REQUEST, probeUpdateString);
Map<String, String> headers = new HashMap<String, String>(2);
headers.put("Long-Pulling-Timeout", "" + timeout);
try {
long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
HttpRestResult<String> result = agent
.httpPost("/v1/cs/configs/listener", headers, params, agent.getEncode(),readTimeoutMs);
if (result.ok()) {
setHealthServer(true);
return parseUpdateDataIdResponse(result.getData());
}
} catch (Exception e) {
setHealthServer(false);
}
return Collections.emptyList();
}
- 第一部分主要是拿到当前批次任务需要检查更新的groupid,比如我们的application.properties、trade-platform.dubbo.properties 等
- 第二、三部分
headers.put("Long-Pulling-Timeout", "" + timeout);中将timeout 作为header 进行请求组装 timeout 默认是30,000ms,然后设置客户端请求的超时时间为timeout + (long) Math.round(timeout >> 1)= 45,000ms,这还是比较有意思的,我想了想这样做大概是为了减少网络io次数和节省流量吧,毕竟都在响应降本增效,最后发送post请求到/v1/cs/configs/listener,请求完成我们会根据返回的结构判断是否有配置变更,判断配置变更程序主要通过md5进行对比,如果有变更那么线程还会通过getServerConfig继续拉取真正的配置文件内容,最后通过checkListenerMd5方法来通知这里我们放后面分析。 - 第五部分 又将任务提交到了线程池中,继续检查服务器变更。
自动刷新
自动刷新的方案比较多这里我们着重说下@RefreshScope注解加@Value 方式和@NacosValue和nacos-spring-context 这两种比较典型的方案
@RefreshScope注解加@Value
@RefreshScope 这个注解不是nacos才有的,是在spring中自带的,其含义主要为了标明bean的作用域,默认我们常见的bean主要是单例(singleton),@RefreshScope 包裹了一层@Scope("refresh") 还有独特的proxyMode为targetClass 这两个标识就为@RefreshScope能支持刷新提供了很好的基础, @RefreshScope 从bean的定义和注册起有有所不同我们先来看看beanD定义有没有区别于其他的beanD,我们从beanFactory的 beanDefinitionMap 中看到有2个关于userController关键词
很明显有2个beanD存在,后面会实例化成2个bean一个是我们自己创建的userController一个是spring的LockedScopedProxyFactoryBean,接下去我们看看最初的doGetBean方法
很明显我们在调用bean的时候会从else分支的scope中去拿bean,那么我们摄像如果scope.get() 方法取不到bean是不是就会重新创建bean?带着这个问题,后面我们看看
checkListenerMd5 中是否真的会与此结合。
顺着 checkListenerMd5 往下看,后置处理中会调用一个listener.receiveConfigInfo(),说到这里要再介绍一个类NacosContextRefresher 这个类实现了ApplicationListener 往我们的nacosclient中注册了一个监听器
private void registerNacosListener(final String groupKey, final String dataKey) {
String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey);
Listener listener = listenerMap.computeIfAbsent(key,
lst -> new AbstractSharedListener() {
public void innerReceive(String dataId, String group,
String configInfo) {
refreshCountIncrement();
nacosRefreshHistory.addRefreshRecord(dataId, group, configInfo);
applicationContext.publishEvent(
new RefreshEvent(this, null, "Refresh Nacos config"));
}
});
configService.addListener(dataKey, groupKey, listener);
}
而这个listener正是listener.receiveConfigInfo() 会被执行的listener,有2个作用,一个是统计用,一个是发布RefreshEvent 事件,而 RefreshEvent 是spring中的事件,又由RefreshEventListener 订阅处理
public void handle(RefreshEvent event) {
if (this.ready.get()) {
Set<String> keys = this.refresh.refresh();
}
}
public synchronized Set<String> refresh() {
Set<String> keys = refreshEnvironment();
this.scope.refreshAll();
return keys;
}
我们深入下几层代码,在refresh方法中,会刷新spring环境,然后出现了scope字眼,scope.refreshAll() 方法实际是将@RefreshScope 中的bean全部销毁了,这样程序再次执行就会从环境中获取新的变量,我验证了下果然,每次变量跟新 userController 就会重新初始化,太吓人了,如果初始化方法比较耗时岂不是block流程了
public void destroy() {
List<Throwable> errors = new ArrayList<Throwable>();
Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
for (BeanLifecycleWrapper wrapper : wrappers) {
Lock lock = this.locks.get(wrapper.getName()).writeLock();
lock.lock();
wrapper.destroy();
@NacosValue和nacos-spring-context
在介绍之前大家可以先去熟悉下spring的bean后置处理器BeanPostProcessor,nacos-spring-context包中含有后置处理器的实现类NacosValueAnnotationBeanPostProcessor,我们看看他都做了啥
public Object postProcessBeforeInitialization(Object bean, final String beanName){
doWithFields(bean, beanName);
doWithMethods(bean, beanName);
return super.postProcessBeforeInitialization(bean, beanName);
}
private void doWithFields(final Object bean, final String beanName) {
ReflectionUtils.doWithFields(bean.getClass(),
new ReflectionUtils.FieldCallback() {
public void doWith(Field field) throws IllegalArgumentException {
NacosValue annotation = getAnnotation(field, NacosValue.class);//部分一
doWithAnnotation(beanName, bean, annotation, field.getModifiers(),
null, field);
}
});
}
private void doWithAnnotation(String beanName, Object bean, NacosValue annotation,
int modifiers, Method method, Field field) {
if (annotation != null) {
if (annotation.autoRefreshed()) {
String placeholder = resolvePlaceholder(annotation.value());
NacosValueTarget nacosValueTarget = new NacosValueTarget(bean, beanName,method, field, annotation.value());
put2ListMap(placeholderNacosValueTargetMap, placeholder,
nacosValueTarget);
}
}
}
我们这里只看 doWithFields 方法,部分一代码中获取到NacosValue注解,并将nacos的key通过placeholder 解析出来,put2ListMap map中关联了key和key所关联的bean,将key和bean的关系放在了 placeholderNacosValueTargetMap 中。接着看nacos-spring-context中的 DelegatingEventPublishingListener 类,实现了Listener接口,上文讲过只要实现该接口,key更新就会触发 receiveConfigInfo() 方法。
public void receiveConfigInfo(String content) {
onReceived(content);
publishEvent(content);
}
private void publishEvent(String content) {
NacosConfigReceivedEvent event = new NacosConfigReceivedEvent(configService,
dataId, groupId, content, configType);
applicationEventPublisher.publishEvent(event);
}
接收到key变更,方法中发布了 NacosConfigReceivedEvent 事件,而NacosConfigReceivedEvent 事件的订阅却有绕回到了NacosValueAnnotationBeanPostProcessor.onApplicationEvent()方法
public void onApplicationEvent(NacosConfigReceivedEvent event) {
for (Map.Entry<String, List<NacosValueTarget>> entry : placeholderNacosValueTargetMap
.entrySet()) {
String key = environment.resolvePlaceholders(entry.getKey());
String newValue = environment.getProperty(key);
List<NacosValueTarget> beanPropertyList = entry.getValue();
for (NacosValueTarget target : beanPropertyList) {
String md5String = MD5Utils.md5Hex(newValue, "UTF-8");
boolean isUpdate = !target.lastMD5.equals(md5String);
if (isUpdate) {
target.updateLastMD5(md5String);
Object evaluatedValue = resolveNotifyValue(target.nacosValueExpr, key, newValue);
setField(target, evaluatedValue);
}
}
}
}
private void setField(final NacosValueTarget nacosValueTarget,
final Object propertyValue) {
final Object bean = nacosValueTarget.bean;
Field field = nacosValueTarget.field;
String fieldName = field.getName();
field.set(bean, convertIfNecessary(field, propertyValue));
}
最后层层下去到了setField(),通过变更的key找到了对应bean,从spring env中得到了最新的值,然后通过反射将我们userController 的属性刷新
扩展
想通过 @Value 也能自动刷新,却没有用@NacosValue,这又是怎么做的?,可以将对@NacosValue 的扫描 改成@Value 来支持@Value的动态刷新。