Skip to content

Commit b4c685c

Browse files
committed
Reset bean to defaults before rebinding values
Signed-off-by: Shannon Pamperl <shanman190@gmail.com>
1 parent 56d7730 commit b4c685c

3 files changed

Lines changed: 219 additions & 2 deletions

File tree

spring-cloud-context/src/main/java/org/springframework/cloud/context/properties/ConfigurationPropertiesRebinder.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616

1717
package org.springframework.cloud.context.properties;
1818

19+
import java.beans.PropertyDescriptor;
20+
import java.util.Collection;
1921
import java.util.HashSet;
2022
import java.util.Map;
2123
import java.util.Set;
2224
import java.util.concurrent.ConcurrentHashMap;
2325

2426
import org.springframework.aop.scope.ScopedProxyUtils;
2527
import org.springframework.aop.support.AopUtils;
28+
import org.springframework.beans.BeanUtils;
29+
import org.springframework.beans.BeanWrapper;
30+
import org.springframework.beans.BeanWrapperImpl;
2631
import org.springframework.beans.BeansException;
2732
import org.springframework.beans.factory.BeanFactoryUtils;
2833
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -136,6 +141,7 @@ private boolean rebind(String name, ApplicationContext appContext) {
136141
return false; // ignore
137142
}
138143
appContext.getAutowireCapableBeanFactory().destroyBean(bean);
144+
resetBeanToDefaults(bean);
139145
appContext.getAutowireCapableBeanFactory().initializeBean(bean, name);
140146
return true;
141147
}
@@ -151,6 +157,41 @@ private boolean rebind(String name, ApplicationContext appContext) {
151157
return false;
152158
}
153159

