SpringBoot中配置屬性熱更新的輕量級實現(xiàn)方案
項目開發(fā)中,每次修改配置(比如調(diào)整接口超時時間、限流閾值)都要重啟服務(wù),不僅開發(fā)效率低,線上重啟還會導(dǎo)致短暫不可用。
雖然Spring Cloud Config、Apollo這類配置中心能解決問題,但對于中小項目來說太重了——要部署服務(wù),成本太高。
今天分享一個輕量級方案,基于SpringBoot原生能力實現(xiàn)配置熱更新,不用額外依賴,代碼量不到200行。
一、為什么需要“輕量級”熱更新?
先說說傳統(tǒng)配置方案的痛點
痛點1:改配置必須重啟服務(wù)
開發(fā)環(huán)境中,改個日志級別都要重啟服務(wù),浪費時間;生產(chǎn)環(huán)境更麻煩,重啟會導(dǎo)致流量中斷,影響用戶體驗。
痛點2:重量級配置中心成本高
Spring Cloud Config、Apollo功能強大,但需要單獨部署服務(wù)、維護元數(shù)據(jù),小項目用不上這么復(fù)雜的功能,純屬“殺雞用牛刀”。
痛點3:@Value注解不支持動態(tài)刷新
即使通過@ConfigurationProperties綁定配置,默認(rèn)也不會自動刷新,必須結(jié)合@RefreshScope,但@RefreshScope會導(dǎo)致Bean重建,可能引發(fā)狀態(tài)丟失。
我們需要什么?
- 無需額外依賴,基于SpringBoot原生API
- 支持properties/yaml文件熱更新
- 不重啟服務(wù),修改配置后自動生效
- 對業(yè)務(wù)代碼侵入小,改造成本低
二、核心原理:3個關(guān)鍵技術(shù)點
輕量級熱更新的實現(xiàn)依賴SpringBoot的3個原生能力,不需要引入任何第三方框架
2.1 配置文件監(jiān)聽:WatchService
Java NIO提供的WatchService可以監(jiān)聽文件系統(tǒng)變化,當(dāng)配置文件(如application.yml)被修改時,能觸發(fā)回調(diào)事件。
2.2 屬性刷新:Environment與ConfigurationProperties
Spring的Environment對象存儲了所有配置屬性,通過反射更新其內(nèi)部的PropertySources,可以實現(xiàn)配置值的動態(tài)替換。
同時,@ConfigurationProperties綁定的Bean需要重新綁定屬性,這一步可以通過ConfigurationPropertiesBindingPostProcessor實現(xiàn)。
2.3 事件通知:ApplicationEvent
自定義一個ConfigRefreshEvent事件,當(dāng)配置更新后發(fā)布事件,業(yè)務(wù)代碼可以通過@EventListener接收通知,處理特殊邏輯(如重新初始化連接池)。
三、手把手實現(xiàn):不到200行代碼
3.1 第一步:監(jiān)聽配置文件變化
創(chuàng)建ConfigFileWatcher類,使用WatchService監(jiān)聽application.yml或application.properties的修改
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 {
// 監(jiān)聽的配置文件路徑(默認(rèn)監(jiān)聽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毫秒防抖時間
// 注入配置刷新處理器(后面實現(xiàn))
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(); // 監(jiān)聽配置文件所在目錄
String fileName = resource.getFilename(); // 配置文件名(如application.yaml)
watchService = FileSystems.getDefault().newWatchService();
// 注冊文件修改事件(ENTRY_MODIFY)
configDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);
// 啟動線程監(jiān)聽文件變化
executor.submit(() -> {
while (true) {
try {
WatchKey key = watchService.take(); // 阻塞等待事件
// 防抖檢查:忽略短時間內(nèi)重復(fù)事件
if (System.currentTimeMillis() - lastProcessTime < EVENT_DEBOUNCE_TIME) {
continue;
}
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) {
continue; // 事件溢出,忽略
}
// 檢查是否是目標(biāo)配置文件被修改
Path changedFile = (Path) event.context();
if (changedFile.getFileName().toString().equals(fileName)) {
log.info("檢測到配置文件修改:{}", fileName);
refreshHandler.refresh(); // 觸發(fā)配置刷新
}
}
boolean valid = key.reset(); // 重置監(jiān)聽器
if (!valid) break; // 監(jiān)聽器失效,退出循環(huán)
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
log.info("配置文件監(jiān)聽器啟動成功,監(jiān)聽路徑:{}", configDir);
}
@PreDestroy
public void destroy() {
executor.shutdownNow();
try {
watchService.close();
} catch (IOException e) {
log.error("關(guān)閉WatchService失敗", e);
}
}
}
3.2 第二步:實現(xiàn)配置刷新邏輯
創(chuàng)建ConfigRefreshHandler類,核心功能是更新Environment中的屬性,并通知@ConfigurationProperties Bean刷新
import org.springframework.context.ApplicationEvent;
import java.util.Set;
/**
* 自定義配置刷新事件
*/
public class ConfigRefreshedEvent extends ApplicationEvent {
// 存儲變化的配置鍵(可選,方便業(yè)務(wù)判斷哪些配置變了)
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. 重新讀取配置文件內(nèi)容
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);
}
}
// 讀取配置文件內(nèi)容(支持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) {
// 重新綁定屬性(關(guān)鍵:不重建Bean,只更新屬性值)
bindingPostProcessor.postProcessBeforeInitialization(
applicationContext.getBean(beanName), beanName);
log.info("刷新配置Bean:{}", beanName);
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
3.3 第三步:注冊監(jiān)聽器Bean
在SpringBoot配置類中注冊ConfigFileWatcher,使其隨應(yīng)用啟動
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綁定屬性
創(chuàng)建業(yè)務(wù)配置類,用@ConfigurationProperties綁定配置,無需額外注解即可支持熱更新
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; // 默認(rèn)超時時間3秒
private int maxRetries = 2; // 默認(rèn)重試次數(shù)2次
}
3.5 第五步:測試熱更新效果
創(chuàng)建測試Controller,驗證配置修改后是否自動生效
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; // 返回當(dāng)前配置
}
// 監(jiān)聽配置刷新事件,可進行業(yè)務(wù)特殊處理
@EventListener(ConfigRefreshedEvent.class)
public void appConfigUpdate(ConfigRefreshedEvent event) {
event.getChangedKeys().forEach(key -> log.info("配置項 {} 發(fā)生變化", key));
}
}
四、生產(chǎn)環(huán)境使用
問題1:使用外部配置文件
解決方案:配置文件外置通過環(huán)境變量或啟動參數(shù)指定外部路徑,結(jié)合ConfigFileWatcher監(jiān)聽外部配置文件
// 修改ConfigFileWatcher的init方法
@PostConstruct
public void init() throws IOException {
// 生產(chǎn)環(huán)境建議監(jiān)聽外部配置文件(如/opt/app/application.yml)
Path configPath = Paths.get("/opt/app/application.yml");
if (Files.exists(configPath)) {
watchConfigFile(configPath); // 監(jiān)聽外部文件
} else {
log.warn("外部配置文件不存在,使用默認(rèn)配置");
}
}
private void watchConfigFile(Path configPath) throws IOException {
Path configDir = configPath.getParent();
String fileName = configPath.getFileName().toString();
// 后續(xù)邏輯同上...
}
問題2:敏感配置解密
解決方案:結(jié)合Jasypt實現(xiàn)配置在loadConfigFile中解密
// 偽代碼:解密配置
private String decrypt(String value) {
if (value.startsWith("ENC(")) {
return jasyptEncryptor.decrypt(value.substring(4, value.length() - 1));
}
return value;
}
五、總結(jié)
輕量級配置熱更新方案的核心是“利用SpringBoot原生能力+最小化改造”,適合中小項目或需要快速集成的場景。相比重量級配置中心,它的優(yōu)勢在于:
零依賴:無需部署額外服務(wù),代碼量少
低成本:對現(xiàn)有項目侵入小,改造成本低
易維護:基于Spring原生API,無需學(xué)習(xí)新框架
到此這篇關(guān)于SpringBoot中配置屬性熱更新的輕量級實現(xiàn)方案的文章就介紹到這了,更多相關(guān)SpringBoot配置屬性熱更新內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring Boot2+JPA之悲觀鎖和樂觀鎖實戰(zhàn)教程
這篇文章主要介紹了Spring Boot2+JPA之悲觀鎖和樂觀鎖實戰(zhàn)教程,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-10-10
解析Spring中@Controller@Service等線程安全問題
這篇文章主要為大家介紹解析了Spring中@Controller@Service等線程的安全問題,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-03-03
基于springboot bean的實例化過程和屬性注入過程
這篇文章主要介紹了基于springboot bean的實例化過程和屬性注入過程,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-11-11
Spring?Boot實現(xiàn)多數(shù)據(jù)源連接和切換的解決方案
文章介紹了在SpringBoot中實現(xiàn)多數(shù)據(jù)源連接和切換的幾種方案,并詳細(xì)描述了一個使用AbstractRoutingDataSource的實現(xiàn)步驟,感興趣的朋友一起看看吧2025-01-01
基于Java和XxlCrawler獲取各城市月度天氣情況實踐分享
本文主要講解使用Java開發(fā)語言,使用XxlCrawler框架進行智能的某城市月度天氣抓取實踐開發(fā),文章首先介紹目標(biāo)網(wǎng)站的相關(guān)頁面及目標(biāo)數(shù)據(jù)的元素,然后講解在信息獲取過程的一些參數(shù)配置以及問題應(yīng)對,需要的朋友可以參考下2024-05-05
spring-boot整合Micrometer+Prometheus的詳細(xì)過程
這篇文章主要介紹了springboot整合Micrometer+Prometheus的詳細(xì)過程,本文通過實例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-05-05
httpclient staleConnectionCheckEnabled獲取連接流程解析
這篇文章主要為大家介紹了httpclient staleConnectionCheckEnabled獲取連接流程示例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-11-11

