Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT

Spring Boot 4.1.1 深度源码解析-第 09 章 · @ConfigurationProperties 全生命周期:注册、绑定、校验与 AOT

本章定位

第 08 章我们把 Binder 拆到了源码级。但 Binder 只是引擎------谁来调用它?

答案是 ConfigurationPropertiesBindingPostProcessor。而这个"调用者"本身有一整套复杂机制:

  • Bean 是怎么注册的? @ConfigurationProperties 本身不是 stereotype 注解(它不含 @Component),所以光加注解不会有任何事发生。必须有 @EnableConfigurationProperties、@ConfigurationPropertiesScan、或 @Component 三者之一
  • 注解可以写在 @Bean 方法上 ,而不只是类上------这两种位置的语义不同(ConfigurationPropertiesBean 专门处理这个差异)
  • 校验(JSR-303)是怎么接进去的? 通过 ValidationBindHandler,它是 BindHandler 装饰器链的一环
  • ignoreUnknownFields / ignoreInvalidFields 是怎么实现的? 也是装饰器链的一环
  • 构造器绑定怎么被识别? BindMethodAttribute + ConfigurationPropertiesBindConstructorProvider
  • AOT 下怎么办? 有两个专门的 AotProcessor

本章拆解这套"引擎之上的调度层"。


第 1 节 · 四种注册方式:为什么加个注解没反应

1.1 最常见的困惑

java 复制代码
@ConfigurationProperties(prefix = "my")
public class MyProps {
    private String name;
    // getter/setter
}

然后:

java 复制代码
@Autowired
private MyProps myProps;    // ❌ NoSuchBeanDefinitionException

原因 :@ConfigurationProperties 的定义里没有 @Component:

java 复制代码
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ConfigurationProperties {
    // ...
}

(确切的元注解以源码为准,但关键是:它不含 @Component,所以组件扫描不会捡起它。)

1.2 四种注册方式

方式 机制 适用场景
@Component + @ConfigurationProperties 组件扫描当普通 Bean 注册,再由 BeanPostProcessor 绑定 简单场景(但有陷阱,见 1.3)
@EnableConfigurationProperties(MyProps.class) EnableConfigurationPropertiesRegistrar 注册 推荐:显式、可控
@ConfigurationPropertiesScan 扫描指定包下所有 @ConfigurationProperties 类 大量配置类时
@Bean 方法上标 @ConfigurationProperties 方法返回的对象被绑定 第三方类的配置化

1.3 @Component + @ConfigurationProperties 的陷阱

java 复制代码
@Component
@ConfigurationProperties(prefix = "my")
public class MyProps { ... }

这能工作,但有一个重要限制:

组件扫描(@ComponentScan)必须覆盖这个类所在的包。如果你的 @ConfigurationProperties 类在 @SpringBootApplication 所在包的外面,就不会被扫描到。

而 @EnableConfigurationProperties / @ConfigurationPropertiesScan 明确列出类名/包名,不受此限。

实践建议 :优先用 @EnableConfigurationProperties 或 @ConfigurationPropertiesScan。@Component 方式在大型项目里容易造成"为什么这个配置类没生效"的困惑。

1.4 @ConfigurationPropertiesScan 的一个关键约束(Javadoc 实证)

java 复制代码
/**
 * ...
 * Note: Classes annotated or meta-annotated with {@link Component @Component} will not be
 * picked up by this annotation.
 *
 * @author Madhura Bhave
 * @since 2.2.0
 */

"被 @Component 标注或元标注的类不会被本注解捡起。"

这是一个显式的设计决策:

  • @Component + @ConfigurationProperties → 由组件扫描处理
  • 纯 @ConfigurationProperties(无 @Component)→ 由 @ConfigurationPropertiesScan 处理

两者互斥 。为什么?为了避免重复注册------如果一个类同时被两个机制捡到,会注册两次 Bean(同名则覆盖,不同名则产生两个实例)。

实践含义 :不要给 @ConfigurationProperties 类加 @Component,如果你打算用 @ConfigurationPropertiesScan。加了反而不会被扫描到,造成"静默失效"。

1.5 @ConfigurationPropertiesScan 自带 @EnableConfigurationProperties

java 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(ConfigurationPropertiesScanRegistrar.class)
@EnableConfigurationProperties        // ★ 注意这一行
public @interface ConfigurationPropertiesScan {
    String[] value() default {};
    String[] basePackages() default {};
    Class<?>[] basePackageClasses() default {};
}

@ConfigurationPropertiesScan 元标注了 @EnableConfigurationProperties。

这意味着:用了 @ConfigurationPropertiesScan,就自动 有了 @EnableConfigurationProperties(无参数版),后者会注册 ConfigurationPropertiesBindingPostProcessor 等基础设施。

这是一个贴心的设计:不需要同时写两个注解。

1.6 扫描的实现

java 复制代码
private void scan(BeanDefinitionRegistry registry, Set<String> packages) {
    ConfigurationPropertiesBeanRegistrar registrar = new ConfigurationPropertiesBeanRegistrar(registry);
    ClassPathScanningCandidateComponentProvider scanner = getScanner(registry);
    for (String basePackage : packages) {
        for (BeanDefinition candidate : scanner.findCandidateComponents(basePackage)) {
            String beanClassName = candidate.getBeanClassName();
            Assert.state(beanClassName != null, "'beanClassName' must not be null");
            register(registrar, beanClassName);
        }
    }
}

private ClassPathScanningCandidateComponentProvider getScanner(BeanDefinitionRegistry registry) {
    ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
    scanner.setEnvironment(this.environment);
    scanner.setResourceLoader(this.resourceLoader);
    scanner.addIncludeFilter(new AnnotationTypeFilter(ConfigurationProperties.class));
    TypeExcludeFilter typeExcludeFilter = new TypeExcludeFilter();
    typeExcludeFilter.setBeanFactory((BeanFactory) registry);
    scanner.addExcludeFilter(typeExcludeFilter);
    return scanner;
}

三个关键点:

(a)new ClassPathScanningCandidateComponentProvider(false)

构造参数 false = 不使用默认的过滤器 。默认的过滤器是"捡起所有 @Component",这里要完全自定义,所以关掉。

这印证了 1.4 节的互斥设计 :扫描器只认 @ConfigurationProperties(AnnotationTypeFilter),不认 @Component。而 @Component 类即使同时有 @ConfigurationProperties......

等等,这里有个逻辑矛盾:如果一个类同时有 @Component 和 @ConfigurationProperties,AnnotationTypeFilter(ConfigurationProperties.class) 会匹配它。那 Javadoc 为什么说"不会被捡起"?

答案在 excludeFilters:TypeExcludeFilter 。它是 Boot 的扩展机制,测试切片(@WebMvcTest 等)用它来排除不该加载的类。但这里更关键的是------

实际上,让我重新审视。AnnotationTypeFilter(ConfigurationProperties.class) 的默认 considerMetaAnnotations = true,所以元标注也算。而 @Component 类的排除可能来自 ConfigurationPropertiesBeanRegistrar 内部的检查。

诚实的边界声明 :Javadoc 的这条约束("@Component 类不会被捡起")我确认了它的存在,但没有逐行追踪到实现它的具体代码 。可能的机制是 ClassPathScanningCandidateComponentProvider 的 isCandidateComponent 检查(要求类是"独立的且具体的"),或 registrar 内的二次过滤。

这不影响使用------Javadoc 是权威的行为声明。但我不对实现机制做断言。

(b)TypeExcludeFilter 的加入

java 复制代码
TypeExcludeFilter typeExcludeFilter = new TypeExcludeFilter();
typeExcludeFilter.setBeanFactory((BeanFactory) registry);
scanner.addExcludeFilter(typeExcludeFilter);

TypeExcludeFilter 是 Spring Boot 测试框架的核心机制------@WebMvcTest、@DataJpaTest 等切片测试通过它排除无关的组件。

setBeanFactory 是必需的 :TypeExcludeFilter 从 BeanFactory 里查找所有 TypeExcludeFilter 类型的 Bean 来做实际的排除判断。忘记设置会导致 IllegalArgumentException。

(c)register 用 ClassUtils.forName

java 复制代码
private void register(ConfigurationPropertiesBeanRegistrar registrar, String className) throws LinkageError {
    try {
        register(registrar, ClassUtils.forName(className, null));
    }
    // ...
}

用 ASM 元数据扫描(不加载类)找到候选,然后真正加载类 来注册。这是 ClassPathScanningCandidateComponentProvider 的标准两段式。

1.7 @EnableConfigurationProperties 的两种用法

java 复制代码
// 方式一:列出类
@EnableConfigurationProperties({MyProps.class, OtherProps.class})

// 方式二:列出"配置类"(其中的 @Bean 方法带 @ConfigurationProperties)
@EnableConfigurationProperties(MyConfig.class)

第二种用于:

java 复制代码
@Configuration(proxyBeanMethods = false)
public class MyConfig {
    @Bean
    @ConfigurationProperties("my")
    public SomeThirdPartyType someBean() {
        return new SomeThirdPartyType();     // 第三方类型,不能加注解
    }
}

这是"给第三方类型做配置化"的唯一方式 ------你无法修改第三方类的源码加 @ConfigurationProperties,但可以在 @Bean 方法上加。


第 2 节 · ConfigurationPropertiesBean:两种注解位置的统一抽象

2.1 类的职责

java 复制代码
/**
 * Provides access to {@link ConfigurationProperties @ConfigurationProperties} bean
 * details, regardless of if the annotation was used directly or on a {@link Bean @Bean}
 * factory method. This class can be used to access {@link #getAll(ApplicationContext)
 * all} configuration properties beans in an ApplicationContext, or
 * {@link #get(ApplicationContext, Object, String) individual beans} on a case-by-case
 * basis (for example, in a {@link BeanPostProcessor}).
 *
 * @author Phillip Webb
 * @since 2.2.0
 */
public final class ConfigurationPropertiesBean {

	private final String name;
	private final @Nullable Object instance;
	private final Bindable<?> bindTarget;
	// ...
}

@since 2.2.0。它的存在理由写在 Javadoc 第一句:

"regardless of if the annotation was used directly or on a @Bean factory method"

即:统一"注解在类上"和"注解在 @Bean 方法上"两种情况的差异。

2.2 两种情况的差异在哪

注解在类上 注解在 @Bean 方法上
类型来源 类本身 方法的返回类型
注解来源 类上的注解 方法上的注解
实例 由容器创建 由 @Bean 方法创建
泛型 通常无 可能是 List<X> 等复杂泛型

Bindable 的构造必须同时携带类型 和注解 (第 08 章讲过 ResolvableTypeDescriptor 需要注解)。所以:

  • 类上:Bindable.of(type).withAnnotations(类上的注解)
  • 方法上:Bindable.of(返回类型的 ResolvableType).withAnnotations(方法上的注解)

ConfigurationPropertiesBean 屏蔽了这个差异,对外只暴露 asBindTarget()。

2.3 getAnnotation() 的语义

java 复制代码
public ConfigurationProperties getAnnotation() {
    ConfigurationProperties annotation = this.bindTarget.getAnnotation(ConfigurationProperties.class);
    Assert.state(annotation != null, "'annotation' must not be null");
    return annotation;
}

从 bindTarget 的 annotations 里取------而这些注解是在构造 Bindable 时放进去的(来自类或方法)。

这印证了第 08 章的 ResolvableTypeDescriptor :注解被装进 Bindable,一路传到 TypeDescriptor,最终能被 Converter 读到(@DurationUnit 等)。

2.4 getAll(ApplicationContext) 与 Actuator

java 复制代码
public static Map<String, ConfigurationPropertiesBean> getAll(ApplicationContext applicationContext)

返回所有 @ConfigurationProperties Bean(包括通过 @Bean 方法注册的)。

这是 Actuator /actuator/configprops 端点的数据来源之一 (另一个是 BoundPropertiesTrackingBindHandler,第 08 章第 17 节讲过)。

2.5 instance 可以为 null

java 复制代码
private final @Nullable Object instance;

因为在 BeanPostProcessor 的某些阶段,实例可能还没创建(比如处理 BeanDefinition 时)。


第 3 节 · ConfigurationPropertiesBindingPostProcessor:绑定时机

3.1 它是一个 BeanPostProcessor

java 复制代码
public class ConfigurationPropertiesBindingPostProcessor
        implements BeanPostProcessor, BeanFactoryAware, EnvironmentAware, ApplicationContextAware, InitializingBean, Ordered {

(确切的接口清单以源码为准,但核心是 BeanPostProcessor。)

BeanPostProcessor 意味着绑定发生在 Bean 初始化阶段 ------具体是 postProcessBeforeInitialization。

3.2 时间线

scss 复制代码
ApplicationContext.refresh()
  ├─ invokeBeanFactoryPostProcessors(...)     ← BeanDefinition 注册(@EnableConfigurationProperties 在这里)
  ├─ registerBeanPostProcessors(...)          ← ConfigurationPropertiesBindingPostProcessor 注册
  ├─ finishBeanFactoryInitialization(...)     ← 实例化所有非懒加载单例
  │     ├─ createBean(...)
  │     │    ├─ instantiate
  │     │    ├─ populateBean(依赖注入)
  │     │    └─ initializeBean
  │     │         ├─ postProcessBeforeInitialization  ← ★ 绑定在这里
  │     │         ├─ invokeInitMethods(@PostConstruct)
  │     │         └─ postProcessAfterInitialization
  └─ finishRefresh()

关键 :绑定发生在 @PostConstruct 之前 。所以在 @PostConstruct 里能读到绑定后的值。

但注意:构造器注入发生在绑定之前。所以构造器里读不到配置值(第 08 章讲过构造器绑定的例外------构造器绑定时值通过参数传入,不是读字段)。

3.3 构造器的绑定:VALUE_OBJECT 的跳过

java 复制代码
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
    // ...
    bind(ConfigurationPropertiesBean.get(this.applicationContext, bean, beanName));
    return bean;
}

