springboot 轻量级接入 nacos 以及自动刷新配置参考(不引入 springcloud)

14 阅读3分钟

一、背景

最近在做一个智能体项目,javaer 直接采用了 springboot4.x + springai2.x,没有使用 springcloud 组件,感觉太重了。

现在遇到了运行时切换模型的需求,总不能修改配置文件然后重启吧,也太啰嗦了。

于是想要仅接入 nacos 作为配置中心,并且不想要 springcoud 那一套,感觉对于这个项目有点重量级。

研究了大半天,这里记录下。

二、引入依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-alibaba-nacos-config</artifactId>
    <version>2025.1.0.0</version>
</dependency>

比较干净,没有 springcloud 那一套。

三、添加配置

spring:
  config:
    import: "nacos:my-app.yaml"
  nacos:
    config:
      server-addr: 127.0.0.1:8848
      namespace: 6fef744c-b525-449e-9abf-d9d5b3af4da6
      group: my-group
      file-extension: yaml

springboot4.x 的配置方式有点不同,参考上面的配置就可以了。

四、自定义 RefreshScope 注解

引用 nacos 的目的是自动刷新配置,但是 @RefreshScope 注解在 springcloud 里面,个人觉得真是不合理,难道非微服务就没有刷新配置的场景吗?

没办法,想要轻量级那就自己造。

1、首先自定义注解

@Documented
@Target(ElementType.TYPE)
@Scope(DefaultRefreshScope.NAME)
@Retention(RetentionPolicy.RUNTIME)
public @interface RefreshScope {
    @AliasFor(annotation = Scope.class)
    String value() default DefaultRefreshScope.NAME;

    @AliasFor(annotation = Scope.class)
    String scopeName() default DefaultRefreshScope.NAME;

    @AliasFor(annotation = Scope.class)
    ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;
}

2、实现 Scope 接口

@Slf4j
@Component
public class DefaultRefreshScope implements Scope, BeanFactoryPostProcessor, ApplicationListener<EnvironmentRefreshedEvent> {
    public static final String NAME = "refreshed";

    private ConfigurableListableBeanFactory beanFactory;

    private final ConcurrentMap<String, DecorateBean> cache;

    public DefaultRefreshScope() {
        this.cache = new ConcurrentHashMap<>();
    }

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        return this.cache.computeIfAbsent(name, k -> new DecorateBean(objectFactory)).getProxy();
    }

    @Override
    public Object remove(String name) {
        try {
            return Mapping.from(Mapping.from(this.cache.get(name)).then(DecorateBean::clear).get()).map(e -> e.target).get();
        } catch (Throwable e) {
            log.error("clear refresh bean error.", e);
            return Mapping.from(this.cache.remove(name)).map(b -> b.target).get();
        }
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {

    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        beanFactory.registerScope(NAME, this);
        this.beanFactory = beanFactory;
    }

    @Override
    public void onApplicationEvent(EnvironmentRefreshedEvent event) {
        new LinkedList<>(this.cache.keySet()).forEach(e -> beanFactory.destroyScopedBean(e));
    }

    @RequiredArgsConstructor
    private static class DecorateBean {

        private volatile Object target;

        private final ObjectFactory<?> factory;

        public Object getProxy() {
            ProxyFactory proxyFactory = new ProxyFactory();
            proxyFactory.setTargetClass(factory.getObject().getClass());
            proxyFactory.addAdvice(new MethodInterceptor() {
                @Override
                public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
                    Method method = invocation.getMethod();
                    if (AopUtils.isEqualsMethod(method) || AopUtils.isToStringMethod(method) || AopUtils.isHashCodeMethod(method)) {
                        return invocation.proceed();
                    }
                    return ReflectionUtils.invokeMethod(method, getObject(), invocation.getArguments());
                }
            });
            return proxyFactory.getProxy();
        }

        public Object getObject() {
            if (target == null) {
                synchronized (this) {
                    if (target == null) {
                        target = factory.getObject();
                    }
                }
            }
            return target;
        }

        public void clear() {
            this.target = null;
        }
    }
}

代码逻辑很简单,使用 Map 缓存 bean 实例,而 bean 实例创建的时候需要返回一个代理对象,这样我们才能拦截 bean 的方法调用。

