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