而:

java 复制代码
private void bind(@Nullable ConfigurationPropertiesBean bean) {
    if (bean == null) {
        return;
    }
    Assert.state(bean.asBindTarget().getBindMethod() != BindMethod.VALUE_OBJECT,
            () -> "Cannot bind value object ...");
    // ...
    this.binder.bind(bean);
}

Assert.state 拒绝 VALUE_OBJECT ------构造器绑定的 Bean 不能 走 BeanPostProcessor 路径。

为什么?因为构造器绑定的对象在构造时 就已经完整了(值通过构造参数传入),实例是不可变 的。BeanPostProcessor 是"创建后修改",对一个 final 字段的 record 无能为力。

这是构造器绑定与 setter 绑定的根本差异:

  • setter 绑定 :容器创建实例(无参构造器)→ BeanPostProcessor 调 setter 填值
  • 构造器绑定 :注册阶段就把值准备好,直接通过构造器传入

这也是为什么构造器绑定需要 ConfigurationPropertiesBeanRegistrationAotProcessor 等额外机制------它必须在 BeanDefinition 层面就介入。

3.4 BindMethodAttribute:构造器绑定的标记

java 复制代码
// BindMethodAttribute.java
public final class BindMethodAttribute {
    // 在 BeanDefinition 上存/取 BindMethod
}

(类名与存在性已确认;具体 API 以源码为准。)

它的作用:在 BeanDefinition 上记录这个 Bean 应该用哪种绑定方法。因为在 postProcessBeforeInitialization 时,需要知道"这是构造器绑定的吗"(3.3 的 Assert),而这个信息在注册时就确定了。

这是一个典型的"跨阶段传递元信息"的模式 :注册阶段(BeanFactoryPostProcessor)算出结论 → 存到 BeanDefinition 的 attribute → 初始化阶段(BeanPostProcessor)读出来用。

Spring 里同类模式还有 @Lazy、@Primary 等------它们都通过 BeanDefinition 的 attribute 在不同阶段间传递。


第 4 节 · ConfigurationPropertiesBinder 与 BindHandler 装饰器链(本章核心)

4.1 getBindHandler 的完整组装

java 复制代码
private <T> BindHandler getBindHandler(Bindable<T> target, ConfigurationProperties annotation) {
    List<Validator> validators = getValidators(target);
    BindHandler handler = getHandler();
    handler = new ConfigurationPropertiesBindHandler(handler);
    if (annotation.ignoreInvalidFields()) {
        handler = new IgnoreErrorsBindHandler(handler);
    }
    if (!annotation.ignoreUnknownFields()) {
        // ...
        handler = new NoUnboundElementsBindHandler(handler, filter);
    }
    if (!validators.isEmpty()) {
        handler = new ValidationBindHandler(handler, validators.toArray(new Validator[0]));
    }
    for (ConfigurationPropertiesBindHandlerAdvisor advisor : getBindHandlerAdvisors()) {
        // ...
    }
    return handler;
}

这是本章最重要的代码 。它把 @ConfigurationProperties 的每个属性翻译成一个 BindHandler 装饰器:

注解属性 对应的 Handler 作用
(总是) getHandler()(推测为 IgnoreTopLevelConverterNotFoundBindHandler) 忽略顶层转换失败
(总是) ConfigurationPropertiesBindHandler Boot 自己的处理(如 @NestedConfigurationProperty 支持)
ignoreInvalidFields=true IgnoreErrorsBindHandler 忽略字段级转换错误
ignoreUnknownFields=false NoUnboundElementsBindHandler 未绑定属性检测
有 Validator ValidationBindHandler 校验
--- 所有 ConfigurationPropertiesBindHandlerAdvisor 扩展点

4.2 装饰器链的顺序决定语义

java 复制代码
handler = getHandler();                                                // 最内层
handler = new ConfigurationPropertiesBindHandler(handler);
if (ignoreInvalidFields) handler = new IgnoreErrorsBindHandler(handler);
if (!ignoreUnknownFields) handler = new NoUnboundElementsBindHandler(handler, filter);
if (!validators.isEmpty()) handler = new ValidationBindHandler(handler, ...);
// advisors 最外层
return handler;

后加的在外层 。回忆第 08 章 AbstractBindHandler 的委托方向:外层 handler 的 onSuccess 先执行,然后调 super.onSuccess 委托给内层。

所以执行顺序(以 onSuccess 为例):

scss 复制代码
advisors → ValidationBindHandler → NoUnboundElements → IgnoreErrors → ConfigurationPropertiesBindHandler → getHandler()

这个顺序有实际意义:

  • IgnoreErrorsBindHandler 在 NoUnboundElements 之内 ------ 意味着:如果某个字段转换失败被 IgnoreErrors 吞掉(返回 null),NoUnboundElements 仍然会检测到"这个属性没被成功绑定"?

不对。看第 08 章的 NoUnboundElementsBindHandler.onSuccess:

java 复制代码
@Override
public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
        Object result) {
    this.boundNames.add(name);     // 无条件记录
    return super.onSuccess(name, target, context, result);
}

它无条件 记录 boundNames(不看 result 是否为 null)。而 IgnoreErrorsBindHandler 在内层------它的 onFailure 被触发时(内层先抛异常),外层的 NoUnboundElements 的 onSuccess 根本不会被调用 (因为异常路径走 onFailure)。

所以:ignoreInvalidFields=true 时,转换失败的属性不会被记入 boundNames,如果同时 ignoreUnknownFields=false,会被报告为"未绑定"。

这是一个真实的交互后果 :ignoreInvalidFields=true + ignoreUnknownFields=false 会产生"明明配了却说未绑定"的困惑。因为该属性绑定失败了(被忽略),所以没被计为已绑定。

我在第 08 章第 3.3 节讲过 onFailure 能"救回"绑定------但 IgnoreErrorsBindHandler 的"救回"是返回 null,这不触发外层的 onSuccess。

ValidationBindHandler 在最外层(advisors 之前):校验在所有绑定完成后才进行(因为它在最外层,能"看到"最终的对象)。

4.3 ConfigurationPropertiesBindHandlerAdvisor:扩展点

java 复制代码
for (ConfigurationPropertiesBindHandlerAdvisor advisor : getBindHandlerAdvisors()) {
    // ...
}
java 复制代码
private volatile @Nullable List<ConfigurationPropertiesBindHandlerAdvisor> bindHandlerAdvisors;

volatile + 懒加载(缓存,避免每次绑定都查 BeanFactory)。

ConfigurationPropertiesBindHandlerAdvisor 是一个 SPI:任何 Bean 实现它,就能往绑定链里插入自己的 handler。

典型用途 :Spring Cloud 用它插入"解密 handler"({cipher}... 的解密)------在绑定过程中解密配置值。

这是 Boot 为生态预留的扩展点 ,与第 07 章的 ConfigDataLocationResolver、第 06 章的 EnvironmentPostProcessor 是同一思路:在关键流程的每一步都留 SPI。

4.4 bind 与 bindOrCreate

java 复制代码
BindHandler bindHandler = getBindHandler(target, annotation);
// ...
Object bindOrCreate(ConfigurationPropertiesBean propertiesBean) {
    // ...
    return getBinder().bindOrCreate(annotation.prefix(), target, bindHandler);
}

用 bindOrCreate 而不是 bind ------保证 @ConfigurationProperties Bean 永远非 null,即使配置文件里一个相关属性都没有。

这与第 08 章的对比:

  • spring.main.* 用 bind(第 05 章):绑定到已有实例,绑定失败就保持原样
  • @ConfigurationProperties 用 bindOrCreate:一定产出实例

语义差异的原因:@ConfigurationProperties 是容器里的 Bean ,必须是非 null 的(否则注入点会 NPE)。而 ApplicationProperties 是内部字段,有默认值即可。


第 5 节 · 校验:JSR-303 与 ValidationBindHandler

5.1 两种校验器

java 复制代码
private final @Nullable Validator configurationPropertiesValidator;

this.configurationPropertiesValidator = getConfigurationPropertiesValidator(applicationContext);
this.jsr303Present = ConfigurationPropertiesJsr303Validator.isJsr303Present(applicationContext);
java 复制代码
private @Nullable Validator getConfigurationPropertiesValidator(ApplicationContext applicationContext) {
    // ...
    return applicationContext.getBean(VALIDATOR_BEAN_NAME, Validator.class);
    // ...
}
  • configurationPropertiesValidator :容器里名为 configurationPropertiesValidator 的 Bean(Spring Boot 提供的默认实现)
  • JSR-303:jakarta validation 的实现(Hibernate Validator)

5.2 ConfigurationPropertiesJsr303Validator.isJsr303Present

java 复制代码
this.jsr303Present = ConfigurationPropertiesJsr303Validator.isJsr303Present(applicationContext);

显式检测 JSR-303 是否在 classpath 上。这又是一个"能力模块化 → 运行时检测"的例子(第 01、05 章讲过同类模式)。

实践含义 :如果没有引入 spring-boot-starter-validation,@Validated + @NotNull 等注解不会生效,且可能没有明确提示。

这正是"为什么我的校验不生效"的第一排查点:检查 classpath 上有没有 validation 实现。

5.3 @Validated 的位置

java 复制代码
@ConfigurationProperties("my")
@Validated                      // ★ 必须有
public class MyProps {
    @NotNull
    private String name;
}

@Validated(org.springframework.validation.annotation.Validated)出现在 ConfigurationPropertiesBean 的 import 清单里------说明 Boot 专门处理它。

@Validated 在 @ConfigurationProperties 类上是触发校验的开关 。没有它,即使字段上有 @NotNull 也不校验。

5.4 ValidationBindHandler 在链上的位置

第 4.2 节讲过它在外层。它的工作时机(onSuccess/onFinish)意味着:校验在对象构造完成后执行。

所以校验的是"最终对象的状态",而不是"逐个字段"。这支持跨字段校验 (自定义 Validator 可以检查多个字段的关系)。

5.5 校验失败的异常

校验失败抛 BindException(由 ValidationBindHandler 抛出),最终被 ConfigurationPropertiesBindException 包装。

而 ConfigurationPropertiesBindException 有对应的 FailureAnalyzer------这就是启动时看到的:

yaml 复制代码
Description:

Binding to target org.springframework.boot.context.properties.bind.BindException:
Failed to bind properties under 'my' to com.example.MyProps:

    Property: my.name
    Value: null
    Reason: must not be null

Action:

Update your application's configuration

第 6 节 · BindMethod 的确定:ConfigurationPropertiesBindConstructorProvider

(第 08 章讲过 BindConstructorProvider,这里补充 @ConfigurationProperties 场景的特殊处理。)

6.1 EnableConfigurationPropertiesRegistrar 的职责

@EnableConfigurationProperties 通过 @Import(EnableConfigurationPropertiesRegistrar.class) 生效。它做两件事:

  1. 注册 ConfigurationPropertiesBindingPostProcessor(基础设施)
  2. 为每个列出的类注册 BeanDefinition

6.2 构造器绑定时的 BeanDefinition 特殊处理

构造器绑定的 Bean 不能走 BeanPostProcessor(3.3 节),所以必须在 BeanDefinition 层面就准备好值:

markdown 复制代码
注册阶段:
  - 判断是否需要构造器绑定(BindMethodAttribute)
  - 如果是:为 BeanDefinition 设置 InstanceSupplier
  - InstanceSupplier 内部:调 Binder 绑定 → 用结果调构造器

InstanceSupplier 是 Spring Framework 5.x 引入的机制:允许 BeanDefinition 直接提供一个"实例供给函数",绕过常规的构造器推断。

这是构造器绑定能工作的技术基础------BeanDefinition 不再描述"用哪个构造器、传什么参数",而是直接说"调用这个函数拿实例"。

6.3 ConstructorBound 注解

文件清单里有 ConstructorBound.java。从名字看是标记"这个类型应该用构造器绑定"。

(具体语义我未逐行核实,不做断言。)

6.4 @ConstructorBinding 与 BindMethod.VALUE_OBJECT

第 08 章讲过:@ConstructorBinding 用在多构造器 场景指定用哪个。而 BindMethod.VALUE_OBJECT 是显式强制构造器绑定。

两者的关系:

  • @ConstructorBinding:注解在构造器上,"用这个构造器"
  • BindMethod.VALUE_OBJECT:编程式指定,"用构造器绑定模式"

第 7 节 · AOT:两个专门的 Processor

文件清单里有两个 AOT 相关类:

复制代码
ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java
ConfigurationPropertiesBeanRegistrationAotProcessor.java

7.1 ConfigurationPropertiesBeanRegistrationAotProcessor

在 BeanDefinition 注册阶段做 AOT 处理。