然后监听 EnvironmentRefreshedEvent 事件,事件发生时,将缓存的 bean 实例销毁即可。

3、配置 nacos 监听器

当 nacos 配置变更时,会发布 NacosConfigRefreshEvent 事件,为了通用,我们监听这个事件,并将这个事件转换为 EnvironmentRefreshedEvent 发布。

除此之外,还有一个问题,那就是配置变更后,我们还需要将配置更新到 spring 的 ConfigurableEnvironment 里,否则重建 bean 的时候,拿到的配置还是旧的,还是无法实现配置刷新。

这里刷新 spring 的 ConfigurableEnvironment 的时候有两个坑点,这里特别记录下:

1、@ConfigurationProperties 注解,是由 ConfigurationPropertiesBindingPostProcessor 处理的,所以刷新环境时,需要调用 ConfigurationPropertiesBindingPostProcessor#afterPropertiesSet(),从而更新内部的 binder 字段,否则的话,配置还是无法自动刷新。

2、@Value 注解,是由 AutowiredAnnotationBeanPostProcessor 处理的,而实际上,是调用了 ConfigurableListableBeanFactory 内部的 embeddedValueResolvers 解析的,所以还需要更新下这个解析器,否则配置还是无法自动刷新。

这段逻辑代码如下:

@Slf4j
public class EnvironmentRefreshListener {
    private static final String BEAN_NAME = "org.springframework.boot.context.internalConfigurationPropertiesBinder";

    @Autowired
    protected ConfigurableListableBeanFactory beanFactory;

    @Autowired
    protected ConfigurableApplicationContext applicationContext;

    protected void refreshEnvironment(List<PropertySource<?>> refreshedPropertySources) throws Exception {
        StandardEnvironment environment = new StandardEnvironment() {
            @Override
            protected void customizePropertySources(MutablePropertySources propertySources) {
                for (PropertySource<?> refreshedPropertySource : refreshedPropertySources) {
                    propertySources.addLast(refreshedPropertySource);
                }
            }
        };

        // 合并环境
        environment.merge(this.applicationContext.getEnvironment());

        // 设置新环境
        this.applicationContext.setEnvironment(environment);

        // 删除旧的,下面的 configurer 会添加新的解析器
        @SuppressWarnings("unchecked")
        List<StringValueResolver> resolvers = (List<StringValueResolver>) ReflectUtil.getFieldValue(this.beanFactory, "embeddedValueResolvers");
        resolvers.clear();

        // 更新环境及配置
        PropertySourcesPlaceholderConfigurer configurer = this.beanFactory.getBean(PropertySourcesPlaceholderConfigurer.class);
        ReflectUtil.setFieldValue(configurer, "propertySources", null);
        configurer.setEnvironment(environment);
        configurer.postProcessBeanFactory(this.beanFactory);

        // 销毁单例并触发初始化
        ReflectUtil.invoke(this.beanFactory, "removeSingleton", BEAN_NAME);
        this.beanFactory.getBean(ConfigurationPropertiesBindingPostProcessor.class).afterPropertiesSet();
    }
}

好了,那么 nacos 监听器的代码就比较简单了:

@Slf4j
@Component
public class NacosConfigRefreshListener extends EnvironmentRefreshListener implements ApplicationListener<NacosConfigRefreshEvent> {

    @Override
    public void onApplicationEvent(NacosConfigRefreshEvent event) {
        try {
            String config = NacosConfigManager.getInstance().getConfigService().getConfig(event.getDataId(), event.getGroup(), 5000);

            List<PropertySource<?>> refreshedPropertySources = new YamlPropertySourceLoader().load(
                    event.getGroup() + '@' + event.getDataId(),
                    new ByteArrayResource(config.getBytes(StandardCharsets.UTF_8))
            );

            this.refreshEnvironment(refreshedPropertySources);

            this.applicationContext.publishEvent(new EnvironmentRefreshedEvent(applicationContext));
        } catch (Exception e) {
            log.error("nacos config refresh error", e);
        }
    }
}

到这里就结束了,给希望以轻量级方式接入 nacos 的开发者一个参考。