🔔🔔🔔好消息!好消息!🔔🔔🔔

有需要的朋友👉:微信号 kaigejava2022

spring boot 2.5.x配置属性热更新的轻量级实现方案--【开发阶段配置】

2025-07-25 11:52   65 浏览

项目开发中,每次修改配置(比如调整接口超时时间、限流阈值)都要重启服务,不仅开发效率低,线上重启还会导致短暂不可用。

虽然Spring Cloud Config、Apollo这类配置中心能解决问题,但对于中小项目来说太重了——要部署服务,成本太高。

今天分析一个轻量级方案,基于SpringBoot原生能力实现配置热更新,不用额外依赖,代码量不到200行。

一、为什么需要“轻量级”热更新?

先说说传统配置方案的痛点

痛点1:改配置必须重启服务
开发环境中,改个日志级别都要重启服务,浪费时间;生产环境更麻烦,重启会导致流量中断,影响用户体验。

痛点2:重量级配置中心成本高
Spring Cloud Config、Apollo功能强大,但需要单独部署服务、维护元数据,小项目用不上这么复杂的功能,纯属“杀鸡用牛刀”。

痛点3:@Value注解不支持动态刷新
即使通过@ConfigurationProperties绑定配置,默认也不会自动刷新,必须结合@RefreshScope,但@RefreshScope会导致Bean重建,可能引发状态丢失。

我们需要什么?

  • • 无需额外依赖,基于SpringBoot原生API

  • • 支持properties/yaml文件热更新

  • • 不重启服务,修改配置后自动生效

  • • 对业务代码侵入小,改造成本低

二、核心原理:3个关键技术点

轻量级热更新的实现依赖SpringBoot的3个原生能力,不需要引入任何第三方框架

2.1 配置文件监听:WatchService

Java NIO提供的WatchService可以监听文件系统变化,当配置文件(如application.yml)被修改时,能触发回调事件。

2.2 属性刷新:Environment与ConfigurationProperties

Spring的Environment对象存储了所有配置属性,通过反射更新其内部的PropertySources,可以实现配置值的动态替换。
同时,@ConfigurationProperties绑定的Bean需要重新绑定属性,这一步可以通过ConfigurationPropertiesBindingPostProcessor实现。

2.3 事件通知:ApplicationEvent

自定义一个ConfigRefreshEvent事件,当配置更新后发布事件,业务代码可以通过@EventListener接收通知,处理特殊逻辑(如重新初始化连接池)。


三、手把手实现:不到200行代码

结构:

48539a6eeca3f0ce6b20faefa20c00da.png

3.1 第一步:监听配置文件变化

创建ConfigFileWatcher类,使用WatchService监听application.ymlapplication.properties的修改

import lombok.extern.slf4j.Slf4j;


/**
 * @BelongsProject: 
 * @BelongsPackage: com.kaigejava.common.config
 * @Author: kaigejava
 * @CreateTime: 2025-07-24  15:41
 * @Description: 监听配置文件变化
 * 创建ConfigFileWatcher类,使用WatchService监听application.yml或application.properties的修改
 * @Version: 1.0
 */

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.util.ResourceUtils;
import java.io.IOException;
import java.nio.file.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Slf4j
public class ConfigFileWatcher {
    private final String configPath = "classpath:application.yml";
    private WatchService watchService;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final ConfigRefreshHandler refreshHandler;
    private long lastProcessTime;
    private final long EVENT_DEBOUNCE_TIME = 500;

    public ConfigFileWatcher(ConfigRefreshHandler refreshHandler) {
        this.refreshHandler = refreshHandler;
    }

    @PostConstruct
    public void init() throws IOException {
        Resource resource = new FileSystemResource(ResourceUtils.getFile(configPath));
        Path configDir = resource.getFile().toPath().getParent();
        String fileName = resource.getFilename();

        watchService = FileSystems.getDefault().newWatchService();
        configDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);