职责(推测,未逐行核实):

  • 为 @ConfigurationProperties 类型登记反射 hints(构造器、setter、getter)
  • 记录绑定元信息,供 native 下快速绑定

7.2 ConfigurationPropertiesBeanFactoryInitializationAotProcessor

在 BeanFactory 初始化阶段做 AOT 处理。

7.3 AOT 下的行为差异

方面 JVM AOT/native
反射绑定 直接反射 需要 hints
配置值 运行时读取 构建期读取(部分固化)
校验 运行时 运行时(校验逻辑本身不固化)

重要提醒 :@ConfigurationProperties 的值 在 native 下仍是运行时读取的(不像 profiles 那样被固化)。所以 application.properties 里的值在 native 下可以通过环境变量覆盖(但 profile 不行,见第 06/07 章)。

这是"值"与"profile"在 AOT 下的不同待遇:profile 是构建期常量,配置值不是。


第 8 节 · ConfigurationPropertiesBeanRegistrar:注册的实现细节

8.1 Bean 名的生成规则

java 复制代码
void register(Class<?> type, MergedAnnotation<ConfigurationProperties> annotation) {
    String name = getName(type, annotation);
    // ...
    registerBeanDefinition(name, type, annotation);
}

private String getName(Class<?> type, MergedAnnotation<ConfigurationProperties> annotation) {
    // ...
    return (StringUtils.hasText(prefix) ? prefix + "-" + type.getName() : type.getName());
}

Bean 名 = 前缀-类全限定名(有前缀时),否则就是类全限定名。

例如:

java 复制代码
@ConfigurationProperties(prefix = "my")
public class MyProps { ... }

→ Bean 名:my-com.example.MyProps

这个命名规则有几个实践含义:

  1. Bean 名是确定的、可预测的 ------ 你可以 @Autowired @Qualifier("my-com.example.MyProps")
  2. 同名冲突:两个不同包但前缀相同的类不会冲突(因为用了全限定名)
  3. 但同一个类被注册两次会覆盖 (同名 Bean)------ 这正是 @Component + @ConfigurationPropertiesScan 要互斥的原因之一

8.2 deduceBindMethod:绑定模式的推断

java 复制代码
BindMethod bindMethod = ConfigurationPropertiesBean.deduceBindMethod(type);
BindMethodAttribute.set(definition, bindMethod);
if (bindMethod == BindMethod.VALUE_OBJECT) {
    // 构造器绑定的特殊处理
}

deduceBindMethod 是构造器绑定识别的入口 。它在注册阶段 就确定绑定模式,并写入 BindMethodAttribute(第 3.4 节讲过跨阶段传递)。

推断逻辑(基于第 08 章的 DefaultBindConstructorProvider 规则):

css 复制代码
类只有一个构造器 且 该构造器有参数
    → VALUE_OBJECT
类有多个构造器 且 其中一个标了 @ConstructorBinding
    → VALUE_OBJECT
类有无参构造器
    → JAVA_BEAN

实践规则(Boot 3+/4.x):

  • record 类 → 自动 VALUE_OBJECT
  • 只有全参构造器的普通类 → 自动 VALUE_OBJECT
  • 有无参构造器 + setter → JAVA_BEAN
  • 多个构造器 → 必须标 @ConstructorBinding

8.3 重复的检查

