Apollo配置改变SpringBoot中@ConfigurationProperties如何动态刷新

IT 文章1天前发布 小编
2 0 0

SpringBoot项目里用Apollo配置中心,不少开发者会遇到这样的情况:用@Value注解注入的属性,动态刷新功能妥妥的没问题,但换成@ConfigurationProperties注解的Bean时,配置变更后新值就是死活注入不进去。这篇文章就实打实跟大家分享三种解决办法,还会聊聊每种方法的坑和注意事项,代码示例都给大家摆好了,照着用就行。

先搞懂核心问题

Apollo对@Value注解的支持很直接,属性映射能实时响应配置变更。但@ConfigurationProperties是基于Bean的属性映射,默认情况下没法感知Apollo的配置变化。官方文档也明确说了,想让@ConfigurationProperties实现动态刷新,得配合EnvironmentChangeEvent或者RefreshScope来用,这也是咱们下面解决办法的核心思路。

先给大家贴一下常用的配置和实体类示例,后面三种方法都基于这个基础来搞:

ad

程序员导航

优网导航旗下整合全网优质开发资源,一站式IT编程学习与工具大全网站

1. YML配置示例

my.config:
  age: 12
  name:
    first-name: lao
    last-name: liu
  map:
    eq: '09' # 这里必须加引号,不然会自动转成数字9,加引号才能保留字符串"09"
    iq: 85
  list:
    - 吃喝睡
    - 打豆豆

2. 对应的配置Bean

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.Map;

@Data // Lombok注解,省掉getter、setter这些模板代码
@Configuration
@ConfigurationProperties(prefix = "my.config") // 绑定前缀为my.config的配置
public class MyConfigProperties {
    private Integer age; // 对应my.config.age
    private Name name; // 嵌套对象,对应my.config.name下的子属性
    private Map<String, Integer> map; // 对应my.config.map下的键值对
    private List<String> list; // 对应my.config.list下的集合元素

    // 嵌套静态内部类,封装name相关的子属性
    @Data
    public static class Name {
        private String firstName; // 对应my.config.name.first-name(横线会自动转驼峰)
        private String lastName;
    }
}

三种动态刷新实现方案

方案一:EnvironmentChangeEvent + @ApolloConfigChangeListener

这种方式是通过Apollo的配置变更监听器,捕捉到配置变化后,发布EnvironmentChangeEvent事件,让Spring容器更新对应的Bean属性。

实现代码

import com.ctrip.framework.apollo.model.ConfigChange;
import com.ctrip.framework.apollo.model.ConfigChangeEvent;
import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.cloud.context.environment.EnvironmentChangeEvent;

@Slf4j
@Configuration
public class ApolloConfigRefreshListener {

    // 注入Spring上下文,用于发布事件
    @Autowired
    private ApplicationContext applicationContext;

    /**
     * 监听application.yml命名空间下,前缀为my.config.的配置变更
     * value:指定要监听的配置文件(命名空间)
     * interestedKeyPrefixes:只关注指定前缀的配置,减少不必要的触发
     */
    @ApolloConfigChangeListener(value = "application.yml", interestedKeyPrefixes = "my.config.")
    public void handleConfigChange(ConfigChangeEvent changeEvent) {
        // 打印变更的命名空间
        log.info("配置变更 - 命名空间:{}", changeEvent.getNamespace());
        
        // 遍历所有变更的key,打印详细变更信息
        for (String key : changeEvent.changedKeys()) {
            ConfigChange change = changeEvent.getChange(key);
            log.info("属性变更详情 - key:{},旧值:{},新值:{},变更类型:{}",
                    change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType());
        }

        // 发布环境变更事件,Spring会自动更新@ConfigurationProperties注解的Bean属性
        applicationContext.publishEvent(new EnvironmentChangeEvent(changeEvent.changedKeys()));
        
        // 这里可以加自己的业务处理逻辑,比如配置变更后需要做的额外操作
    }
}

方案一的坑:不是所有场景都支持

实际用下来发现,这种方式对不同数据类型和操作的支持并不完整,整理了个表格给大家参考:

数据类型/操作场景 新增key-value 更新value 增改二维key-value 删除二维key-value 清空value 删除key
Integer(比如age) 支持 支持 无此场景 无此场景 不支持 不支持
嵌套对象(比如Name) 支持 支持 支持 不支持 不支持 不支持
Map集合(比如map) 支持 支持 支持 不支持 不支持 不支持
List集合(比如list) 支持 支持 支持 支持 支持 支持

简单说,除了List集合能完美支持所有操作,其他类型在“删除key”“清空value”这些场景下都会失效。如果你的业务场景里不需要这些操作,用这个方案没问题;如果需要全量支持,就得看后面的方案。

方案二:RefreshScope + @ApolloConfigChangeListener(推荐,完美解决)

这是最稳妥的方案,通过@RefreshScope注解标记配置Bean,配置变更时触发Bean刷新,重新从Apollo拉取最新配置并赋值,能解决方案一的所有坑。

ad

AI 工具导航

优网导航旗下AI工具导航,精选全球千款优质 AI 工具集

实现步骤

  1. 先给配置Bean加@RefreshScope@Component注解:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;

@Data
@Component // 注册为Spring组件,让RefreshScope能扫描到
@RefreshScope // 关键注解,支持Bean刷新
@ConfigurationProperties(prefix = "my.config")
public class MyConfigProperties {
    private Integer age;
    private Name name;
    private Map<String, Integer> map;
    private List<String> list;

    @Data
    public static class Name {
        private String firstName;
        private String lastName;
    }
}
  1. 编写监听类,触发刷新:
import com.ctrip.framework.apollo.model.ConfigChange;
import com.ctrip.framework.apollo.model.ConfigChangeEvent;
import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
public class ApolloRefreshScopeListener {

    // 注入RefreshScope,用于触发Bean刷新
    @Autowired
    private RefreshScope refreshScope;

    @ApolloConfigChangeListener(value = "application.yml", interestedKeyPrefixes = "my.config.")
    public void handleConfigChange(ConfigChangeEvent changeEvent) {
        log.info("配置变更 - 命名空间:{}", changeEvent.getNamespace());
        
        for (String key : changeEvent.changedKeys()) {
            ConfigChange change = changeEvent.getChange(key);
            log.info("属性变更详情 - key:{},旧值:{},新值:{},变更类型:{}",
                    change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType());
        }

        // 两种刷新方式,选一种就行
        refreshScope.refreshAll(); // 刷新所有加了@RefreshScope的Bean
        // refreshScope.refresh("myConfigProperties"); // 只刷新指定名称的Bean(Bean名称默认是类名首字母小写)
    }
}

方案二的优势

不管是哪种数据类型(Integer、嵌套对象、Map、List),还是哪种操作(新增、更新、删除、清空),都能完美支持。配置变更后,Bean会被重新初始化,所有属性都会更新为最新值,不会有方案一的局限性。

方案三:自定义逻辑 + @ApolloConfigChangeListener

如果你的配置场景比较特殊,比如只需要监听部分属性,或者需要在配置变更时做复杂的业务处理,就可以用这种自定义的方式,手动处理属性的新增、修改、删除。

实现代码

import com.ctrip.framework.apollo.model.ConfigChange;
import com.ctrip.framework.apollo.model.ConfigChangeEvent;
import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
public class ApolloCustomRefreshListener {

    // 注入配置Bean,手动更新属性
    @Autowired
    private MyConfigProperties myConfigProperties;

    @ApolloConfigChangeListener(value = "application.yml", interestedKeyPrefixes = "my.config.")
    public void handleConfigChange(ConfigChangeEvent changeEvent) {
        log.info("配置变更 - 命名空间:{}", changeEvent.getNamespace());
        
        for (String key : changeEvent.changedKeys()) {
            ConfigChange change = changeEvent.getChange(key);
            log.info("属性变更详情 - key:{},旧值:{},新值:{},变更类型:{}",
                    change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType());

            // 这里根据key判断要处理的属性,手动更新Bean的属性值
            if (key.equals("my.config.age")) {
                // 处理age属性的变更
                switch (change.getChangeType()) {
                    case ADDED:
                    case MODIFIED:
                        myConfigProperties.setAge(Integer.parseInt(change.getNewValue()));
                        break;
                    case DELETED:
                        myConfigProperties.setAge(null); // 删除时设为默认值
                        break;
                    default:
                        break;
                }
            } else if (key.startsWith("my.config.name.")) {
                // 处理name嵌套对象的属性变更,比如my.config.name.first-name
                String subKey = key.substring("my.config.name.".length());
                MyConfigProperties.Name name = myConfigProperties.getName();
                if (subKey.equals("first-name")) {
                    name.setFirstName(change.getNewValue());
                } else if (subKey.equals("last-name")) {
                    name.setLastName(change.getNewValue());
                }
            }
            // 其他属性可以继续加else if判断,按需处理
        }
    }
}

