SpringBoot配置属性热更新的轻量级实现

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

虽然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行代码

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

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

java 复制代码
package com.example.config;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
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 {
    // 监听的配置文件路径(默认监听classpath下的application.yaml)
    private final String configPath = "classpath:application.yaml";
    private WatchService watchService;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final ConfigRefreshHandler refreshHandler;
    private long lastProcessTime;
    private final long EVENT_DEBOUNCE_TIME = 500; // 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(); // 配置文件名(如application.yaml)

        watchService = FileSystems.getDefault().newWatchService();
        // 注册文件修改事件(ENTRY_MODIFY)
        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);
        }
    }
}

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

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

typescript 复制代码
import org.springframework.context.ApplicationEvent;
import java.util.Set;

/**
 * 自定义配置刷新事件
 */
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;
    }
}



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.ConfigurationPropertiesBindingPostProcessor;
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;

    @Autowired
    private ConfigurationPropertiesBindingPostProcessor bindingPostProcessor; // 属性绑定工具

    // 刷新配置的核心方法
    public void refresh() {
        try {
            // 1. 重新读取配置文件内容
            Properties properties = loadConfigFile();

            // 2. 更新Environment中的属性
            Set<String> changeKeys = updateEnvironment(properties);

            // 3. 重新绑定所有@ConfigurationProperties Bean
            if (!changeKeys.isEmpty()) {
                rebindConfigurationProperties();
            }

            applicationContext.publishEvent( new ConfigRefreshedEvent(this,changeKeys));
            log.info("配置文件刷新完成");
        } catch (Exception e) {
            log.error("配置文件刷新失败", e);
        }
    }

    // 读取配置文件内容(支持properties和yaml)
    private Properties loadConfigFile() throws IOException {
        // 使用Spring工具类读取classpath下的配置文件
        Resource resource = new ClassPathResource("application.yaml");
        YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();
        yamlFactory.setResources(resource);

        // 获取解析后的Properties对象
        Properties properties = yamlFactory.getObject();
        if (properties == null) {
            throw new IOException("Failed to load configuration file");
        }
        return properties;
    }

    // 更新Environment中的属性,返回变化的配置键集合
    private Set<String> updateEnvironment(Properties properties) {
        String sourceName = "Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/'";
        Set<String> changedKeys = new HashSet<>();
        PropertySource<?> appConfig = environment.getPropertySources().get(sourceName);

        if (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(sourceName, new MapPropertySource(sourceName, sourceMap));
        }
        return changedKeys;
    }

    // 重新绑定所有@ConfigurationProperties Bean
    private void rebindConfigurationProperties() {
        // 获取所有@ConfigurationProperties Bean的名称
        String[] beanNames = applicationContext.getBeanNamesForAnnotation(org.springframework.boot.context.properties.ConfigurationProperties.class);
        for (String beanName : beanNames) {
            // 重新绑定属性(关键:不重建Bean,只更新属性值)
            bindingPostProcessor.postProcessBeforeInitialization(
                    applicationContext.getBean(beanName), beanName);
            log.info("刷新配置Bean:{}", beanName);
        }
    }

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

3.3 第三步:注册监听器Bean

在SpringBoot配置类中注册ConfigFileWatcher,使其随应用启动

kotlin 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

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

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

java 复制代码
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "app") // 绑定配置前缀
public class AppConfig {
    private int timeout = 3000; // 默认超时时间3秒
    private int maxRetries = 2; // 默认重试次数2次
}

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

创建测试Controller,验证配置修改后是否自动生效

kotlin 复制代码
package com.example.controller;

import com.example.AppConfig;
import com.example.config.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 ConfigController {
    @Autowired
    private AppConfig appConfig;

    @GetMapping("/config")
    public AppConfig getConfig() {
        return appConfig; // 返回当前配置
    }

    // 监听配置刷新事件,可进行业务特殊处理
    @EventListener(ConfigRefreshedEvent.class)
    public void appConfigUpdate(ConfigRefreshedEvent event) {
        event.getChangedKeys().forEach(key -> log.info("配置项 {} 发生变化", key));
    }

}

四、生产环境使用

问题1:使用外部配置文件

解决方案 :配置文件外置通过环境变量或启动参数指定外部路径,结合ConfigFileWatcher监听外部配置文件

java 复制代码
// 修改ConfigFileWatcher的init方法
@PostConstruct
public void init() throws IOException {
    // 生产环境建议监听外部配置文件(如/opt/app/application.yml)
    Path configPath = Paths.get("/opt/app/application.yml");
    if (Files.exists(configPath)) {
        watchConfigFile(configPath); // 监听外部文件
    } else {
        log.warn("外部配置文件不存在,使用默认配置");
    }
}

private void watchConfigFile(Path configPath) throws IOException {
    Path configDir = configPath.getParent();
    String fileName = configPath.getFileName().toString();
    // 后续逻辑同上...
}

问题2:敏感配置解密

解决方案 :结合Jasypt实现配置在loadConfigFile中解密

typescript 复制代码
// 伪代码:解密配置
private String decrypt(String value) {
    if (value.startsWith("ENC(")) {
        return jasyptEncryptor.decrypt(value.substring(4, value.length() - 1));
    }
    return value;
}

五、总结

轻量级配置热更新方案的核心是"利用SpringBoot原生能力+最小化改造",适合中小项目或需要快速集成的场景。相比重量级配置中心,它的优势在于:

零依赖 :无需部署额外服务,代码量少
低成本 :对现有项目侵入小,改造成本低
易维护:基于Spring原生API,无需学习新框架

相关推荐
MadPrinter1 小时前
SpringBoot学习日记 Day11:博客系统核心功能深度开发
java·spring boot·后端·学习·spring·mybatis
dasseinzumtode1 小时前
nestJS 使用ExcelJS 实现数据的excel导出功能
前端·后端·node.js
淦出一番成就1 小时前
Java反序列化接收多种格式日期-JsonDeserialize
java·后端
Java中文社群1 小时前
Hutool被卖半年多了,现状是逆袭还是沉寂?
java·后端
程序员蜗牛2 小时前
9个Spring Boot参数验证高阶技巧,第8,9个代码量直接减半!
后端
yeyong2 小时前
咨询kimi关于设计日志告警功能,还是有启发的
后端
库森学长2 小时前
2025年,你不能错过Spring AI,那个汲取了LangChain灵感的家伙!
后端·openai·ai编程
爱吃苹果的日记本2 小时前
开学第一课
java
Java水解2 小时前
Spring Boot 启动流程详解
spring boot·后端
学历真的很重要2 小时前
Claude Code Windows 原生版安装指南
人工智能·windows·后端·语言模型·面试·go