java 复制代码
private void registerBeanDefinition(String beanName, Class<?> type, /* ... */) {
    // ...
    Assert.state(!this.registry.containsBeanDefinition(beanName),
            // "... annotation found on '" + type.getName() + "'.");
    BeanDefinitionReaderUtils.registerBeanDefinition(beanDefinition, this.registry);
}

Assert.state 检查重复注册------如果同名 BeanDefinition 已存在,直接失败。

这是一个"早失败"的设计:重复注册会导致"哪个生效"的不确定性,Boot 选择在注册时就报错,而不是让后注册者静默覆盖。

8.4 MergedAnnotation 的使用

java 复制代码
void register(Class<?> type, MergedAnnotation<ConfigurationProperties> annotation)

注意参数类型是 MergedAnnotation<ConfigurationProperties> 而不是 ConfigurationProperties。

MergedAnnotation 是 Spring Framework 5.2+ 的注解抽象,它支持:

  • 元注解(注解上的注解)
  • 属性别名 (@AliasFor)
  • 属性覆盖

这让 @ConfigurationProperties 可以被"组合":

java 复制代码
@ConfigurationProperties(prefix = "my")
@Target(ElementType.TYPE)
public @interface MyModuleProperties { }

然后 @MyModuleProperties 标注的类也会被识别为配置属性类。

这是"自定义组合注解"的基础------Spring Boot 自己的很多注解就是这么组合的。


第 9 节 · 配置元数据体系

9.1 @ConfigurationPropertiesSource:跨模块的元数据提示

java 复制代码
/**
 * Indicates that the annotated type is a source of configuration properties metadata.
 * <p>
 * This annotation has no effect on the actual binding process, but serves as a hint to
 * the {@code spring-boot-configuration-processor} to generate full metadata for the type.
 * <p>
 * Typically, this annotation is only required for types located in a different module
 * than the {@code @ConfigurationProperties} class that references them. When both types
 * are in the same module, the annotation processor can automatically discover full
 * metadata as long as the source is available.
 * <p>
 * Use this annotation when metadata for types located outside the module is needed:
 * <ol>
 * <li>Nested types annotated by {@code @NestedConfigurationProperty}</li>
 * <li>Base classes that a {@code @ConfigurationProperties}-annotated type extends
 * from</li>
 * </ol>
 * ...
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ConfigurationPropertiesSource {
}

Javadoc 说得很清楚:

"This annotation has no effect on the actual binding process"(对绑定过程没有任何影响)

它纯粹是给注解处理器(spring-boot-configuration-processor)的提示 ,用于生成 spring-configuration-metadata.json。

为什么需要它?

元数据生成是编译期 的(注解处理器)。处理器能看到当前模块的源码,但看不到依赖 jar 里的源码(只有 class 文件)。所以:

java 复制代码
// 模块 A
@ConfigurationProperties("example.server")
class ServerProperties {
    @NestedConfigurationProperty
    private final Host host = new Host();      // Host 在模块 B
}

// 模块 B
public class Host { ... }                       // 处理器看不到源码

处理器知道 host 属性的类型 是 Host,但不知道 Host 里有哪些字段、每个字段的 description 和默认值。

加上 @ConfigurationPropertiesSource 并在模块 B 里也启用 spring-boot-configuration-processor,模块 B 编译时会为 Host 生成完整元数据,模块 A 就能用上。

实践场景 :如果你维护一个多模块项目,且配置类拆分在不同模块,跨模块的嵌套配置类的 IDE 补全需要这个注解。这是个少有人知但很实用的细节。

9.2 @NestedConfigurationProperty

用于标记"这个字段是一个嵌套的配置对象,它的属性应该平铺 在父前缀下,而不是作为 prefix.field 的一个值"。

java 复制代码
@ConfigurationProperties("my")
public class MyProps {
    @NestedConfigurationProperty
    private Server server = new Server();
}
// my.server.host=xxx    ← 而不是 my.server={"host":"xxx"}

为什么需要显式标记? 因为 Boot 无法可靠地区分"这个字段是要绑定成一个对象"还是"这个字段是要从单个字符串转换来的"。

对比:

java 复制代码
private Duration timeout;     // 从字符串 "30s" 转换 → 不是嵌套
private Server server;        // 绑定成对象 → 是嵌套

Duration 能靠"没有可绑定的属性"来排除,但自定义类型(如 Server)如果既有 setter 又能从字符串转换,就有歧义。

@NestedConfigurationProperty 是消除歧义的显式声明。

实际上,即使不加这个注解,绝大多数情况 Boot 也能正确处理(因为 JavaBeanBinder 会尝试绑定嵌套)。它的主要价值是元数据生成------告诉处理器"展开这个类型的属性"。

9.3 @DeprecatedConfigurationProperty

标记一个配置属性已弃用,会在元数据 JSON 里生成 deprecation 节点(第 05 章讲过 spring.main.show-banner 的例子)。

java 复制代码
public class MyProps {
    @Deprecated
    @DeprecatedConfigurationProperty(reason = "use my.new-name", replacement = "my.new-name")
    public void setOldName(String value) { ... }
}

三个信息:reason、replacement、since(推测)。

这体现了 Boot 对"配置属性"的兼容性承诺强度:配置写在 yaml/环境变量/CI 脚本里,改一个属性名的破坏力远大于改一个方法名。所以弃用必须有明确的迁移指引。

9.4 元数据与 IDE 的关系

元数据 JSON 的位置:

bash 复制代码
META-INF/spring-configuration-metadata.json

它由 spring-boot-configuration-processor 在编译期生成,来源:

  1. @ConfigurationProperties 类的字段(类型、getter/setter)
  2. 字段上的 javadoc(作为 description)
  3. 字段的初始值(作为 defaultValue)
  4. @DeprecatedConfigurationProperty(作为 deprecation)

这就是为什么给 @ConfigurationProperties 字段写 javadoc 是有实际价值的------它会变成 IDE 里看到的提示文本。


第 10 节 · IncompatibleConfigurationException:跨属性校验

java 复制代码
/**
 * Exception thrown when the application has configured an incompatible set of
 * {@link ConfigurationProperties} keys.
 *
 * @author Brian Clozel
 * @since 2.4.0
 */
public class IncompatibleConfigurationException extends RuntimeException {

	private final List<String> incompatibleKeys;

	public IncompatibleConfigurationException(String... incompatibleKeys) {
		super("The following configuration properties have incompatible values: " + Arrays.toString(incompatibleKeys));
		this.incompatibleKeys = Arrays.asList(incompatibleKeys);
	}

	public Collection<String> getIncompatibleKeys() {
		return this.incompatibleKeys;
	}
}

@since 2.4.0,用于表达"一组配置属性之间存在冲突"。

典型场景:

java 复制代码
@ConfigurationProperties("spring.mvc")
public class WebMvcProperties {
    // spring.mvc.servlet.path 与 spring.mvc.servlet.load-on-startup 的组合冲突
}

或:

java 复制代码
// 同时设置了两个互斥的属性
my.mode=A
my.legacy-mode=B      // 冲突

用法 :在 @ConfigurationProperties 类里做一个"自检方法",在 @PostConstruct 或自定义 Validator 里抛出。

这是一个专门的异常类型,说明 Boot 把"配置冲突"当作一类独立的错误------它比"单个属性无效"更需要上下文(要看多个属性才能判断)。

10.1 IncompatibleConfigurationFailureAnalyzer

文件清单里有 IncompatibleConfigurationFailureAnalyzer.java。它把该异常转成可读的 FailureAnalysis:

yaml 复制代码
Description:

The following configuration properties have incompatible values: [my.mode, my.legacy-mode]

Action:

...

第 11 节 · NotConstructorBoundInjectionFailureAnalyzer:错误信息设计的典范

这是本章最值得学习的一个类------它把一类极难排查的错误变成了明确的启动失败信息。

11.1 完整源码

java 复制代码
/**
 * An {@link AbstractInjectionFailureAnalyzer} for
 * {@link ConfigurationProperties @ConfigurationProperties} that are intended to use
 * {@link ConstructorBinding constructor binding} but did not.
 *
 * @author Andy Wilkinson
 */
class NotConstructorBoundInjectionFailureAnalyzer
		extends AbstractInjectionFailureAnalyzer<NoSuchBeanDefinitionException> implements Ordered {

	@Override
	public int getOrder() {
		return 0;
	}

	@Override
	protected @Nullable FailureAnalysis analyze(Throwable rootFailure, NoSuchBeanDefinitionException cause,
			@Nullable String description) {
		InjectionPoint injectionPoint = findInjectionPoint(rootFailure);
		if (isConstructorBindingConfigurationProperties(injectionPoint)) {
			String simpleName = injectionPoint.getMember().getDeclaringClass().getSimpleName();
			String action = "Update your configuration so that " + simpleName + " is defined via @"
					+ ConfigurationPropertiesScan.class.getSimpleName() + " or @"
					+ EnableConfigurationProperties.class.getSimpleName() + ".";
			return new FailureAnalysis(
					simpleName + " is annotated with @" + ConstructorBinding.class.getSimpleName()
							+ " but it is defined as a regular bean which caused dependency injection to fail.",
					action, cause);
		}
		return null;
	}

	@Contract("null -> false")
	private boolean isConstructorBindingConfigurationProperties(@Nullable InjectionPoint injectionPoint) {
		return injectionPoint != null && injectionPoint.getMember() instanceof Constructor<?> constructor
				&& isConstructorBindingConfigurationProperties(constructor);
	}

	private boolean isConstructorBindingConfigurationProperties(Constructor<?> constructor) {
		Class<?> declaringClass = constructor.getDeclaringClass();
		BindMethod bindMethod = ConfigurationPropertiesBean.deduceBindMethod(declaringClass);
		return MergedAnnotations.from(declaringClass, SearchStrategy.TYPE_HIERARCHY)
			.isPresent(ConfigurationProperties.class) && bindMethod == BindMethod.VALUE_OBJECT;
	}

	private @Nullable InjectionPoint findInjectionPoint(Throwable failure) {
		// ...
	}
}

11.2 它解决什么问题

场景:

java 复制代码
@ConfigurationProperties(prefix = "my")
@Component                      // ← 用 @Component 注册
public class MyProps {
    private final String name;

    @ConstructorBinding
    public MyProps(String name) { this.name = name; }
}

这样写会失败 。原因(第 3.3 节讲过):构造器绑定必须在 BeanDefinition 注册阶段 就准备好值(用 InstanceSupplier),而 @Component 走的是普通组件扫描路径,没有这个特殊处理 → 容器尝试用无参构造器或直接构造 → 失败 → NoSuchBeanDefinitionException。

用户看到的原始错误可能只是:

bash 复制代码
NoSuchBeanDefinitionException: No qualifying bean of type 'MyProps'

或一堆构造器参数无法解析的错误。完全看不出真正原因。

有了这个 Analyzer 后:

vbnet 复制代码
Description:

MyProps is annotated with @ConstructorBinding but it is defined as a regular bean which caused dependency injection to fail.

Action:

Update your configuration so that MyProps is defined via @ConfigurationPropertiesScan or @EnableConfigurationProperties.

直接告诉你要做什么。

11.3 三个值得学习的设计点

(a)检测逻辑的精确性

java 复制代码
return MergedAnnotations.from(declaringClass, SearchStrategy.TYPE_HIERARCHY)
    .isPresent(ConfigurationProperties.class) && bindMethod == BindMethod.VALUE_OBJECT;

两个条件同时满足:

  • 类上有 @ConfigurationProperties(用 MergedAnnotations + TYPE_HIERARCHY 支持元注解和继承)
  • deduceBindMethod 推断为 VALUE_OBJECT

复用 deduceBindMethod 而不是自己重新推断------保证与实际的绑定行为一致。

(b)implements Ordered 且 getOrder() == 0

java 复制代码
@Override
public int getOrder() {
    return 0;
}

FailureAnalyzer 是有顺序的(多个 analyzer 可能匹配同一个异常)。0 表示较高优先级------这个 analyzer 应该在其他通用 analyzer 之前尝试。

为什么需要高优先级? 因为 NoSuchBeanDefinitionException 是一个非常通用的异常,可能有其他 analyzer(如"No qualifying bean"的通用分析器)也会匹配。这个更具体的诊断应该先被尝试。

(c)Action 里给出两个具体的替代方案

java 复制代码
String action = "Update your configuration so that " + simpleName + " is defined via @"
        + ConfigurationPropertiesScan.class.getSimpleName() + " or @"
        + EnableConfigurationProperties.class.getSimpleName() + ".";

注意它用的是 xxx.class.getSimpleName() 而不是硬编码字符串 "@ConfigurationPropertiesScan"。

这是一个防御性细节 :如果注解改名了,错误信息自动跟着改,不会出现"信息过期"。虽然注解改名会破坏兼容性,但这体现了 Boot 在避免魔法字符串上的习惯。

11.4 这一模式在 Boot 里的普遍性

NotConstructorBoundInjectionFailureAnalyzer 是 Boot "FailureAnalyzer 体系"的一员(第 01 章讲过)。整个体系的理念是:

不要让用户看堆栈。把每种常见的配置/装配错误,翻译成一句"发生了什么"+ 一句"你该做什么"。

这个文件清单里我们能看到的 analyzer 就有:

  • NotConstructorBoundInjectionFailureAnalyzer(本章)
  • IncompatibleConfigurationFailureAnalyzer
  • ConfigDataNotFoundFailureAnalyzer(第 07 章)

写作/工程洞察 :这是"错误信息即产品"的理念。Boot 在这上面投入的工程量不小------每个 analyzer 都要写出准确的检测逻辑和有指导性的 Action 文本。这是 Boot 易用性的重要来源,也是最容易被自研框架忽略的部分。


第 12 节 · ConversionServiceDeducer 与 PropertySourcesDeducer

12.1 两个"推断器"

文件清单里:

复制代码
ConversionServiceDeducer.java
PropertySourcesDeducer.java

从名字和 Deducer 后缀(第 01 章讲过 Boot 用 Deducer 命名"推断器")可以判断它们的职责:

  • ConversionServiceDeducer :从 ApplicationContext 里推断出应该用于绑定的 ConversionService
  • PropertySourcesDeducer :推断出应该用于绑定的 PropertySources

为什么需要"推断"而不是直接取?

因为 ConfigurationPropertiesBindingPostProcessor 可能运行在各种上下文里:

  • 普通 ApplicationContext
  • 有父上下文的场景
  • 测试上下文
  • 手动创建的 Binder

它需要从可用资源里"推断"出最合适的。

ConversionServiceDeducer 的存在印证了第 06 章的结论 :Environment 的 ConversionService 和 BeanFactory 的不是同一个。而绑定应该用哪个?由这个 Deducer 决定。

(具体推断规则我未逐行核实,不做断言。可确认的是它们存在,且职责如上。)

12.2 Deducer 命名约定

第 01 章讲过:WebApplicationType.Deducer 是 4.x 新增的 SPI。而这里有两个非 SPI 的 Deducer 类。

这说明 Deducer 是 Boot 内部的一个命名约定 :"从环境推断出某个值"的类就叫 XxxDeducer。这个约定值得在自己的项目里借鉴------它比 XxxResolver 更明确(Resolver 通常指"解析一个输入",Deducer 指"从上下文推断")。


第 13 节 · BoundConfigurationProperties 与运行时查询

文件清单里有 BoundConfigurationProperties.java。

从名字推断:它提供"某个 @ConfigurationProperties Bean 实际绑定了哪些属性"的信息。

典型用途 :Actuator 的 /actuator/configprops 用它展示每个配置 Bean 绑定了哪些属性(配合第 08 章的 BoundPropertiesTrackingBindHandler)。

这两个类的分工:

  • BoundPropertiesTrackingBindHandler:绑定过程中 收集 ConfigurationProperty
  • BoundConfigurationProperties:绑定完成后提供查询接口

(具体实现我未逐行核实,不做断言。)


第 14 节 · PropertyMapper:Boot 内部的映射工具

文件清单里有 PropertyMapper.java(注意与 bind/source/PropertyMapper 不同,这是 context/properties/ 包下的)。

它是 Boot 内部的一个流式映射工具,用于"从源对象取值,转换,设置到目标对象"。

典型用法(示意):

java 复制代码
PropertyMapper mapper = PropertyMapper.get().alwaysApplyingWhenNonNull();
mapper.from(source::getPort).to(target::setPort);
mapper.from(source::getAddress).asInt().to(target::setAddress);

它与 Binder 的区别:

Binder PropertyMapper
数据来源 Environment(配置属性) 任意 Java 对象(getter)
典型用途 配置 → 对象 对象 A → 对象 B(如 Boot 属性 → 第三方库配置)
空值处理 属性不存在 alwaysApplyingWhenNonNull() 等策略

典型场景 :Boot 的自动配置里,把 ServerProperties 的值映射到 Tomcat 的 Connector 上:

java 复制代码
PropertyMapper mapper = PropertyMapper.get().alwaysApplyingWhenNonNull();
mapper.from(properties::getPort).to(connector::setPort);

这不是配置绑定,而是"Boot 属性对象 → 第三方 API 对象"的映射。PropertyMapper 让这个映射简洁且空值安全。

这是一个可以在自己项目里复用的工具类(虽然是 Boot 内部的,但思路值得借鉴)。


第 15 节 · 4.x 相对 3.x 的变化

方面 3.x 4.1.1
@ConfigurationProperties 注解本身 --- 基本稳定
BindMethod 3.0.8 引入 沿用
ConfigurationPropertiesScan 2.2.0 引入 沿用
AOT processor 有 沿用(两个)
ConfigurationPropertiesSource 有 沿用
NotConstructorBoundInjectionFailureAnalyzer 有 沿用
包名 org.springframework.boot.context.properties 不变(未被 4.x 迁移影响)

值得注意 :context.properties 包没有 被 4.x 的大规模包迁移波及(对比 env → support 的迁移)。这说明它被视为"核心稳定 API"。

第 06 章讲过一批类从 org.springframework.boot.env / context.config 迁到了 org.springframework.boot.support。而 context.properties 保持不动------这是一个兼容性信号 :升级到 4.x 时,@ConfigurationProperties 相关的代码基本不用改。


第 16 节 · 常见故障与源码级解释

故障一:加了 @ConfigurationProperties 但注入失败

现象 :NoSuchBeanDefinitionException: MyProps

源码解释 :@ConfigurationProperties 不含 @Component,组件扫描不捡它。

解法 :@EnableConfigurationProperties(MyProps.class) 或 @ConfigurationPropertiesScan。

故障二:@Component + @ConfigurationPropertiesScan 不生效

现象 :类上有 @Component,@ConfigurationPropertiesScan 扫不到

源码解释:Javadoc 明确说 "@Component 类不会被捡起"(1.4 节)

解法 :去掉 @Component。

故障三:构造器绑定 + @Component 失败

现象 :NoSuchBeanDefinitionException,但错误信息被 NotConstructorBoundInjectionFailureAnalyzer 转成明确提示

源码解释 :构造器绑定需要在 BeanDefinition 层面准备值,@Component 路径没有(3.3、11.2 节)

解法 :用 @EnableConfigurationProperties 或 @ConfigurationPropertiesScan

故障四:校验不生效

现象 :@NotNull 没起作用

排查顺序:

  1. 类上有没有 @Validated?(5.3 节)
  2. classpath 上有没有 validation 实现?(5.2 节的 isJsr303Present)
  3. @ConfigurationProperties 的 ignoreInvalidFields 是不是 true?(会吞掉错误)

故障五:ignoreUnknownFields=false 但没报错

源码解释 :NoUnboundElementsBindHandler 只对 IterableConfigurationPropertySource 检查(第 08 章第 7.3 节)

解法:确认你的配置源是可枚举的。

故障六:属性值没绑定(字段为 null)

排查顺序:

  1. prefix 写对了?(my.name 对应 prefix="my" + 字段 name)
  2. 用的是 kebab-case?(第 06 章讲过 camelCase 的问题)
  3. 有 setter 吗?(JavaBean 绑定)
  4. 是构造器绑定吗?(有 setter 也不走)
  5. 该属性被 ignoreInvalidFields 吞掉了吗?

第 17 节 · 端到端追踪:从启动到绑定的完整链路

把第 06-09 章的内容串成一条完整的时间线。假设:

java 复制代码
@SpringBootApplication
@EnableConfigurationProperties(MyProps.class)
public class App { }

@ConfigurationProperties(prefix = "my")
@Validated
public class MyProps {
    @NotNull private String name;
    private Duration timeout = Duration.ofSeconds(10);
    // getter/setter
}

阶段 1:SpringApplication.run() 的前半程

(第 04-07 章)

scss 复制代码
run()
 ├─ prepareEnvironment
 │    ├─ getOrCreateEnvironment → ApplicationEnvironment
 │    ├─ configureEnvironment
 │    │    ├─ setConversionService(new ApplicationConversionService())   ← 第 06 章
 │    │    ├─ configurePropertySources(commandLineArgs/defaultProperties/applicationInfo)
 │    │    └─ configureProfiles(空实现)
 │    ├─ ConfigurationPropertySources.attach(environment)
 │    ├─ listeners.environmentPrepared
 │    │    └─ EnvironmentPostProcessorApplicationListener
 │    │         ├─ ConfigDataEnvironmentPostProcessor       ← 第 07 章(六步)
 │    │         ├─ RandomValuePropertySourceEnvironmentPostProcessor
 │    │         ├─ SpringApplicationJsonEnvironmentPostProcessor
 │    │         └─ SystemEnvironmentPropertySourceEnvironmentPostProcessor
 │    ├─ ApplicationInfoPropertySource.moveToEnd / DefaultPropertiesPropertySource.moveToEnd
 │    ├─ bindToSpringApplication(spring.main.*)
 │    ├─ EnvironmentConverter.convertEnvironmentIfNecessary
 │    └─ ConfigurationPropertySources.attach(第二次)
 ├─ printBanner
 └─ createApplicationContext

此时 :Environment 里有完整的属性(命令行 > 系统属性 > 环境变量 > ConfigData > defaultProperties)。

阶段 2:prepareContext 与 refresh

scss 复制代码
prepareContext
 ├─ context.setEnvironment(environment)
 ├─ applyInitializers
 └─ load(注册 primarySources)

refreshContext → AbstractApplicationContext.refresh()
 ├─ invokeBeanFactoryPostProcessors(beanFactory)
 │    ├─ ConfigurationClassPostProcessor 处理 @Configuration
 │    │    └─ 处理 @EnableConfigurationProperties
 │    │         └─ EnableConfigurationPropertiesRegistrar.registerBeanDefinitions
 │    │              ├─ 注册 ConfigurationPropertiesBindingPostProcessor  ★
 │    │              └─ 为 MyProps 注册 BeanDefinition                    ★
 │    │                   ├─ name = "my-com.example.MyProps"
 │    │                   ├─ deduceBindMethod(MyProps) → JAVA_BEAN
 │    │                   └─ BindMethodAttribute.set(definition, JAVA_BEAN)
 │    └─ 自动配置的 @AutoConfiguration 类也被处理
 │         └─ 每个自动配置类上的 @EnableConfigurationProperties 同样生效
 ├─ registerBeanPostProcessors(beanFactory)      ★ ConfigurationPropertiesBindingPostProcessor 注册为 BPP
 └─ finishBeanFactoryInitialization(beanFactory)
      └─ 实例化 MyProps
           ├─ instantiate(无参构造器)
           ├─ populateBean(依赖注入)------ MyProps 通常无依赖
           └─ initializeBean
                ├─ postProcessBeforeInitialization
                │    └─ ConfigurationPropertiesBindingPostProcessor.bind(...)   ★★ 绑定
                ├─ invokeInitMethods(@PostConstruct)
                └─ postProcessAfterInitialization

阶段 3:绑定内部

(第 08 章 + 本章第 4 节)

scss 复制代码
ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization
 └─ ConfigurationPropertiesBean.get(context, bean, beanName)
      └─ 构造 Bindable(类型 + 注解)
 └─ binder.bind(propertiesBean)
      └─ ConfigurationPropertiesBinder.bindOrCreate
           ├─ getBindHandler(target, annotation)
           │    ├─ getHandler()(IgnoreTopLevelConverterNotFound)
           │    ├─ new ConfigurationPropertiesBindHandler(...)
           │    ├─ ignoreInvalidFields? → IgnoreErrorsBindHandler
           │    ├─ !ignoreUnknownFields? → NoUnboundElementsBindHandler
           │    ├─ validators 非空? → ValidationBindHandler           ★ 校验
           │    └─ advisors
           └─ getBinder().bindOrCreate("my", target, bindHandler)
                └─ Binder.bind(...)
                     ├─ onStart
                     ├─ bindObject
                     │    └─ bindDataObject → JavaBeanBinder
                     │         ├─ bind("my.name") → "demo"
                     │         └─ bind("my.timeout") → Duration
                     ├─ onSuccess(每层)
                     │    ├─ ValidationBindHandler 记录
                     │    ├─ NoUnboundElements 记录 boundNames
                     │    └─ ConfigurationPropertiesBindHandler
                     └─ onFinish(depth==0)
                          ├─ NoUnboundElements.checkNoUnboundElements  ★ 未绑定检测
                          └─ ValidationBindHandler 执行校验              ★ 校验

阶段 4:校验

ini 复制代码
ValidationBindHandler.onFinish(depth == 0)
 └─ 对每个 Validator:
      ├─ configurationPropertiesValidator(Spring 的)
      └─ JSR-303 Validator(如果有)
           └─ @NotNull 检查 MyProps.name
                └─ 失败 → ConstraintViolationException
                     → ValidationBindHandler 抛 BindException
                     → ConfigurationPropertiesBindException
                     → FailureAnalyzer → 可读错误信息

完整链路结束。四个阶段分别对应第 06-09 章。


第 18 节 · @ConfigurationProperties 在自动配置中的角色

18.1 为什么 Boot 自己的配置都用 @ConfigurationProperties

Boot 有几百个自动配置,每个都需要"读取配置 → 决定是否启用 / 如何配置"。

如果不这样做,代码会是:

java 复制代码
// 反例:直接用 Environment
String port = environment.getProperty("server.port");
if (port != null) {
    connector.setPort(Integer.parseInt(port));   // 无类型转换、无默认值、无校验
}

有了 @ConfigurationProperties:

java 复制代码
@AutoConfiguration
@EnableConfigurationProperties(ServerProperties.class)
public class TomcatAutoConfiguration {

    @Bean
    public TomcatWebServerFactoryCustomizer tomcatCustomizer(ServerProperties properties) {
        return new TomcatWebServerFactoryCustomizer(properties);
    }
}

四个好处:

  1. 类型安全 :properties.getPort() 是 Integer,不是 String
  2. 默认值 :private int port = 8080;
  3. 元数据 :IDE 能补全 server.port
  4. 校验 :可以加 @Validated

18.2 自动配置的"三段式"模式

Boot 的自动配置几乎都遵循这个模式:

less 复制代码
① @AutoConfiguration
② @ConditionalOnXxx(条件判断)
③ @EnableConfigurationProperties(XxxProperties.class)
④ 定义 Bean,把 Properties 注入进去
⑤ 用 PropertyMapper 把 Properties 映射到第三方 API
java 复制代码
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
@ConditionalOnMissingBean(DataSource.class)
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

    @Bean
    @ConfigurationProperties("spring.datasource.hikari")   // ★ 注解在 @Bean 方法上
    public HikariDataSource dataSource(DataSourceProperties properties) {
        // ...
    }
}

