├── example ├── settings.gradle ├── src │ └── main │ │ ├── resources │ │ ├── conf-tree │ │ │ ├── moduleB │ │ │ │ └── xyz.yaml │ │ │ └── module-a │ │ │ │ └── abc.yaml │ │ ├── application.yml │ │ ├── application-another-profile.yml │ │ └── static │ │ │ └── index.html │ │ └── java │ │ └── com │ │ └── example │ │ └── demo │ │ ├── DemoApplication.java │ │ ├── DemoDynamicValue.java │ │ ├── DemoConfigProperties.java │ │ ├── DemoConfigTreeProperties.java │ │ └── DemoController.java ├── demo.gif ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── .gitignore ├── build.gradle ├── gradlew.bat └── gradlew ├── lombok.config ├── settings.gradle ├── src ├── test │ ├── resources │ │ ├── no-config-location │ │ │ └── application.yml │ │ ├── application-dynamic.yml │ │ ├── conf-import-test │ │ │ ├── import-file.yaml │ │ │ └── import-file-another.yml │ │ ├── conf-tree-test │ │ │ ├── module_a │ │ │ │ └── xyz.yaml │ │ │ └── moduleB │ │ │ │ └── abc.yaml │ │ └── application.yml │ └── java │ │ └── top │ │ └── code2life │ │ └── config │ │ ├── sample │ │ ├── TestApplication.java │ │ ├── TestBeanConfiguration.java │ │ ├── TestComponent.java │ │ ├── configtree │ │ │ ├── TestConfigTreeBeanConfiguration.java │ │ │ ├── TestConfigTreeComponent.java │ │ │ └── TestConfTreeConfigurationProperties.java │ │ └── TestConfigurationProperties.java │ │ ├── FeatureGateTest.java │ │ ├── TestUtils.java │ │ ├── DynamicConfigPropertiesWatcherTest.java │ │ ├── ImportConfigFileTests.java │ │ ├── ImportConfigTreeTests.java │ │ └── DynamicConfigTests.java └── main │ ├── resources │ └── META-INF │ │ └── spring.factories │ └── java │ └── top │ └── code2life │ └── config │ ├── DynamicConfigAutoConfiguration.java │ ├── PropertySourceMeta.java │ ├── ValueBeanFieldBinder.java │ ├── DynamicConfig.java │ ├── FileSystemWatchTarget.java │ ├── FeatureGate.java │ ├── ConfigurationChangedEvent.java │ ├── DynamicConfigBeanPostProcessor.java │ ├── ConfigTreeEnvironmentPostProcessor.java │ ├── ConfigurationChangedEventHandler.java │ ├── ConfigurationUtils.java │ └── DynamicConfigPropertiesWatcher.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── .github ├── badges │ ├── jacoco.svg │ └── branches.svg └── workflows │ ├── gradle.yml │ └── codeql-analysis.yml ├── gradlew.bat ├── gradlew ├── README-zh.md ├── README.md └── LICENSE /example/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'demo' 2 | -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | lombok.addLombokGeneratedAnnotation = true -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'spring-boot-dynamic-config' 2 | -------------------------------------------------------------------------------- /example/src/main/resources/conf-tree/moduleB/xyz.yaml: -------------------------------------------------------------------------------- 1 | helloImport: import-from-config-tree -------------------------------------------------------------------------------- /src/test/resources/no-config-location/application.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | level: 3 | top.code2life: debug -------------------------------------------------------------------------------- /example/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code2Life/spring-boot-dynamic-config/HEAD/example/demo.gif -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code2Life/spring-boot-dynamic-config/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /example/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code2Life/spring-boot-dynamic-config/HEAD/example/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/application-dynamic.yml: -------------------------------------------------------------------------------- 1 | dynamicFeatureConf: "a,b,c" 2 | 3 | dynamic: 4 | transform-a: 100 5 | transform-b: 25 6 | 7 | dynamicTestPlain: dynamic-test -------------------------------------------------------------------------------- /example/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | include: 4 | - another-profile 5 | 6 | logging: 7 | level: 8 | top.code2life: debug 9 | -------------------------------------------------------------------------------- /src/test/resources/conf-import-test/import-file.yaml: -------------------------------------------------------------------------------- 1 | dynamicFeatureConf: "a,b,c" 2 | 3 | dynamic: 4 | transform-a: 100 5 | transform-b: 25 6 | 7 | dynamicTestPlain: dynamic-test -------------------------------------------------------------------------------- /src/test/resources/conf-tree-test/module_a/xyz.yaml: -------------------------------------------------------------------------------- 1 | dynamicFeatureConf: "a,b,c" 2 | 3 | dynamic: 4 | transform-a: 100 5 | transform-b: 25 6 | 7 | dynamicTestPlain: dynamic-test -------------------------------------------------------------------------------- /example/src/main/resources/conf-tree/module-a/abc.yaml: -------------------------------------------------------------------------------- 1 | prop: 2 | str: Hello @ConfigurationProperties in Config Tree 3 | map-val: 4 | k1: v3 5 | k2: v2 6 | listVal: 7 | - 1 8 | - 2 9 | - 3 10 | nested: 11 | str: Hello Recursive ! -------------------------------------------------------------------------------- /example/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | top.code2life.config.DynamicConfigAutoConfiguration 3 | org.springframework.boot.env.EnvironmentPostProcessor=\ 4 | top.code2life.config.ConfigTreeEnvironmentPostProcessor -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed May 19 22:12:06 CST 2021 2 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip 3 | distributionBase=GRADLE_USER_HOME 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/TestApplication.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample; 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication; 4 | 5 | /** 6 | * @author Code2Life 7 | **/ 8 | @SpringBootApplication 9 | public class TestApplication { 10 | } 11 | -------------------------------------------------------------------------------- /example/src/main/java/com/example/demo/DemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class DemoApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(DemoApplication.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/DynamicConfigAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.springframework.context.annotation.Import; 4 | 5 | /** 6 | * @author Code2Life 7 | */ 8 | @Import({DynamicConfigPropertiesWatcher.class, DynamicConfigBeanPostProcessor.class, FeatureGate.class, ConfigurationChangedEventHandler.class}) 9 | public class DynamicConfigAutoConfiguration { 10 | } 11 | -------------------------------------------------------------------------------- /src/test/resources/conf-tree-test/moduleB/abc.yaml: -------------------------------------------------------------------------------- 1 | myProp: 2 | str: dynamic 3 | double-val: 1 4 | intVal: 2 5 | boxedIntVal: 3 6 | map-val: 7 | k1: v1 8 | k2: v2 9 | k3: v3 10 | list-val: 11 | - l1 12 | - l2 13 | list-obj: 14 | - str: recursive 15 | double-val: 3.14 16 | nested: 17 | mapVal: 18 | m1: v1 19 | m2: v2 20 | collection-val: 21 | - a1 22 | - a2 -------------------------------------------------------------------------------- /src/test/resources/conf-import-test/import-file-another.yml: -------------------------------------------------------------------------------- 1 | myProp: 2 | str: dynamic 3 | double-val: 1 4 | intVal: 2 5 | boxedIntVal: 3 6 | map-val: 7 | k1: v1 8 | k2: v2 9 | k3: v3 10 | list-val: 11 | - l1 12 | - l2 13 | list-obj: 14 | - str: recursive 15 | double-val: 3.14 16 | nested: 17 | mapVal: 18 | m1: v1 19 | m2: v2 20 | collection-val: 21 | - a1 22 | - a2 -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/PropertySourceMeta.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import org.springframework.core.env.PropertySource; 6 | 7 | import java.nio.file.Path; 8 | 9 | @Data 10 | @AllArgsConstructor 11 | class PropertySourceMeta { 12 | 13 | private PropertySource propertySource; 14 | 15 | private Path filePath; 16 | 17 | private long lastModifyTime; 18 | 19 | } -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | include: 4 | - dynamic 5 | logging: 6 | level: 7 | top.code2life: debug 8 | 9 | myProp: 10 | str: dynamic 11 | double-val: 1 12 | intVal: 2 13 | boxedIntVal: 3 14 | map-val: 15 | k1: v1 16 | k2: v2 17 | k3: v3 18 | list-val: 19 | - l1 20 | - l2 21 | list-obj: 22 | - str: recursive 23 | double-val: 3.14 24 | nested: 25 | mapVal: 26 | m1: v1 27 | m2: v2 28 | collection-val: 29 | - a1 30 | - a2 -------------------------------------------------------------------------------- /example/src/main/resources/application-another-profile.yml: -------------------------------------------------------------------------------- 1 | # Run application with specified config location: 2 | # eg: java -jar app.jar --spring.config.location=src/main/resources/ 3 | # then, it just works ! 4 | dynamic.hello-world: Hello @DynamicConfig 5 | # beta.enabled: false 6 | some: 7 | feature: 8 | beta-list: user1, user2, user3 9 | dynamic: 10 | prop: 11 | str: Hello @ConfigurationProperties ! 12 | map-val: 13 | k1: v1 14 | k2: v2 15 | listVal: 16 | - 1 17 | - 2 18 | - 3 19 | nested: 20 | str: Hello Recursive ! -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /example/src/main/java/com/example/demo/DemoDynamicValue.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import lombok.Getter; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.stereotype.Component; 6 | import top.code2life.config.DynamicConfig; 7 | 8 | import java.util.Set; 9 | 10 | /** 11 | * @author Code2Life 12 | **/ 13 | @Getter 14 | @DynamicConfig 15 | @Component 16 | public class DemoDynamicValue { 17 | 18 | @Value("${dynamic.hello-world}") 19 | private String dynamicHello; 20 | 21 | @Value("${module-b.xyz.hello-import}") 22 | private String dynamicHelloImport; 23 | 24 | @Value("#{@featureGate.convert('${some.feature.beta-list}')}") 25 | private Set betaUserList; 26 | } 27 | -------------------------------------------------------------------------------- /example/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '2.7.3' 4 | } 5 | } 6 | plugins { 7 | id 'org.springframework.boot' version "${springBootVersion}" 8 | id 'java-library' 9 | } 10 | 11 | group = 'com.example' 12 | version = '1.0.0' 13 | sourceCompatibility = JavaVersion.VERSION_1_8 14 | 15 | bootJar { 16 | mainClass.set("com.example.demo.DemoApplication") 17 | } 18 | 19 | repositories { 20 | mavenCentral() 21 | } 22 | 23 | dependencies { 24 | api platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") 25 | 26 | compileOnly 'org.projectlombok:lombok:1.18.20' 27 | annotationProcessor 'org.projectlombok:lombok:1.18.20' 28 | implementation 'org.springframework.boot:spring-boot-starter-web' 29 | 30 | implementation 'top.code2life:spring-boot-dynamic-config:1.0.9' 31 | } -------------------------------------------------------------------------------- /example/src/main/java/com/example/demo/DemoConfigProperties.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import lombok.Data; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.context.annotation.Configuration; 7 | import top.code2life.config.DynamicConfig; 8 | 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | /** 13 | * @author Code2Life 14 | **/ 15 | @Data 16 | @DynamicConfig 17 | @ConfigurationProperties(prefix = "dynamic.prop") 18 | @Configuration 19 | @JsonInclude(JsonInclude.Include.NON_NULL) 20 | public class DemoConfigProperties { 21 | 22 | private String str; 23 | 24 | private Map mapVal; 25 | 26 | private List listVal; 27 | 28 | private Map nestedMap; 29 | 30 | private DemoConfigProperties nested; 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/TestBeanConfiguration.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | /** 10 | * @author Code2Life 11 | **/ 12 | @Configuration 13 | public class TestBeanConfiguration { 14 | 15 | 16 | @Autowired 17 | private TestConfigurationProperties testProperty; 18 | 19 | @Bean 20 | public TestBean testBean() { 21 | return new TestBean(testProperty.getStr()); 22 | } 23 | 24 | public String getCurrentStrValue() { 25 | return testProperty.getStr(); 26 | } 27 | 28 | @Data 29 | @AllArgsConstructor 30 | public static class TestBean { 31 | private String internalStr; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/src/main/java/com/example/demo/DemoConfigTreeProperties.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import lombok.Data; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.context.annotation.Configuration; 7 | import top.code2life.config.DynamicConfig; 8 | 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | /** 13 | * @author Code2Life 14 | **/ 15 | @Data 16 | @DynamicConfig 17 | @ConfigurationProperties(prefix = "module-a.abc.prop") 18 | @Configuration 19 | @JsonInclude(JsonInclude.Include.NON_NULL) 20 | public class DemoConfigTreeProperties { 21 | 22 | private String str; 23 | 24 | private Map mapVal; 25 | 26 | private List listVal; 27 | 28 | private Map nestedMap; 29 | 30 | private DemoConfigTreeProperties nested; 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/TestComponent.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.stereotype.Component; 6 | import top.code2life.config.DynamicConfig; 7 | 8 | import java.util.Set; 9 | 10 | /** 11 | * @author Code2Life 12 | **/ 13 | @Data 14 | @DynamicConfig 15 | @Component 16 | public class TestComponent { 17 | 18 | @Value("${dynamic-test-plain:default}") 19 | private String plainValue; 20 | 21 | @Value("#{@featureGate.convert('${dynamic-feature-conf:}')}") 22 | private Set someBetaFeatureConfig; 23 | 24 | @Value("#{T(top.code2life.config.sample.TestComponent).transform(${dynamic.transform-a:20}, ${dynamic.transform-b:10})} ") 25 | private double transformBySpEL; 26 | 27 | 28 | public static double transform(double t1, double t2) { 29 | return t1 / t2; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/configtree/TestConfigTreeBeanConfiguration.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample.configtree; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | /** 10 | * @author Code2Life 11 | **/ 12 | @Configuration 13 | public class TestConfigTreeBeanConfiguration { 14 | 15 | 16 | @Autowired 17 | private TestConfTreeConfigurationProperties testProperty; 18 | 19 | @Bean 20 | public TestBean testConfigTreeBean() { 21 | return new TestBean(testProperty.getStr()); 22 | } 23 | 24 | public String getCurrentStrValue() { 25 | return testProperty.getStr(); 26 | } 27 | 28 | @Data 29 | @AllArgsConstructor 30 | public static class TestBean { 31 | private String internalStr; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/badges/jacoco.svg: -------------------------------------------------------------------------------- 1 | coverage94.2% -------------------------------------------------------------------------------- /.github/badges/branches.svg: -------------------------------------------------------------------------------- 1 | branches82.5% -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/ValueBeanFieldBinder.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | 4 | import lombok.Data; 5 | 6 | import java.lang.ref.WeakReference; 7 | import java.lang.reflect.Field; 8 | 9 | @Data 10 | class ValueBeanFieldBinder { 11 | 12 | /** 13 | * Value placeholder / Value SpringEL Expression / ConfigurationProperties annotation prefix 14 | */ 15 | private String expr; 16 | 17 | /** 18 | * Reference of the Dynamic Bean instance 19 | */ 20 | private WeakReference beanRef; 21 | 22 | /** 23 | * Record the bound field, only for {@literal @}Value fields binding case 24 | */ 25 | private Field dynamicField; 26 | 27 | /** 28 | * name of the Spring bean 29 | */ 30 | private String beanName; 31 | 32 | ValueBeanFieldBinder(String expr, Field dynamicField, Object bean, String beanName) { 33 | this.beanRef = new WeakReference<>(bean); 34 | this.expr = expr; 35 | this.dynamicField = dynamicField; 36 | this.beanName = beanName; 37 | } 38 | } -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/configtree/TestConfigTreeComponent.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample.configtree; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.stereotype.Component; 6 | import top.code2life.config.DynamicConfig; 7 | 8 | import java.util.Set; 9 | 10 | /** 11 | * @author Code2Life 12 | **/ 13 | @Data 14 | @DynamicConfig 15 | @Component 16 | public class TestConfigTreeComponent { 17 | 18 | @Value("${module-a.xyz.dynamic-test-plain:default}") 19 | private String plainValue; 20 | 21 | @Value("#{@featureGate.convert('${module-a.xyz.dynamic-feature-conf:}')}") 22 | private Set someBetaFeatureConfig; 23 | 24 | @Value("#{T(top.code2life.config.sample.configtree.TestConfigTreeComponent).transform(${module-a.xyz.dynamic.transform-a:20}, ${module-a.xyz.dynamic.transform-b:10})} ") 25 | private double transformBySpEL; 26 | 27 | 28 | public static double transform(double t1, double t2) { 29 | return t1 / t2; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/TestConfigurationProperties.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample; 2 | 3 | import lombok.Data; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.context.annotation.Configuration; 6 | import top.code2life.config.DynamicConfig; 7 | 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.concurrent.ConcurrentHashMap; 11 | 12 | /** 13 | * @author Code2Life 14 | **/ 15 | @Data 16 | @DynamicConfig 17 | @Configuration 18 | @ConfigurationProperties(prefix = "my-prop") 19 | public class TestConfigurationProperties { 20 | 21 | private String str; 22 | private Double doubleVal; 23 | private int intVal; 24 | private Integer boxedIntVal; 25 | private ConcurrentHashMap mapVal; 26 | private List listVal; 27 | private List listObj; 28 | private Nested nested; 29 | 30 | @Data 31 | public static class Nested { 32 | 33 | private Map mapVal; 34 | 35 | private List collectionVal; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/sample/configtree/TestConfTreeConfigurationProperties.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config.sample.configtree; 2 | 3 | import lombok.Data; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.context.annotation.Configuration; 6 | import top.code2life.config.DynamicConfig; 7 | 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.concurrent.ConcurrentHashMap; 11 | 12 | /** 13 | * @author Code2Life 14 | **/ 15 | @Data 16 | @Configuration 17 | @DynamicConfig 18 | @ConfigurationProperties(prefix = "module-b.abc.my-prop") 19 | public class TestConfTreeConfigurationProperties { 20 | 21 | private String str; 22 | private Double doubleVal; 23 | private int intVal; 24 | private Integer boxedIntVal; 25 | private ConcurrentHashMap mapVal; 26 | private List listVal; 27 | private List listObj; 28 | private Nested nested; 29 | 30 | @Data 31 | public static class Nested { 32 | 33 | private Map mapVal; 34 | 35 | private List collectionVal; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/FeatureGateTest.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.Set; 6 | 7 | import static org.junit.jupiter.api.Assertions.*; 8 | 9 | /** 10 | * @author Code2Life 11 | **/ 12 | public class FeatureGateTest { 13 | 14 | private final FeatureGate featureGate = new FeatureGate(null); 15 | 16 | @Test 17 | public void testConvertEmptyConfigValue() { 18 | Set configVal = featureGate.convert(""); 19 | assertEquals(0, configVal.size()); 20 | } 21 | 22 | @Test 23 | public void testGetBetaListOfFeature() { 24 | Set configVal = featureGate.convert("a, b ,c"); 25 | assertTrue(featureGate.isFeatureEnabled(configVal, "a")); 26 | assertFalse(featureGate.isFeatureEnabled(configVal, "d")); 27 | } 28 | 29 | @Test 30 | public void testFullyOpenFeature() { 31 | Set configVal = featureGate.convert("a, b ,all"); 32 | assertTrue(featureGate.isFeatureEnabled(configVal, "a")); 33 | assertTrue(featureGate.isFeatureEnabled(configVal, "e")); 34 | } 35 | 36 | @Test 37 | public void testToKebabCase() { 38 | assertEquals("abc-ef-g-x-a-bbcc-ef-z.ac.%", ConfigurationUtils.normalizePropKey("Abc_EfG-xA-bbcc_EF-z.ac.%")); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/DynamicConfig.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.lang.annotation.*; 6 | 7 | /** 8 | * The annotation @DynamicConfig could be added on Class or Field level, 9 | * to indicate that all @Value fields or single @Value field is dynamic loaded, 10 | * which means the actual value changes upon configuration file changes. 11 | * The processor of this annotation is not thread-safe, when configuration changes, 12 | * it will modify fields of the corresponding bean, during the period, dirty value could exist. 13 | * 14 | * Example: 15 | * 16 | * {@literal @}Component 17 | * {@literal @}DynamicConfig 18 | * class MyConfiguration { 19 | * {@literal @}Value("${some.prop}") 20 | * private String someProp; 21 | * 22 | * {@literal @}Value("${another.prop}") 23 | * {@literal @}DynamicConfig 24 | * private Long anotherProp; 25 | * } 26 | * 27 | * Make sure you are using 'java -jar your-jar-file.jar --spring.config.location' to 28 | * start your application on none-local environments, or -Dspring.config.location before '-jar', 29 | * if this parameter set, file watch will be started to monitor properties/yml changes. 30 | * 31 | * @author Code2Life 32 | * @see DynamicConfigPropertiesWatcher 33 | */ 34 | @Target({ElementType.FIELD, ElementType.TYPE}) 35 | @Retention(RetentionPolicy.RUNTIME) 36 | @Documented 37 | @Component 38 | public @interface DynamicConfig { 39 | } 40 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/TestUtils.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.yaml.snakeyaml.Yaml; 4 | 5 | import java.io.*; 6 | import java.util.Map; 7 | import java.util.Random; 8 | 9 | /** 10 | * @author Code2Life 11 | **/ 12 | public class TestUtils { 13 | 14 | private static final Yaml YAML = new Yaml(); 15 | private static final Random RANDOM = new Random(); 16 | 17 | public static Map readYmlData(String basePath, String confFilePath) throws IOException { 18 | File file = new File(basePath, confFilePath); 19 | try (InputStream inputStream = new FileInputStream(file)) { 20 | return YAML.load(inputStream); 21 | } 22 | } 23 | 24 | public static void writeYmlData(Map data, String basePath, String confFilePath) throws IOException { 25 | File file = new File(basePath, confFilePath); 26 | try (PrintWriter writer = new PrintWriter(file)) { 27 | YAML.dump(data, writer); 28 | } 29 | } 30 | 31 | public static String randomStr(int len) { 32 | int leftLimit = 97; // letter 'a' 33 | int rightLimit = 122; // letter 'z' 34 | return RANDOM.ints(leftLimit, rightLimit + 1) 35 | .limit(len) 36 | .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) 37 | .toString(); 38 | } 39 | 40 | public static Double randomDouble() { 41 | return RANDOM.nextDouble(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | 4 | name: Gradle Build 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: set-up-jdk 17 | uses: actions/setup-java@v2 18 | with: 19 | java-version: '17' 20 | distribution: 'adopt' 21 | - name: gradlew-permission 22 | run: chmod +x gradlew 23 | - name: build-and-test 24 | run: ./gradlew clean build -x test 25 | env: 26 | OSSRHUSERNAME: ${{ secrets.OSSRHUSERNAME }} 27 | OSSRHPASSWORD: ${{ secrets.OSSRHPASSWORD }} 28 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 29 | - name: codecov report 30 | uses: codecov/codecov-action@v1 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | files: build/reports/jacoco/report.xml 34 | verbose: false 35 | - name: jacoco-badge 36 | id: jacoco 37 | uses: cicirello/jacoco-badge-generator@v2 38 | with: 39 | generate-branches-badge: true 40 | jacoco-csv-file: build/reports/jacoco/test/jacocoTestReport.csv 41 | - name: log-jacoco-coverage-percent 42 | run: | 43 | echo "coverage = ${{ steps.jacoco.outputs.coverage }}" 44 | echo "branch coverage = ${{ steps.jacoco.outputs.branches }}" 45 | - name: commit-badges 46 | uses: EndBug/add-and-commit@v7 47 | with: 48 | default_author: github_actions 49 | message: 'commit badges' 50 | add: '*.svg' -------------------------------------------------------------------------------- /example/src/main/java/com/example/demo/DemoController.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.beans.BeanUtils; 5 | import org.springframework.web.bind.annotation.RequestMapping; 6 | import org.springframework.web.bind.annotation.RequestMethod; 7 | import org.springframework.web.bind.annotation.RestController; 8 | import top.code2life.config.FeatureGate; 9 | 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | 13 | /** 14 | * @author Code2Life 15 | **/ 16 | @RequiredArgsConstructor 17 | @RestController 18 | public class DemoController { 19 | 20 | 21 | private final DemoConfigProperties demoConfigProps; 22 | 23 | private final DemoConfigTreeProperties demoConfigTreeProps; 24 | 25 | private final DemoDynamicValue demoDynamicValue; 26 | 27 | private final FeatureGate featureGate; 28 | 29 | @RequestMapping(value = "/demo", method = RequestMethod.GET) 30 | public Object getDemoConfigProps() { 31 | Map resp = new HashMap<>(4); 32 | DemoConfigProperties configurationProps = new DemoConfigProperties(); 33 | DemoConfigTreeProperties configurationTreeProps = new DemoConfigTreeProperties(); 34 | BeanUtils.copyProperties(demoConfigProps, configurationProps); 35 | BeanUtils.copyProperties(demoConfigTreeProps, configurationTreeProps); 36 | resp.put("valuePlaceHolder", demoDynamicValue.getDynamicHello()); 37 | resp.put("valuePlaceHolderInTree", demoDynamicValue.getDynamicHelloImport()); 38 | resp.put("valueSpringEL", demoDynamicValue.getBetaUserList()); 39 | resp.put("someUserInWhiteList", featureGate.isFeatureEnabled(demoDynamicValue.getBetaUserList(), "user4")); 40 | resp.put("betaFeatureEnabled", featureGate.isFeatureEnabled("beta.enabled")); 41 | resp.put("configurationPropertiesObj", configurationProps); 42 | resp.put("configurationPropertiesTreeObj", configurationTreeProps); 43 | return resp; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/FileSystemWatchTarget.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.Data; 4 | 5 | import java.nio.file.Path; 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | import static top.code2life.config.ConfigurationUtils.CONFIG_FILE_PREFIX; 10 | import static top.code2life.config.ConfigurationUtils.trimRelativePathAndReplaceBackSlash; 11 | 12 | /** 13 | * Directory WatchService target, could be file or directory 14 | * 15 | * @author Code2Life 16 | */ 17 | @Data 18 | public class FileSystemWatchTarget { 19 | 20 | private WatchTargetType type; 21 | 22 | private String normalizedDir; 23 | 24 | private List filterFiles; 25 | 26 | private Path rootDir; 27 | 28 | FileSystemWatchTarget(WatchTargetType type, String originalPath) { 29 | this.type = type; 30 | if (originalPath.startsWith(CONFIG_FILE_PREFIX)) { 31 | originalPath = trimRelativePathAndReplaceBackSlash(originalPath.substring(CONFIG_FILE_PREFIX.length())); 32 | } else { 33 | originalPath = trimRelativePathAndReplaceBackSlash(originalPath); 34 | } 35 | 36 | if (type == WatchTargetType.CONFIG_LOCATION) { 37 | this.normalizedDir = originalPath; 38 | } else if (type == WatchTargetType.CONFIG_IMPORT_FILE) { 39 | int idx = originalPath.lastIndexOf("/"); 40 | this.normalizedDir = originalPath.substring(0, idx); 41 | this.filterFiles = new ArrayList<>(2); 42 | this.filterFiles.add(originalPath.substring(idx + 1)); 43 | } else if (type == WatchTargetType.CONFIG_IMPORT_TREE) { 44 | this.normalizedDir = originalPath; 45 | } 46 | } 47 | 48 | /** 49 | * The watch target type, from spring.config.location or import:configtree / file 50 | */ 51 | public enum WatchTargetType { 52 | 53 | /** 54 | * When using spring.config.location 55 | */ 56 | CONFIG_LOCATION, 57 | 58 | /** 59 | * When using spring.config.import=file: 60 | */ 61 | CONFIG_IMPORT_FILE, 62 | 63 | /** 64 | * When using spring.config.import=configtree: 65 | */ 66 | CONFIG_IMPORT_TREE 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '38 21 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'java' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | env: 58 | OSSRHUSERNAME: ${{ secrets.OSSRHUSERNAME }} 59 | OSSRHPASSWORD: ${{ secrets.OSSRHPASSWORD }} 60 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 https://git.io/JvXDl 64 | 65 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 66 | # and modify them (or add more) to build your code if your project 67 | # uses a compiled language 68 | 69 | #- run: | 70 | # make bootstrap 71 | # make release 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v1 75 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /example/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/FeatureGate.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.core.env.Environment; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.util.StringUtils; 7 | 8 | import java.util.Arrays; 9 | import java.util.Collections; 10 | import java.util.Set; 11 | import java.util.stream.Collectors; 12 | 13 | /** 14 | * A util bean for simple feature gate implementation. 15 | * Step1: 16 | * {@literal @}Value("#{{@literal @}featureGate.convert('${dynamic-feature-conf}')}") 17 | * private Set{@literal <}String{@literal >} someBetaFeatureConfig; 18 | * Step2: 19 | * boolean featureEnabled = FeatureGate.isFeatureEnabled(dynamicConfigBean.someBetaFeatureConfig(), "someId") 20 | * 21 | * @author Code2Life 22 | **/ 23 | @Component("featureGate") 24 | @RequiredArgsConstructor 25 | public class FeatureGate { 26 | 27 | /** 28 | * If some feature is configured as "all", it means this feature is enabled 29 | * within all accounts/users/groups 30 | */ 31 | public static final String FEATURE_ENABLE_FOR_ALL = "all"; 32 | 33 | private static final String SEPARATOR_COMMA = ","; 34 | 35 | private final Environment environment; 36 | 37 | /** 38 | * Transform a comma separated string into a set, 39 | * indicate which entities enable that feature 40 | * eg: 41 | * someFeatureBetaList: userGroup1, userGroup2, ... 42 | * 43 | * @param val configuration value 44 | * @return a set of unique entity identifiers 45 | */ 46 | public Set convert(String val) { 47 | if (!StringUtils.hasText(val)) { 48 | return Collections.emptySet(); 49 | } 50 | return Arrays.stream(val.split(SEPARATOR_COMMA)).map(StringUtils::trimWhitespace).filter(StringUtils::hasText).collect(Collectors.toSet()); 51 | } 52 | 53 | 54 | /** 55 | * Judge if some entity is configured as enabling certain feature 56 | * 57 | * @param featureConfigValues the Set contains all entities which enable certain feature 58 | * @param entityId the identifier of current requesting user/entity 59 | * @return if that feature enabled or not for certain user/account/entity 60 | */ 61 | public boolean isFeatureEnabled(Set featureConfigValues, String entityId) { 62 | return featureConfigValues.contains(FEATURE_ENABLE_FOR_ALL) || featureConfigValues.contains(entityId); 63 | } 64 | 65 | /** 66 | * Judge if some feature is enabled in configuration files 67 | * eg: my.feature.enabled=true / True / TRUE 68 | * 69 | * @param featureName feature name 70 | * @return if that feature is enabled or not 71 | */ 72 | public boolean isFeatureEnabled(String featureName) { 73 | String configVal = environment.getProperty(featureName); 74 | if (StringUtils.hasText(configVal)) { 75 | return Boolean.parseBoolean(configVal); 76 | } 77 | return false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /example/src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Spring Boot Dynamic Config 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

Spring Boot Dynamic Config

17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | False 27 | True 28 | 29 | 30 | 31 | 32 | 33 | False 34 | True 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 | 83 | 84 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/ConfigurationChangedEvent.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.Getter; 4 | import lombok.Setter; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.boot.origin.OriginTrackedValue; 7 | import org.springframework.context.ApplicationEvent; 8 | import org.springframework.core.env.PropertySource; 9 | 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | import java.util.Objects; 13 | 14 | /** 15 | * Application event that represents configuration file has been changed 16 | * 17 | * @author Code2Life 18 | * @see DynamicConfigPropertiesWatcher 19 | */ 20 | @Getter 21 | @Setter 22 | @Slf4j 23 | public class ConfigurationChangedEvent extends ApplicationEvent { 24 | 25 | /** 26 | * Path of the file that changed and triggered this event 27 | */ 28 | private String path; 29 | 30 | /** 31 | * previous property source of changed config file 32 | */ 33 | private PropertySource previous; 34 | 35 | /** 36 | * current property source of changed config file 37 | */ 38 | private PropertySource current; 39 | 40 | /** 41 | * The diff properties, keys are normalized, values are newest values, null means the value deleted 42 | */ 43 | private Map diff; 44 | 45 | ConfigurationChangedEvent(String path, PropertySource previous, PropertySource current, Map diff) { 46 | super(path); 47 | this.path = path; 48 | this.previous = previous; 49 | this.current = current; 50 | this.diff = diff; 51 | } 52 | 53 | /** 54 | * loop current properties and prev properties, find diff 55 | * removed properties won't impact existing bean values 56 | */ 57 | static Map getPropertyDiff(Map prev, Map current) { 58 | Map diff = new HashMap<>(4); 59 | filterAddOrUpdatedKeys(prev, current, diff); 60 | filterMissingKeys(prev, current, diff); 61 | return diff; 62 | } 63 | 64 | private static void filterAddOrUpdatedKeys(Map prev, Map current, Map diff) { 65 | for (Map.Entry entry : current.entrySet()) { 66 | Object k = entry.getKey(); 67 | OriginTrackedValue v = entry.getValue(); 68 | if (prev.containsKey(k)) { 69 | if (!Objects.equals(v, prev.get(k))) { 70 | diff.put(k.toString(), v.getValue()); 71 | log.debug("found changed key of dynamic config: {}", k); 72 | } 73 | } else { 74 | diff.put(k.toString(), v.getValue()); 75 | log.debug("found new added key of dynamic config: {}", k); 76 | } 77 | } 78 | } 79 | 80 | private static void filterMissingKeys(Map prev, Map current, Map diff) { 81 | for (Map.Entry entry : prev.entrySet()) { 82 | Object k = entry.getKey(); 83 | if (!current.containsKey(k)) { 84 | diff.put(k.toString(), null); 85 | log.debug("found deleted k of dynamic config: {}", k); 86 | } 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/DynamicConfigPropertiesWatcherTest.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.BeansException; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.context.ApplicationEventPublisher; 10 | import org.springframework.core.env.StandardEnvironment; 11 | 12 | import java.io.File; 13 | import java.io.PrintWriter; 14 | import java.nio.file.Files; 15 | import java.nio.file.LinkOption; 16 | 17 | import static top.code2life.config.DynamicConfigPropertiesWatcher.WATCHABLE_TARGETS; 18 | import static top.code2life.config.DynamicConfigTests.CONFIG_LOCATION; 19 | 20 | /** 21 | * @author Code2Life 22 | **/ 23 | @SpringBootTest(classes = {DynamicConfigPropertiesWatcher.class}) 24 | public class DynamicConfigPropertiesWatcherTest { 25 | 26 | @Autowired 27 | private ApplicationContext applicationContext; 28 | 29 | @Autowired 30 | private StandardEnvironment environment; 31 | 32 | @Autowired 33 | private ApplicationEventPublisher eventPublisher; 34 | 35 | @Test 36 | public void testBeanLoaded() throws Exception { 37 | try { 38 | applicationContext.getBean(DynamicConfigPropertiesWatcher.class); 39 | throw new Exception("bean should not be loaded"); 40 | } catch (BeansException ex) { 41 | // should throw this exception, because of no --spring.config.location configured 42 | } 43 | } 44 | 45 | @Test 46 | public void testSymbolLinkWatch() throws Exception { 47 | FileSystemWatchTarget watchTarget = new FileSystemWatchTarget(FileSystemWatchTarget.WatchTargetType.CONFIG_LOCATION, CONFIG_LOCATION); 48 | WATCHABLE_TARGETS.put(watchTarget.getNormalizedDir(), watchTarget); 49 | DynamicConfigPropertiesWatcher watcher = new DynamicConfigPropertiesWatcher(environment, eventPublisher); 50 | File file = new File(CONFIG_LOCATION, "..data"); 51 | try (PrintWriter writer = new PrintWriter(file)) { 52 | writer.write("test-symbolic-link"); 53 | watcher.watchConfigDirectory(); 54 | } 55 | Thread.sleep(1000); 56 | long mdt1 = Files.getLastModifiedTime(file.toPath(), LinkOption.NOFOLLOW_LINKS).toMillis(); 57 | try (PrintWriter writer = new PrintWriter(file)) { 58 | writer.write("test-symbolic-link-2"); 59 | } 60 | long mdt2 = Files.getLastModifiedTime(file.toPath(), LinkOption.NOFOLLOW_LINKS).toMillis(); 61 | Assertions.assertTrue(mdt1 != mdt2); 62 | Thread.sleep(7000); 63 | } 64 | 65 | @Test 66 | public void testWatchUnRecognizedFile() throws Exception { 67 | FileSystemWatchTarget watchTarget = new FileSystemWatchTarget(FileSystemWatchTarget.WatchTargetType.CONFIG_LOCATION, CONFIG_LOCATION); 68 | WATCHABLE_TARGETS.put(watchTarget.getNormalizedDir(), watchTarget); 69 | DynamicConfigPropertiesWatcher watcher = new DynamicConfigPropertiesWatcher(environment, eventPublisher); 70 | // watch nothing since config.location not set 71 | watcher.watchConfigDirectory(); 72 | File file = new File(CONFIG_LOCATION, "unknown.txt"); 73 | try (PrintWriter writer = new PrintWriter(file)) { 74 | writer.write("test-data"); 75 | } 76 | Thread.sleep(500); 77 | // should not throw any exception 78 | } 79 | 80 | @Test 81 | public void testFileWatch() { 82 | DynamicConfigPropertiesWatcher watcher = new DynamicConfigPropertiesWatcher(environment, eventPublisher); 83 | watcher.destroy(); 84 | // should not throw any exception 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/DynamicConfigBeanPostProcessor.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.beans.factory.config.BeanPostProcessor; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; 7 | import org.springframework.boot.context.properties.ConfigurationProperties; 8 | import org.springframework.util.StringUtils; 9 | 10 | import java.lang.reflect.Field; 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.List; 14 | import java.util.Map; 15 | import java.util.concurrent.ConcurrentHashMap; 16 | 17 | import static top.code2life.config.ConfigurationUtils.SP_EL_PREFIX; 18 | import static top.code2life.config.ConfigurationUtils.VALUE_EXPR_PREFIX; 19 | 20 | /** 21 | * PostProcessor for any bean which has annotation @DynamicConfig on its Class/Field, 22 | * The processor will maintain field accessor for @Value fields, listen ConfigurationChangedEvent, 23 | * If any event arrives, it will retrieve diff results of changed properties, evaluate SpEL and set field values. 24 | * 25 | * @author Code2Life 26 | */ 27 | @Slf4j 28 | @ConditionalOnBean(DynamicConfigPropertiesWatcher.class) 29 | public class DynamicConfigBeanPostProcessor implements BeanPostProcessor { 30 | 31 | static final Map> DYNAMIC_FIELD_BINDER_MAP = new ConcurrentHashMap<>(16); 32 | static final Map DYNAMIC_CONFIG_PROPS_BINDER_MAP = new ConcurrentHashMap<>(8); 33 | 34 | DynamicConfigBeanPostProcessor() { 35 | DYNAMIC_FIELD_BINDER_MAP.clear(); 36 | DYNAMIC_CONFIG_PROPS_BINDER_MAP.clear(); 37 | } 38 | 39 | /** 40 | * Process all beans contains @DynamicConfig annotation, collect metadata for continuous field value binding 41 | * 42 | * @param bean bean instance 43 | * @param beanName bean name 44 | * @return original bean instance 45 | */ 46 | @Override 47 | public Object postProcessAfterInitialization(Object bean, String beanName) { 48 | handleDynamicBean(bean, beanName); 49 | return bean; 50 | } 51 | 52 | private void handleDynamicBean(Object bean, String beanName) { 53 | Class clazz = ConfigurationUtils.getTargetClassOfBean(bean); 54 | Field[] fields = clazz.getDeclaredFields(); 55 | // handle @ConfigurationProperties beans 56 | boolean clazzLevelDynamicConf = clazz.isAnnotationPresent(DynamicConfig.class); 57 | if (clazzLevelDynamicConf && clazz.isAnnotationPresent(ConfigurationProperties.class)) { 58 | bindConfigurationProperties(clazz, bean, beanName); 59 | return; 60 | } 61 | // handle beans contains @Value + @DynamicConfig annotations 62 | for (Field field : fields) { 63 | boolean isDynamic = field.isAnnotationPresent(Value.class) && (clazzLevelDynamicConf || field.isAnnotationPresent(DynamicConfig.class)); 64 | if (isDynamic) { 65 | collectionValueAnnotationMetadata(bean, beanName, clazz, field); 66 | } 67 | } 68 | } 69 | 70 | private void bindConfigurationProperties(Class clazz, Object bean, String beanName) { 71 | ConfigurationProperties properties = clazz.getAnnotation(ConfigurationProperties.class); 72 | String prefix = properties.prefix(); 73 | if (!StringUtils.hasText(prefix)) { 74 | prefix = properties.value(); 75 | } 76 | prefix = ConfigurationUtils.normalizePropKey(prefix); 77 | ValueBeanFieldBinder binder = new ValueBeanFieldBinder(prefix, null, bean, beanName); 78 | DYNAMIC_CONFIG_PROPS_BINDER_MAP.putIfAbsent(prefix, binder); 79 | } 80 | 81 | private void collectionValueAnnotationMetadata(Object bean, String beanName, Class clazz, Field field) { 82 | String valueExpr = field.getAnnotation(Value.class).value(); 83 | if (!valueExpr.startsWith(VALUE_EXPR_PREFIX) && !valueExpr.startsWith(SP_EL_PREFIX)) { 84 | return; 85 | } 86 | List propKeyList = ConfigurationUtils.extractValueFromExpr(valueExpr); 87 | for (String key : propKeyList) { 88 | if (!DYNAMIC_FIELD_BINDER_MAP.containsKey(key)) { 89 | DYNAMIC_FIELD_BINDER_MAP.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>(2))); 90 | } 91 | DYNAMIC_FIELD_BINDER_MAP.get(key).add(new ValueBeanFieldBinder(valueExpr, field, bean, beanName)); 92 | } 93 | if (propKeyList.size() > 0 && log.isDebugEnabled()) { 94 | log.debug("dynamic config annotation found on class: {}, field: {}, prop: {}", clazz.getName(), field.getName(), String.join(",", propKeyList)); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/ConfigTreeEnvironmentPostProcessor.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 6 | import org.springframework.boot.context.config.ConfigTreeConfigDataLoader; 7 | import org.springframework.boot.env.ConfigTreePropertySource; 8 | import org.springframework.boot.env.EnvironmentPostProcessor; 9 | import org.springframework.boot.env.OriginTrackedMapPropertySource; 10 | import org.springframework.boot.env.PropertySourceLoader; 11 | import org.springframework.core.env.ConfigurableEnvironment; 12 | import org.springframework.core.env.MutablePropertySources; 13 | import org.springframework.core.env.PropertySource; 14 | import org.springframework.core.io.FileSystemResource; 15 | import org.springframework.core.io.support.SpringFactoriesLoader; 16 | 17 | import java.io.IOException; 18 | import java.nio.file.Path; 19 | import java.util.Arrays; 20 | import java.util.List; 21 | import java.util.Map; 22 | import java.util.Optional; 23 | 24 | import static top.code2life.config.ConfigurationUtils.addConfigPropPrefix; 25 | import static top.code2life.config.ConfigurationUtils.findAllKeyAndFilesInConfigTree; 26 | 27 | /** 28 | * Since Spring Boot 2.4, it supports spring.config.import with configTree, 29 | * but configTree just loop and read file as String properties, even it has extension. 30 | * Dynamic config will enhance this use case, if the files has extension like yaml or properties, 31 | * the lib will inject additional property sources to allow @Value and @ConfigurationProperties 32 | * with the file-path-like prefix. 33 | * For instance, if configtree:/etc/config, and we have /etc/config/moduleA/fileB.yaml in place, 34 | * then the application could @Value("${module-a.file-b.some-param}"), 35 | * or @ConfigurationProperties(prefix="module-a.file-b.another-obj-param") 36 | * 37 | * @author Code2Life 38 | * @see org.springframework.boot.env.ConfigTreePropertySource 39 | **/ 40 | @Slf4j 41 | @ConditionalOnClass(ConfigTreeConfigDataLoader.class) 42 | public class ConfigTreeEnvironmentPostProcessor implements EnvironmentPostProcessor { 43 | 44 | /** 45 | * Config file template as PropertySource name 46 | */ 47 | public static final String ADDITIONAL_PROPERTY_TEMPLATE = "Config resource 'file [%s]' via configTree"; 48 | 49 | @Override 50 | public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication application) { 51 | List loaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, 52 | getClass().getClassLoader()); 53 | MutablePropertySources propertySources = env.getPropertySources(); 54 | Optional> configTreeProp = propertySources.stream() 55 | .filter(p -> p instanceof ConfigTreePropertySource).findFirst(); 56 | if (configTreeProp.isPresent()) { 57 | ConfigTreePropertySource configTreeSource = (ConfigTreePropertySource) configTreeProp.get(); 58 | Path source = configTreeSource.getSource(); 59 | 60 | Map allPropertyFiles = findAllKeyAndFilesInConfigTree(source); 61 | if (allPropertyFiles.size() == 0) { 62 | log.debug("no loadable extension in config tree files, won't append additional property sources"); 63 | return; 64 | } 65 | try { 66 | for (Map.Entry entry : allPropertyFiles.entrySet()) { 67 | String prefix = entry.getKey(); 68 | Path path = entry.getValue(); 69 | String extension = ConfigurationUtils.getFileExtension(path.toString()); 70 | for (PropertySourceLoader loader : loaders) { 71 | if (Arrays.asList(loader.getFileExtensions()).contains(extension)) { 72 | FileSystemResource resource = new FileSystemResource(path); 73 | String propertySourceName = String.format(ADDITIONAL_PROPERTY_TEMPLATE, path); 74 | List> newPropsList = loader.load(propertySourceName, resource); 75 | if (newPropsList.size() > 0) { 76 | PropertySource ps = addConfigPropPrefix((OriginTrackedMapPropertySource) newPropsList.get(0), prefix); 77 | propertySources.addLast(ps); 78 | } 79 | break; 80 | } 81 | } 82 | } 83 | } catch (IOException ex) { 84 | log.error("can not load additional property sources in config tree", ex); 85 | } 86 | 87 | } else { 88 | log.debug("no ConfigTreePropertySource found, skip environment post processor"); 89 | } 90 | } 91 | 92 | 93 | } 94 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /example/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | ## Spring Boot Dynamic Config 2 | 3 |

4 |
5 | 6 | 7 | codebeat badge 8 |
9 |

10 | 11 | 一个注解实现SpringBoot应用的**动态配置**,配置热重载最简洁的方案。 12 | 13 | [English](https://github.com/Code2Life/spring-boot-dynamic-config/blob/main/README.md) [简体中文](https://github.com/Code2Life/spring-boot-dynamic-config/blob/main/README-zh.md) 14 | 15 | - :heart: **无侵入**,完全兼容SpringBoot原生的配置获取方式(@Value / @ConfigurationProperties) 16 | - :zap: **超轻量,超快响应**, 不依赖SpringBoot核心库以外的任何三方库 17 | - :grinning: **极易使用**, 只提供一个简单的注解: @DynamicConfig;一个事件:ConfigurationChangedEvent 18 | - ☸ 在K8S集群中和K8S ConfigMap完美结合的SpringBoot/SpringCloud应用的动态配置方式 19 | 20 | #### 相比于spring-cloud-starter-config: 21 | 22 | - 不需要SpringCloud ConfigServer配置中心服务 23 | - 不需要SpringCloud依赖,不需要@RefreshScope注解,不会重建Spring Bean 24 | 25 | #### 相比于阿里Nacos/携程Apollo: 26 | 27 | - 不需要配置中心服务器 28 | - 不需要学习额外的注解和SDK API 29 | 30 | ## 演示 31 | 32 | Demo 33 | 34 | ## 快速开始 35 | 36 | ### 步骤一:添加依赖spring-boot-dynamic-config 37 | 38 | 注意:Spring Boot 2.4以下版本请使用 1.0.8 版本 39 | 40 | Maven 41 | 42 | ```xml 43 | 44 | 45 | top.code2life 46 | spring-boot-dynamic-config 47 | 1.0.9 48 | 49 | ``` 50 | 51 | Gradle 52 | 53 | ```groovy 54 | implementation 'top.code2life:spring-boot-dynamic-config:1.0.9' 55 | ``` 56 | 57 | ### 步骤二:在代码中添加 @DynamicConfig 注解 58 | 59 | **使用方法1: 在包含@Value成员的类上添加 @DynamicConfig。** 60 | 61 | ```java 62 | import lombok.Data; 63 | import org.springframework.beans.factory.annotation.Value; 64 | import org.springframework.stereotype.Component; 65 | import top.code2life.config.DynamicConfig; 66 | 67 | import java.util.Set; 68 | 69 | @Data 70 | @Component 71 | @DynamicConfig // add annotation here ! 72 | public class DynamicFeatures { 73 | 74 | @Value("${dynamic-test-plain:default}") 75 | private String plainValue; 76 | 77 | @Value("#{@featureGate.convert('${dynamic-feature-conf}')}") 78 | private Set someBetaFeatureConfig; 79 | 80 | // @DynamicConfig // adding annotation here also works! 81 | @Value("#{@testComponent.transform(${dynamic.transform-a:20}, ${dynamic.transform-b:10})} ") 82 | private double transformBySpEL; 83 | 84 | 85 | public double transform(double t1, double t2) { 86 | return t1 / t2; 87 | } 88 | } 89 | 90 | // file: application-profile.yml 91 | // ============================ 92 | // dynamic-test-plain: someVal # kebab-case is recommended 93 | // dynamicFeatureConf: a,b,c # camelCase compatible 94 | // dynamic: 95 | // transform-a: 100 96 | // transform-b: 10 97 | ``` 98 | 99 | **使用方法2: 在有 @ConfigurationProperties 注解的类上添加 @DynamicConfig。** 100 | 101 | ```java 102 | import lombok.Data; 103 | import org.springframework.boot.context.properties.ConfigurationProperties; 104 | import org.springframework.context.annotation.Configuration; 105 | import top.code2life.config.DynamicConfig; 106 | 107 | import java.util.Map; 108 | 109 | @Data 110 | @DynamicConfig // add annotation here ! 111 | @Configuration 112 | @ConfigurationProperties(prefix = "my-prop") 113 | public class TestConfigurationProperties { 114 | 115 | private String str; 116 | 117 | private Double doubleVal; 118 | 119 | private Map mapVal; 120 | } 121 | 122 | // file: application-another-profile.yml 123 | // ============================ 124 | // my-prop: # or myProp, relax binding supported 125 | // str: someVal 126 | // double-val: 100.0 127 | // mapVal: 128 | // k: v 129 | ``` 130 | 131 | ### 步骤三:使用指定配置路径的方式启动SpringBoot应用 132 | 133 | ```bash 134 | java -jar your-spring-boot-app.jar --spring.config.location=/path/to/config 135 | ``` 136 | 137 | 在Dynamic Config的1.0.9版本支持了Spring Boot 2.4之后版本的 'spring.config.import' 特性,并且增强了原生的 configtree 特性! 138 | 139 | ```bash 140 | # config.import could be used with config.location TOGETHER 141 | java -jar your-spring-boot-app.jar --spring.config.import=/path/to/configA.yaml,/path/to/configB.yaml 142 | # or use the config tree feature 143 | java -jar your-spring-boot-app.jar --spring.config.import=configtree:/path/to/conf-dir/ 144 | ``` 145 | 146 | 启动后配置路径下的**任何文件修改**(/path/to/config/application-xxx.yml)都会在**相关联的注有@DynamicConfig的Spring Bean里立即生效** 147 | ,getter方法可以直接获取到最新配置值。 148 | 149 | ### 新特性:Import Config Tree 150 | 151 | Spring Boot 2.4之后支持了导入配置文件或目录的参数,比如'--spring.config.import=configtree:/path/to/conf-dir/' 这个启动参数会让Spring 152 | Boot递归查找所有子目录和文件,加载到Spring Environment中作为Property,Key就是文件相对路径名: 153 | 154 | 比如配置文件是: 'path/to/conf-dir/module-a/file-b.yaml', 则代码中可以用 'module-a.file-b.yaml' 155 | 取到文件的内容,但类型只有String,并没有加载成完整的PropertySource。 156 | 157 | Dynamic Config 1.0.9 版本增强了这个特性,如果查找到带后缀的配置文件,会额外加载到Spring Environment中成为独立的、可热重载的PropertySource,使用方式如下。 158 | 159 | 举个例子,比如spring.config.import目录是 '/path/to/conf-dir', 子目录中有这个配置文件 '/path/to/conf-dir/module-a/file-b.yaml' 160 | 161 | ```yaml 162 | # file: /path/to/conf-dir/module-a/file-b.yaml 163 | prop-c-in-file: example-value 164 | 165 | prop-obj-key-infile: 166 | fieldA: value 167 | fieldB: value 168 | ``` 169 | 170 | 代码中无需做任何变更,只需要加上文件路径的前缀即可,比如 module-a/file-b.yaml => module-a.file-b。 171 | 172 | ```java 173 | @Value("${module-a.file-b.prop-c-in-file}") 174 | @DynamicConfig 175 | private String loadedByConfigTree 176 | 177 | // or 178 | @DynamicConfig 179 | @ConfigurationProperties(prefix = "module-a.file-b.prop-obj-key-infile") 180 | public class PropsLoadedByConfigTree { 181 | // ... 182 | } 183 | ``` 184 | 185 | 参考文档: https://docs.spring.io/spring-boot/docs/2.7.3/reference/htmlsingle/#features.external-config.files.configtree 186 | 187 | ### 配置管理的最佳实践 188 | 189 | - 以代码的方式管理配置,Everything as Code; 190 | - Git是维护配置信息的最佳版本控制系统,绝大多数应用,不需要配置管理中心这样的系统; 191 | - 配置由**持续集成系统自动化部署**到开发/产线环境,而不是登陆到某个系统,手动输入配置值; 192 | - Git Ops的方式做自动化运维,更符合DevOps的思想,在应用服务数量非常多的时候,也更具备伸缩性。 193 | 194 | ## 实现核心逻辑 195 | 196 | 1. 使用 --spring.config.location 参数启动时,初始化 DynamicConfigPropertiesWatcher 这个Bean; 197 | 2. 与此同时,初始化 DynamicConfigBeanPostProcessor 这个BeanPostProcessor,用来处理 @DynamicConfig; 198 | 3. DynamicConfigBeanPostProcessor 收集所有带有 @DynamicConfig 注解的Bean的元数据,包括Bean的名称、实例、@Value成员变量等等; 199 | 4. DynamicConfigPropertiesWatcher 开始监听 spring.config.location 200 | 目录下所有的文件变动,对于变化的Yaml/Properties,生成PropertySource动态替换Environment Bean中的PropertySource; 201 | 5. DynamicConfigPropertiesWatcher 在ApplicationContext中发布配置变动的Event:ConfigurationChangedEvent; 202 | 6. DynamicConfigBeanPostProcessor 订阅了上述事件,计算变动的文件,对哪些Key造成了差异 203 | 7. 对于每个有差异的Property Key, DynamicConfigBeanPostProcessor 比对记录的Bean元数据,找到相关联的Bean 204 | 8. 对于这次变动相关联的Bean, 调用反射方法,或 ConfigurationPropertiesBindingPostProcessor 的API,绑定到当前Bean的成员变量中,实现配置动态生效 205 | 206 | ## 兼容性 207 | 208 | 任何SpringBoot/SpringCloud应用都可以使用这个库,只要依赖的SpringBoot版本在SpringBoot 2.0以上即可。 209 | 210 | - √ SpringBoot 2.4.x, 2.5.x, 2.6.x, 2.7.x, 3.0.0 and Above (1.0.9以上版本) 211 | - √ SpringBoot 2.3.x (1.0.8及以下版本) 212 | - √ SpringBoot 2.2.x (1.0.8及以下版本) 213 | - √ SpringBoot 2.1.x (1.0.8及以下版本) 214 | - √ SpringBoot 2.0.x (1.0.8及以下版本) 215 | - X SpringBoot 1.5.x 不支持 216 | 217 | 注意: 218 | 219 | - SpringBoot 2.0.x中不能使用JUnit5,只能使用JUnit4,因此在Spring 2.0.x版本跑本仓库里的单元测试,需要切换到JUnit4 220 | - spring-boot-dynamic-config仅包含spring-boot库的编译依赖,不依赖任何其他三方库 221 | 222 | ## 开源许可证 223 | 224 | Spring Boot Dynamic Config is Open Source software released under 225 | the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html). 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Spring Boot Dynamic Config 2 | 3 |

4 |
5 | 6 | 7 | codebeat badge 8 |
9 |

10 | 11 | Hot-reload your SpringBoot configurations, with just a '@DynamicConfig' annotation, the simplest solution, ever. 12 | 13 | [English](https://github.com/Code2Life/spring-boot-dynamic-config/blob/main/README.md) [简体中文](https://github.com/Code2Life/spring-boot-dynamic-config/blob/main/README-zh.md) 14 | 15 | - :heart: **Non-intrusive**, compatible with SpringBoot native ways (@Value, @ConfigurationProperties) 16 | - :zap: **Lightweight & Blazing Fast**, depend on nothing but SpringBoot core libs 17 | - :grinning: **Extremely easy to use**, only provide an annotation: @DynamicConfig, an event: ConfigurationChangedEvent 18 | - ☸ Perfect solution for hot-reloading configuration of SpringBoot application on Kubernetes, with K8S ConfigMap 19 | 20 | #### Compare with spring-cloud-starter-config 21 | 22 | - No need for config server 23 | - No SpringCloud dependency and @RefreshScope annotation, won't destroy and rebuild beans 24 | 25 | #### Compare with Alibaba Nacos / Ctripcorp Apollo 26 | 27 | - No need for Nacos/Apollo server 28 | - No need for learning Annotations, Client APIs, etc. 29 | 30 | ## Demo 31 | 32 | Demo 33 | 34 | ## Getting Started 35 | 36 | ### Step1. Add spring-boot-dynamic-config Dependency 37 | 38 | **If your Spring Boot version is 2.4 or lower, please use version 1.0.8** 39 | 40 | Maven 41 | 42 | ```xml 43 | 44 | 45 | top.code2life 46 | spring-boot-dynamic-config 47 | 1.0.9 48 | 49 | ``` 50 | 51 | Gradle 52 | 53 | ```groovy 54 | implementation 'top.code2life:spring-boot-dynamic-config:1.0.9' 55 | ``` 56 | 57 | ### Step2. Add @DynamicConfig Annotation 58 | 59 | Option1: Add @DynamicConfig annotation on class which contains @Value field. 60 | 61 | ```java 62 | import lombok.Data; 63 | import org.springframework.beans.factory.annotation.Value; 64 | import org.springframework.stereotype.Component; 65 | import top.code2life.config.DynamicConfig; 66 | 67 | import java.util.Set; 68 | 69 | @Data 70 | @Component 71 | @DynamicConfig // add annotation here ! 72 | public class DynamicFeatures { 73 | 74 | @Value("${dynamic-test-plain:default}") 75 | private String plainValue; 76 | 77 | @Value("#{@featureGate.convert('${dynamic-feature-conf}')}") 78 | private Set someBetaFeatureConfig; 79 | 80 | // @DynamicConfig // adding annotation here also works! 81 | @Value("#{@testComponent.transform(${dynamic.transform-a:20}, ${dynamic.transform-b:10})} ") 82 | private double transformBySpEL; 83 | 84 | 85 | public double transform(double t1, double t2) { 86 | return t1 / t2; 87 | } 88 | } 89 | 90 | // file: application-profile.yml 91 | // ============================ 92 | // dynamic-test-plain: someVal # kebab-case is recommended 93 | // dynamicFeatureConf: a,b,c # camelCase compatible 94 | // dynamic: 95 | // transform-a: 100 96 | // transform-b: 10 97 | ``` 98 | 99 | Option2: Add @ConfigurationProperties annotation on configuration class. 100 | 101 | ```java 102 | import lombok.Data; 103 | import org.springframework.boot.context.properties.ConfigurationProperties; 104 | import org.springframework.context.annotation.Configuration; 105 | import top.code2life.config.DynamicConfig; 106 | 107 | import java.util.Map; 108 | 109 | @Data 110 | @DynamicConfig // add annotation here ! 111 | @Configuration 112 | @ConfigurationProperties(prefix = "my-prop") 113 | public class TestConfigurationProperties { 114 | 115 | private String str; 116 | 117 | private Double doubleVal; 118 | 119 | private Map mapVal; 120 | } 121 | 122 | // file: application-another-profile.yml 123 | // ============================ 124 | // my-prop: # or myProp, relax binding supported 125 | // str: someVal 126 | // double-val: 100.0 127 | // mapVal: 128 | // k: v 129 | ``` 130 | 131 | ### Step3. Run Application with Configuration Location 132 | 133 | ```bash 134 | java -jar your-spring-boot-app.jar --spring.config.location=/path/to/config 135 | ``` 136 | 137 | Since Version 1.0.9 and Spring Boot 2.4, 'spring.config.import' is available ! 138 | 139 | ```bash 140 | # config.import could be used with config.location TOGETHER 141 | java -jar your-spring-boot-app.jar --spring.config.import=/path/to/configA.yaml,/path/to/configB.yaml 142 | # or use the config tree feature 143 | java -jar your-spring-boot-app.jar --spring.config.import=configtree:/path/to/conf-dir/ 144 | ``` 145 | 146 | Then, modifications on /path/to/config/application-.yml will take effect and reflect on @DynamicConfig 147 | beans **immediately**. 148 | 149 | ### Import Config Tree 150 | 151 | When using '--spring.config.import=configtree:/path/to/conf-dir/', Spring Boot will load all files recursively. 152 | 153 | It will result in ConfigTreePropertySource in Spring environments, with key of file path, for example: 154 | If file is 'path/to/conf-dir/module-a/file-b.yaml', the property key is 'module-a.file-b.yaml', the value is the content 155 | of the file. 156 | 157 | Dynamic Config version 1.0.9 enhanced this feature, when properties or yaml files found in config tree, it will append 158 | additional property sources, and will watch all file changes and then manipulate these property sources to keep them 159 | Dynamic, so that you could use like following: 160 | 161 | Suppose you have this file in spring.config.import directory '/path/to/conf-dir', 162 | 163 | ```yaml 164 | # file: /path/to/conf-dir/module-a/file-b.yaml 165 | prop-c-in-file: example-value 166 | 167 | prop-obj-key-infile: 168 | fieldA: value 169 | fieldB: value 170 | ``` 171 | 172 | Just adding prefix it will work, the prefix is similar to the file's relative path module-a/file-b.yaml => 173 | module-a.file-b。 174 | 175 | ```java 176 | @Value("${module-a.file-b.prop-c-in-file}") 177 | @DynamicConfig 178 | private String loadedByConfigTree 179 | 180 | // or 181 | @DynamicConfig 182 | @ConfigurationProperties(prefix = "module-a.file-b.prop-obj-key-infile") 183 | public class PropsLoadedByConfigTree { 184 | // ... 185 | } 186 | ``` 187 | 188 | Refer: https://docs.spring.io/spring-boot/docs/2.7.3/reference/htmlsingle/#features.external-config.files.configtree 189 | 190 | ### Best Practices 191 | 192 | - Configuration as Code, Everything as Code 193 | - Configurations should be maintained in Git, rather than any GUI system. 194 | - Configurations should be applied to dev/production environments by Continuous Delivery system. 195 | - Git-Based DevOps workflow is the modern way of operating services, at scale. 196 | 197 | ## Implementation 198 | 199 | 1. Bean 'DynamicConfigPropertiesWatcher' will be initialized if 'spring.config.location/import' is specified 200 | 2. Bean 'DynamicConfigBeanPostProcessor' will be initialized if 'DynamicConfigPropertiesWatcher' exists 201 | 3. DynamicConfigBeanPostProcessor collects beans' metadata after initializing 202 | 4. DynamicConfigPropertiesWatcher watches configuration directory, then replace PropertySource in Environment on changes 203 | 5. DynamicConfigPropertiesWatcher publishes 'ConfigurationChangedEvent' 204 | 6. DynamicConfigBeanPostProcessor listens 'ConfigurationChangedEvent', calculate diff 205 | 7. For each changed key, DynamicConfigBeanPostProcessor will use preserved bean metadata to check if it's related 206 | 8. After filtering related beans, it will use reflect API or ConfigurationPropertiesBindingPostProcessor API to modify 207 | fields of existing bean 208 | 209 | ## Compatibility 210 | 211 | Any SpringBoot/SpringCloud application within following SpringBoot version can use this lib. 212 | 213 | - √ SpringBoot 2.4.x, 2.5.x, 2.6.x, 2.7.x, 3.0.0 and Above (Use spring-boot-dynamic-config 1.0.9 and above) 214 | - √ SpringBoot 2.3.x (spring-boot-dynamic-config version: <= 1.0.8) 215 | - √ SpringBoot 2.2.x 216 | - √ SpringBoot 2.1.x 217 | - √ SpringBoot 2.0.x 218 | - X SpringBoot 1.5.x and Lower 219 | 220 | NOTES: 221 | 222 | - For SpringBoot 2.0.x, use Junit4 rather than Junit5. 223 | - This lib does not depend on ANY other libs except SpringBoot core libs. 224 | 225 | ## License 226 | 227 | Spring Boot Dynamic Config is Open Source software released under 228 | the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html). 229 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/ConfigurationChangedEventHandler.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.beans.BeanUtils; 5 | import org.springframework.beans.TypeConverter; 6 | import org.springframework.beans.factory.BeanFactory; 7 | import org.springframework.beans.factory.config.BeanExpressionContext; 8 | import org.springframework.beans.factory.config.BeanExpressionResolver; 9 | import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; 11 | import org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor; 12 | import org.springframework.context.ApplicationContext; 13 | import org.springframework.context.ApplicationEventPublisher; 14 | import org.springframework.context.event.EventListener; 15 | import org.springframework.core.Ordered; 16 | import org.springframework.core.annotation.Order; 17 | import org.springframework.util.StringUtils; 18 | 19 | import java.lang.reflect.Field; 20 | import java.lang.reflect.Modifier; 21 | import java.util.HashMap; 22 | import java.util.List; 23 | import java.util.Map; 24 | 25 | import static top.code2life.config.ConfigurationUtils.*; 26 | import static top.code2life.config.DynamicConfigBeanPostProcessor.DYNAMIC_FIELD_BINDER_MAP; 27 | 28 | /** 29 | * @author Code2Life 30 | **/ 31 | @Slf4j 32 | @ConditionalOnBean(DynamicConfigPropertiesWatcher.class) 33 | public class ConfigurationChangedEventHandler { 34 | 35 | private static final String DOT_SYMBOL = "."; 36 | private static final String INDEXED_PROP_PATTERN = "\\[\\d{1,3}]"; 37 | 38 | private final BeanExpressionResolver exprResolver; 39 | private final BeanExpressionContext exprContext; 40 | private final ConfigurationPropertiesBindingPostProcessor processor; 41 | private final ConfigurableListableBeanFactory beanFactory; 42 | 43 | ConfigurationChangedEventHandler(ApplicationContext applicationContext, BeanFactory beanFactory, 44 | ApplicationEventPublisher eventPublisher) { 45 | if (!(beanFactory instanceof ConfigurableListableBeanFactory)) { 46 | throw new IllegalArgumentException( 47 | "DynamicConfig requires a ConfigurableListableBeanFactory"); 48 | } 49 | ConfigurableListableBeanFactory factory = (ConfigurableListableBeanFactory) beanFactory; 50 | this.beanFactory = factory; 51 | this.processor = applicationContext.getBean(ConfigurationPropertiesBindingPostProcessor.class); 52 | this.exprResolver = (factory).getBeanExpressionResolver(); 53 | this.exprContext = new BeanExpressionContext(factory, null); 54 | } 55 | 56 | /** 57 | * Listen config changed event, to process related beans and set latest values for their fields 58 | * 59 | * @param event ConfigurationChangedEvent indicates a configuration file changed event 60 | */ 61 | @Order(Ordered.HIGHEST_PRECEDENCE) 62 | @EventListener(ConfigurationChangedEvent.class) 63 | public synchronized void handleEvent(ConfigurationChangedEvent event) { 64 | try { 65 | Map diff = event.getDiff(); 66 | Map toRefreshProps = new HashMap<>(4); 67 | for (Map.Entry entry : diff.entrySet()) { 68 | String key = entry.getKey(); 69 | processConfigPropsClass(toRefreshProps, key); 70 | processValueField(key, entry.getValue()); 71 | } 72 | rebindRelatedConfigurationPropsBeans(diff, toRefreshProps); 73 | log.info("config changes of {} have been processed", event.getSource()); 74 | } catch (Exception ex) { 75 | log.warn("config changes of {} can not be processed, error:", event.getSource(), ex); 76 | } 77 | } 78 | 79 | private void processConfigPropsClass(Map result, String key) { 80 | DynamicConfigBeanPostProcessor.DYNAMIC_CONFIG_PROPS_BINDER_MAP.forEach((prefix, binder) -> { 81 | if (StringUtils.startsWithIgnoreCase(normalizePropKey(key), prefix)) { 82 | log.debug("prefix matched for ConfigurationProperties bean: {}, prefix: {}", binder.getBeanName(), prefix); 83 | result.put(binder.getBeanName(), binder); 84 | } 85 | }); 86 | } 87 | 88 | private void processValueField(String keyRaw, Object val) throws IllegalAccessException { 89 | String key = normalizePropKey(keyRaw); 90 | if (!DYNAMIC_FIELD_BINDER_MAP.containsKey(key)) { 91 | log.debug("no bound field of changed property found, skip dynamic config processing of key: {}", keyRaw); 92 | return; 93 | } 94 | List valueFieldBinders = DYNAMIC_FIELD_BINDER_MAP.get(key); 95 | for (ValueBeanFieldBinder binder : valueFieldBinders) { 96 | Object bean = binder.getBeanRef().get(); 97 | if (bean == null) { 98 | continue; 99 | } 100 | convertAndBindFieldValue(val, binder, bean); 101 | } 102 | } 103 | 104 | private void convertAndBindFieldValue(Object val, ValueBeanFieldBinder binder, Object bean) throws IllegalAccessException { 105 | Field field = binder.getDynamicField(); 106 | field.setAccessible(true); 107 | String expr = binder.getExpr(); 108 | String newExpr = beanFactory.resolveEmbeddedValue(expr); 109 | if (expr.startsWith(SP_EL_PREFIX)) { 110 | Object evaluatedVal = exprResolver.evaluate(newExpr, exprContext); 111 | field.set(bean, convertIfNecessary(field, evaluatedVal)); 112 | } else { 113 | field.set(bean, convertIfNecessary(field, val)); 114 | } 115 | if (log.isDebugEnabled()) { 116 | log.debug("dynamic config found, set field: '{}' of class: '{}' with new value", field.getName(), bean.getClass().getSimpleName()); 117 | } 118 | } 119 | 120 | private void rebindRelatedConfigurationPropsBeans(Map diff, Map toRefreshProps) throws IllegalAccessException { 121 | for (Map.Entry entry : toRefreshProps.entrySet()) { 122 | String beanName = entry.getKey(); 123 | ValueBeanFieldBinder binder = entry.getValue(); 124 | Object bean = binder.getBeanRef().get(); 125 | if (bean != null) { 126 | processor.postProcessBeforeInitialization(bean, beanName); 127 | // AggregateBinder - MapBinder will merge properties while binding 128 | // need to check deleted keys and remove from map fields 129 | removeMissingPropsMapFields(diff, bean, binder.getExpr()); 130 | log.debug("changes detected, re-bind ConfigurationProperties bean: {}", beanName); 131 | } 132 | } 133 | } 134 | 135 | private void removeMissingPropsMapFields(Map diff, Object rootBean, String prefix) throws IllegalAccessException { 136 | for (Map.Entry entry : diff.entrySet()) { 137 | Object value = entry.getValue(); 138 | if (value != null) { 139 | // only null value prop need to be removed from field value 140 | continue; 141 | } 142 | String rawKey = entry.getKey(); 143 | // 'a.b[1].c.d' liked changes would be refreshed wholly, no need to handle 144 | if (rawKey.matches(INDEXED_PROP_PATTERN)) { 145 | continue; 146 | } 147 | 148 | // if key 'a.b.c.d' is removed, need to check if 'a.b.c' is a map, if so, remove map key 'd' 149 | String normalizedFieldPath = findParentPath(prefix, rawKey); 150 | String leafKey = rawKey.substring(rawKey.lastIndexOf(DOT_SYMBOL) + 1); 151 | removeMissingMapKeyIfMatch(getTargetClassOfBean(rootBean), rootBean, normalizedFieldPath, leafKey); 152 | } 153 | } 154 | 155 | private void removeMissingMapKeyIfMatch(Class clazz, Object obj, String path, String mapKey) throws IllegalAccessException { 156 | int pos = path.indexOf(DOT_SYMBOL); 157 | boolean onLeaf = pos == -1; 158 | Field[] fields = clazz.getDeclaredFields(); 159 | for (Field f : fields) { 160 | if (isIgnorableField(f)) { 161 | continue; 162 | } 163 | String fieldName = f.getName(); 164 | boolean matchObjPath = StringUtils.startsWithIgnoreCase(path, normalizePropKey(fieldName)); 165 | if (matchObjPath && onLeaf && Map.class.isAssignableFrom(f.getType())) { 166 | f.setAccessible(true); 167 | ((Map) f.get(obj)).remove(mapKey); 168 | log.info("key {} has been removed from {} because of configuration change.", mapKey, path); 169 | break; 170 | } 171 | // dive to next level for case: path: a.b.c, field: b 172 | if (matchObjPath && !onLeaf) { 173 | f.setAccessible(true); 174 | Object subObj = f.get(obj); 175 | removeMissingMapKeyIfMatch(subObj.getClass(), subObj, path.substring(pos + 1), mapKey); 176 | } 177 | } 178 | } 179 | 180 | private boolean isIgnorableField(Field f) { 181 | int modifiers = f.getModifiers(); 182 | Class type = f.getType(); 183 | return Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers) || BeanUtils.isSimpleValueType(type); 184 | } 185 | 186 | private String findParentPath(String prefix, String rawKey) { 187 | String normalizedFieldPath = normalizePropKey(rawKey).substring(prefix.length() + 1); 188 | int pathPos = normalizedFieldPath.lastIndexOf(DOT_SYMBOL); 189 | if (pathPos != -1) { 190 | normalizedFieldPath = normalizedFieldPath.substring(0, pathPos); 191 | } else { 192 | normalizedFieldPath = ""; 193 | } 194 | return normalizedFieldPath; 195 | } 196 | 197 | private Object convertIfNecessary(Field field, Object value) { 198 | TypeConverter converter = beanFactory.getTypeConverter(); 199 | return converter.convertIfNecessary(value, field.getType(), field); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/ImportConfigFileTests.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.test.context.TestPropertySource; 9 | import top.code2life.config.sample.TestApplication; 10 | import top.code2life.config.sample.TestBeanConfiguration; 11 | import top.code2life.config.sample.TestComponent; 12 | import top.code2life.config.sample.TestConfigurationProperties; 13 | 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | import static top.code2life.config.TestUtils.*; 19 | 20 | /** 21 | * Test new features in Spring Boot 2.4: spring.config.import feature 22 | */ 23 | @Slf4j 24 | @TestPropertySource( 25 | properties = { 26 | "spring.config.location=" + DynamicConfigTests.NO_CONFIG_LOCATION, 27 | "spring.config.import=" + ImportConfigFileTests.IMPORT_LOCATION_1 + "," + ImportConfigFileTests.IMPORT_LOCATION_2 28 | } 29 | ) 30 | @SpringBootTest(classes = {TestApplication.class}) 31 | public class ImportConfigFileTests { 32 | 33 | public static final String IMPORT_LOCATION_1 = "./build/resources/test/conf-import-test/import-file.yaml"; 34 | public static final String IMPORT_LOCATION_2 = "./build/resources/test/conf-import-test/import-file-another.yml"; 35 | 36 | @Autowired 37 | private TestConfigurationProperties testProperty; 38 | 39 | @Autowired 40 | private TestComponent testComponent; 41 | 42 | @Autowired 43 | private FeatureGate featureGate; 44 | 45 | @Autowired 46 | private TestBeanConfiguration testBeanConfig; 47 | 48 | @Autowired 49 | private TestBeanConfiguration.TestBean testBean; 50 | 51 | @Autowired 52 | private Environment env; 53 | 54 | @Test 55 | @SuppressWarnings("unchecked") 56 | public void testDynamicValueOnConfigurationProperties() throws Exception { 57 | assertEquals("dynamic", testBean.getInternalStr()); 58 | assertEquals("dynamic", testBeanConfig.getCurrentStrValue()); 59 | assertEquals("dynamic", testProperty.getStr()); 60 | 61 | String testVal = randomStr(8); 62 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 63 | Map myProp = (Map) data.get("myProp"); 64 | myProp.put("str", testVal); 65 | writeYmlData(data, IMPORT_LOCATION_2, ""); 66 | Thread.sleep(1000); 67 | assertEquals(testVal, testProperty.getStr()); 68 | // initialized bean won't be refreshed with new value, should take effect on injected bean 69 | assertEquals("dynamic", testBean.getInternalStr()); 70 | assertEquals(testVal, testBeanConfig.getCurrentStrValue()); 71 | } 72 | 73 | @Test 74 | @SuppressWarnings("unchecked") 75 | public void testDynamicValueWithWrongType() throws Exception { 76 | String testVal = randomStr(8); 77 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 78 | Map myProp = (Map) data.get("myProp"); 79 | // should throw exception because of mal-type, won't impact running application 80 | myProp.put("double-val", testVal); 81 | writeYmlData(data, IMPORT_LOCATION_2, ""); 82 | Thread.sleep(1000); 83 | assertEquals(1f, testProperty.getDoubleVal()); 84 | } 85 | 86 | @Test 87 | public void testDynamicValuePlainText() throws Exception { 88 | assertEquals("dynamic-test", testComponent.getPlainValue()); 89 | 90 | String testVal = randomStr(8); 91 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 92 | data.put("dynamicTestPlain", testVal); 93 | writeYmlData(data, IMPORT_LOCATION_1, ""); 94 | Thread.sleep(1000); 95 | assertEquals(testVal, testComponent.getPlainValue()); 96 | } 97 | 98 | @Test 99 | public void testDynamicValuePlainTextWithKebabCase() throws Exception { 100 | String testVal = randomStr(8); 101 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 102 | data.put("dynamic-test-plain", testVal); 103 | writeYmlData(data, IMPORT_LOCATION_1, ""); 104 | Thread.sleep(1000); 105 | assertEquals(testVal, testComponent.getPlainValue()); 106 | } 107 | 108 | @Test 109 | public void testConfigPropRemoveBoxedValue() throws Exception { 110 | assertEquals(3, testProperty.getBoxedIntVal()); 111 | 112 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 113 | Map myProp = (Map) data.get("myProp"); 114 | myProp.remove("boxedIntVal"); 115 | writeYmlData(data, IMPORT_LOCATION_2, ""); 116 | Thread.sleep(1000); 117 | 118 | // because of Spring binder mechanism, value would not be removed as Property being removed 119 | assertEquals(3, testProperty.getBoxedIntVal()); 120 | } 121 | 122 | @Test 123 | @SuppressWarnings("unchecked") 124 | public void testConfigPropRemoveNestedCollectionValue() throws Exception { 125 | assertEquals("a1", testProperty.getNested().getCollectionVal().get(0)); 126 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 127 | Map myProp = (Map) data.get("myProp"); 128 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 129 | collectionVal.remove(0); 130 | writeYmlData(data, IMPORT_LOCATION_2, ""); 131 | Thread.sleep(1000); 132 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 133 | } 134 | 135 | @Test 136 | @SuppressWarnings("unchecked") 137 | public void testConfigPropAddOrRemoveNestedCollectionValue() throws Exception { 138 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 139 | 140 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 141 | Map myProp = (Map) data.get("myProp"); 142 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 143 | collectionVal.remove(0); 144 | collectionVal.add("a3"); 145 | writeYmlData(data, IMPORT_LOCATION_2, ""); 146 | Thread.sleep(1000); 147 | 148 | assertEquals("a3", testProperty.getNested().getCollectionVal().get(0)); 149 | } 150 | 151 | @Test 152 | @SuppressWarnings("unchecked") 153 | public void testConfigPropAddOrRemoveNestedMapValue() throws Exception { 154 | assertEquals("v1", testProperty.getNested().getMapVal().get("m1")); 155 | 156 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 157 | Map myProp = (Map) data.get("myProp"); 158 | Map mapVal = (Map) ((Map) myProp.get("nested")).get("mapVal"); 159 | mapVal.remove("m1"); 160 | writeYmlData(data, IMPORT_LOCATION_2, ""); 161 | Thread.sleep(1000); 162 | 163 | assertNull(testProperty.getNested().getMapVal().get("m1")); 164 | } 165 | 166 | @Test 167 | @SuppressWarnings("unchecked") 168 | public void testConfigPropRemoveAddMapValue() throws Exception { 169 | assertEquals(2, testProperty.getIntVal()); 170 | assertEquals("v3", testProperty.getMapVal().get("k3")); 171 | 172 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 173 | Map myProp = (Map) data.get("myProp"); 174 | myProp.remove("intVal"); 175 | ((Map) myProp.get("map-val")).remove("k3"); 176 | ((Map) myProp.get("map-val")).put("k4", "v4"); 177 | writeYmlData(data, IMPORT_LOCATION_2, ""); 178 | Thread.sleep(1000); 179 | 180 | assertEquals(2, testProperty.getIntVal()); 181 | assertEquals("v4", testProperty.getMapVal().get("k4")); 182 | assertNull(testProperty.getMapVal().get("k3")); 183 | } 184 | 185 | @Test 186 | @SuppressWarnings("unchecked") 187 | public void testListValues() throws Exception { 188 | assertEquals("l1", testProperty.getListVal().get(0)); 189 | 190 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 191 | Map myProp = (Map) data.get("myProp"); 192 | List listVal = (List) myProp.get("list-val"); 193 | listVal.remove(0); 194 | listVal.add("l3"); 195 | 196 | writeYmlData(data, IMPORT_LOCATION_2, ""); 197 | Thread.sleep(1000); 198 | 199 | assertEquals("l2", testProperty.getListVal().get(0)); 200 | assertEquals("l3", testProperty.getListVal().get(1)); 201 | } 202 | 203 | @Test 204 | public void testDynamicValueOnEnvBean() throws Exception { 205 | String testVal = randomStr(8); 206 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 207 | data.put("dynamicEnvTest", testVal); 208 | writeYmlData(data, IMPORT_LOCATION_2, ""); 209 | Thread.sleep(1000); 210 | assertEquals(testVal, env.getProperty("dynamicEnvTest")); 211 | } 212 | 213 | @Test 214 | public void testDynamicValueOnFeatureGate() throws Exception { 215 | String testVal = randomStr(8); 216 | assertFalse(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 217 | 218 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 219 | data.put("dynamicFeatureConf", data.get("dynamicFeatureConf") + ", " + testVal + " ,"); 220 | writeYmlData(data, IMPORT_LOCATION_1, ""); 221 | Thread.sleep(1000); 222 | 223 | assertTrue(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 224 | assertFalse(featureGate.isFeatureEnabled(testVal)); 225 | data.put(testVal, "True"); 226 | writeYmlData(data, IMPORT_LOCATION_1, ""); 227 | Thread.sleep(1000); 228 | assertTrue(featureGate.isFeatureEnabled(testVal)); 229 | 230 | } 231 | 232 | @Test 233 | @SuppressWarnings("unchecked") 234 | public void testMultipleDynamicValueOnSpEl() throws Exception { 235 | double testVal = randomDouble(); 236 | double testVal2 = randomDouble(); 237 | 238 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 239 | Map internal = (Map) data.get("dynamic"); 240 | internal.put("transform-a", testVal); 241 | internal.put("transform-b", testVal2); 242 | writeYmlData(data, IMPORT_LOCATION_1, ""); 243 | Thread.sleep(1000); 244 | 245 | assertEquals(testVal / testVal2, testComponent.getTransformBySpEL()); 246 | 247 | double testVal3 = randomDouble(); 248 | internal.put("transform-b", testVal3); 249 | writeYmlData(data, IMPORT_LOCATION_1, ""); 250 | Thread.sleep(1000); 251 | 252 | assertEquals(testVal / testVal3, testComponent.getTransformBySpEL()); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/ImportConfigTreeTests.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.test.context.TestPropertySource; 9 | import top.code2life.config.sample.TestApplication; 10 | import top.code2life.config.sample.configtree.TestConfTreeConfigurationProperties; 11 | import top.code2life.config.sample.configtree.TestConfigTreeBeanConfiguration; 12 | import top.code2life.config.sample.configtree.TestConfigTreeComponent; 13 | 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | import static top.code2life.config.ImportConfigTreeTests.IMPORT_LOCATION; 19 | import static top.code2life.config.TestUtils.*; 20 | 21 | /** 22 | * Test new features in Spring Boot 2.4: spring.config.import feature 23 | * https://docs.spring.io/spring-boot/docs/2.7.3/reference/htmlsingle/#features.external-config.files.configtree 24 | */ 25 | @Slf4j 26 | @TestPropertySource( 27 | properties = { 28 | "spring.config.location=" + DynamicConfigTests.NO_CONFIG_LOCATION, 29 | "spring.config.import=configtree:" + IMPORT_LOCATION 30 | } 31 | ) 32 | @SpringBootTest(classes = {TestApplication.class}) 33 | public class ImportConfigTreeTests { 34 | 35 | public static final String IMPORT_LOCATION = "build/resources/test/conf-tree-test/"; 36 | public static final String IMPORT_LOCATION_1 = "./build/resources/test/conf-tree-test/module_a/xyz.yaml"; 37 | public static final String IMPORT_LOCATION_2 = "./build/resources/test/conf-tree-test/moduleB/abc.yaml"; 38 | 39 | @Autowired 40 | private TestConfTreeConfigurationProperties testProperty; 41 | 42 | @Autowired 43 | private TestConfigTreeComponent testComponent; 44 | 45 | @Autowired 46 | private FeatureGate featureGate; 47 | 48 | @Autowired 49 | private TestConfigTreeBeanConfiguration testBeanConfig; 50 | 51 | @Autowired 52 | private TestConfigTreeBeanConfiguration.TestBean testBean; 53 | 54 | @Autowired 55 | private Environment env; 56 | 57 | @Test 58 | @SuppressWarnings("unchecked") 59 | public void testDynamicValueOnConfigurationProperties() throws Exception { 60 | String testVal = randomStr(8); 61 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 62 | Map myProp = (Map) data.get("myProp"); 63 | myProp.put("str", testVal); 64 | writeYmlData(data, IMPORT_LOCATION_2, ""); 65 | Thread.sleep(1000); 66 | assertEquals(testVal, testProperty.getStr()); 67 | // initialized bean won't be refreshed with new value, should take effect on injected bean 68 | assertEquals("dynamic", testBean.getInternalStr()); 69 | assertEquals(testVal, testBeanConfig.getCurrentStrValue()); 70 | } 71 | 72 | @Test 73 | @SuppressWarnings("unchecked") 74 | public void testDynamicValueWithWrongType() throws Exception { 75 | String testVal = randomStr(8); 76 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 77 | Map myProp = (Map) data.get("myProp"); 78 | // should throw exception because of mal-type, won't impact running application 79 | myProp.put("double-val", testVal); 80 | writeYmlData(data, IMPORT_LOCATION_2, ""); 81 | Thread.sleep(1000); 82 | assertEquals(1f, testProperty.getDoubleVal()); 83 | } 84 | 85 | @Test 86 | public void testDynamicValuePlainText() throws Exception { 87 | assertEquals("dynamic-test", testComponent.getPlainValue()); 88 | 89 | String testVal = randomStr(8); 90 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 91 | data.put("dynamicTestPlain", testVal); 92 | writeYmlData(data, IMPORT_LOCATION_1, ""); 93 | Thread.sleep(1000); 94 | assertEquals(testVal, testComponent.getPlainValue()); 95 | } 96 | 97 | @Test 98 | public void testDynamicValuePlainTextWithKebabCase() throws Exception { 99 | String testVal = randomStr(8); 100 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 101 | data.put("dynamic-test-plain", testVal); 102 | writeYmlData(data, IMPORT_LOCATION_1, ""); 103 | Thread.sleep(1000); 104 | assertEquals(testVal, testComponent.getPlainValue()); 105 | } 106 | 107 | @Test 108 | public void testConfigPropRemoveBoxedValue() throws Exception { 109 | assertEquals(3, testProperty.getBoxedIntVal()); 110 | 111 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 112 | Map myProp = (Map) data.get("myProp"); 113 | myProp.remove("boxedIntVal"); 114 | writeYmlData(data, IMPORT_LOCATION_2, ""); 115 | Thread.sleep(1000); 116 | 117 | // because of Spring binder mechanism, value would not be removed as Property being removed 118 | assertEquals(3, testProperty.getBoxedIntVal()); 119 | } 120 | 121 | @Test 122 | @SuppressWarnings("unchecked") 123 | public void testConfigPropRemoveNestedCollectionValue() throws Exception { 124 | assertEquals("a1", testProperty.getNested().getCollectionVal().get(0)); 125 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 126 | Map myProp = (Map) data.get("myProp"); 127 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 128 | collectionVal.remove(0); 129 | writeYmlData(data, IMPORT_LOCATION_2, ""); 130 | Thread.sleep(1000); 131 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 132 | } 133 | 134 | @Test 135 | @SuppressWarnings("unchecked") 136 | public void testConfigPropAddOrRemoveNestedCollectionValue() throws Exception { 137 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 138 | 139 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 140 | Map myProp = (Map) data.get("myProp"); 141 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 142 | collectionVal.remove(0); 143 | collectionVal.add("a3"); 144 | writeYmlData(data, IMPORT_LOCATION_2, ""); 145 | Thread.sleep(1000); 146 | 147 | assertEquals("a3", testProperty.getNested().getCollectionVal().get(0)); 148 | } 149 | 150 | @Test 151 | @SuppressWarnings("unchecked") 152 | public void testConfigPropAddOrRemoveNestedMapValue() throws Exception { 153 | assertEquals("v1", testProperty.getNested().getMapVal().get("m1")); 154 | 155 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 156 | Map myProp = (Map) data.get("myProp"); 157 | Map mapVal = (Map) ((Map) myProp.get("nested")).get("mapVal"); 158 | mapVal.remove("m1"); 159 | writeYmlData(data, IMPORT_LOCATION_2, ""); 160 | Thread.sleep(1000); 161 | 162 | assertNull(testProperty.getNested().getMapVal().get("m1")); 163 | } 164 | 165 | @Test 166 | @SuppressWarnings("unchecked") 167 | public void testConfigPropRemoveAddMapValue() throws Exception { 168 | assertEquals(2, testProperty.getIntVal()); 169 | assertEquals("v3", testProperty.getMapVal().get("k3")); 170 | 171 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 172 | Map myProp = (Map) data.get("myProp"); 173 | myProp.remove("intVal"); 174 | ((Map) myProp.get("map-val")).remove("k3"); 175 | ((Map) myProp.get("map-val")).put("k4", "v4"); 176 | writeYmlData(data, IMPORT_LOCATION_2, ""); 177 | Thread.sleep(1000); 178 | 179 | assertEquals(2, testProperty.getIntVal()); 180 | assertEquals("v4", testProperty.getMapVal().get("k4")); 181 | assertNull(testProperty.getMapVal().get("k3")); 182 | } 183 | 184 | @Test 185 | @SuppressWarnings("unchecked") 186 | public void testListValues() throws Exception { 187 | assertEquals("l1", testProperty.getListVal().get(0)); 188 | 189 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 190 | Map myProp = (Map) data.get("myProp"); 191 | List listVal = (List) myProp.get("list-val"); 192 | listVal.remove(0); 193 | listVal.add("l3"); 194 | 195 | writeYmlData(data, IMPORT_LOCATION_2, ""); 196 | Thread.sleep(1000); 197 | 198 | assertEquals("l2", testProperty.getListVal().get(0)); 199 | assertEquals("l3", testProperty.getListVal().get(1)); 200 | } 201 | 202 | @Test 203 | public void testDynamicValueOnEnvBean() throws Exception { 204 | String testVal = randomStr(8); 205 | Map data = readYmlData(IMPORT_LOCATION_2, ""); 206 | data.put("dynamicEnvTest", testVal); 207 | writeYmlData(data, IMPORT_LOCATION_2, ""); 208 | Thread.sleep(1000); 209 | assertEquals(testVal, env.getProperty("module-b.abc.dynamicEnvTest")); 210 | } 211 | 212 | @Test 213 | public void testDynamicValueOnFeatureGate() throws Exception { 214 | String testVal = randomStr(8); 215 | assertFalse(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 216 | 217 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 218 | data.put("dynamicFeatureConf", data.get("dynamicFeatureConf") + ", " + testVal + " ,"); 219 | writeYmlData(data, IMPORT_LOCATION_1, ""); 220 | Thread.sleep(1000); 221 | 222 | assertTrue(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 223 | assertFalse(featureGate.isFeatureEnabled(testVal)); 224 | data.put(testVal, "True"); 225 | writeYmlData(data, IMPORT_LOCATION_1, ""); 226 | Thread.sleep(1000); 227 | assertTrue(featureGate.isFeatureEnabled("module-a.xyz." + testVal)); 228 | 229 | } 230 | 231 | @Test 232 | @SuppressWarnings("unchecked") 233 | public void testMultipleDynamicValueOnSpEl() throws Exception { 234 | double testVal = randomDouble(); 235 | double testVal2 = randomDouble(); 236 | 237 | Map data = readYmlData(IMPORT_LOCATION_1, ""); 238 | Map internal = (Map) data.get("dynamic"); 239 | internal.put("transform-a", testVal); 240 | internal.put("transform-b", testVal2); 241 | writeYmlData(data, IMPORT_LOCATION_1, ""); 242 | Thread.sleep(1000); 243 | 244 | assertEquals(testVal / testVal2, testComponent.getTransformBySpEL()); 245 | 246 | double testVal3 = randomDouble(); 247 | internal.put("transform-b", testVal3); 248 | writeYmlData(data, IMPORT_LOCATION_1, ""); 249 | Thread.sleep(1000); 250 | 251 | assertEquals(testVal / testVal3, testComponent.getTransformBySpEL()); 252 | } 253 | } -------------------------------------------------------------------------------- /src/test/java/top/code2life/config/DynamicConfigTests.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.context.ApplicationContext; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.test.context.TestPropertySource; 9 | import top.code2life.config.sample.TestApplication; 10 | import top.code2life.config.sample.TestBeanConfiguration; 11 | import top.code2life.config.sample.TestComponent; 12 | import top.code2life.config.sample.TestConfigurationProperties; 13 | 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | import static top.code2life.config.TestUtils.*; 19 | 20 | @TestPropertySource( 21 | properties = {"spring.config.location=" + DynamicConfigTests.CONFIG_LOCATION} 22 | ) 23 | @SpringBootTest(classes = {TestApplication.class}) 24 | public class DynamicConfigTests { 25 | 26 | public static final String CONFIG_LOCATION = "./build/resources/test/"; 27 | public static final String NO_CONFIG_LOCATION = "./build/resources/test/no-config-location/"; 28 | 29 | @Autowired 30 | private ApplicationContext context; 31 | 32 | @Autowired 33 | private TestConfigurationProperties testProperty; 34 | 35 | @Autowired 36 | private TestComponent testComponent; 37 | 38 | @Autowired 39 | private FeatureGate featureGate; 40 | 41 | @Autowired 42 | private TestBeanConfiguration testBeanConfig; 43 | 44 | @Autowired 45 | private TestBeanConfiguration.TestBean testBean; 46 | 47 | @Autowired 48 | private Environment env; 49 | 50 | @Test 51 | public void testBeanLoaded() { 52 | DynamicConfigPropertiesWatcher bean = context.getBean(DynamicConfigPropertiesWatcher.class); 53 | FeatureGate featureGate = context.getBean(FeatureGate.class); 54 | DynamicConfigBeanPostProcessor processor = context.getBean(DynamicConfigBeanPostProcessor.class); 55 | assertNotNull(bean); 56 | assertNotNull(featureGate); 57 | assertNotNull(processor); 58 | } 59 | 60 | @Test 61 | @SuppressWarnings("unchecked") 62 | public void testDynamicValueOnConfigurationProperties() throws Exception { 63 | assertEquals("dynamic", testBean.getInternalStr()); 64 | assertEquals("dynamic", testBeanConfig.getCurrentStrValue()); 65 | assertEquals("dynamic", testProperty.getStr()); 66 | 67 | String testVal = randomStr(8); 68 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 69 | Map myProp = (Map) data.get("myProp"); 70 | myProp.put("str", testVal); 71 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 72 | Thread.sleep(1000); 73 | assertEquals(testVal, testProperty.getStr()); 74 | // initialized bean won't be refreshed with new value, should take effect on injected bean 75 | assertEquals("dynamic", testBean.getInternalStr()); 76 | assertEquals(testVal, testBeanConfig.getCurrentStrValue()); 77 | } 78 | 79 | @Test 80 | @SuppressWarnings("unchecked") 81 | public void testDynamicValueWithWrongType() throws Exception { 82 | String testVal = randomStr(8); 83 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 84 | Map myProp = (Map) data.get("myProp"); 85 | // should throw exception because of mal-type, won't impact running application 86 | myProp.put("double-val", testVal); 87 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 88 | Thread.sleep(1000); 89 | assertEquals(1f, testProperty.getDoubleVal()); 90 | } 91 | 92 | @Test 93 | public void testDynamicValuePlainText() throws Exception { 94 | assertEquals("dynamic-test", testComponent.getPlainValue()); 95 | 96 | String testVal = randomStr(8); 97 | Map data = readYmlData(CONFIG_LOCATION, "application-dynamic.yml"); 98 | data.put("dynamicTestPlain", testVal); 99 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 100 | Thread.sleep(1000); 101 | assertEquals(testVal, testComponent.getPlainValue()); 102 | } 103 | 104 | @Test 105 | public void testDynamicValuePlainTextWithKebabCase() throws Exception { 106 | String testVal = randomStr(8); 107 | Map data = readYmlData(CONFIG_LOCATION, "application-dynamic.yml"); 108 | data.put("dynamic-test-plain", testVal); 109 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 110 | Thread.sleep(1000); 111 | assertEquals(testVal, testComponent.getPlainValue()); 112 | } 113 | 114 | @Test 115 | public void testConfigPropRemoveBoxedValue() throws Exception { 116 | assertEquals(3, testProperty.getBoxedIntVal()); 117 | 118 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 119 | Map myProp = (Map) data.get("myProp"); 120 | myProp.remove("boxedIntVal"); 121 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 122 | Thread.sleep(1000); 123 | 124 | // because of Spring binder mechanism, value would not be removed as Property being removed 125 | assertEquals(3, testProperty.getBoxedIntVal()); 126 | } 127 | 128 | @Test 129 | @SuppressWarnings("unchecked") 130 | public void testConfigPropRemoveNestedCollectionValue() throws Exception { 131 | assertEquals("a1", testProperty.getNested().getCollectionVal().get(0)); 132 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 133 | Map myProp = (Map) data.get("myProp"); 134 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 135 | collectionVal.remove(0); 136 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 137 | Thread.sleep(1000); 138 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 139 | } 140 | 141 | @Test 142 | @SuppressWarnings("unchecked") 143 | public void testConfigPropAddOrRemoveNestedCollectionValue() throws Exception { 144 | assertEquals("a2", testProperty.getNested().getCollectionVal().get(0)); 145 | 146 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 147 | Map myProp = (Map) data.get("myProp"); 148 | List collectionVal = (List) ((Map) myProp.get("nested")).get("collection-val"); 149 | collectionVal.remove(0); 150 | collectionVal.add("a3"); 151 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 152 | Thread.sleep(1000); 153 | 154 | assertEquals("a3", testProperty.getNested().getCollectionVal().get(0)); 155 | } 156 | 157 | @Test 158 | @SuppressWarnings("unchecked") 159 | public void testConfigPropAddOrRemoveNestedMapValue() throws Exception { 160 | assertEquals("v1", testProperty.getNested().getMapVal().get("m1")); 161 | 162 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 163 | Map myProp = (Map) data.get("myProp"); 164 | Map mapVal = (Map) ((Map) myProp.get("nested")).get("mapVal"); 165 | mapVal.remove("m1"); 166 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 167 | Thread.sleep(1000); 168 | 169 | assertNull(testProperty.getNested().getMapVal().get("m1")); 170 | } 171 | 172 | @Test 173 | @SuppressWarnings("unchecked") 174 | public void testConfigPropRemoveAddMapValue() throws Exception { 175 | assertEquals(2, testProperty.getIntVal()); 176 | assertEquals("v3", testProperty.getMapVal().get("k3")); 177 | 178 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 179 | Map myProp = (Map) data.get("myProp"); 180 | myProp.remove("intVal"); 181 | ((Map) myProp.get("map-val")).remove("k3"); 182 | ((Map) myProp.get("map-val")).put("k4", "v4"); 183 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 184 | Thread.sleep(1000); 185 | 186 | assertEquals(2, testProperty.getIntVal()); 187 | assertEquals("v4", testProperty.getMapVal().get("k4")); 188 | assertNull(testProperty.getMapVal().get("k3")); 189 | } 190 | 191 | @Test 192 | @SuppressWarnings("unchecked") 193 | public void testListValues() throws Exception { 194 | assertEquals("l1", testProperty.getListVal().get(0)); 195 | 196 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 197 | Map myProp = (Map) data.get("myProp"); 198 | List listVal = (List) myProp.get("list-val"); 199 | listVal.remove(0); 200 | listVal.add("l3"); 201 | 202 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 203 | Thread.sleep(1000); 204 | 205 | assertEquals("l2", testProperty.getListVal().get(0)); 206 | assertEquals("l3", testProperty.getListVal().get(1)); 207 | } 208 | 209 | @Test 210 | public void testDynamicValueOnEnvBean() throws Exception { 211 | String testVal = randomStr(8); 212 | Map data = readYmlData(CONFIG_LOCATION, "application.yml"); 213 | data.put("dynamicEnvTest", testVal); 214 | writeYmlData(data, CONFIG_LOCATION, "application.yml"); 215 | Thread.sleep(1000); 216 | assertEquals(testVal, env.getProperty("dynamicEnvTest")); 217 | } 218 | 219 | @Test 220 | public void testDynamicValueOnFeatureGate() throws Exception { 221 | String testVal = randomStr(8); 222 | assertFalse(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 223 | 224 | Map data = readYmlData(CONFIG_LOCATION, "application-dynamic.yml"); 225 | data.put("dynamicFeatureConf", data.get("dynamicFeatureConf") + ", " + testVal + " ,"); 226 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 227 | Thread.sleep(1000); 228 | 229 | assertTrue(featureGate.isFeatureEnabled(testComponent.getSomeBetaFeatureConfig(), testVal)); 230 | assertFalse(featureGate.isFeatureEnabled(testVal)); 231 | data.put(testVal, "True"); 232 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 233 | Thread.sleep(1000); 234 | assertTrue(featureGate.isFeatureEnabled(testVal)); 235 | 236 | } 237 | 238 | @Test 239 | @SuppressWarnings("unchecked") 240 | public void testMultipleDynamicValueOnSpEl() throws Exception { 241 | double testVal = randomDouble(); 242 | double testVal2 = randomDouble(); 243 | 244 | Map data = readYmlData(CONFIG_LOCATION, "application-dynamic.yml"); 245 | Map internal = (Map) data.get("dynamic"); 246 | internal.put("transform-a", testVal); 247 | internal.put("transform-b", testVal2); 248 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 249 | Thread.sleep(1000); 250 | 251 | assertEquals(testVal / testVal2, testComponent.getTransformBySpEL()); 252 | 253 | double testVal3 = randomDouble(); 254 | internal.put("transform-b", testVal3); 255 | writeYmlData(data, CONFIG_LOCATION, "application-dynamic.yml"); 256 | Thread.sleep(1000); 257 | 258 | assertEquals(testVal / testVal3, testComponent.getTransformBySpEL()); 259 | } 260 | 261 | } 262 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/ConfigurationUtils.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.aop.support.AopUtils; 5 | import org.springframework.boot.env.OriginTrackedMapPropertySource; 6 | import org.springframework.core.env.PropertySource; 7 | import org.springframework.util.ClassUtils; 8 | import org.springframework.util.StringUtils; 9 | 10 | import java.io.IOException; 11 | import java.nio.file.FileVisitOption; 12 | import java.nio.file.Files; 13 | import java.nio.file.Path; 14 | import java.nio.file.Paths; 15 | import java.nio.file.attribute.BasicFileAttributes; 16 | import java.util.*; 17 | import java.util.function.Predicate; 18 | import java.util.regex.Matcher; 19 | import java.util.regex.Pattern; 20 | import java.util.stream.Collectors; 21 | 22 | import static top.code2life.config.DynamicConfigPropertiesWatcher.WATCHABLE_TARGETS; 23 | 24 | /** 25 | * @author Code2Life 26 | **/ 27 | @Slf4j 28 | public class ConfigurationUtils { 29 | 30 | static final String VALUE_EXPR_PREFIX = "$"; 31 | static final String SP_EL_PREFIX = "#"; 32 | static final String OPTIONAL_PREFIX = "optional:"; 33 | static final String CONFIG_TREE_PREFIX = "configtree:"; 34 | static final String CONFIG_FILE_PREFIX = "file:"; 35 | 36 | private static final int MAX_DEPTH = 3; 37 | private static final Pattern VALUE_PATTERN = Pattern.compile("\\$\\{([^:}]+):?([^}]*)}"); 38 | private static final Pattern CAMEL_CASE_PATTERN = Pattern.compile("([^A-Z-])([A-Z])"); 39 | private static final Set VALID_EXTENSION = new HashSet() { 40 | { 41 | add("xml"); 42 | add("yml"); 43 | add("yaml"); 44 | add("properties"); 45 | } 46 | }; 47 | private static final Map> CONFIG_TREE_CACHE = new HashMap<>(4); 48 | 49 | static List extractValueFromExpr(String valueExpr) { 50 | List keys = new ArrayList<>(2); 51 | Matcher matcher = VALUE_PATTERN.matcher(valueExpr); 52 | while (matcher.find()) { 53 | try { 54 | // normalized into kebab case (abc-def.g-h) 55 | keys.add(normalizePropKey(matcher.group(1).trim())); 56 | } catch (Exception ex) { 57 | log.warn("can not extract target property from @Value declaration, expr: {}. error: {}", valueExpr, ex.getMessage()); 58 | } 59 | } 60 | return keys; 61 | } 62 | 63 | /** 64 | * Convert camelCase or snake_case key into kebab-case 65 | * 66 | * @param name the key name 67 | * @return normalized key name 68 | */ 69 | static String normalizePropKey(String name) { 70 | return toKebabCase(name); 71 | } 72 | 73 | static Class getTargetClassOfBean(Object bean) { 74 | Class clazz = AopUtils.getTargetClass(bean); 75 | if (clazz.getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)) { 76 | clazz = clazz.getSuperclass(); 77 | } 78 | return clazz; 79 | } 80 | 81 | static String normalizePath(String path, String expectedBaseDir) { 82 | path = trimRelativePathAndReplaceBackSlash(path); 83 | expectedBaseDir = trimRelativePathAndReplaceBackSlash(expectedBaseDir); 84 | if (!path.startsWith(expectedBaseDir)) { 85 | String combined = Paths.get(expectedBaseDir, path).toString(); 86 | return trimRelativePathAndReplaceBackSlash(combined); 87 | } 88 | return path; 89 | } 90 | 91 | static String trimRelativePathAndReplaceBackSlash(String str) { 92 | if (!StringUtils.hasText(str)) { 93 | throw new IllegalArgumentException("wrong parameters when processing path"); 94 | } 95 | boolean beginWithRelative = str.length() > 2 && (str.startsWith("./") || str.startsWith(".\\")); 96 | if (beginWithRelative) { 97 | return str.substring(2).replaceAll("\\\\", "/"); 98 | } 99 | return str.replaceAll("\\\\", "/"); 100 | } 101 | 102 | 103 | static String getFileExtension(String path) { 104 | String extension = ""; 105 | int i = path.lastIndexOf('.'); 106 | if (i > 0) { 107 | extension = path.substring(i + 1); 108 | } 109 | return extension; 110 | } 111 | 112 | /** 113 | * Called in ConditionalOnExpression, as bean creation condition 114 | * 115 | * @param configLocation spring.config.location value 116 | * @param configImport spring.config.import value 117 | * @return need watch config directory or not 118 | * @see DynamicConfigPropertiesWatcher 119 | */ 120 | public static boolean hasWatchableConf(String configLocation, String configImport) { 121 | if (WATCHABLE_TARGETS.size() > 0) { 122 | return true; 123 | } 124 | getNeedWatchDirAndPath(configLocation, configImport); 125 | return WATCHABLE_TARGETS.size() > 0; 126 | } 127 | 128 | private static void getNeedWatchDirAndPath(String configLocation, String configImport) { 129 | if (StringUtils.hasText(configLocation)) { 130 | List targets = splitCommaAndFilter(configLocation, s -> StringUtils.hasText(s) && !s.startsWith("classpath:")); 131 | for (String target : targets) { 132 | FileSystemWatchTarget watchTarget = new FileSystemWatchTarget(FileSystemWatchTarget.WatchTargetType.CONFIG_LOCATION, target); 133 | WATCHABLE_TARGETS.put(watchTarget.getNormalizedDir(), watchTarget); 134 | } 135 | } 136 | 137 | if (StringUtils.hasText(configImport)) { 138 | List targets = splitCommaAndFilter(configImport, StringUtils::hasText); 139 | for (String target : targets) { 140 | boolean isOptional = target.startsWith(OPTIONAL_PREFIX); 141 | if (isOptional) { 142 | target = target.substring(OPTIONAL_PREFIX.length()); 143 | } 144 | boolean isImportConfTree = target.startsWith(CONFIG_TREE_PREFIX); 145 | if (isImportConfTree) { 146 | target = target.substring(CONFIG_TREE_PREFIX.length()); 147 | if (!Files.exists(Paths.get(target))) { 148 | log.warn("config-tree import directory not exists, skip dynamic config watch: {}", target); 149 | continue; 150 | } 151 | } else { 152 | target = target.startsWith(CONFIG_FILE_PREFIX) ? target.substring(CONFIG_FILE_PREFIX.length()) : target; 153 | if (!Files.exists(Paths.get(target))) { 154 | log.warn("'config import not exists as file, skip dynamic config watch: {}", target); 155 | continue; 156 | } 157 | } 158 | 159 | if (isImportConfTree) { 160 | // find all available paths of property source files 161 | List configFilePaths = findAllPropertyFilesInTree(Paths.get(target)); 162 | if (configFilePaths.size() == 0) { 163 | log.info("no property sources found in config tree: {}, skip dynamic config file watch", target); 164 | } else { 165 | // group the to directory, then create multiple watch targets 166 | Map> groupedPaths = configFilePaths.stream().collect(Collectors.groupingBy(Path::getParent)); 167 | for (Map.Entry> entry : groupedPaths.entrySet()) { 168 | appendToWatchableTargets(FileSystemWatchTarget.WatchTargetType.CONFIG_IMPORT_TREE, entry.getKey().toString(), entry.getValue(), Paths.get(target)); 169 | } 170 | } 171 | } else { 172 | appendToWatchableTargets(FileSystemWatchTarget.WatchTargetType.CONFIG_IMPORT_FILE, target); 173 | } 174 | } 175 | } 176 | } 177 | 178 | static List splitCommaAndFilter(String str, Predicate predicate) { 179 | if (!StringUtils.hasText(str)) { 180 | return Collections.emptyList(); 181 | } 182 | return Arrays.stream(str.split(",")) 183 | .map(String::trim).filter(predicate) 184 | .collect(Collectors.toList()); 185 | } 186 | 187 | 188 | static Map findAllKeyAndFilesInConfigTree(Path sourceDirectory) { 189 | Map propKeyPathMap = new HashMap<>(4); 190 | findAllPropertyFilesInTree(sourceDirectory).forEach((path) -> { 191 | String prefix = getPropertyPrefix(sourceDirectory, path); 192 | if (StringUtils.hasText(prefix)) { 193 | propKeyPathMap.put(prefix, path); 194 | } 195 | }); 196 | return propKeyPathMap; 197 | } 198 | 199 | static List findAllPropertyFilesInTree(Path sourceDirectory) { 200 | List configTreePaths = new ArrayList<>(); 201 | if (!CONFIG_TREE_CACHE.containsKey(sourceDirectory)) { 202 | try { 203 | configTreePaths = Files.find(sourceDirectory, MAX_DEPTH, ConfigurationUtils::isPropertyFile, FileVisitOption.FOLLOW_LINKS).collect(Collectors.toList()); 204 | } catch (IOException e) { 205 | log.error("can not find property sources for dynamic config: {}", sourceDirectory, e); 206 | } 207 | CONFIG_TREE_CACHE.put(sourceDirectory, configTreePaths); 208 | } else { 209 | configTreePaths = CONFIG_TREE_CACHE.get(sourceDirectory); 210 | } 211 | return configTreePaths; 212 | } 213 | 214 | static PropertySource addConfigPropPrefix(OriginTrackedMapPropertySource prop, String prefix) { 215 | Map src = prop.getSource(); 216 | Map map = new HashMap<>(src.size()); 217 | src.forEach((key, val) -> { 218 | map.put(prefix + '.' + key, val); 219 | }); 220 | return new OriginTrackedMapPropertySource(prop.getName(), map, true); 221 | } 222 | 223 | static boolean isPropertyFile(Path path, BasicFileAttributes attributes) { 224 | String extension = ConfigurationUtils.getFileExtension(path.toString()); 225 | return !hasHiddenPathElement(path) && 226 | (attributes.isRegularFile() || attributes.isSymbolicLink()) && 227 | VALID_EXTENSION.contains(extension); 228 | } 229 | 230 | static String getPropertyPrefix(Path rootDirPath, Path fullPath) { 231 | Path relativePath = rootDirPath.relativize(fullPath); 232 | int nameCount = relativePath.getNameCount(); 233 | if (nameCount == 1) { 234 | return relativePath.toString(); 235 | } 236 | StringBuilder name = new StringBuilder(); 237 | for (int i = 0; i < nameCount; i++) { 238 | name.append((i != 0) ? "." : ""); 239 | String subPath = relativePath.getName(i).toString(); 240 | if (i == nameCount - 1) { 241 | // remove file extension 242 | subPath = subPath.substring(0, subPath.lastIndexOf(".")); 243 | } 244 | name.append(subPath); 245 | } 246 | return toKebabCase(name.toString()); 247 | } 248 | 249 | static String toKebabCase(String input) { 250 | if (!StringUtils.hasText(input)) { 251 | return null; 252 | } 253 | int length = input.length(); 254 | StringBuilder result = new StringBuilder(length * 2); 255 | int resultLength = 0; 256 | boolean wasPrevTranslated = false; 257 | for (int i = 0; i < length; i++) { 258 | char c = input.charAt(i); 259 | if (i > 0 && c == '_') { 260 | result.append('-'); 261 | resultLength++; 262 | continue; 263 | } 264 | if (i > 0 || c != '-') { 265 | if (Character.isUpperCase(c)) { 266 | if (!wasPrevTranslated && resultLength > 0 && result.charAt(resultLength - 1) != '-') { 267 | result.append('-'); 268 | resultLength++; 269 | } 270 | c = Character.toLowerCase(c); 271 | wasPrevTranslated = true; 272 | } else { 273 | wasPrevTranslated = false; 274 | } 275 | result.append(c); 276 | resultLength++; 277 | } 278 | } 279 | return resultLength > 0 ? result.toString() : input; 280 | } 281 | 282 | private static boolean hasHiddenPathElement(Path path) { 283 | for (Path value : path) { 284 | if (value.toString().startsWith("..")) { 285 | return true; 286 | } 287 | } 288 | return false; 289 | } 290 | 291 | 292 | private static void appendToWatchableTargets(FileSystemWatchTarget.WatchTargetType type, String target, List filterFiles, Path rootDir) { 293 | FileSystemWatchTarget finalTarget = new FileSystemWatchTarget(type, target); 294 | finalTarget.setRootDir(rootDir); 295 | if (filterFiles != null) { 296 | finalTarget.setFilterFiles(filterFiles.stream() 297 | .map(p -> trimRelativePathAndReplaceBackSlash(p.getFileName().toString())) 298 | .collect(Collectors.toList())); 299 | } 300 | String dirKey = finalTarget.getNormalizedDir(); 301 | if (WATCHABLE_TARGETS.containsKey(dirKey)) { 302 | FileSystemWatchTarget existing = WATCHABLE_TARGETS.get(dirKey); 303 | if (existing.getType() != finalTarget.getType()) { 304 | log.error("config.import/config.location has overlap, dynamic config validation failed: {}", dirKey); 305 | } else { 306 | existing.getFilterFiles().addAll(finalTarget.getFilterFiles()); 307 | } 308 | } else { 309 | WATCHABLE_TARGETS.put(dirKey, finalTarget); 310 | } 311 | } 312 | 313 | private static void appendToWatchableTargets(FileSystemWatchTarget.WatchTargetType type, String target) { 314 | appendToWatchableTargets(type, target, null, null); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/main/java/top/code2life/config/DynamicConfigPropertiesWatcher.java: -------------------------------------------------------------------------------- 1 | package top.code2life.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.beans.factory.DisposableBean; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; 6 | import org.springframework.boot.env.OriginTrackedMapPropertySource; 7 | import org.springframework.boot.env.PropertySourceLoader; 8 | import org.springframework.boot.origin.OriginTrackedValue; 9 | import org.springframework.context.ApplicationEventPublisher; 10 | import org.springframework.core.env.MutablePropertySources; 11 | import org.springframework.core.env.PropertySource; 12 | import org.springframework.core.env.StandardEnvironment; 13 | import org.springframework.core.io.FileSystemResource; 14 | import org.springframework.core.io.support.SpringFactoriesLoader; 15 | 16 | import javax.annotation.PostConstruct; 17 | import java.io.File; 18 | import java.io.IOException; 19 | import java.nio.file.*; 20 | import java.util.*; 21 | import java.util.concurrent.Executors; 22 | import java.util.concurrent.TimeUnit; 23 | import java.util.stream.Stream; 24 | 25 | import static top.code2life.config.ConfigurationUtils.*; 26 | 27 | /** 28 | * Enhance PropertySource when spring.config.location is specified, it will start directory-watch, 29 | * listening any changes on configuration files, then publish ConfigurationChangedEvent. 30 | * Support config import feature since Spring Boot 2.4, check following link for further info: 31 | * https://docs.spring.io/spring-boot/docs/2.7.3/reference/htmlsingle/#features.external-config.files.configtree 32 | * 33 | * @author Code2Life 34 | * @see ConfigurationChangedEvent 35 | */ 36 | @Slf4j 37 | @ConditionalOnExpression("T(top.code2life.config.ConfigurationUtils).hasWatchableConf('${spring.config.location:}', '${spring.config.import:}')") 38 | public class DynamicConfigPropertiesWatcher implements DisposableBean { 39 | 40 | static final Map WATCHABLE_TARGETS = new HashMap<>(4); 41 | 42 | private static final long SYMBOL_LINK_POLLING_INTERVAL = 5000; 43 | private static final long NORMAL_FILE_POLLING_INTERVAL = 90000; 44 | 45 | private static final String FILE_COLON_SYMBOL = "file:"; 46 | 47 | /** 48 | * Kubernetes will inject ..data when mounting configMap or secret, it's not watchable symbol link 49 | */ 50 | private static final String HIDDEN_SYMBOL_LINK_DIR = "..data"; 51 | 52 | private static final String WATCH_THREAD = "config-watcher"; 53 | private static final String POLLING_THREAD = "config-watcher-polling"; 54 | private static final String FILE_SOURCE_CONFIGURATION_PATTERN = "^.*Config\\sresource.*file.*$"; 55 | private static final String FILE_SOURCE_CONFIGURATION_PATTERN_LEGACY = "^.+Config:\\s\\[file:.*$"; 56 | private static final Map PROPERTY_SOURCE_META_MAP = new HashMap<>(8); 57 | 58 | private final StandardEnvironment env; 59 | private final ApplicationEventPublisher eventPublisher; 60 | private final List propertyLoaders; 61 | 62 | private final List watchServices = new ArrayList<>(2); 63 | private long symbolicLinkModifiedTime = 0; 64 | 65 | DynamicConfigPropertiesWatcher(StandardEnvironment env, ApplicationEventPublisher eventPublisher) { 66 | this.env = env; 67 | this.eventPublisher = eventPublisher; 68 | this.propertyLoaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, 69 | getClass().getClassLoader()); 70 | } 71 | 72 | @Override 73 | public void destroy() { 74 | closeConfigDirectoryWatch(); 75 | } 76 | 77 | /** 78 | * Watch config directory after initializing, using WatchService API 79 | */ 80 | @PostConstruct 81 | @SuppressWarnings("AlibabaThreadPoolCreation") 82 | public void watchConfigDirectory() { 83 | MutablePropertySources propertySources = env.getPropertySources(); 84 | for (PropertySource ps : propertySources) { 85 | boolean isFilePropSource = ps.getName().matches(FILE_SOURCE_CONFIGURATION_PATTERN_LEGACY) || ps.getName().matches(FILE_SOURCE_CONFIGURATION_PATTERN); 86 | if (isFilePropSource) { 87 | normalizeAndRecordPropSource(ps); 88 | } 89 | } 90 | if (WATCHABLE_TARGETS.size() > 32) { 91 | log.error("too many watch targets of dynamic config, skipped."); 92 | } else { 93 | int counter = 0; 94 | for (FileSystemWatchTarget target : WATCHABLE_TARGETS.values()) { 95 | int threadId = counter; 96 | Executors.newSingleThreadExecutor(r -> new Thread(r, WATCH_THREAD + "-" + threadId)).submit(() -> this.startWatchDir(target)); 97 | counter++; 98 | } 99 | } 100 | } 101 | 102 | private void normalizeAndRecordPropSource(PropertySource ps) { 103 | String name = ps.getName(); 104 | int beginIndex = name.indexOf("[") + 1; 105 | int endIndex = name.indexOf("]"); 106 | if (beginIndex < 1 && endIndex < 1) { 107 | log.warn("unrecognized config location, property source name is: {}", name); 108 | } 109 | String pathStr = name.substring(beginIndex, endIndex); 110 | if (pathStr.contains(FILE_COLON_SYMBOL)) { 111 | pathStr = pathStr.replace(FILE_COLON_SYMBOL, ""); 112 | } 113 | PROPERTY_SOURCE_META_MAP.put(trimRelativePathAndReplaceBackSlash(pathStr), new PropertySourceMeta(ps, Paths.get(pathStr), 0L)); 114 | log.debug("configuration file found: {}", pathStr); 115 | } 116 | 117 | @SuppressWarnings("BusyWait") 118 | private void startWatchDir(FileSystemWatchTarget target) { 119 | try { 120 | String configLocation = target.getNormalizedDir(); 121 | List filterFiles = target.getFilterFiles(); 122 | log.info("start watching configuration directory: {}", configLocation); 123 | WatchService watchService = FileSystems.getDefault().newWatchService(); 124 | watchServices.add(watchService); 125 | Paths.get(configLocation).register(watchService, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE); 126 | checkChangesWithPeriod(); 127 | WatchKey key; 128 | while ((key = watchService.take()) != null) { 129 | // avoid receiving two ENTRY_MODIFY events: file modified and timestamp updated 130 | Thread.sleep(50); 131 | for (WatchEvent event : key.pollEvents()) { 132 | Path path = (Path) event.context(); 133 | String confPath = path.toString(); 134 | if (filterFiles == null) { 135 | reloadChangedFile(target, confPath, false); 136 | } else { 137 | if (filterFiles.contains(confPath)) { 138 | reloadChangedFile(target, confPath, false); 139 | } else { 140 | log.debug("changed path {} is not watched file, skipped.", confPath); 141 | } 142 | } 143 | } 144 | key.reset(); 145 | } 146 | log.warn("config directory watch stopped unexpectedly, dynamic configuration won't take effect."); 147 | } catch (ClosedWatchServiceException cse) { 148 | log.info("configuration watcher has been stopped."); 149 | } catch (Exception ex) { 150 | log.error("failed to watch config directory: ", ex); 151 | } 152 | } 153 | 154 | 155 | private void checkChangesWithPeriod() throws IOException { 156 | for (FileSystemWatchTarget target : WATCHABLE_TARGETS.values()) { 157 | String configLocation = target.getNormalizedDir(); 158 | Path symLinkPath = Paths.get(configLocation, HIDDEN_SYMBOL_LINK_DIR); 159 | boolean hasDotDataLinkFile = new File(configLocation, HIDDEN_SYMBOL_LINK_DIR).exists(); 160 | if (hasDotDataLinkFile) { 161 | log.info("ConfigMap/Secret mode detected, will polling symbolic link instead."); 162 | symbolicLinkModifiedTime = Files.getLastModifiedTime(symLinkPath, LinkOption.NOFOLLOW_LINKS).toMillis(); 163 | startFixedRateCheckThread(() -> checkSymbolicLink(target), SYMBOL_LINK_POLLING_INTERVAL); 164 | } else { 165 | // longer check for all config files, make up mechanism if WatchService doesn't work 166 | startFixedRateCheckThread(() -> reloadAllConfigFiles(target), NORMAL_FILE_POLLING_INTERVAL); 167 | } 168 | } 169 | } 170 | 171 | @SuppressWarnings("AlibabaThreadPoolCreation") 172 | private void startFixedRateCheckThread(Runnable cmd, long interval) { 173 | Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, POLLING_THREAD)) 174 | .scheduleWithFixedDelay(cmd, interval, interval, TimeUnit.MILLISECONDS); 175 | } 176 | 177 | private void checkSymbolicLink(FileSystemWatchTarget target) { 178 | try { 179 | Path symLinkPath = Paths.get(target.getNormalizedDir(), HIDDEN_SYMBOL_LINK_DIR); 180 | long tmp = Files.getLastModifiedTime(symLinkPath, LinkOption.NOFOLLOW_LINKS).toMillis(); 181 | if (tmp != symbolicLinkModifiedTime) { 182 | reloadAllConfigFiles(target, true); 183 | symbolicLinkModifiedTime = tmp; 184 | } 185 | } catch (IOException ex) { 186 | log.warn("could not check symbolic link of config dir: {}", ex.getMessage()); 187 | } 188 | } 189 | 190 | private void reloadAllConfigFiles(FileSystemWatchTarget target) { 191 | reloadAllConfigFiles(target, false); 192 | } 193 | 194 | private void reloadAllConfigFiles(FileSystemWatchTarget target, boolean forceReload) { 195 | try (Stream paths = Files.walk(Paths.get(target.getNormalizedDir()))) { 196 | paths.filter(path -> !Files.isDirectory(path)).forEach((path) -> { 197 | String rawPath = path.toString(); 198 | if (target.getFilterFiles() != null) { 199 | if (target.getFilterFiles().contains(rawPath)) { 200 | reloadChangedFile(target, rawPath, forceReload); 201 | } 202 | } else { 203 | reloadChangedFile(target, rawPath, forceReload); 204 | } 205 | }); 206 | } catch (IOException e) { 207 | log.warn("can not walk through config directory: {}", e.getMessage()); 208 | } 209 | } 210 | 211 | private void reloadChangedFile(FileSystemWatchTarget target, String rawPath, boolean forceReload) { 212 | String fullPathStr = normalizePath(rawPath, target.getNormalizedDir()); 213 | Path path = Paths.get(fullPathStr); 214 | if (HIDDEN_SYMBOL_LINK_DIR.equals(path.getFileName().toString())) { 215 | return; 216 | } 217 | try { 218 | PropertySourceMeta propertySourceMeta = PROPERTY_SOURCE_META_MAP.get(fullPathStr); 219 | if (propertySourceMeta == null) { 220 | // also try abs path, in case of the configTree case 221 | String absolutePath = trimRelativePathAndReplaceBackSlash(new File(fullPathStr).getAbsolutePath()); 222 | propertySourceMeta = PROPERTY_SOURCE_META_MAP.get(absolutePath); 223 | if (propertySourceMeta == null) { 224 | log.debug("changed file at config location is not recognized: {}", fullPathStr); 225 | return; 226 | } 227 | } 228 | long currentModTs = Files.getLastModifiedTime(path).toMillis(); 229 | long mdt = propertySourceMeta.getLastModifyTime(); 230 | if (forceReload || mdt != currentModTs) { 231 | doReloadConfigFile(target, propertySourceMeta, fullPathStr, currentModTs); 232 | } 233 | } catch (Exception ex) { 234 | log.error("reload configuration file {} failed: ", fullPathStr, ex); 235 | } 236 | } 237 | 238 | private void doReloadConfigFile(FileSystemWatchTarget target, PropertySourceMeta propertySourceMeta, String path, long modifyTime) throws IOException { 239 | log.info("dynamic config file has been changed: {}", path); 240 | String extension = ConfigurationUtils.getFileExtension(path); 241 | for (PropertySourceLoader loader : propertyLoaders) { 242 | if (Arrays.asList(loader.getFileExtensions()).contains(extension)) { 243 | // use this loader to load config resource 244 | loadPropertiesAndPublishEvent(target, propertySourceMeta, loader, path, modifyTime); 245 | break; 246 | } 247 | } 248 | } 249 | 250 | @SuppressWarnings("unchecked") 251 | private void loadPropertiesAndPublishEvent(FileSystemWatchTarget target, PropertySourceMeta propertySourceMeta, PropertySourceLoader loader, String path, long modifyTime) throws IOException { 252 | FileSystemResource resource = new FileSystemResource(path); 253 | String propertySourceName = propertySourceMeta.getPropertySource().getName(); 254 | List> newPropsList = loader.load(propertySourceName, resource); 255 | if (newPropsList.size() < 1) { 256 | log.warn("properties not loaded after config changed: {}", path); 257 | return; 258 | } 259 | PropertySource previous = env.getPropertySources().get(propertySourceName); 260 | PropertySource newProps = newPropsList.get(0); 261 | if (previous == null) { 262 | log.warn("previous property source can not be found, skipped."); 263 | return; 264 | } 265 | if (target.getType() == FileSystemWatchTarget.WatchTargetType.CONFIG_IMPORT_TREE) { 266 | // need add the key prefix back 267 | String prefix = getPropertyPrefix(target.getRootDir(), Paths.get(resource.getPath())); 268 | newProps = addConfigPropPrefix((OriginTrackedMapPropertySource) newPropsList.get(0), prefix); 269 | } 270 | Map diff = ConfigurationChangedEvent.getPropertyDiff( 271 | (Map) previous.getSource(), 272 | (Map) newProps.getSource() 273 | ); 274 | if (diff.size() == 0) { 275 | log.info("config file has been changed but no actual value changed, dynamic config event skipped."); 276 | return; 277 | } 278 | ConfigurationChangedEvent event = new ConfigurationChangedEvent(path, previous, newProps, diff); 279 | env.getPropertySources().replace(propertySourceName, newProps); 280 | propertySourceMeta.setLastModifyTime(modifyTime); 281 | eventPublisher.publishEvent(event); 282 | } 283 | 284 | private void closeConfigDirectoryWatch() { 285 | if (watchServices.size() > 0) { 286 | try { 287 | for (WatchService w : watchServices) { 288 | w.close(); 289 | } 290 | log.info("config properties watcher bean is destroying, WatchService stopped."); 291 | } catch (IOException e) { 292 | log.warn("can not close config directory watcher. ", e); 293 | } 294 | } 295 | } 296 | } 297 | --------------------------------------------------------------------------------