方案三的注意事项

这种方式灵活性最高,但需要自己写大量重复代码,每个属性的变更都要手动处理。而且如果是Redis、Ribbon这类需要初始化连接的配置,即使手动更新了属性,旧的连接可能还在生效,需要手动重新建立连接才能用新配置。

关键注解@ApolloConfigChangeListener详解

这个注解是实现配置监听的核心,给大家详细说下它的参数用法,避免用错:

1. 核心参数

  • value:指定要监听的配置命名空间(比如配置文件名称),可以填单个值,也可以填数组(多个命名空间)。
  • interestedKeyPrefixes:指定要监听的属性前缀,只当这些前缀的属性变更时才触发方法,减少无效触发。

2. 常用用法示例

// 1. 不填参数:默认监听application命名空间,所有属性变更都触发
@ApolloConfigChangeListener

// 2. 监听单个命名空间(比如mysql-config.properties)
@ApolloConfigChangeListener("mysql-config")

// 3. 监听多个命名空间,用数组形式
@ApolloConfigChangeListener(value = {"application.yml", "redis-config"})

// 4. 监听指定前缀的属性,多个前缀用数组
@ApolloConfigChangeListener(interestedKeyPrefixes = {"spring.datasource.", "my.config."})

// 5. 组合使用:监听application.yml和MySQL命名空间,且属性前缀是spring.或my.config.
@ApolloConfigChangeListener(
    value = {"application.yml", "MySQL"},
    interestedKeyPrefixes = {"spring.", "my.config."}
)

// 6. 用配置参数指定命名空间(支持多个,用逗号分隔)
@ApolloConfigChangeListener("${apollo.bootstrap.namespaces}")

避坑指南:动态刷新后配置不生效?

有时候明明看到日志打印了配置变更,但调用接口时还是用的旧配置,大概率是下面这两种情况:

1. 配置Bean被其他Bean依赖,且依赖Bean没刷新

比如有个XXXService在项目启动时注入了MyConfigProperties,并初始化了一些数据。即使MyConfigProperties的属性更新了,但XXXService里缓存的旧数据没更新,导致接口还是用旧配置。

ad

免费在线工具导航

优网导航旗下整合全网优质免费、免注册的在线工具导航大全

解决办法:给依赖的Bean也加上@RefreshScope注解,或者在配置刷新后,手动重新初始化依赖Bean。

2. 配置涉及连接池、客户端初始化(比如Redis、Ribbon)

这类配置在项目启动时会建立连接或初始化客户端,配置变更后,旧的连接/客户端还在运行,新配置不会自动生效。

解决办法:在配置刷新后,手动关闭旧连接,重新初始化客户端。比如Redis可以重新创建RedisTemplate,Ribbon可以刷新负载均衡规则。

总结

  • 简单场景,不需要删除、清空操作:用方案一(EnvironmentChangeEvent),代码简单,不用额外依赖RefreshScope
  • 大多数业务场景,需要全量支持各种操作:用方案二(RefreshScope),完美解决所有坑,推荐优先选。
  • 特殊场景,需要自定义业务逻辑:用方案三(自定义处理),灵活性高,但需要自己写更多代码。

其实核心就是利用Apollo的@ApolloConfigChangeListener捕捉配置变更,再通过合适的方式更新@ConfigurationPropertiesBean的属性。根据自己的业务场景选对方案,就能轻松实现配置动态刷新,不用重启项目啦。

© 版权声明

相关文章

暂无评论

暂无评论...