注意第 ④ 行的 @ConfigurationProperties 在 @Bean 方法上 ------这是把第三方类型(HikariDataSource)配置化的标准手法(第 1.7 节讲过)。

18.3 PropertyMapper 在自动配置里的典型用法

java 复制代码
private void customizeTomcat(Tomcat tomcat, ServerProperties properties) {
    PropertyMapper mapper = PropertyMapper.get().alwaysApplyingWhenNonNull();
    mapper.from(properties::getPort).to(tomcat::setPort);
    mapper.from(properties::getAddress).to(tomcat::setAddress);
    // ...
}

alwaysApplyingWhenNonNull() 是关键------只在源值非 null 时才设置。这样"用户没配置"就不会覆盖第三方的默认值。

这解决了一个重要问题 :Boot 的 Properties 对象的默认值(如 port = 8080)与第三方库的默认值(Tomcat 的 8080)可能重复或冲突。PropertyMapper 的空值处理 + Boot 的默认值设计共同保证行为正确。

18.4 一个真实的设计权衡:默认值放哪

Boot 的惯例是:默认值写在 @ConfigurationProperties 类的字段初始化里。

java 复制代码
public class ServerProperties {
    private int port = 8080;      // ← 默认值
}

而不是:

java 复制代码
// 反例
String port = environment.getProperty("server.port", "8080");

理由:

  1. 默认值会被元数据处理器提取,显示在 IDE 提示里(defaultValue 字段)
  2. 默认值是类型安全的
  3. 集中在一处,易维护

实践建议 :迁移老代码时,把散落的 getProperty(key, default) 收拢到 @ConfigurationProperties 类的字段初始化里。这不只是"整洁",它能让 IDE 提示更准确。


第 19 节 · 性能考量

19.1 绑定的成本来源

一次 @ConfigurationProperties 绑定的成本来自:

  1. 反射 :JavaBeanBinder 用 Introspector 发现属性(有缓存)
  2. 属性查找 :每个属性都要遍历 ConfigurationPropertySource 链
  3. containsDescendantOf:判断是否有后代属性(第 08 章讲过缓存策略)
  4. 转换:每次值转换都要查 Converter

19.2 优化手段

(a)BindConverter 的共享实例(第 08 章 15.5 节)

(b)ConfigurationPropertyCaching(第 08 章 2.4 节)

(c)containsNoDescendantOf 短路(第 08 章 4.2 节)

(d)isUnbindableBean 跳过(第 08 章第 10 节)

(e)bindHandlerAdvisors 的 volatile 缓存

java 复制代码
private volatile @Nullable List<ConfigurationPropertiesBindHandlerAdvisor> bindHandlerAdvisors;

避免每次绑定都查 BeanFactory。

19.3 构造器绑定的性能优势

构造器绑定(record / 全参构造器)比 JavaBean 绑定更快:

  • 不需要 Introspector(直接读构造参数)
  • 不需要 setter 反射调用
  • 对象是不可变的,容器可以优化

实践建议 :在配置类很多的大型项目里,用构造器绑定(record)能略微加快启动。但差异通常不大------不要为了性能而改用构造器绑定,应该为了不可变性。

19.4 AOT 下的优化

AOT 模式下,@ConfigurationProperties 的反射元信息在构建期就确定了,native 下不需要运行时反射发现。这是 AOT 启动快的原因之一。

但注意:配置值本身仍是运行时读取的(第 7.3 节)。


第 20 节 · 三个完整的实战示例

20.1 多模块项目的元数据配置

模块 B(配置类型):

java 复制代码
package com.example.common.config;

@ConfigurationPropertiesSource      // ★ 关键:让模块 A 的处理器能看到
public class RetryPolicy {
    /**
     * Maximum number of retry attempts.
     */
    private int maxAttempts = 3;

    /**
     * Backoff interval between retries.
     */
    private Duration backoff = Duration.ofSeconds(1);
    // getter/setter
}

模块 B 的 build.gradle:

groovy 复制代码
dependencies {
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
}

模块 A(配置类):

java 复制代码
package com.example.app;

@ConfigurationProperties(prefix = "my.client")
public class ClientProperties {
    @NestedConfigurationProperty
    private final RetryPolicy retry = new RetryPolicy();
    // getter
}

效果 :IDE 里输入 my.client.retry. 会提示 max-attempts 和 backoff,且带描述和默认值。

没有 @ConfigurationPropertiesSource 的话 :my.client.retry 能被识别(因为类型是 RetryPolicy),但它的内部属性没有描述和默认值提示。

20.2 给第三方类型做配置化

java 复制代码
@Configuration(proxyBeanMethods = false)
public class HttpClientConfiguration {

    @Bean
    @ConfigurationProperties("my.http-client")     // ★ 注解在方法上
    public OkHttpClient.Builder okHttpClientBuilder() {
        return new OkHttpClient.Builder();
    }
}

配置:

yaml 复制代码
my:
  http-client:
    connect-timeout: 5s
    read-timeout: 30s

原理 :ConfigurationPropertiesBean 从 @Bean 方法的返回类型构造 Bindable,绑定后写回(通过 setter)。

注意 :第三方类型必须有无参构造器 + setter (JavaBean 绑定)。OkHttpClient.Builder 正好符合。

20.3 带校验和默认值的不可变配置

java 复制代码
@ConfigurationProperties(prefix = "my.service")
@Validated
public record ServiceProperties(
        @NotNull String endpoint,

        @DefaultValue("30s") Duration timeout,

        @DefaultValue("3") int maxRetries,

        @Min(1) @Max(100) int poolSize) {

    public ServiceProperties {
        // record 的紧凑构造器:额外的不变式检查
        Assert.isTrue(timeout.toMillis() > 0, "timeout must be positive");
    }
}

注册:

java 复制代码
@SpringBootApplication
@ConfigurationPropertiesScan
public class App { }

四个机制同时生效:

  1. @ConfigurationPropertiesScan 扫描注册(第 1 节)
  2. deduceBindMethod 推断为 VALUE_OBJECT(record,第 8.2 节)
  3. @DefaultValue 提供默认值(第 08 章 5.4 节)
  4. @Validated + @NotNull / @Min 校验(第 5 节)

这是 Boot 3+/4.x 推荐的现代写法 :record + 构造器绑定 + @DefaultValue + 校验。不可变、类型安全、有默认值、能校验。


第 21 节 · 14 条反直觉结论汇总

  1. @ConfigurationProperties 不含 @Component ------ 光加注解不会有 Bean。必须有 @EnableConfigurationProperties / @ConfigurationPropertiesScan / @Component 之一。

  2. @Component 与 @ConfigurationPropertiesScan 互斥 ------ Javadoc 明确说"被 @Component 标注的类不会被 @ConfigurationPropertiesScan 捡起"。加了 @Component 反而扫不到。

  3. @ConfigurationPropertiesScan 自带 @EnableConfigurationProperties ------ 不需要同时写两个。

  4. 构造器绑定的 Bean 不能走 BeanPostProcessor ------ Assert.state 显式拒绝 VALUE_OBJECT。它必须在 BeanDefinition 层面(InstanceSupplier)就准备好值。

  5. 绑定发生在 @PostConstruct 之前,但在依赖注入之后 ------ 所以 @PostConstruct 里能读到绑定后的值,但构造器里不能。

  6. bindOrCreate 保证非 null ------ @ConfigurationProperties Bean 永远是实例,即使一个相关属性都没配。这与 spring.main.* 用的 bind 不同。

  7. ignoreInvalidFields=true + ignoreUnknownFields=false 会产生困惑 ------ 转换失败的属性被 IgnoreErrors 吞掉(走 onFailure),不会被记入 boundNames,所以会被报告为"未绑定"。

  8. ValidationBindHandler 在装饰器链最外层 ------ 校验在所有绑定完成后执行,支持跨字段校验。

  9. @Validated 是校验的开关 ------ 没有它,字段上的 @NotNull 不生效。

  10. JSR-303 需要 classpath 上有实现 ------ ConfigurationPropertiesJsr303Validator.isJsr303Present 显式检测。没有 spring-boot-starter-validation 就静默不校验。

  11. Bean 名 = 前缀-类全限定名 ------ 可预测,可用于 @Qualifier;也意味着同类重复注册会被 Assert.state 拦下。

  12. @ConfigurationPropertiesSource 对绑定完全无影响** ------ 它纯粹是给编译期注解处理器的提示,用于跨模块生成元数据。

  13. NotConstructorBoundInjectionFailureAnalyzer 用 xxx.class.getSimpleName() 而非硬编码字符串 ------ 错误信息不会因改名而过期。

  14. context.properties 包在 4.x 大迁移中保持不动 ------ 对比 env → support 的迁移。这是"核心稳定 API"的信号。

补充:

  1. PropertyMapper 与 Binder 是两套东西 ------ 前者是"Java 对象 → Java 对象"的映射(自动配置里把 Properties 映射到第三方 API),后者是"配置 → Java 对象"的绑定。

  2. 默认值应写在字段初始化里 ,而不是 getProperty(key, default) ------ 前者会被元数据处理器提取,IDE 能显示。

  3. MergedAnnotation 让 @ConfigurationProperties 可被组合 ------ 自定义注解元标注它,同样生效。

  4. ConversionServiceDeducer / PropertySourcesDeducer 的存在印证了第 06 章的结论:Environment 的 ConversionService 与 BeanFactory 的不是同一个,绑定时需要"推断"用哪个。


