Skip to content

Commit e41c5a4

Browse files
committed
Require annotation signal for constructor binding
Update `@ConfigurationProperties` constructor binding support to only apply when a `@ConstructorBinding` annotation is present on either the type or the specific constructor to use. Prior to this commit we didn't have a good way to tell when constructor binding should be used vs regular autowiring. For convenience, an `@ImmutableConfigurationProperties` meta-annotation has also been added which is composed of `@ConfigurationProperties` and `@ConstructorBinding`. Closes gh-18469
1 parent ecf751e commit e41c5a4

24 files changed

+584
-143
lines changed

spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,7 @@ The example in the previous section can be rewritten in an immutable fashion as
852852
import org.springframework.boot.context.properties.ConfigurationProperties;
853853
import org.springframework.boot.context.properties.DefaultValue;
854854
855-
@ConfigurationProperties("acme")
855+
@ImmutableConfigurationProperties("acme")
856856
public class AcmeProperties {
857857
858858
private final boolean enabled;
@@ -881,6 +881,7 @@ The example in the previous section can be rewritten in an immutable fashion as
881881
882882
private final List<String> roles;
883883
884+
@ConstructorBinding
884885
public Security(String username, String password,
885886
@DefaultValue("USER") List<String> roles) {
886887
this.username = username;
@@ -899,11 +900,18 @@ The example in the previous section can be rewritten in an immutable fashion as
899900
}
900901
----
901902

902-
In this setup one, and only one constructor must be defined with the list of properties that you wish to bind and not other properties than the ones in the constructor are bound.
903+
In this setup, the `@ImmutableConfigurationProperties` annotation is used to indicate that constructor binding should be used.
904+
This means that the binder will expect to find a constructor with the parameters that you wish to have bound bind.
905+
906+
Nested classes that also require constructor binding (such as `Security` in the example above) should use the `@ConstructorBinding` annotation.
903907

904908
Default values can be specified using `@DefaultValue` and the same conversion service will be applied to coerce the `String` value to the target type of a missing property.
905909

906-
NOTE: To use constructor binding the class must not be annotated with `@Component` and must be enabled using `@EnableConfigurationProperties` or configuration property scanning instead.
910+
TIP: You can also use `@ConstructorBinding` on the actual constructor that should be bound.
911+
This is required if you have more than one constructor for your class.
912+
913+
NOTE: To use constructor binding the class must be enabled using `@EnableConfigurationProperties` or configuration property scanning.
914+
You cannot use constructor binding with beans that are created by the regular Spring mechanisms (e.g. `@Component` beans, beans created via `@Bean` methods or beans loaded using `@Import`)
907915

908916

909917

spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleProperties.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@
1616

1717
package org.springframework.boot.test.autoconfigure.web.client;
1818

19-
import org.springframework.boot.context.properties.ConfigurationProperties;
19+
import org.springframework.boot.context.properties.ImmutableConfigurationProperties;
2020
import org.springframework.boot.context.properties.bind.DefaultValue;
2121

2222
/**
23-
* Example {@link ConfigurationProperties} used to test the use of configuration
24-
* properties scan with sliced test.
23+
* Example {@link ImmutableConfigurationProperties @ImmutableConfigurationProperties} used
24+
* to test the use of configuration properties scan with sliced test.
2525
*
2626
* @author Stephane Nicoll
2727
*/
28-
@ConfigurationProperties("example")
28+
@ImmutableConfigurationProperties("example")
2929
public class ExampleProperties {
3030

3131
private final String name;

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationProperties.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@
2929
* {@code @Bean} method in a {@code @Configuration} class if you want to bind and validate
3030
* some external Properties (e.g. from a .properties file).
3131
* <p>
32+
* Binding can is either performed by calling setters on the annotated class or, if
33+
* {@link ConstructorBinding @ConstructorBinding} is in use, by binding to the constructor
34+
* parameters.
35+
* <p>
3236
* Note that contrary to {@code @Value}, SpEL expressions are not evaluated since property
3337
* values are externalized.
3438
*
3539
* @author Dave Syer
3640
* @since 1.0.0
41+
* @see ConfigurationPropertiesScan
42+
* @see ConstructorBinding
43+
* @see ImmutableConfigurationProperties
3744
* @see ConfigurationPropertiesBindingPostProcessor
3845
* @see EnableConfigurationProperties
3946
*/

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java

Lines changed: 122 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
package org.springframework.boot.context.properties;
1818

1919
import java.lang.annotation.Annotation;
20+
import java.lang.reflect.Constructor;
2021
import java.lang.reflect.Method;
2122
import java.util.Iterator;
2223
import java.util.LinkedHashMap;
2324
import java.util.Map;
2425

2526
import org.springframework.aop.support.AopUtils;
27+
import org.springframework.beans.BeanUtils;
2628
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
2729
import org.springframework.beans.factory.config.BeanDefinition;
2830
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
@@ -32,8 +34,11 @@
3234
import org.springframework.context.ApplicationContext;
3335
import org.springframework.context.ConfigurableApplicationContext;
3436
import org.springframework.context.annotation.Bean;
37+
import org.springframework.core.KotlinDetector;
3538
import org.springframework.core.ResolvableType;
36-
import org.springframework.core.annotation.AnnotationUtils;
39+
import org.springframework.core.annotation.MergedAnnotation;
40+
import org.springframework.core.annotation.MergedAnnotations;
41+
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
3742
import org.springframework.util.Assert;
3843
import org.springframework.validation.annotation.Validated;
3944

@@ -56,12 +61,15 @@ public final class ConfigurationPropertiesBean {
5661

5762
private final Bindable<?> bindTarget;
5863

64+
private final BindMethod bindMethod;
65+
5966
private ConfigurationPropertiesBean(String name, Object instance, ConfigurationProperties annotation,
60-
Bindable<?> bindTarget) {
67+
Bindable<?> bindTarget, BindMethod bindMethod) {
6168
this.name = name;
6269
this.instance = instance;
6370
this.annotation = annotation;
6471
this.bindTarget = bindTarget;
72+
this.bindMethod = bindMethod;
6573
}
6674

6775
/**
@@ -80,6 +88,22 @@ public Object getInstance() {
8088
return this.instance;
8189
}
8290

91+
/**
92+
* Return the bean type.
93+
* @return the bean type
94+
*/
95+
Class<?> getType() {
96+
return this.bindTarget.getType().resolve();
97+
}
98+
99+
/**
100+
* Return the property binding method that was used for the bean.
101+
* @return the bind type
102+
*/
103+
public BindMethod getBindMethod() {
104+
return this.bindMethod;
105+
}
106+
83107
/**
84108
* Return the {@link ConfigurationProperties} annotation for the bean. The annotation
85109
* may be defined on the bean itself or from the factory method that create the bean
@@ -152,18 +176,7 @@ private static Map<String, ConfigurationPropertiesBean> getAll(ConfigurableAppli
152176
*/
153177
public static ConfigurationPropertiesBean get(ApplicationContext applicationContext, Object bean, String beanName) {
154178
Method factoryMethod = findFactoryMethod(applicationContext, beanName);
155-
ConfigurationProperties annotation = getAnnotation(applicationContext, bean, beanName, factoryMethod,
156-
ConfigurationProperties.class);
157-
if (annotation == null) {
158-
return null;
159-
}
160-
ResolvableType type = (factoryMethod != null) ? ResolvableType.forMethodReturnType(factoryMethod)
161-
: ResolvableType.forClass(bean.getClass());
162-
Validated validated = getAnnotation(applicationContext, bean, beanName, factoryMethod, Validated.class);
163-
Annotation[] annotations = (validated != null) ? new Annotation[] { annotation, validated }
164-
: new Annotation[] { annotation };
165-
Bindable<?> bindTarget = Bindable.of(type).withAnnotations(annotations).withExistingValue(bean);
166-
return new ConfigurationPropertiesBean(beanName, bean, annotation, bindTarget);
179+
return create(beanName, bean, bean.getClass(), factoryMethod);
167180
}
168181

169182
private static Method findFactoryMethod(ApplicationContext applicationContext, String beanName) {
@@ -184,22 +197,105 @@ private static Method findFactoryMethod(ConfigurableApplicationContext applicati
184197
return null;
185198
}
186199

187-
private static <A extends Annotation> A getAnnotation(ApplicationContext applicationContext, Object bean,
188-
String beanName, Method factoryMethod, Class<A> annotationType) {
189-
if (factoryMethod != null) {
190-
A annotation = AnnotationUtils.findAnnotation(factoryMethod, annotationType);
191-
if (annotation != null) {
192-
return annotation;
200+
static ConfigurationPropertiesBean forValueObject(Class<?> beanClass, String beanName) {
201+
ConfigurationPropertiesBean propertiesBean = create(beanName, null, beanClass, null);
202+
Assert.state(propertiesBean != null && propertiesBean.getBindMethod() == BindMethod.VALUE_OBJECT,
203+
"Bean '" + beanName + "' is not a @ConfigurationProperties value object");
204+
return propertiesBean;
205+
}
206+
207+
private static ConfigurationPropertiesBean create(String name, Object instance, Class<?> type, Method factory) {
208+
ConfigurationProperties annotation = findAnnotation(instance, type, factory, ConfigurationProperties.class);
209+
if (annotation == null) {
210+
return null;
211+
}
212+
Validated validated = findAnnotation(instance, type, factory, Validated.class);
213+
Annotation[] annotations = (validated != null) ? new Annotation[] { annotation, validated }
214+
: new Annotation[] { annotation };
215+
Constructor<?> bindConstructor = BindMethod.findBindConstructor(type);
216+
BindMethod bindMethod = (bindConstructor != null) ? BindMethod.VALUE_OBJECT : BindMethod.forClass(type);
217+
ResolvableType bindType = (factory != null) ? ResolvableType.forMethodReturnType(factory)
218+
: ResolvableType.forClass(type);
219+
Bindable<Object> bindTarget = Bindable.of(bindType).withAnnotations(annotations)
220+
.withConstructorFilter(ConfigurationPropertiesBean::isBindableConstructor);
221+
if (instance != null) {
222+
bindTarget = bindTarget.withExistingValue(instance);
223+
}
224+
return new ConfigurationPropertiesBean(name, instance, annotation, bindTarget, bindMethod);
225+
}
226+
227+
private static <A extends Annotation> A findAnnotation(Object instance, Class<?> type, Method factory,
228+
Class<A> annotationType) {
229+
MergedAnnotation<A> annotation = MergedAnnotation.missing();
230+
if (factory != null) {
231+
annotation = MergedAnnotations.from(factory, SearchStrategy.TYPE_HIERARCHY).get(annotationType);
232+
}
233+
if (!annotation.isPresent()) {
234+
annotation = MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY).get(annotationType);
235+
}
236+
if (!annotation.isPresent() && AopUtils.isAopProxy(instance)) {
237+
annotation = MergedAnnotations.from(AopUtils.getTargetClass(instance), SearchStrategy.TYPE_HIERARCHY)
238+
.get(annotationType);
239+
}
240+
return annotation.isPresent() ? annotation.synthesize() : null;
241+
}
242+
243+
private static boolean isBindableConstructor(Constructor<?> constructor) {
244+
Class<?> declaringClass = constructor.getDeclaringClass();
245+
Constructor<?> bindConstructor = BindMethod.findBindConstructor(declaringClass);
246+
if (bindConstructor != null) {
247+
return bindConstructor.equals(constructor);
248+
}
249+
return BindMethod.forClass(declaringClass) == BindMethod.VALUE_OBJECT;
250+
}
251+
252+
/**
253+
* The binding method that use used for the bean.
254+
*/
255+
public enum BindMethod {
256+
257+
/**
258+
* Java Bean using getter/setter binding.
259+
*/
260+
JAVA_BEAN,
261+
262+
/**
263+
* Value object using constructor binding.
264+
*/
265+
VALUE_OBJECT;
266+
267+
static BindMethod forClass(Class<?> type) {
268+
if (MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY).isPresent(ConstructorBinding.class)
269+
|| findBindConstructor(type) != null) {
270+
return VALUE_OBJECT;
193271
}
272+
return JAVA_BEAN;
194273
}
195-
A annotation = AnnotationUtils.findAnnotation(bean.getClass(), annotationType);
196-
if (annotation != null) {
197-
return annotation;
274+
275+
static Constructor<?> findBindConstructor(Class<?> type) {
276+
if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinType(type)) {
277+
Constructor<?> constructor = BeanUtils.findPrimaryConstructor(type);
278+
if (constructor != null) {
279+
return findBindConstructor(type, constructor);
280+
}
281+
}
282+
return findBindConstructor(type, type.getDeclaredConstructors());
198283
}
199-
if (AopUtils.isAopProxy(bean)) {
200-
return AnnotationUtils.findAnnotation(AopUtils.getTargetClass(bean), annotationType);
284+
285+
private static Constructor<?> findBindConstructor(Class<?> type, Constructor<?>... candidates) {
286+
Constructor<?> constructor = null;
287+
for (Constructor<?> candidate : candidates) {
288+
if (MergedAnnotations.from(candidate).isPresent(ConstructorBinding.class)) {
289+
Assert.state(candidate.getParameterCount() > 0,
290+
type.getName() + " declares @ConstructorBinding on a no-args constructor");
291+
Assert.state(constructor == null,
292+
type.getName() + " has more than one @ConstructorBinding constructor");
293+
constructor = candidate;
294+
}
295+
}
296+
return constructor;
201297
}
202-
return null;
298+
203299
}
204300

205301
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrar.java

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,13 @@
1515
*/
1616
package org.springframework.boot.context.properties;
1717

18-
import java.lang.reflect.Constructor;
19-
20-
import org.springframework.beans.BeanUtils;
2118
import org.springframework.beans.factory.BeanFactory;
2219
import org.springframework.beans.factory.HierarchicalBeanFactory;
2320
import org.springframework.beans.factory.ListableBeanFactory;
2421
import org.springframework.beans.factory.config.BeanDefinition;
2522
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
2623
import org.springframework.beans.factory.support.GenericBeanDefinition;
27-
import org.springframework.core.KotlinDetector;
24+
import org.springframework.boot.context.properties.ConfigurationPropertiesBean.BindMethod;
2825
import org.springframework.core.annotation.MergedAnnotation;
2926
import org.springframework.core.annotation.MergedAnnotations;
3027
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
@@ -91,23 +88,12 @@ private void registerBeanDefinition(String beanName, Class<?> type,
9188
}
9289

9390
private BeanDefinition createBeanDefinition(String beanName, Class<?> type) {
94-
if (isValueObject(type)) {
91+
if (BindMethod.forClass(type) == BindMethod.VALUE_OBJECT) {
9592
return new ConfigurationPropertiesValueObjectBeanDefinition(this.beanFactory, beanName, type);
9693
}
9794
GenericBeanDefinition definition = new GenericBeanDefinition();
9895
definition.setBeanClass(type);
9996
return definition;
10097
}
10198

102-
private boolean isValueObject(Class<?> type) {
103-
if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinType(type)) {
104-
Constructor<?> primaryConstructor = BeanUtils.findPrimaryConstructor(type);
105-
if (primaryConstructor != null) {
106-
return primaryConstructor.getParameterCount() > 0;
107-
}
108-
}
109-
Constructor<?>[] constructors = type.getDeclaredConstructors();
110-
return constructors.length == 1 && constructors[0].getParameterCount() > 0;
111-
}
112-
11399
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBindException.java

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,41 +29,34 @@
2929
*/
3030
public class ConfigurationPropertiesBindException extends BeanCreationException {
3131

32-
private final Class<?> beanType;
33-
34-
private final ConfigurationProperties annotation;
32+
private final ConfigurationPropertiesBean bean;
3533

3634
ConfigurationPropertiesBindException(ConfigurationPropertiesBean bean, Exception cause) {
37-
this(bean.getName(), bean.getInstance().getClass(), bean.getAnnotation(), cause);
38-
}
39-
40-
ConfigurationPropertiesBindException(String beanName, Class<?> beanType, ConfigurationProperties annotation,
41-
Exception cause) {
42-
super(beanName, getMessage(beanType, annotation), cause);
43-
this.beanType = beanType;
44-
this.annotation = annotation;
35+
super(bean.getName(), getMessage(bean), cause);
36+
this.bean = bean;
4537
}
4638

4739
/**
4840
* Return the bean type that was being bound.
4941
* @return the bean type
5042
*/
5143
public Class<?> getBeanType() {
52-
return this.beanType;
44+
return this.bean.getType();
5345
}
5446

5547
/**
5648
* Return the configuration properties annotation that triggered the binding.
5749
* @return the configuration properties annotation
5850
*/
5951
public ConfigurationProperties getAnnotation() {
60-
return this.annotation;
52+
return this.bean.getAnnotation();
6153
}
6254

63-
private static String getMessage(Class<?> beanType, ConfigurationProperties annotation) {
55+
private static String getMessage(ConfigurationPropertiesBean bean) {
56+
ConfigurationProperties annotation = bean.getAnnotation();
6457
StringBuilder message = new StringBuilder();
6558
message.append("Could not bind properties to '");
66-
message.append(ClassUtils.getShortName(beanType)).append("' : ");
59+
message.append(ClassUtils.getShortName(bean.getType())).append("' : ");
6760
message.append("prefix=").append(annotation.prefix());
6861
message.append(", ignoreInvalidFields=").append(annotation.ignoreInvalidFields());
6962
message.append(", ignoreUnknownFields=").append(annotation.ignoreUnknownFields());

0 commit comments

Comments
 (0)