160+
/**
161+
* Reset bean properties to their class-level defaults so that removed properties do
162+
* not retain stale values after rebinding.
163+
*/
164+
private void resetBeanToDefaults(Object bean) {
165+
try {
166+
Object freshInstance = BeanUtils.instantiateClass(bean.getClass());
167+
BeanWrapper target = new BeanWrapperImpl(bean);
168+
BeanWrapper defaults = new BeanWrapperImpl(freshInstance);
169+
for (PropertyDescriptor pd : target.getPropertyDescriptors()) {
170+
String propertyName = pd.getName();
171+
if ("class".equals(propertyName)) {
172+
continue;
173+
}
174+
if (target.isWritableProperty(propertyName) && defaults.isReadableProperty(propertyName)) {
175+
Object defaultValue = defaults.getPropertyValue(propertyName);
176+
target.setPropertyValue(propertyName, defaultValue);
177+
}
178+
else if (target.isReadableProperty(propertyName)) {
179+
Object value = target.getPropertyValue(propertyName);
180+
if (value instanceof Collection<?> collection) {
181+
collection.clear();
182+
}
183+
else if (value instanceof Map<?, ?> map) {
184+
map.clear();
185+
}
186+
}
187+
}
188+
}
189+
catch (Exception ex) {
190+
// If we cannot create a default instance (e.g. no default constructor),
191+
// skip the reset and rely on the existing rebind behavior.
192+
}
193+
}
194+
154195
@ManagedAttribute
155196
public Set<String> getNeverRefreshable() {
156197
String neverRefresh = this.applicationContext.getEnvironment()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2012-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.context.properties;
18+
19+
import java.util.Map;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.beans.factory.annotation.Autowired;
24+
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
25+
import org.springframework.boot.context.properties.ConfigurationProperties;
26+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
27+
import org.springframework.boot.test.context.SpringBootTest;
28+
import org.springframework.boot.test.util.TestPropertyValues;
29+
import org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration;
30+
import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration;
31+
import org.springframework.cloud.context.properties.ConfigurationPropertiesRebinderClearIntegrationTests.TestConfiguration;
32+
import org.springframework.context.ApplicationContext;
33+
import org.springframework.context.annotation.Bean;
34+
import org.springframework.context.annotation.Configuration;
35+
import org.springframework.context.annotation.Import;
36+
import org.springframework.core.env.ConfigurableEnvironment;
37+
import org.springframework.core.env.PropertySource;
38+
import org.springframework.test.annotation.DirtiesContext;
39+
40+
import static org.assertj.core.api.BDDAssertions.then;
41+
42+
@SpringBootTest(classes = TestConfiguration.class, properties = { "test.message=Hello", "test.count=5" })
43+
public class ConfigurationPropertiesRebinderClearIntegrationTests {
44+
45+
@Autowired
46+
private TestProperties properties;
47+
48+
@Autowired
49+
private ConfigurationPropertiesRebinder rebinder;
50+
51+
@Autowired
52+
private ConfigurableEnvironment environment;
53+
54+
@Test
55+
@DirtiesContext
56+
public void testPropertyClearedToNull() {
57+
then(this.properties.getMessage()).isEqualTo("Hello");
58+
// Remove the property from the environment
59+
Map<String, Object> map = findTestProperties();
60+
map.remove("test.message");
61+
// Rebind
62+
this.rebinder.rebind();
63+
// The property should be cleared to its default (null)
64+
then(this.properties.getMessage()).isNull();
65+
}
66+
67+
@Test
68+
@DirtiesContext
69+
public void testPrimitivePropertyClearedToDefault() {
70+
then(this.properties.getCount()).isEqualTo(5);
71+
// Remove the property from the environment
72+
Map<String, Object> map = findTestProperties();
73+
map.remove("test.count");
74+
// Rebind
75+
this.rebinder.rebind();
76+
// The primitive property should be cleared to its class-level default (0)
77+
then(this.properties.getCount()).isEqualTo(0);
78+
}
79+
80+
@Test
81+
@DirtiesContext
82+
public void testPropertyWithFieldDefaultRestoredOnRemoval() {
83+
then(this.properties.getName()).isEqualTo("default-name");
84+
// Set a value
85+
TestPropertyValues.of("test.name=custom").applyTo(this.environment);
86+
this.rebinder.rebind();
87+
then(this.properties.getName()).isEqualTo("custom");
88+
// Remove the property
89+
Map<String, Object> map = findTestProperties();
90+
map.remove("test.name");
91+
this.rebinder.rebind();
92+
// Should revert to field initializer default
93+
then(this.properties.getName()).isEqualTo("default-name");
94+
}
95+
96+
@Test
97+
@DirtiesContext
98+
public void testPropertyChangedToNewValue() {
99+
then(this.properties.getMessage()).isEqualTo("Hello");
100+
// Change the property
101+
TestPropertyValues.of("test.message=World").applyTo(this.environment);
102+
this.rebinder.rebind();
103+
then(this.properties.getMessage()).isEqualTo("World");
104+
}
105+
106+
private Map<String, Object> findTestProperties() {
107+
for (PropertySource<?> source : this.environment.getPropertySources()) {
108+
if (source.getName().toLowerCase().contains("test")) {
109+
@SuppressWarnings("unchecked")
110+
Map<String, Object> map = (Map<String, Object>) source.getSource();
111+
return map;
112+
}
113+
}
114+
throw new IllegalStateException("Could not find test property source");
115+
}
116+
117+
@Configuration(proxyBeanMethods = false)
118+
@EnableConfigurationProperties
119+
@Import({ RefreshConfiguration.RebinderConfiguration.class, PropertyPlaceholderAutoConfiguration.class })
120+
protected static class TestConfiguration {
121+
122+
@Bean
123+
protected TestProperties testProperties() {
124+
return new TestProperties();
125+
}
126+
127+
}
128+
129+
// Hack out a protected inner class for testing
130+
protected static class RefreshConfiguration extends RefreshAutoConfiguration {
131+
132+
@Configuration(proxyBeanMethods = false)
133+
protected static class RebinderConfiguration extends ConfigurationPropertiesRebinderAutoConfiguration {
134+
135+
public RebinderConfiguration(ApplicationContext context) {
136+
super(context);
137+
}
138+
139+
}
140+
141+
}
142+
143+
@ConfigurationProperties("test")
144+
protected static class TestProperties {
145+
146+
private String message;
147+
148+
private int count;
149+
150+
private String name = "default-name";
151+
152+
public String getMessage() {
153+
return this.message;
154+
}
155+
156+
public void setMessage(String message) {
157+
this.message = message;
158+
}
159+
160+
public int getCount() {
161+
return this.count;
162+
}
163+
164+
public void setCount(int count) {
165+
this.count = count;
166+
}
167+
168+
public String getName() {
169+
return this.name;
170+
}
171+
172+
public void setName(String name) {
173+
this.name = name;
174+
}
175+
176+
}
177+
178+
}

spring-cloud-context/src/test/java/org/springframework/cloud/context/properties/ConfigurationPropertiesRebinderListIntegrationTests.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import java.util.List;
2020
import java.util.Map;
2121

22-
import org.junit.jupiter.api.Disabled;
2322
import org.junit.jupiter.api.Test;
2423

2524
import org.springframework.beans.factory.InitializingBean;
@@ -65,7 +64,6 @@ public void testAppendProperties() {
6564

6665
@Test
6766
@DirtiesContext
68-
@Disabled("Can't rebind to list and re-initialize it (need refresh scope for this to work)")
6967
public void testReplaceProperties() {
7068
then(this.properties.getMessages()).containsOnly("one", "two");
7169
Map<String, Object> map = findTestProperties();

0 commit comments

Comments
 (0)