第 22 节 · 动手验证清单

java 复制代码
// 1. 验证 @ConfigurationProperties 不含 @Component
@ConfigurationProperties("my")
public class MyProps { ... }
@Autowired MyProps props;    // ❌ NoSuchBeanDefinitionException

// 2. 验证四种注册方式
@EnableConfigurationProperties(MyProps.class)   // ✅
@ConfigurationPropertiesScan                     // ✅(且自动带 EnableConfigurationProperties)
@Component + @ConfigurationProperties            // ✅(但不被 Scan 捡起)

// 3. 验证 Bean 名
ctx.getBeanDefinitionNames()  // 含 "my-com.example.MyProps"

// 4. 验证构造器绑定不能走 BPP
@Component
@ConfigurationProperties("my")
public record MyProps(String name) { }
// → 启动失败,错误信息由 NotConstructorBoundInjectionFailureAnalyzer 生成

// 5. 验证 bindOrCreate 保证非 null
//    不配任何 my.* → MyProps 实例存在,字段为默认值

// 6. 验证 @Validated 是开关
@ConfigurationProperties("my")
public class MyProps { @NotNull private String name; }
//    不生效(无 @Validated)
@Validated
@ConfigurationProperties("my")
public class MyProps { @NotNull private String name; }
//    生效

// 7. 验证 JSR-303 依赖 classpath
//    移除 spring-boot-starter-validation → 校验静默失效

// 8. 验证 @DefaultValue
public record MyProps(@DefaultValue("30s") Duration timeout) { }
//    不配 my.timeout → 30s

// 9. 验证 @Bean 方法上的注解
@Bean @ConfigurationProperties("my.client")
public OkHttpClient.Builder builder() { ... }

// 10. 验证绑定时机
@PostConstruct
void init() { System.out.println(name); }   // ✅ 能读到绑定后的值
bash 复制代码
# 11. 查看所有配置属性 Bean
curl http://localhost:8080/actuator/configprops

# 12. 查看元数据
cat build/classes/java/main/META-INF/spring-configuration-metadata.json

# 13. 验证跨模块元数据
#     模块 B 加 @ConfigurationPropertiesSource + 注解处理器
#     → 模块 A 的 IDE 补全显示描述和默认值

# 14. 验证 ignoreInvalidFields 与 ignoreUnknownFields 的交互
@ConfigurationProperties(prefix="my", ignoreInvalidFields=true, ignoreUnknownFields=false)
#    配置一个转换失败的属性 → 报告为"未绑定"

第 23 节 · 源码索引

主题 文件
核心注解 context/properties/ConfigurationProperties.java
Bean 抽象 context/properties/ConfigurationPropertiesBean.java
注册器 context/properties/ConfigurationPropertiesBeanRegistrar.java
绑定后处理器 context/properties/ConfigurationPropertiesBindingPostProcessor.java
绑定器 context/properties/ConfigurationPropertiesBinder.java
启用注解 context/properties/EnableConfigurationProperties.java、EnableConfigurationPropertiesRegistrar.java
扫描注解 context/properties/ConfigurationPropertiesScan.java、ConfigurationPropertiesScanRegistrar.java
绑定模式标记 context/properties/BindMethodAttribute.java
构造器绑定标记 context/properties/ConstructorBound.java
已绑定属性 context/properties/BoundConfigurationProperties.java
元数据提示 context/properties/ConfigurationPropertiesSource.java
嵌套属性 context/properties/NestedConfigurationProperty.java
弃用标记 context/properties/DeprecatedConfigurationProperty.java
校验器 context/properties/ConfigurationPropertiesJsr303Validator.java
Handler 扩展点 context/properties/ConfigurationPropertiesBindHandlerAdvisor.java
冲突异常 context/properties/IncompatibleConfigurationException.java
冲突分析器 context/properties/IncompatibleConfigurationFailureAnalyzer.java
构造器绑定分析器 context/properties/NotConstructorBoundInjectionFailureAnalyzer.java
推断器 context/properties/ConversionServiceDeducer.java、PropertySourcesDeducer.java
映射工具 context/properties/PropertyMapper.java
AOT context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java、ConfigurationPropertiesBeanRegistrationAotProcessor.java
绑定异常 context/properties/ConfigurationPropertiesBindException.java
校验 handler context/properties/bind/validation/ValidationBindHandler.java

本章未逐行核实 / 待你实测的项

  1. Javadoc 中"@Component 类不会被捡起"的实现代码未追踪到(文中已声明)。
  2. getHandler() 返回 IgnoreTopLevelConverterNotFoundBindHandler 是推测(文中已声明)。
  3. ConfigurationPropertiesBindingPostProcessor 的完整流程只讲了主干。
  4. @ConfigurationPropertiesScan 与 *.imports 的交互未实测。
  5. AOT 下构造器绑定的代码生成细节在第 28 章。

下一章 :第 10 章进入 Profile 语义与多文档------spring.config.activate.*、profile-specific 文件与多文档解析。


阶段性小结(第一次收束)

@ConfigurationProperties 是 Spring Boot 配置体系的用户接口层 。它把第 08 章的 Binder 引擎包装成一个"容器管理的、可校验的、有元数据的"Bean。

五个核心机制:

  1. 注册 :@ConfigurationProperties 本身不注册 Bean------需要 @EnableConfigurationProperties / @ConfigurationPropertiesScan / @Component 之一。ConfigurationPropertiesBeanRegistrar 负责实际的 BeanDefinition 注册,并在此时就确定 BindMethod(写入 BindMethodAttribute)。

  2. 统一抽象 :ConfigurationPropertiesBean 屏蔽"注解在类上"与"注解在 @Bean 方法上"的差异------这是"给第三方类型做配置化"的技术基础。

  3. 装饰器链 :getBindHandler 把 @ConfigurationProperties 的每个属性翻译成一个 BindHandler,按顺序组装。ignoreInvalidFields → IgnoreErrorsBindHandler;ignoreUnknownFields=false → NoUnboundElementsBindHandler;校验 → ValidationBindHandler。这是"配置驱动的行为"以组合而非分支实现的典范。

  4. 绑定时机 :BeanPostProcessor 的 postProcessBeforeInitialization,在依赖注入之后、@PostConstruct 之前。但构造器绑定不走这条路------必须在 BeanDefinition 层面准备值。

  5. 诊断 :NotConstructorBoundInjectionFailureAnalyzer 这类 FailureAnalyzer 把难排查的装配错误翻译成"发生了什么 + 你该做什么"。

三个最有实践价值的结论:

  • @Component + @ConfigurationPropertiesScan 会静默失效(互斥),这是最容易踩的坑。
  • ignoreInvalidFields=true 与 ignoreUnknownFields=false 同时用会产生反直觉的"未绑定"报告。
  • 校验需要两个条件 :类上有 @Validated,且 classpath 上有 validation 实现。

下一章(第 10 章)我们进入条件注解体系 ------@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等。它们是"自动配置如何决定自己是否生效"的机制,也是 Boot "约定优于配置" 的技术实现。

第 24 节 · getHandler() 的完整实证(修正第 4 节的推测)

第 4 节我推测 getHandler() 可能是 IgnoreTopLevelConverterNotFoundBindHandler。现在有了源码实证------推测正确,而且实际情况比推测更精细:

java 复制代码
private IgnoreTopLevelConverterNotFoundBindHandler getHandler() {
    BoundConfigurationProperties bound = BoundConfigurationProperties.get(this.applicationContext);
    return (bound != null)
            ? new IgnoreTopLevelConverterNotFoundBindHandler(new BoundPropertiesTrackingBindHandler(bound::add))
            : new IgnoreTopLevelConverterNotFoundBindHandler();
}

三条信息:

(a)返回类型就是 IgnoreTopLevelConverterNotFoundBindHandler ------ 它是链的最内层。

(b)内部可选地包一个 BoundPropertiesTrackingBindHandler ------ 但只当 BoundConfigurationProperties.get(context) 非 null 时。

BoundConfigurationProperties 是从 ApplicationContext 里取的(推测是作为一个 Bean 或 attribute)。它是 Actuator 需要的数据容器------所以"是否收集已绑定属性"是条件性的:只有 Actuator(或类似功能)存在时才收集。

这是一个优雅的性能优化 :没有 Actuator 时,不做无用的属性收集。这体现了 Boot 的一贯做法------可观测性功能的开销只在启用时产生。

(c)这印证了第 08 章第 17 节的推断 ------ 我在那里说 "/actuator/configprops 的数据来源之一是 BoundPropertiesTrackingBindHandler",现在有了直接的组装证据。

24.1 完整的装饰器链(最终确认版)

从内到外:

ini 复制代码
① IgnoreTopLevelConverterNotFoundBindHandler        ← getHandler()
   └(可选)② BoundPropertiesTrackingBindHandler
③ ConfigurationPropertiesBindHandler
④ IgnoreErrorsBindHandler          (若 ignoreInvalidFields=true)
⑤ NoUnboundElementsBindHandler     (若 ignoreUnknownFields=false)
⑥ ValidationBindHandler            (若有 Validator)
⑦... advisors(按 @Order 排序)

执行顺序(onSuccess 为例) :从 ⑦ 到 ①(外层先执行,再委托内层)。 执行顺序(onStart 为例):同样从外到内。

注意 ② 的位置 :它在最内层 (仅次于 ①)。这意味着它在 ConfigurationPropertiesBindHandler 之后才被调用......

等等,不对。BoundPropertiesTrackingBindHandler 是 ① 的构造参数 ,即它是 ① 的 parent(内层)。所以 onSuccess 的执行顺序是:③ → ① → ②(③ 先,然后委托给 ①,① 委托给 ②)。

而 ② 的检查条件是 context.getConfigurationProperty() != null && name.equals(...getName())。因为它在最内层,只有真正到达"属性绑定成功"这一步才会执行 ------中间层的对象绑定(如 my.server)在被 ③ 处理后...... 嗯,实际上 onSuccess 会在每层都调用,每层都会委托到最内层。

所以 ② 的条件判断(第 08 章 17.2 节讲过)是必要的:它过滤掉中间层的对象绑定,只记录叶子属性。

24.2 一个重要的推论

IgnoreTopLevelConverterNotFoundBindHandler 在最内层意味着:它是最后 才处理 onFailure 的(因为 onFailure 从外层往内层传播,外层先有机会"救回")。

但 IgnoreErrorsBindHandler(④)在外层------它先拿到错误,如果它决定"忽略",错误就不会传播到 ①。

这解释了第 21 节第 7 条 :ignoreInvalidFields=true 时错误被 ④ 吞掉,① 根本不知道发生过错误。而 NoUnboundElementsBindHandler(⑤)在 ④ 的外层 ------它的 onSuccess 在 ④ 之前执行,而 ④ 吞错走的是 onFailure 路径,⑤ 的 onSuccess 不会被调用(异常路径)。

完整因果链确认:

csharp 复制代码
属性绑定失败
 → ③ ConfigurationPropertiesBindHandler.onFailure(默认:抛)
 → ④ IgnoreErrorsBindHandler.onFailure(吞掉,返回 null)
 → ⑤ NoUnboundElementsBindHandler 的 onSuccess **未被调用**(因为走了异常路径)
 → 该属性未加入 boundNames
 → onFinish 时被报告为"未绑定"

这个链条现在有了完整的源码证据。第 21 节第 7 条的结论从"推断"升级为"实证"。


第 25 节 · getValidators:@Validated 是开关的源码证据

java 复制代码
private List<Validator> getValidators(Bindable<?> target) {
    List<Validator> validators = new ArrayList<>(3);
    if (this.configurationPropertiesValidator != null) {
        validators.add(this.configurationPropertiesValidator);
    }
    if (this.jsr303Present && target.getAnnotation(Validated.class) != null) {
        // ...
    }
    // ...
}

两个条件都必须满足才加 JSR-303 校验器:

  1. this.jsr303Present ------ classpath 上有 JSR-303 实现
  2. target.getAnnotation(Validated.class) != null ------ 目标上有 @Validated 注解

而第二个条件用的是 target.getAnnotation(...) ------ 即 Bindable 的注解!

这完美印证了第 2.3 节的论述 :注解被装进 Bindable(来自类或 @Bean 方法),通过 ResolvableTypeDescriptor 传递,最终在这里被读取。

三类注解通过同一条通道传递:

  • ConfigurationProperties(getAnnotation() 读,用于 prefix)
  • Validated(getValidators 读,用于决定是否校验)
  • @DurationUnit / @DataSizeUnit(Converter 通过 TypeDescriptor 读)

25.1 new ArrayList<>(3) 的容量预设

java 复制代码
List<Validator> validators = new ArrayList<>(3);

预设容量 3------因为最多可能有:

  1. configurationPropertiesValidator
  2. JSR-303 validator
  3. 可能的第三个(如 @Validated 上的自定义 validator)

这是一个微优化,但也反映了作者对"最多几个"的清晰认识。

25.2 configurationPropertiesValidator 的查找

java 复制代码
private @Nullable Validator getConfigurationPropertiesValidator(ApplicationContext applicationContext) {
    if (applicationContext.containsBean(VALIDATOR_BEAN_NAME)) {
        return applicationContext.getBean(VALIDATOR_BEAN_NAME, Validator.class);
    }
    return null;
}