        executor.submit(() -> {
            while (true) {
                try {
                    WatchKey key = watchService.take();
                    if (System.currentTimeMillis() - lastProcessTime < EVENT_DEBOUNCE_TIME) {
                        continue;
                    }
                    for (WatchEvent<?> event : key.pollEvents()) {
                        WatchEvent.Kind<?> kind = event.kind();
                        if (kind == StandardWatchEventKinds.OVERFLOW) {
                            continue;
                        }
                        Path changedFile = (Path) event.context();
                        if (changedFile.getFileName().toString().equals(fileName)) {
                            log.info("检测到配置文件修改:{}", fileName);
                            refreshHandler.refresh();
                        }
                    }
                    boolean valid = key.reset();
                    if (!valid) break;
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
        log.info("配置文件监听器启动成功,监听路径:{}", configDir);
    }

    @PreDestroy
    public void destroy() {
        executor.shutdownNow();
        try {
            watchService.close();
        } catch (IOException e) {
            log.error("关闭WatchService失败", e);
        }
    }
}

需要注意:classpath:application.yml 这个需要根据自己项目修改成自己的。

比如有些喜欢写成:application.yaml

3.2 第二步:实现配置刷新逻辑

创建ConfigRefreshHandler类,核心功能是更新Environment中的属性,并通知@ConfigurationProperties Bean刷新

import com.kaigejava.config.hotrefresh.norefreshannotation.NoRefresh;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;

@Component
@Slf4j
public class ConfigRefreshHandler implements ApplicationContextAware {
    @Autowired
    private ConfigurableEnvironment environment;
    private ApplicationContext applicationContext;

    public void refresh() {
        try {
            Properties properties = loadConfigFile();
            Set<String> changeKeys = updateEnvironment(properties);
            if (!changeKeys.isEmpty()) {
                rebindConfigurationProperties();
            }
            applicationContext.publishEvent(new ConfigRefreshedEvent(this, changeKeys));
            log.info("配置文件刷新完成");
        } catch (Exception e) {
            log.error("配置文件刷新失败", e);
        }
    }

    private Properties loadConfigFile() throws IOException {
        Resource resource = new ClassPathResource("application.yml");
        YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();
        yamlFactory.setResources(resource);
        Properties properties = yamlFactory.getObject();
        if (properties == null) {
            throw new IOException("Failed to load configuration file");
        }
        return properties;
    }

    private Set<String> updateEnvironment(Properties properties) {
        Set<String> changedKeys = new HashSet<>();
        PropertySource<?> appConfig = null;
        for (PropertySource<?> ps : environment.getPropertySources()) {
            if (ps.getName().contains("application.yml")) {
                appConfig = ps;
                break;
            }
        }
        if (appConfig != null && appConfig instanceof MapPropertySource) {
            Map<String, Object> sourceMap = new HashMap<>(((MapPropertySource) appConfig).getSource());
            properties.forEach((k, v) -> {
                String key = k.toString();
                Object oldValue = sourceMap.get(key);
                if (!Objects.equals(oldValue, v)) {
                    changedKeys.add(key);
                }
                sourceMap.put(key, v);
            });
            environment.getPropertySources().replace(appConfig.getName(), new MapPropertySource(appConfig.getName(), sourceMap));
        } else {
            // environment.getPropertySources().addFirst(new MapPropertySource("application.yml", properties));
        }
        return changedKeys;
    }

    private void rebindConfigurationProperties() {
        String[] beanNames = applicationContext.getBeanNamesForAnnotation(ConfigurationProperties.class);
        for (String beanName : beanNames) {
            Object bean = applicationContext.getBean(beanName);
            Class<?> clazz = bean.getClass();

            // 检查是否带有 @NoRefresh 注解
            if (clazz.isAnnotationPresent(NoRefresh.class)) {
                log.info("跳过带有 @NoRefresh 注解的配置Bean:{}", beanName);
                continue;
            }

            // 跳过 GenConfig,避免报错
            if (bean.getClass().getName().equals("com.kaigejava.generator.config.GenConfig")
            || bean.getClass().getName().equals("com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceWrapper")) {
                log.info("跳过 GenConfig 配置Bean刷新:{}", beanName);
                continue;
            }

            // 正常进行绑定
            ConfigurationProperties annotation = clazz.getAnnotation(ConfigurationProperties.class);
            if (annotation != null) {
                String prefix = annotation.prefix();
                try {
                    Binder.get(environment).bind(prefix, Bindable.ofInstance(bean));
                    log.info("刷新配置Bean:{}", beanName);
                } catch (Exception e) {
                    log.warn("跳过绑定失败的Bean [{}],原因:{}", beanName, e.getMessage());
                }
            }
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

需要注意:NoRefresh 这个是自定义注解.作用是哪些类可以不用

// 跳过 GenConfig,避免报错
            if (bean.getClass().getName().equals("com.kaigejava.generator.config.GenConfig")
            || bean.getClass().getName().equals("com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceWrapper")) {
                log.info("跳过 GenConfig 配置Bean刷新:{}", beanName);
                continue;
            }

这段代码是如果不使用注解的话,可以使用完整路径来过滤。

ConfigRefreshedEvent

这个类代码如下:

import org.springframework.context.ApplicationEvent;
import java.util.Set;
/**
 * @BelongsProject:
 * @BelongsPackage: com.kaigejava.business.config.autorefresh
 * @Author: kaigejava
 * @CreateTime: 2025-07-24  16:40
 * @Description: 创建ConfigRefreshHandler类,核心功能是更新Environment中的属性,并通知@ConfigurationProperties Bean刷新
 * @Version: 1.0
 */
/**
 * 自定义配置刷新事件
 */
public class ConfigRefreshedEvent extends ApplicationEvent {
    // 存储变化的配置键(可选,方便业务判断哪些配置变了)
    private final Set<String> changedKeys;
    public ConfigRefreshedEvent(Object source, Set<String> changedKeys) {
        super(source);
        this.changedKeys = changedKeys;
    }
    // 获取变化的配置键
    public Set<String> getChangedKeys() {
        return changedKeys;
    }
}

3.3 第三步:注册监听器Bean

在SpringBoot配置类中注册ConfigFileWatcher,使其随应用启动。这里使用类名:HotRefreshConfig 

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;

@Configuration
public class HotRefreshConfig {
    @Bean
    public ConfigFileWatcher configFileWatcher(ConfigRefreshHandler refreshHandler) throws IOException {
        return new ConfigFileWatcher(refreshHandler);
    }
}


3.4 第四步:使用@ConfigurationProperties绑定属性

创建业务配置类,用@ConfigurationProperties绑定配置,无需额外注解即可支持热更新

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "business.config") // 绑定配置前缀
public class ReadConfigurationPropertiesAppConfig {
    private int timeout = 3000; // 默认超时时间3秒
    private int maxRetries = 2; // 默认重试次数2次

    //test-key
    private String  testKey ;
}

说明:

其中的testKey是对应配置文件的。


3.5 第五步:配置文件添加对应key

application.yml 中添加如下配置

# 业务相关配置
business:
  # 业务动态配置相关
  config:
    test-key: kaigejava test value 123


3.6 第五步:测试热更新效果

import com.kaigejava.business.config.hotrefresh.ReadConfigurationPropertiesAppConfig;
import com.kaigejava.business.config.hotrefresh.ConfigRefreshedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class TestConfigController {
    @Autowired
    private ReadConfigurationPropertiesAppConfig appConfig;
    @GetMapping("/config")
    public ReadConfigurationPropertiesAppConfig getConfig() {
        log.info("Accessing /config, testKey = {}", appConfig.getTestKey());
        return appConfig; // 返回当前配置
    }
    // 监听配置刷新事件,可进行业务特殊处理
    @EventListener(ConfigRefreshedEvent.class)
    public void appConfigUpdate(ConfigRefreshedEvent event) {
       /* event.getChangedKeys().forEach(
                key -> log.info("配置项 {} 发生变化", key)
                 );*/
        /*System.out.println("配置项发生变化,当前配置:" + appConfig.toString());
        event.getChangedKeys().forEach(
                key -> System.out.println("配置项发生变化,当前配置:" + key)
        );*/
    }
}


然后通过http://localhsot:端口/config就可以看到效果了。


四、入坑须知

如果项目中开启了修改代码后自动部署。可以先注释掉。因为热部署会影响测试结果。

关闭热部署方法:

  1. 检查pom文件

查看pom文件中是否存在spring-boot-devtools 如果存在,注释掉

<dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-devtools</artifactId>
       <optional>true</optional>
   </dependency>

2.检查配置文件

如果配置文件中配置了

# 服务模块
devtools:
  restart:
    # 热部署开关
    enabled: true

把true修改成false.

喜欢 0

评论