0. 阅读完本文你将会
- 使用 @ConfigurationProperties 从配置文件中获取属性 
- 自定义属性转换器 
- 了解 @ConfigurationProperties 与 @Value 的区别 
- 探究 @ConfigurationProperties 背后的源码运作 
1. 前言
我们使用 Spring 框架的时候,经常会从配置文件中获取配置属性,比如发送邮件的时候,需要获取收发件人以及邮箱服务器地址和端口号。
那么本文将会介绍如何获取配置属性这样的小知识点,并做一定的延申。
2. 基础
2.1 准备工作
我们先在 pom.xml 中添加以下依赖项:
1. spring-boot-starter-parent
 <parent>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-parent</artifactId>    <version>2.6.7</version>    <relativePath/></parent>
   复制代码
 
2. spring-boot-starter-validation
用来验证定义的属性。
 <dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
   复制代码
 2.2 定义配置类
官方文档建议我们最好将需要定义的属性分离出来,放在单独的 POJO 类里。
下面我们开始定义一个配置类:
 package com.jay.mydemo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.context.annotation.Configuration;
@Configuration@ConfigurationProperties(prefix = "mail")public class MailConfigProperties {
    private String hostName;    private int port;    private String from;
    // default getters and setters}
   复制代码
 
我们使用了@Configuration,这样 Spring 就会在应用上下文中创建一个对应的Spring Bean。如果我们不使用这个注解,也可以在 Application 类中添加以下注解。
 @EnableConfigurationProperties(MailConfigProperties.class)
   复制代码
 
@ConfigurationProperties中定义了前缀mail。Spring 会自动将 POJO 类与属性文件中前缀为 mail 的属性绑定。
Spring 对绑定的属性比较宽松,比如以下的属性名都会绑到hostName上:
 mail.hostNamemail.hostnamemail.host_namemail.host-namemail.HOST_NAME
   复制代码
 
我们可以使用下面这个简单的属性文件来对应 POJO 类:
 mail.hostname=smtp.163.commail.port=25mail.from=jay.xu@example.com
   复制代码
 
当然,从Spring Boot 2.2开始,我们已经不再需要使用@Component、@Configuration来注释配置类,同样也不需要@EnableConfigurationProperties。
Configuration properties scanning was enabled by default in Spring Boot 2.2.0 but as of Spring Boot 2.2.1 you must opt-in using @ConfigurationPropertiesScan.
因为 Spring 会通过类路径的扫描自动注册@ConfigurationProperties类。
你需要做的是在 Application 类中使用@ConfigurationPropertiesScan注解来扫描配置类的包地址,如:
 @ConfigurationPropertiesScan("com.jay.mydemo.config")
   复制代码
 2.3 属性嵌套
我们创建一个MailCrendential类:
 package com.jay.mydemo.config;
public class MailCrendential {
    private String username;
    private String password;
    // default getters and setters}
   复制代码
 
然后再更新MailConfigProperties类,在其中我们加入一个 List、一个 Map 以及MailCrendential。
 private List<String> recipients;private Map<String, String> headers;private MailCrendential mailCrendential;
   复制代码
 
与此同时,我们更新application.properties:
 # mail props## basicmail.hostname=smtp.163.commail.port=25mail.from=jay.xu@example.com## recipient listmail.recipients[0]=recipients0@example.commail.recipients[1]=recipients1@example.com
## header mapmail.headers.redelivery=truemail.headers.secure=true
## objectmail.mailCrendential.username=jayxumail.mailCrendential.password=password
   复制代码
 
做到这一步,我们可以尝试创建一个测试类来打印看看配置是否无误。
 package com.jay.mydemo;
import com.jay.mydemo.config.MailConfigProperties;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.context.properties.ConfigurationPropertiesScan;import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTestclass MydemoApplicationTests {
  @Autowired  MailConfigProperties mailConfigProperties;
  @Test  void testMailConfigProperties() {    System.out.println(mailConfigProperties);  }
}
   复制代码
 
输出如下:
 MailConfigProperties{hostName='smtp.163.com', port=25, from='jay.xu@example.com', recipients=[recipients0@example.com, recipients1@example.com], headers={redelivery=true, secure=true}, mailCrendential=MailCrendential{username='jayxu', password='password'}}
   复制代码
 2.4 使用在@Bean方法上
除了上面的用法之外,当我们用到第三方类或者无法直接改动原有的类,我们可以将@ConfigurationProperties用在@Bean注解的方法上。
请看这个例子,先创建一个"第三方类":
 package com.jay.mydemo.config;
public class ThirdPartyItem {
    private String name;
    private String description;
    // default getters and setters}
   复制代码
 
然后我们创建一个配置类用来存放这些“第三方类”的 Bean:
 package com.jay.mydemo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class ConfigProperties {
    @Bean    @ConfigurationProperties(prefix = "thirdpartyitem")    public ThirdPartyItem thirdPartyItem(){        return new ThirdPartyItem();    }}
   复制代码
 
这样我们就可以在无法修改第三方类的情况下依然可以将其作为配置 Bean 使用。
3. 进阶
3.1 属性验证
我们可以在配置类中加入属性验证。
 @Configuration@ConfigurationProperties(prefix = "mail")@Validatedpublic class MailConfigProperties {
    @NotBlank    private String hostName;
    @Min(1048)    @Max(6475)    private int port;
    @Pattern(regexp = "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,6}$")    private String from;    // . . .}
   复制代码
 
这样的验证使得代码更加简洁,如果验证失败,那么应用就会启动失败,会报下面的错:
 ***************************APPLICATION FAILED TO START***************************
Description:
Binding to target org.springframework.boot.context.properties.bind.BindException: Failed to bind properties under 'mail' to com.jay.mydemo.config.MailConfigProperties$$EnhancerBySpringCGLIB$$89aa73c0 failed:
    Property: mail.hostName    Value:     Origin: class path resource [application.properties] - 4:0    Reason: 不能为空
   复制代码
 3.2 属性转换
- Duration 类 
我们可以看一个 Duration 类的例子。首先我们定义一个含有 Duration 类型字段的配置类:
 package com.jay.mydemo.config;
import java.time.Duration;
@ConfigurationProperties(prefix = "conversion")public class ConversionConfigProperties {
    private Duration defaultTime;
    private Duration nanoTime;
    // default getters and setters}
   复制代码
 
属性文件中加入以下行:
 # durationconversion.defaultTime=8conversion.nanoTime=8ns
   复制代码
 
打印出结果:
 ConversionConfigProperties{defaultTime=PT0.008S, nanoTime=PT0.000000008S}
   复制代码
 
所以ConversionConfigProperties里已经包含了 8 毫秒,8 纳秒。除此之外,d,h,m,s也支持,分别代表天,小时,分钟,秒。
当然你也可以不写时间,用上@DurationUnit即可:
 @DurationUnit(ChronoUnit.HOURS)private Duration hourTime;
   复制代码
 
对应的属性文件:
这样做是不是很方便,省得你数着指头做时间换算了。
类似于Duration的还有DataSize,你可以用它来方便地定义数据大小——B,KB,MB,GB,TB,用法在此不再赘述。
3.3 自定义属性转换器
我们也可以自定义一个属性转换器——将属性转换成一个指定 class。
我们先来创建一个简单类Book:
 package com.jay.mydemo.config;
public class Book {        private String name;        private double price;        private String description;        // default getters and setters}
   复制代码
 
在属性文件中我们添加下面这行
 conversion.book=java,88.00,java programming
   复制代码
 
并且在ConversionConfigProperties加上
然后我们来定义自己的属性转换器:
实现Converter并且使用@ConfigurationPropertiesBinding注解。
 package com.jay.mydemo.config;
import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;import org.springframework.core.convert.converter.Converter;import org.springframework.stereotype.Component;
@Component@ConfigurationPropertiesBindingpublic class BookConverter implements Converter<String, Book> {
    @Override    public Book convert(String source) {        String[] data = source.split(",");        Book book = new Book();        book.setName(data[0]);        book.setPrice(Double.parseDouble(data[1]));        book.setDescription(data[2]);        return book;    }}
   复制代码
 
这样自定义的属性转换器便大功告成了!
我们可以测试一下,打印出以下结果:
 Book{name='java', price=88.0, description='java programming'}
   复制代码
 3.4 @ConfigurationProperties 与 @Value 比较
除了@ConfigurationProperties注解可以获取配置文件中属性值,我们还可以使用@Value来一个一个地注解字段。
请看示例:
 package com.jay.mydemo.config;
import org.springframework.beans.factory.annotation.Value;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.context.annotation.Configuration;import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.Max;import javax.validation.constraints.Min;import javax.validation.constraints.NotBlank;import javax.validation.constraints.Pattern;import java.util.List;import java.util.Map;
@Configurationpublic class MailConfigProperties2 {
    // SpEL表达式    @Value("#{ '${mail.hostname}'.length() > 0 ? '${mail.hostname}' : 'smtp.163.com'}")    private String hostName;
    // 字面量    @Value("25")    private int port;
    // 属性key    @Value("${mail.from}")    private String from;
    // default getters and setters}
   复制代码
 
在这个改写的MailConfigProperties2中,我们可以看出@Value的常用方式,下表是两个注解之间的主要区别:
如果我们需要使用 SpEL 表达式,我们可以使用@Value。除此之外,我们都应该使用@ConfigurationProperties, 它更方便全能。
即使是只是在某个业务逻辑中偶尔使用一次来获取配置信息,也推荐使用@ConfigurationProperties,因为@Value零散,不易管理,注解属性的时候还要保证前缀和属性名书写无误。
4. 源码探究
在上文中,我们已经学会了@ConfigurationProperties的进阶用法,那么这一节,我们来看看源码吧。
1. 进入 ConfigurationProperties
先点开@ConfigurationProperties的源码。
Spring 的许多功能是通过BeanPostProcessor (后置处理器)来实现的。后置处理器的作用就是在 Bean 对象实例化和依赖注入完毕后,在调用初始化方法前后添加我们自己的逻辑。
2. 进入 ConfigurationPropertiesBindingPostProcessor
而我们从源码的注释中也可以发现ConfigurationPropertiesBindingPostProcessor支持了@ConfigurationProperties的运作。
点开该 Processor 的源码,我们可以发现其实现了BeanPostProcessor的核心方法之一postProcessBeforeInitialization方法。
   @Override  public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {    bind(ConfigurationPropertiesBean.get(this.applicationContext, bean, beanName));    return bean;  }
   复制代码
 
谁调用了这个方法呢?
3. 进入调用者AbstractAutowireCapableBeanFactory
通过 IDE,我们可以发现调用者就是AbstractAutowireCapableBeanFactory :
 @Overridepublic Object applyBeanPostProcessorsBeforeInitialization(Object existingBean, String beanName)      throws BeansException {
  Object result = existingBean;  //遍历该工厂创建的后置处理器  for (BeanPostProcessor processor : getBeanPostProcessors()) {  //核心操作:后置处理器做对应的初始化前的处理  Object current = processor.postProcessBeforeInitialization(result, beanName);  if (current == null) {    return result;  }  result = current;}return result;}
   复制代码
 
postProcessBeforeInitialization调用完之后,我们就回到了ConfigurationPropertiesBindingPostProcessor对应的方法。
4. 继续探究postProcessBeforeInitialization方法
我们点开ConfigurationPropertiesBean.get(this.applicationContext, bean, beanName),发现这样一个方法:
 public static ConfigurationPropertiesBean get(ApplicationContext applicationContext, Object bean, String beanName) {    Method factoryMethod = findFactoryMethod(applicationContext, beanName);    return create(beanName, bean, bean.getClass(), factoryMethod);  }
   复制代码
 
结合这个方法的注释(不得不说官方的注释确实很详细,用词也很精准,值得学习):
Return a @ConfigurationPropertiesBean instance for the given bean details or null if the bean is not a @ConfigurationProperties object. Annotations are considered both on the bean itself, as well as any factory method (for example a @Bean method).
Params:applicationContext – the source application context
bean – the bean to consider
beanName – the bean name
Returns:a configuration properties bean or null if the neither the bean or factory method are annotated with @ConfigurationProperties
我们不难看出ConfigurationPropertiesBean.get(ApplicationContext applicationContext, Object bean, String beanName)是用来返回一个ConfigurationPropertiesBean,也就是我们定义的配置类的 Bean,既可以是直接注解在配置类本身的也可以是注解在@Bean方法上的(其实就是上文提到的@ConfigurationProperties的两种用法)。当然它也可以返回一个 null,如果它没用@ConfigurationProperties注解。
5. 进一步分析
我们打上断点,debug 进行分析:
查看变量表,现在传入的是我们想要的mailConfigProperties:
 this = {ConfigurationPropertiesBindingPostProcessor@3404} bean = {MailConfigProperties$$EnhancerBySpringCGLIB$$c72a3e93@4462} "MailConfigProperties{hostName='null', port=0, from='null', recipients=null, headers=null, mailCrendential=null}"beanName = "mailConfigProperties"this.applicationContext = {AnnotationConfigApplicationContext@3405} "org.springframework.context.annotation.AnnotationConfigApplicationContext@5f77d0f9, started on Mon Apr 25 09:37:19 CST 2022"
   复制代码
 
继续往下走
图中的变量factoryMethod值为 null,这是因为mailConfigProperties是注解在类上,而非@Bean方法上。
点进 create()方法
查看变量表:
 name = "mailConfigProperties"instance = {MailConfigProperties$$EnhancerBySpringCGLIB$$3101bff0@3488} "MailConfigProperties{hostName='null', port=0, from='null', recipients=null, headers=null, mailCrendential=null}"type = {Class@3289} "class com.jay.mydemo.config.MailConfigProperties$$EnhancerBySpringCGLIB$$3101bff0"factory = nullannotation = {$Proxy41@4419} "@org.springframework.boot.context.properties.ConfigurationProperties(ignoreInvalidFields=false, ignoreUnknownFields=true, prefix="mail", value="mail")"validated = {$Proxy28@4426} "@org.springframework.validation.annotation.Validated(value=[])"annotations = {Annotation[2]@4434} bindType = {ResolvableType@4439} "com.jay.mydemo.config.MailConfigProperties$$EnhancerBySpringCGLIB$$3101bff0"bindTarget = {Bindable@4456} "[Bindable@5d10455d type = com.jay.mydemo.config.MailConfigProperties$$EnhancerBySpringCGLIB$$3101bff0, value = 'provided', annotations = array<Annotation>[@org.springframework.boot.context.properties.ConfigurationProperties(ignoreInvalidFields=false, ignoreUnknownFields=true, prefix="mail", value="mail"), @org.springframework.validation.annotation.Validated(value=[])]]"
   复制代码
 
我们可以看出org.springframework.boot.context.properties.ConfigurationPropertiesBean#create(String name, Object instance, Class<?> type, Method factory)最终创建了这样一个ConfigurationPropertiesBean。
它的name是mailConfigProperties,目前还没有赋值,注解属性是ignoreInvalidFields=false, ignoreUnknownFields=true, prefix="mail", value="mail",它包含了验证环节,对应了我们的@Validated注解。
ignoreInvalidFields, 默认为 false。当值是 false 的时候,表示不忽视无效的字段。一般是指类型错误的字段,比如 Java 类里字段类型是 int,但是配置文件中是字符串,那么就会启动报错。
ignoreUnknownFields,默认为 true,意思是忽视未知、多余的字段,比如配置文件中有,但是配置类中未使用的字段,建议默认即可,因为所谓未知、多余的字段可能用在其他地方。
6. 再看 bind 方法
现在让我们重回bind(ConfigurationPropertiesBean.get(this.applicationContext, bean, beanName));
     if (bean == null || hasBoundValueObject(bean.getName())) {      return;    }
   复制代码
 
当 bean 为 null 或者此时的 bean 已绑定对象都直接返回。
7. 进入 hasBoundValueObject(bean.getName())
该方法的源码如下:
   private boolean hasBoundValueObject(String beanName) {    return this.registry.containsBeanDefinition(beanName) && BindMethod.VALUE_OBJECT        .equals(this.registry.getBeanDefinition(beanName).getAttribute(BindMethod.class.getName()));  }
   复制代码
 
这里返回 true,因为mailConfigProperties这个 Bean 在实例化就已经存在。
this.registry.getBeanDefinition(beanName)获取了这样一个ScannedGenericBeanDefinition
ScannedGenericBeanDefinition 继承自 GenericBeanDefinition ,并实现了 AnnotatedBeanDefinition 接口。这个 BeanDefinition 用来描述标注 @Component 注解的 Bean,其派生注解如 @Service、@Controller 也同理。BeanDefinition 主要是用来描述 Bean,其存储了 Bean 的相关信息。
回到代码层面,因为result还没有绑定配置文件中的属性,所以这边getAttribute()的结果为 null,那么最终整个布尔表达式的结果也就是true && false = false。
8. 绑定this.binder.bind(bean);
就是在这个方法里,完成了配置类和配置文件属性的绑定,有兴趣的小伙伴可以继续探究下去哦。
9. 总结
经过以上的源码探索步骤,我们可以简单地总结下@ConfigurationProperties的流程:
- @ConfigurationProperties的后置处理器- ConfigurationPropertiesBindingPostProcessor实现了- BeanPostProcessor的- postProcessBeforeInitialization方法,会在 Bean 初始化之前被调用。
 
- 后置处理器会读取- @ConfigurationProperties注解的对象,获取配置文件中的 prefix,和注解对象的类成员变量,然后递归将配置属性赋值给类成员变量。
 
评论