先 containsBean 再 getBean ------ 避免 NoSuchBeanDefinitionException 的抛出/捕获开销。

这是一个值得借鉴的习惯 :用 containsBean 做存在性检查,比 try-catch 更清晰、更快(异常构造要填充栈轨迹)。

VALIDATOR_BEAN_NAME 的值是 configurationPropertiesValidator(Spring Boot 约定的 Bean 名)。


第 26 节 · ConfigurationPropertiesCharSequenceToObjectConverter:ThreadLocal 防递归

这是一个只有 80 行但极其精巧的类。

26.1 类的来历

java 复制代码
/**
 * Copy of package-private
 * {@code org.springframework.boot.convert.CharSequenceToObjectConverter}, renamed for
 * differentiation.
 *
 * @author Phillip Webb
 * @author Andy Wilkinson
 */
final class ConfigurationPropertiesCharSequenceToObjectConverter implements ConditionalGenericConverter {

它是 CharSequenceToObjectConverter 的一个副本(因为原类是包私有,无法复用,所以复制了一份)。

这是一个值得注意的代码异味 :重复代码。但考虑到 Spring 的包结构限制(org.springframework.boot.convert 与 org.springframework.boot.context.properties 不同包),复制是代价最小的方案。

Boot 在 Javadoc 里明确说明了这是副本及其原因------这比"静默复制"好得多。

26.2 它解决什么问题

配置属性的原始值类型不一定是 String:

  • application.properties 解析出来通常是 String
  • 但 ConfigTreePropertySource 返回的是 Value(实现 CharSequence)
  • 某些自定义 source 可能返回 CharSequence 的其他实现
  • 甚至可能是 OriginTrackedValue(包装的 CharSequence)

而 ConvertiblePair 的匹配是精确类型匹配 ------(CharSequence, Object) 不会匹配 (String, Duration)。

所以需要一个 CharSequence → Object 的兜底转换器:把 CharSequence toString() 后,再走 String → Object 的转换。

java 复制代码
@Override
public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
    if (source == null) {
        return null;
    }
    return this.conversionService.convert(source.toString(), STRING, targetType);
}

26.3 ThreadLocal<Boolean> disable:防止无限递归

这是本类最精彩的部分。

java 复制代码
private final ThreadLocal<Boolean> disable = new ThreadLocal<>();

@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
    if (sourceType.getType() == String.class || this.disable.get() == Boolean.TRUE) {
        return false;
    }
    this.disable.set(Boolean.TRUE);
    try {
        boolean canDirectlyConvertCharSequence = this.conversionService.canConvert(sourceType, targetType);
        if (canDirectlyConvertCharSequence && !isStringConversionBetter(sourceType, targetType)) {
            return false;
        }
        return this.conversionService.canConvert(STRING, targetType);
    }
    finally {
        this.disable.remove();
    }
}

递归风险 :matches 内部调用 this.conversionService.canConvert(...),而 this.conversionService 就是包含本 converter 的那个 ConversionService。所以:

scss 复制代码
canConvert(CharSequence, Duration)
 → 遍历 converters,找到本 converter
 → 调 matches(CharSequence, Duration)
 → 内部调 canConvert(CharSequence, Duration)   ← 又回来了!无限递归

解法 :ThreadLocal<Boolean> disable。

  1. 进入 matches 时,先检查 disable.get() == TRUE,是则返回 false(不再递归)
  2. 否则设 disable = TRUE
  3. 执行内部的 canConvert(此时递归回来会因 disable 而短路)
  4. finally 中 disable.remove()(注意是 remove() 不是 set(null),避免 ThreadLocal 内存泄漏)

这是一个教科书级的"重入保护"模式 。同样的模式在 Spring 的其他地方也能见到(如 AbstractApplicationContext 的 prototypesCurrentlyInCreation)。

remove() vs set(null) 的区别值得记住:ThreadLocal.set(null) 会在当前线程的 map 里留一个 null 值的 entry;remove() 才是真正的清理。虽然现代 JDK 对 set(null) 有优化,但 remove() 是明确的正确写法。

26.4 isStringConversionBetter:什么时候 String 转换更好

java 复制代码
private boolean isStringConversionBetter(TypeDescriptor sourceType, TypeDescriptor targetType) {
    if (this.conversionService instanceof ApplicationConversionService applicationConversionService) {
        if (applicationConversionService.isConvertViaObjectSourceType(sourceType, targetType)) {
            // If an ObjectTo... converter is being used then there might be a
            // better StringTo... version
            return true;
        }
    }
    // StringToArrayConverter / StringToCollectionConverter are better than
    // ObjectToArrayConverter / ObjectToCollectionConverter
    return (targetType.isArray() || targetType.isCollection()) && !targetType.equals(BYTE_ARRAY);
}

两种情况认为"String 转换更好":

(a)当前走的是 ObjectTo... 转换器

isConvertViaObjectSourceType 是第 06 章讲过的 ApplicationConversionService 方法------它检查转换器是否声明了 Object.class 作为 source type。

如果当前能用的转换器是"从任意 Object 出发"的(如 ObjectToStringConverter 的逆向、ObjectToCollectionConverter),那么结果往往是通用的 (如把一个 CharSequence 当一个对象处理),而 StringTo... 版本通常更精确(如 StringToDurationConverter)。

(b)目标是数组或集合(除 byte[])

java 复制代码
return (targetType.isArray() || targetType.isCollection()) && !targetType.equals(BYTE_ARRAY);

ObjectToArrayConverter 会把 CharSequence 转成"单元素数组"(把整个字符串当一个元素); StringToArrayConverter 会做逗号分隔 ("a,b,c" → [a,b,c])。

后者才是配置绑定想要的。

byte[] 被排除 :因为 byte[] 通常表示二进制内容(如证书),不应该做逗号分隔。InputStreamSourceToByteArrayConverter 处理这种情况。

注释写得极好:

java 复制代码
// StringToArrayConverter / StringToCollectionConverter are better than
// ObjectToArrayConverter / ObjectToCollectionConverter

两行注释解释了整个方法的动机。这是 Boot 源码注释质量的又一例。

26.5 这个转换器为什么单独存在

因为它要挂在一个专门的 ConversionService 上(用于配置绑定),而不是全局的 ApplicationConversionService。

(具体挂载点我未逐行核实,但类的存在说明配置绑定有自己独立的转换链,与第 06 章讲的"两个 ConversionService 实例"呼应。)


第 27 节 · PropertySourcesDeducer 与 Binder 的构造

java 复制代码
ConfigurationPropertiesBinder(ApplicationContext applicationContext) {
    this.applicationContext = applicationContext;
    this.propertySources = new PropertySourcesDeducer(applicationContext).getPropertySources();
    this.configurationPropertiesValidator = getConfigurationPropertiesValidator(applicationContext);
    this.jsr303Present = ConfigurationPropertiesJsr303Validator.isJsr303Present(applicationContext);
}

PropertySourcesDeducer 在构造时被调用 ,推断出用于绑定的 PropertySources。

而 getBinder():

java 复制代码
private Binder getBinder() {
    Binder binder = this.binder;
    // ... 懒加载
}

volatile @Nullable Binder binder ------ 懒加载 + 缓存。因为 Binder 是不可变的(构造后 sources 不变),可以缓存复用。

对比第 08 章的 BindConverter :BindConverter 不能 缓存(非线程安全,每次新建),而 Binder 可以缓存。

差异在于:Binder 本身无状态(状态在 Context 里,每次 bind 新建 Context),而 BindConverter 持有有状态的 TypeConverterConversionService。

这是一个清晰的设计边界:无状态对象可共享,有状态对象 per-operation。Boot 在这里划得很清楚。


第 28 节 · 配置体系全景回顾(第 06-09 章)

css 复制代码
                        用户视角
                            │
                   @ConfigurationProperties
                            │
        ┌───────────────────┼───────────────────┐
        │                   │                   │
   注册机制              绑定调度              元数据
(本章第 1-3 节)    (本章第 4 节)      (本章第 9 节)
        │                   │                   │
  EnableConfiguration  ConfigurationProperties  注解处理器
  Properties / Scan    Binder(装饰器链)      (编译期)
        │                   │                   │
        └───────────────────┼───────────────────┘
                            │
                     Binder(第 08 章)
                            │
              ┌─────────────┼─────────────┐
              │             │             │
         ConfigurationPropertySources
              │             │             │
        (第 06 章的适配层) │             │
              │             │             │
              ▼             ▼             ▼
      MutablePropertySources(有序列表,第 06 章)
              │
              ├── commandLineArgs
              ├── systemProperties
              ├── systemEnvironment
              ├── ConfigData 加载的源(第 07 章)
              ├── defaultProperties
              └── applicationInfo
                            │
                            ▼
                    Environment(第 06 章)

四层的职责边界:

层 职责 扩展点
@ConfigurationProperties 用户接口、注册、校验 @EnableConfigurationProperties、ConfigurationPropertiesBindHandlerAdvisor
Binder 字符串 → 对象 BindHandler、Converter、BindConstructorProvider
Environment 属性存储与查找 PropertySource、EnvironmentPostProcessor
ConfigData 属性加载 ConfigDataLocationResolver、ConfigDataLoader、PropertySourceLoader

每一层都有明确的 SPI,且互不影响。这是 Boot 配置体系能被 Spring Cloud 等生态扩展的根本原因。


第 29 节 · 补充的 5 条结论

  1. getHandler() 返回 IgnoreTopLevelConverterNotFoundBindHandler,内部可选包 BoundPropertiesTrackingBindHandler ------ 且只在 BoundConfigurationProperties 存在时(即 Actuator 启用时)才收集。可观测性开销只在启用时产生。

  2. @Validated 是通过 target.getAnnotation(Validated.class) 检测的 ------ 从 Bindable 的注解里读,印证了"注解通过 ResolvableTypeDescriptor 传递"的机制。

  3. ConfigurationPropertiesCharSequenceToObjectConverter 用 ThreadLocal 防止 matches 的无限递归 ------ 因为 matches 内部调用了包含自己的 ConversionService。

  4. isStringConversionBetter 判断"String 转换优于 Object 转换" ------ 两种情况:ObjectTo... 转换器,或目标是数组/集合(除 byte[])。这保证了 "a,b,c" 被逗号分隔成数组,而不是当单元素。

  5. Binder 可缓存(volatile 懒加载),BindConverter 不可缓存 ------ 前者无状态(状态在 Context),后者持有有状态的 TypeConverterConversionService。


第 30 节 · 本章边界(诚实声明)

以下内容我没有逐行核实:

  • ConfigurationPropertiesBeanRegistrar 中 VALUE_OBJECT 分支的具体实现(InstanceSupplier 的设置方式)
  • ConstructorBound 注解的确切语义
  • BoundConfigurationProperties.get(applicationContext) 的具体机制
  • PropertySourcesDeducer / ConversionServiceDeducer 的推断规则
  • ConfigurationPropertiesBindHandler 的内部逻辑(如 @NestedConfigurationProperty 的处理)
  • 两个 AOT processor 的具体实现
  • ValidationBindHandler 的执行时机细节(推断为 onFinish,依据是它在链最外层)

凡涉及推断的地方都在正文标注了"推测""未核实"。


本章小结

@ConfigurationProperties 是 Spring Boot 配置体系的用户接口层 。它把第 08 章的 Binder 引擎包装成"容器管理的、可校验的、有元数据的"Bean。

五个核心机制:

  1. 注册 ------ @ConfigurationProperties 本身不注册 Bean。需要 @EnableConfigurationProperties / @ConfigurationPropertiesScan / @Component 之一。ConfigurationPropertiesBeanRegistrar 在注册时就确定 BindMethod 并写入 BindMethodAttribute(跨阶段传递元信息)。

  2. 统一抽象 ------ ConfigurationPropertiesBean 屏蔽"注解在类上"与"注解在 @Bean 方法上"的差异,这是"给第三方类型做配置化"的技术基础。

  3. 装饰器链 ------ getBindHandler 把每个注解属性翻译成一个 BindHandler。这是"配置驱动的行为以组合而非分支实现"的典范 :从内到外是 IgnoreTopLevelConverterNotFound → ConfigurationPropertiesBindHandler → IgnoreErrors(可选)→ NoUnboundElements(可选)→ Validation(可选)→ advisors。

  4. 绑定时机 ------ BeanPostProcessor.postProcessBeforeInitialization,在依赖注入之后、@PostConstruct 之前。但构造器绑定不走这条路 (Assert.state 显式拒绝 VALUE_OBJECT)。

  5. 诊断 ------ FailureAnalyzer 体系把难排查的装配错误翻译成"发生了什么 + 你该做什么"。NotConstructorBoundInjectionFailureAnalyzer 是最好的例子------它甚至用 xxx.class.getSimpleName() 避免硬编码字符串过期。

三个最有实践价值的结论:

  • @Component + @ConfigurationPropertiesScan 会静默失效(互斥),最容易踩的坑。
  • ignoreInvalidFields=true + ignoreUnknownFields=false 会产生反直觉的"未绑定"报告(现在有完整源码证据)。
  • 校验需要两个条件 :类上有 @Validated,且 classpath 上有 validation 实现。

下一章(第 10 章)进入条件注解体系 ------@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 等。它们是"自动配置如何决定自己是否生效"的机制,也是 Boot "约定优于配置"的技术实现。


附录 A · 配置体系综合排障手册(第 06-09 章整合)

本附录把四章的排障知识整合成一份可查手册。建议收藏------遇到配置问题时按图索骥。

A.1 症状 → 排查路径

症状 1:"配置值不对 / 没生效"

markdown 复制代码
1. dump PropertySource 列表(第 06 章第 9 节)
   ├─ 目标 source 不在列表?→ 文件没被加载(见症状 2)
   └─ 在 → 继续
2. 打印每个 source 里该 key 的值 → 找出"第一个命中的"是谁
   └─ 更高优先级的 source 有意外的值 → 找到了
3. key 是合法 ConfigurationPropertyName 吗?
   ├─ 否(含 @、空元素等)→ 走路径 B,宽松绑定失效(第 06 章 7.3)
   └─ 是 → 继续
4. 用哪种读取方式?
   ├─ @ConfigurationProperties → Binder 路径(第 08 章)
   ├─ @Value → BeanFactory 的 ConversionService(第 06 章 3.3)
   └─ environment.getProperty → 第 06 章路径 A/B
5. profile 是否如你所想?(第 06 章 1.2.1:Boot 关掉了 AbstractEnvironment 的自动读取)
6. AOT/native?profiles 是构建期常量(第 06 章 6.2、第 07 章 11)

症状 2:"配置文件没被加载"

arduino 复制代码
1. 用 spring.config.location 了?→ 它会替换默认位置(第 07 章 3.5)
   └─ 改用 additional-location 或 import
2. profile 文件名对吗?(application-{profile}.properties)
3. spring.config.name 被改了吗?
4. 打开 TRACE:--logging.level.org.springframework.boot.context.config=TRACE
   └─ 看 "Imported N resources [...]"
5. 位置写对了吗?相对路径按父目录解析(第 07 章 3.6)
6. optional: 前缀?非 optional 找不到会启动失败

症状 3:"@ConfigurationProperties Bean 注入失败"

scss 复制代码
1. 有没有注册方式?(@EnableConfigurationProperties / Scan / @Component)
2. 用了 @Component + @ConfigurationPropertiesScan?→ 互斥,扫不到
3. 是构造器绑定(record)吗?
   ├─ 且有 @Component?→ 必须用 Enable/Scan(NotConstructorBoundInjectionFailureAnalyzer)
   └─ 有多个构造器?→ 标 @ConstructorBinding
4. 包名在扫描范围内吗?(@ComponentScan 的包)

症状 4:"字段是 null / 默认值"

markdown 复制代码
1. prefix 对吗?
2. 属性名用 kebab-case 吗?(第 06 章:camelCase 不能被 kebab 读到)
3. 有 setter 吗?(JavaBean 绑定)
4. 是构造器绑定吗?(有 setter 也不走)
5. 被 ignoreInvalidFields 吞掉了吗?(转换失败静默)
6. 集合下标连续吗?(第 08 章 6.2)
7. 该属性所在的 source 优先级够高吗?

症状 5:"校验不生效"

markdown 复制代码
1. 类上有 @Validated 吗?(第 09 章第 5 节)
2. classpath 上有 spring-boot-starter-validation 吗?
3. ignoreInvalidFields 是 true 吗?

症状 6:"启动时报配置错误"

ini 复制代码
1. 读 Action 部分------Boot 的 FailureAnalyzer 通常给出了明确指引
2. 常见类型:
   ├─ ConfigDataNotFoundException → 加 optional: 前缀
   ├─ InvalidConfigDataPropertyException → 见第 07 章 8.1 的两张映射表
   ├─ UnboundConfigurationPropertiesException → 有拼错的属性(ignsoreUnknownFields=false 时)
   ├─ BindException(转换失败)→ 看 Value 和 Reason
   └─ NotConstructorBound... → 注册方式不对

A.2 五条"一定会踩"的坑

# 坑 机制 规避
1 spring.config.location 替换而非追加 orElse(DEFAULT_SEARCH_LOCATIONS) 用 additional-location 或 import
2 optional: 只作用于单片段 split(";") 后逐段解析 每段都加 optional:
3 集合下标不连续 for(i=0;;) break on null yaml 列表语法
4 集合不跨 source 合并 第一个非空 source 胜出 用 Map
5 @Component + Scan 互斥 Javadoc 明确声明 二选一

A.3 三个"打开就明白"的调试开关

bash 复制代码
# 1. ConfigData 全流程(最有用)
--logging.level.org.springframework.boot.context.config=TRACE

# 2. 属性查找过程
--logging.level.org.springframework.core.env=TRACE
--logging.level.org.springframework.boot.context.properties.source=TRACE

# 3. 自动配置决策(第 10 章主题)
--debug

A.4 一份可复用的 dump 代码

java 复制代码
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class ConfigDump implements ApplicationRunner {

    private final Environment environment;

    ConfigDump(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        if (!(this.environment instanceof ConfigurableEnvironment env)) {
            return;
        }
        System.out.println("\n===== PropertySources (高 → 低) =====");
        int i = 0;
        for (PropertySource<?> ps : env.getPropertySources()) {
            System.out.printf("[%2d] %-45s %s%n", i++, ps.getName(),
                    ps.getClass().getSimpleName());
        }

        System.out.println("\n===== activeProfiles =====");
        System.out.println(Arrays.toString(env.getActiveProfiles()));

        System.out.println("\n===== 关键 key 的命中情况 =====");
        for (String key : List.of("server.port", "spring.profiles.active")) {
            System.out.println(key + " => " + env.getProperty(key));
        }

        System.out.println("\n===== ConversionService 实例 =====");
        System.out.println("env: " + System.identityHashCode(env.getConversionService()));
        if (env instanceof ConfigurableApplicationContext) { /* ... */ }
    }
}

提示 :把这个类放进项目的 dev profile 下,遇到配置问题时临时启用。

A.5 配置体系的"心智模型"三句话

  1. 优先级 = 数组下标 ------ MutablePropertySources 是一个 CopyOnWriteArrayList,第一个命中胜出,没有权重计算。
  2. 四层独立,各有 SPI ------ Environment(PropertySource)→ ConfigData(Resolver/Loader)→ Binder(Handler/Converter)→ @ConfigurationProperties(注册/校验)。扩展任一层的 SPI 都不影响其他层。
  3. Boot 选择严格而非宽容 ------ 配置拼错、位置不合法、profile 冲突,都倾向于启动失败 而不是静默忽略。如果遇到"静默失效",先怀疑是不是某个 ignoreXxx 开关导致的。


附录 B · 配置相关属性速查表

B.1 spring.config.*(第 07 章)

属性 类型 默认 说明
spring.config.name String[] application 配置文件名(不含扩展名)。不支持通配 *
spring.config.location ConfigDataLocation[] DEFAULT_SEARCH_LOCATIONS 替换默认位置
spring.config.additional-location ConfigDataLocation[] 空 追加位置(默认位置仍生效)
spring.config.import ConfigDataLocation[] 空 导入任意位置,可在任何配置文件里出现,支持嵌套
spring.config.on-not-found ConfigDataNotFoundAction FAIL 全局控制"找不到时是否失败"
spring.config.activate.on-profile String[](表达式) 空 该文档块/文件在哪些 profile 下生效
spring.config.activate.on-cloud-platform CloudPlatform 空 该文档块在哪个云平台下生效

ConfigDataLocation 语法:

ini 复制代码
[optional:]<prefix>:<path>
多个位置用 ; 分隔(optional: 只作用于单片段)
目录必须以 / 结尾;文件必须有可识别的扩展名
方括号可给扩展名提示:myfile[.properties]

B.2 spring.profiles.*(第 07 章第 5 节)

属性 说明 限制
spring.profiles.active 激活的 profile 不能写在 profile-specific 文件里
spring.profiles.default 默认 profile 不能写在 profile-specific 文件里
spring.profiles.include 额外包含的 profile 不能写在 profile-specific 文件里
spring.profiles.group.<name> profile 组,激活 name 时展开成一组 支持循环定义(Set.add 防无限展开)

spring.profiles(无后缀)已被移除 ------ 改用 spring.config.activate.on-profile(InvalidConfigDataPropertyException.ERRORS 里有明确映射)。

B.3 spring.main.*(第 05 章)

属性 默认 说明
spring.main.web-application-type 自动推断 唯一跨越绑定点的属性(会触发 Environment 替换)
spring.main.banner-mode 结构化日志下 OFF,否则 CONSOLE 只写属性(getter 带参)
spring.main.lazy-initialization false
spring.main.allow-bean-definition-overriding false 2.1 起改
spring.main.allow-circular-references false 2.6 起改
spring.main.keep-alive false 无非守护线程时保持 JVM 存活
spring.main.log-startup-info true
spring.main.register-shutdown-hook true 关闭超时 10 分钟
spring.main.sources 空 Set<String>,AOT 下失效
spring.main.cloud-platform 空 幽灵属性 (元数据有,类里没有);必须写 CLOUD_FOUNDRY 而非 cloud-foundry
spring.main.show-banner --- 已弃用(since 1.3.0),用 banner-mode
spring.main.web-environment --- 已弃用

spring.main.environment-prefix 不能用属性设置 (Assert.state 禁止)------只能编程式 setEnvironmentPrefix(...)。

B.4 @ConfigurationProperties 注解属性(本章)

属性 默认 对应机制
prefix "" 绑定前缀
value --- prefix 的别名
ignoreUnknownFields true false → 加 NoUnboundElementsBindHandler
ignoreInvalidFields false true → 加 IgnoreErrorsBindHandler

注意 :这两个 ignoreXxx 是独立 的开关,可任意组合。但 ignoreInvalidFields=true + ignoreUnknownFields=false 会产生反直觉的"未绑定"报告(第 24.2 节有完整源码证据)。

B.5 相关注解速查

注解 位置 作用
@ConfigurationProperties 类 / @Bean 方法 声明配置属性
@EnableConfigurationProperties 配置类 注册(列出类或配置类)
@ConfigurationPropertiesScan 配置类 扫描注册(自带 @EnableConfigurationProperties)
@ConfigurationPropertiesSource 类型 仅元数据提示,对绑定无影响
@NestedConfigurationProperty 字段 标记嵌套对象(主要影响元数据生成)
@DeprecatedConfigurationProperty 字段/setter 元数据里标记弃用
@ConstructorBinding 构造器 多构造器时指定用哪个
@DefaultValue 构造参数 构造器绑定的默认值
@Validated 类 校验的开关
@DurationUnit / @DataSizeUnit 字段 注解驱动的单位转换(通过 TypeDescriptor 传递)
@ConfigurationPropertiesBinding Converter Bean 限定该转换器只用于配置绑定

B.6 五种常见组合模式

场景 推荐写法
可变配置类(需要 setter 语义) 无参构造器 + setter + @EnableConfigurationProperties
不可变配置类(推荐) record + @DefaultValue + @ConfigurationPropertiesScan
给第三方类型配置化 @Bean 方法 + 方法上的 @ConfigurationProperties
带校验 上述任一 + @Validated + 字段上的 JSR-303 注解 + classpath 有 validation
跨模块嵌套配置 嵌套类型加 @ConfigurationPropertiesSource + 该模块启用注解处理器

一份"现代写法"模板

java 复制代码
// 配置类(不可变 + 默认值 + 校验)
@ConfigurationProperties(prefix = "my.service")
@Validated
public record ServiceProperties(
        @NotNull String endpoint,
        @DefaultValue("30s") Duration timeout,
        @DefaultValue("3") int maxRetries) {

    public ServiceProperties {
        Assert.hasText(endpoint, "endpoint must not be blank");
        Assert.isTrue(maxRetries >= 0, "maxRetries must be >= 0");
    }
}

// 注册
@SpringBootApplication
@ConfigurationPropertiesScan
public class App { }

// 使用
@Service
public class MyService {
    private final ServiceProperties props;
    MyService(ServiceProperties props) { this.props = props; }
}

这个模板同时用到了本章的六个机制:

  1. @ConfigurationPropertiesScan 扫描注册(自带 @EnableConfigurationProperties)
  2. record → deduceBindMethod 推断为 VALUE_OBJECT
  3. 构造器绑定(不走 BeanPostProcessor,BeanDefinition 层面准备值)
  4. @DefaultValue 提供默认值
  5. @Validated + @NotNull 校验(两条件:注解 + classpath)
  6. 紧凑构造器做额外的不变式检查

附录结束。第 06-09 章构成了 Spring Boot 配置体系的完整图景。从第 10 章开始进入自动配置与条件注解------那是 Boot "约定优于配置" 的技术核心。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序
java·后端·开源
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件
java·百度·交友
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源
希洛兔1 小时前
开闭原则详解:对扩展开放,对修改关闭(含图形编辑器反例 + 抽象正例 + JDK/Spring 实战)
java·开闭原则
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 03 章 · 模块化的自动配置:`*.imports`机制、classic 兼容层与 Starter 生成
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理
java·后端·开源
geats人山人海1 小时前
java 8 字符串
java·开发语言
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 04 章 · `SpringApplication` 全景:`run()` 的十二步状态机
java·后端·开源
鱼宵1 小时前
支付网关收官:Spring Boot 里,策略、模板方法、观察者怎么一起干活
spring boot·后端·面试