Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径

13 阅读16分钟

Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径

本章定位

第 06 章我们指出:Environment 里有三套并存的属性查找机制:

  1. 原生 PropertySourcesPropertyResolver(精确名查找)
  2. ConfigurationPropertySourcesPropertyResolver(宽松名查找,路径 A/B)
  3. Binder(本章主题)

前两章已经覆盖了前两套和 ConfigData(往 Environment 里塞数据)。现在是最后一块:Environment 里已经有了 "30s" 这个字符串,它怎么变成 Duration.ofSeconds(30)?server.port=8080 怎么变成 ServerProperties 对象的 port 字段?

绝大多数人只知道答案的一半:@ConfigurationProperties + Converter。但真正的引擎是 Binder,它比"调 Converter"复杂得多:

  • 它要处理聚合类型(List/Map/Array),而聚合的写法有 [0] 下标、逗号分隔、Map 的任意 key 三种
  • 它要处理嵌套对象,且要防止无限递归
  • 它要支持构造器绑定(不可变对象)和 JavaBean 绑定(setter)两种模式,并自动选择
  • 它要知道"哪些属性被绑定了、哪些没有",以支持 @ConfigurationProperties(ignoreUnknownFields=false) 的报错
  • 它要处理占位符 ${...}
  • 它要在失败时给出"哪个属性、哪个值、来自哪个文件哪一行"的完整信息

本章逐行拆解 Binder、Bindable、JavaBeanBinder、ValueObjectBinder、BindHandler 的完整实现。


第 1 节 · Bindable:绑定目标的完整描述

1.1 为什么不能只用 Class<T>

最朴素的绑定 API 是:

T bind(String prefix, Class<T> type)

但 Boot 需要表达的能力远超 "一个 Class":

需求需要的额外信息
List<String> 的元素类型泛型信息 → ResolvableType
绑定到已有实例(而非新建)现有实例引用
绑定到 Supplier 提供的值Supplier
强制用构造器绑定还是 JavaBean 绑定BindMethod
禁止直接绑定(只能绑定后代)BindRestriction
参数上的注解(如 @DefaultValue)Annotation[]

所以 Bindable<T> 应运而生。

1.2 Bindable 的字段与工厂方法

public final class Bindable<T> {

	private final ResolvableType type;
	private final @Nullable BindMethod bindMethod;
	private final @Nullable Object existingValue;
	private final @Nullable Supplier<T> supplier;
	private final @Nullable BindRestriction bindRestriction;
	private final Annotation[] annotations;
	// ...
}

六个字段,全部 final。

工厂方法:

// 1. 按类型新建
static <T> Bindable<T> of(Class<T> type)
static <T> Bindable<T> of(ResolvableType type)

// 2. 绑定到已有实例(第 05 章 ApplicationProperties 用的就是这个)
static <T> Bindable<T> ofInstance(T instance)

// 3. 用 Supplier 提供实例
static <T> Bindable<T> ofSupplier(Supplier<T> supplier)

// 4. 聚合类型
static <E> Bindable<List<E>> listOf(Class<E> elementType)
static <E> Bindable<Set<E>> setOf(Class<E> elementType)
static <K, V> Bindable<Map<K, V>> mapOf(Class<K> keyType, Class<V> valueType)

1.3 ResolvableType 为什么是必需的

Class<T> 在 Java 里丢失泛型信息:List<String> 和 List<Integer> 的 Class 都是 List.class。

而绑定 my.list[0] 时,Binder 必须知道元素是 String 还是 Integer —— 否则不知道该调哪个 Converter。

ResolvableType 是 Spring Framework 的泛型信息载体,它能完整表达 Map<String, List<Duration>> 这样的嵌套泛型。

这是 Spring Boot 绑定能力远超 @Value 的根本原因:@Value("${my.list}") 只能转成 String 或简单类型(因为注解里没有完整的泛型上下文);而 @ConfigurationProperties 的字段有 ResolvableType,能绑定到任意复杂的嵌套泛型结构。

1.4 BindMethod:4.x 的显式绑定模式选择

/**
 * Configuration property binding methods.
 *
 * @author Andy Wilkinson
 * @since 3.0.8
 */
public enum BindMethod {

	/**
	 * Java Bean using getter/setter binding.
	 */
	JAVA_BEAN,

	/**
	 * Value object using constructor binding.
	 */
	VALUE_OBJECT

}

@since 3.0.8 —— 一个小版本加入的枚举,通常是为修复具体问题而引入:给 @ConfigurationProperties 提供显式指定绑定模式的能力,解决"类同时有 setter 和多个构造器"时的歧义。

Bindable.of(Class) 的 getBindMethod() 返回 null(未指定),由 Binder 自动推断(第 5 节)。

显式指定的方式(@ConfigurationProperties 之外,编程式):

Bindable.of(MyType.class).withBindMethod(BindMethod.VALUE_OBJECT)

1.5 BindRestriction.NO_DIRECT_PROPERTY

在 findProperty 里用到:

private <T> @Nullable ConfigurationProperty findProperty(ConfigurationPropertyName name, Bindable<T> target,
        Context context) {
    if (name.isEmpty() || target.hasBindRestriction(BindRestriction.NO_DIRECT_PROPERTY)) {
        return null;
    }
    // ...
}

禁止"直接绑定"——即该目标只能通过绑定其后代属性来构造,不能有 my.prop=value 这种直接赋值。

用在哪?聚合类型的元素(第 4 节)以及某些嵌套场景。例如 my.list[0] 的绑定:如果目标是 List<String>,my.list=a,b,c 是合法(逗号分隔);但如果目标是 List<MyObject>,my.list=value 无意义,只能 my.list[0].name=xxx。

1.6 ofInstance 与 bind vs bindOrCreate

第 05 章讲过 spring.main.* 用的是:

Binder.get(environment).bind("spring.main", Bindable.ofInstance(this.properties));

注意是 bind 而不是 bindOrCreate。

区别:

  • bind(name, target):返回 BindResult<T>,可能为 null(未绑定成功)。对 ofInstance,成功时返回的就是那个已有实例
  • bindOrCreate(name, target):保证返回非 null——绑定不到就创建一个新实例(用 dataObjectBinder.create)
public <T> T bindOrCreate(ConfigurationPropertyName name, Bindable<T> target, @Nullable BindHandler handler) {
    // create = true
}

看 handleBindResult 的 create 分支:

if (result == null && create) {
    result = fromDataObjectBinders(target.getBindMethod(),
            (dataObjectBinder) -> dataObjectBinder.create(target, context));
    if (result == null) {
        IllegalStateException ex = new IllegalStateException(
                "Unable to create instance for " + target.getType());
        List<DataObjectBinder> dataObjectBinders = this.dataObjectBinders.get(target.getBindMethod());
        Assert.state(dataObjectBinders != null, "'dataObjectBinders' must not be null");
        dataObjectBinders
            .forEach((dataObjectBinder) -> dataObjectBinder.onUnableToCreateInstance(target, context, ex));
        throw ex;
    }
    result = handler.onCreate(name, target, context, result);
    result = context.getConverter().convert(result, target);
}

创建失败时的错误信息增强:

dataObjectBinders.forEach((dataObjectBinder) -> dataObjectBinder.onUnableToCreateInstance(target, context, ex));

每个 DataObjectBinder 都被问一遍:"你能给出更好的错误信息吗?"

ValueObjectBinder 的实现:

@Override
public <T> void onUnableToCreateInstance(Bindable<T> target, Context context, RuntimeException exception) {
    try {
        ValueObject.get(target, context, this.constructorProvider, Discoverer.STRICT);
    }
    catch (Exception ex) {
        exception.addSuppressed(ex);
    }
}

它故意用 STRICT 模式再解析一次,期待这次会抛出更具体的错误(比如"构造器参数 port 缺少默认值"),然后 addSuppressed 挂到原异常上。

这是一个精巧的错误信息增强手法:正常绑定用 LENIENT(宽容,失败就返回 null 继续尝试其他 binder);但当所有 binder 都失败时,回头用 STRICT 再跑一次,只为拿到一个"真正的原因",然后作为 suppressed 附加到异常上。

这样用户看到的错误信息既有"无法创建实例"的顶层描述,又有"具体是哪个构造器参数有问题"的底层原因。


第 2 节 · Binder 的构造与两个 binder 的选择

2.1 构造器

Binder(Iterable<ConfigurationPropertySource> sources, @Nullable PlaceholdersResolver placeholdersResolver,
        @Nullable ConversionService conversionService, @Nullable Consumer<PropertyEditorRegistry> propertyEditorInitializer,
        @Nullable BindHandler defaultBindHandler, @Nullable BindConstructorProvider constructorProvider) {
    // ...
    if (constructorProvider == null) {
        constructorProvider = BindConstructorProvider.DEFAULT;
    }
    ValueObjectBinder valueObjectBinder = new ValueObjectBinder(constructorProvider);
    // ...
}

2.2 dataObjectBinders 的分类存储

Binder 内部按 BindMethod 分类存储 DataObjectBinder:

private final Map<@Nullable BindMethod, List<DataObjectBinder>> dataObjectBinders;

三类 key:JAVA_BEAN、VALUE_OBJECT、null(未指定)。

fromDataObjectBinders:

private @Nullable Object fromDataObjectBinders(@Nullable BindMethod bindMethod,
        Function<DataObjectBinder, @Nullable Object> operation) {
    List<DataObjectBinder> dataObjectBinders = this.dataObjectBinders.get(bindMethod);
    Assert.state(dataObjectBinders != null, "'dataObjectBinders' must not be null");
    for (DataObjectBinder dataObjectBinder : dataObjectBinders) {
        Object bound = operation.apply(dataObjectBinder);
        if (bound != null) {
            return bound;
        }
    }
    return null;
}

顺序遍历,第一个返回非 null 的胜出。

所以 BindMethod 未指定(null)时,列表里既有 ValueObjectBinder 又有 JavaBeanBinder,谁先谁后决定了默认行为。

这是"构造器绑定优先于 setter 绑定"这一默认行为的物理实现。具体顺序我未逐行核实构造代码,但从 ValueObjectBinder 在构造器中被首先创建、以及 Boot 文档"构造器绑定优先"可以确认方向。

可实证的判断方法:一个有 @ConstructorBinding 的类,即使同时有 setter,也会走构造器路径。

2.3 BindConstructorProvider 与 @ConstructorBinding

if (constructorProvider == null) {
    constructorProvider = BindConstructorProvider.DEFAULT;
}

BindConstructorProvider.DEFAULT 的实际实现是 DefaultBindConstructorProvider。它的职责:给定一个类,返回应该用于绑定的构造器(或 null 表示"没有构造器绑定")。

@ConstructorBinding 注解:

public record MyProps(@ConstructorBinding ... ) { ... }

实际上 @ConstructorBinding 用在构造器上(当类有多个构造器时指定用哪个);对于单构造器的类,Boot 会自动使用它(DefaultBindConstructorProvider 的规则)。

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

  • 类只有一个构造器 → 自动构造器绑定,不需要 @ConstructorBinding
  • 类有多个构造器 → 必须在一个上标 @ConstructorBinding,否则编译/启动报错
  • 想用 setter 绑定(可变对象)→ 提供无参构造器 + setter,且不要有多个构造器

2.4 ConfigurationPropertyCaching.CacheOverride

try (ConfigurationPropertyCaching.CacheOverride cacheOverride = this.configurationPropertyCaching.override()) {
    // 绑定过程
}

绑定期间覆盖缓存策略。这是性能优化:绑定过程会大量查询 ConfigurationPropertySource(尤其是 containsDescendantOf),期间启用缓存能显著加速;绑定结束后恢复默认策略。

try-with-resources 保证恢复——这是"作用域化的临时策略"的标准写法。

性能提示:ConfigurationPropertyCaching 的存在说明 containsDescendantOf 是热路径。而它的实现(第 06 章讲过)对非 enumerable source 只能返回 UNKNOWN——这会导致缓存失效并退化为保守行为。所以:让你的 PropertySource 是 enumerable 的,能显著提升启动性能。


第 3 节 · bind() 的六步生命周期

3.1 完整流程

@Contract("_, _, _, _, _, true -> !null")
private <T> @Nullable T bind(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
        Context context, boolean allowRecursiveBinding, boolean create) {
    try (ConfigurationPropertyCaching.CacheOverride cacheOverride = this.configurationPropertyCaching.override()) {
        try {
            // ① onStart:handler 可以拒绝绑定或替换 target
            Bindable<T> replacementTarget = handler.onStart(name, target, context);
            if (replacementTarget == null) {
                return handleBindResult(name, target, handler, context, null, create);
            }
            target = replacementTarget;

            // ② 真正的绑定
            Object bound = bindObject(name, target, handler, context, allowRecursiveBinding);

            // ③ onSuccess + 转换 + onCreate + onFinish
            return handleBindResult(name, target, handler, context, bound, create);
        }
        catch (Exception ex) {
            // ④ 错误处理
            return handleBindError(name, target, handler, context, ex);
        }
    }
}

3.2 handleBindResult:成功路径的四个动作

private <T> @Nullable T handleBindResult(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
        Context context, @Nullable Object result, boolean create) throws Exception {
    if (result != null) {
        result = handler.onSuccess(name, target, context, result);     // (a)
        result = context.getConverter().convert(result, target);        // (b)
    }
    if (result == null && create) {
        result = fromDataObjectBinders(target.getBindMethod(),
                (dataObjectBinder) -> dataObjectBinder.create(target, context));   // (c)
        // ... onCreate + convert
    }
    handler.onFinish(name, target, context, result);                    // (d)
    return context.getConverter().convert(result, target);
}

四个动作:

  • (a) onSuccess:handler 有机会替换成功的结果(如做校验、装饰)
  • (b) convert:转换到目标类型
  • (c) create(仅 create=true 且结果为 null):用 DataObjectBinder.create 创建实例,然后 onCreate
  • (d) onFinish:无条件调用(成功/失败/创建都调),用于清理与统计

onFinish 的无条件调用是"未绑定属性检测"的实现基础:NoUnboundElementsBindHandler 在 onStart 记录"尝试绑定的属性",在 onFinish 记录"实际用到的属性",两者相减得到"未绑定的属性"。

3.3 handleBindError:handler 可以"救回"失败

private <T> @Nullable T handleBindError(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
        Context context, Exception error) {
    try {
        Object result = handler.onFailure(name, target, context, error);
        return context.getConverter().convert(result, target);
    }
    catch (Exception ex) {
        if (ex instanceof BindException bindException) {
            throw bindException;
        }
        throw new BindException(name, target, context.getConfigurationProperty(), ex);
    }
}

onFailure 的返回值如果是非 null,绑定就"成功"了——handler 吞掉了错误并提供了一个替代值。

这就是 IgnoreErrorsBindHandler 的实现方式:

// handler/IgnoreErrorsBindHandler.java
@Override
public Object onFailure(ConfigurationPropertyName name, Bindable<?> target, Context context, Exception error) {
    return null;    // 吞掉错误,返回 null
}

(具体返回值视实现而定,但语义是"忽略错误"。)

如果 onFailure 自己也抛异常,则包装成 BindException 抛出。注意:

if (ex instanceof BindException bindException) {
    throw bindException;      // 已经是 BindException 则直接抛,不重复包装
}
throw new BindException(name, target, context.getConfigurationProperty(), ex);

避免异常嵌套过深。

3.4 BindException 携带的三个上下文

throw new BindException(name, target, context.getConfigurationProperty(), ex);
  • name:绑定失败的属性名
  • target:绑定目标(Bindable)
  • context.getConfigurationProperty():当前正在处理的 ConfigurationProperty(含 Origin!)

第三个是错误信息质量的来源。正因为携带了 ConfigurationProperty,BindException 才能输出:

Property: my.timeout
Value: "abc"
Origin: class path resource [application.yml] - 12:9
Reason: failed to convert java.lang.String to java.time.Duration

Origin 在这里被带进异常——第 07 章讲的 OriginTrackedMapPropertySource 的成果最终在这里兑现。


第 4 节 · bindObject:四条分支与它们的优先级

这是 Binder 最核心的分派逻辑:

private <T> @Nullable Object bindObject(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
        Context context, boolean allowRecursiveBinding) {
    ConfigurationProperty property = findProperty(name, target, context);                      // (1)
    if (property == null && context.depth != 0 && containsNoDescendantOf(context.getSources(), name)) {
        return null;                                                                            // (2) 短路优化
    }
    AggregateBinder<?> aggregateBinder = getAggregateBinder(target, context);                   // (3)
    if (aggregateBinder != null) {
        return bindAggregate(name, target, handler, context, aggregateBinder);                  // (4) 聚合分支
    }
    if (property != null) {
        try {
            return bindProperty(target, context, property);                                     // (5) 简单属性分支
        }
        catch (ConverterNotFoundException ex) {
            // We might still be able to bind it using the recursive binders
            boolean fallbackToDefaultValue = ObjectUtils.isEmpty(property.getValue());
            Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding,
                    fallbackToDefaultValue);                                                     // (6) 降级到对象绑定
            if (instance != null) {
                return instance;
            }
            throw ex;
        }
    }
    return bindDataObject(name, target, handler, context, allowRecursiveBinding, false);         // (7) 对象绑定
}

4.1 分支优先级(重要)

1. 聚合类型(Map/Collection/Array)  ← 最优先,即使有对应的简单属性
2. 有直接属性 → 简单属性绑定
   └─ 若 Converter 找不到 → 降级到对象绑定
3. 无直接属性 → 对象绑定(遍历后代属性)

反直觉结论 #1:聚合类型的判断优先于简单属性。所以如果目标是 List<String>,即使存在 my.list=xxx 这样的简单属性,也会先走 CollectionBinder——由 CollectionBinder 决定是"逗号分隔"还是"下标"还是"单个值转单元素集合"。

4.2 短路优化(第 2 步)

if (property == null && context.depth != 0 && containsNoDescendantOf(context.getSources(), name)) {
    return null;
}

三个条件同时满足才短路:

  • property == null:没有直接属性
  • context.depth != 0:不是顶层(顶层即使没属性也要走 bindDataObject,因为可能需要 create)
  • containsNoDescendantOf(...):确定没有任何后代属性

containsNoDescendantOf 依赖 ConfigurationPropertySource.containsDescendantOf,返回 PRESENT / ABSENT / UNKNOWN 三态。只有确定 ABSENT 才短路。

这解释了第 2.4 节的性能提示:非 enumerable 的 source 返回 UNKNOWN,导致无法短路,必须继续尝试 bindDataObject(要遍历所有字段去试)。这是"enumerable 更快"的机制原因。

4.3 bindProperty:简单属性的三步

private <T> @Nullable Object bindProperty(Bindable<T> target, Context context, ConfigurationProperty property) {
    context.setConfigurationProperty(property);      // (a) 记录当前属性(供异常携带 Origin)
    Object result = property.getValue();             // (b) 取原始值
    result = this.placeholdersResolver.resolvePlaceholders(result);   // (c) 解析 ${...}
    result = context.getConverter().convert(result, target);          // (d) 类型转换
    return result;
}

四个动作,顺序固定:记录 → 取值 → 解析占位符 → 转换。

(a) setConfigurationProperty 是错误信息质量的保障——后续任何转换失败,handleBindError 都能从 context 里拿到这个 property(含 Origin)。

(c) 占位符解析在转换之前:${my.timeout} 先被解析成 "30s",再转成 Duration。顺序反了会失败("${my.timeout}" 无法转成 Duration)。

(d) 转换由 BindConverter(第 6 节)完成。

4.4 getAggregateBinder:三种聚合

private @Nullable AggregateBinder<?> getAggregateBinder(Bindable<?> target, Context context) {
    Class<?> resolvedType = target.getType().resolve(Object.class);
    if (Map.class.isAssignableFrom(resolvedType)) {
        return new MapBinder(context);
    }
    if (Collection.class.isAssignableFrom(resolvedType)) {
        return new CollectionBinder(context);
    }
    if (target.getType().isArray()) {
        return new ArrayBinder(context);
    }
    return null;
}

注意判断顺序:Map → Collection → Array。

因为 Map 不是 Collection(Java 里 Map 不继承 Collection),所以顺序其实不影响正确性。但每次都 new——MapBinder/CollectionBinder/ArrayBinder 是每次绑定新建的,不是单例。

为什么每次新建? 因为它们持有 Context,而 Context 是绑定过程专用的。这是"有状态的策略对象"而非无状态单例。

4.5 bindAggregate 与 AggregateElementBinder

private <T> @Nullable Object bindAggregate(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
        Context context, AggregateBinder<?> aggregateBinder) {
    AggregateElementBinder elementBinder = (itemName, itemTarget, source) -> {
        boolean allowRecursiveBinding = aggregateBinder.isAllowRecursiveBinding(source);
        Supplier<?> supplier = () -> bind(itemName, itemTarget, handler, context, allowRecursiveBinding, false);
        return context.withSource(source, supplier);
    };
    Supplier<@Nullable Object> supplier = () -> aggregateBinder.bind(name, target, elementBinder);
    return context.withIncreasedDepth(supplier);
}

两个关键机制:

(a)context.withSource(source, supplier) —— 绑定某个元素时,临时把 source 列表换成"该元素所在的那个 source":

private <T> T withSource(@Nullable ConfigurationPropertySource source, Supplier<T> supplier) {
    if (source == null) {
        return supplier.get();
    }
    ConfigurationPropertySource previous = this.source.get(0);
    this.source.set(0, source);
    this.sourcePushCount++;
    try {
        return supplier.get();
    }
    finally {
        this.sourcePushCount--;
        this.source.set(0, previous);
    }
}

而 getSources():

@Override
public Iterable<ConfigurationPropertySource> getSources() {
    if (this.sourcePushCount > 0) {
        return this.source;         // 只返回那一个 source
    }
    return Binder.this.sources;     // 返回全部
}

为什么? 因为聚合元素来自不同的 PropertySource:

# application.properties
my.list[0]=a
my.list[1]=b
# application-extra.yml
my.list[2]=c

如果不隔离 source,绑定 my.list[2] 时可能会从 application.properties 里读到别的值。隔离后,my.list[2] 只从它实际存在的那个 source 读。

这是一个容易被忽略但重要的正确性细节:聚合绑定是"按 source 分段"的。这也意味着同一个 list 的不同下标可以来自不同的配置文件,且它们的相对顺序由 source 优先级和下标共同决定。

(b)withIncreasedDepth —— 增加深度计数。depth 被用在短路优化里(context.depth != 0)。


第 5 节 · 两种 DataObjectBinder:JavaBean vs ValueObject

5.1 JavaBeanBinder:setter 绑定

(第 05 章讲过它的属性发现逻辑,这里补充绑定过程。)

核心流程:

  1. 用 java.beans.Introspector 发现所有属性(getter/setter 对)
  2. 对每个属性,构造 ConfigurationPropertyName(父名 + 属性名)
  3. 调 propertyBinder.bind(propertyName, propertyTarget) 递归绑定
  4. 用 setter 写回

BeanProperty 的类型推导(第 05 章读过):从 getter 的返回类型和 setter 的参数类型共同决定,用 ResolvableType 保留泛型。

addMethodIfPossible 的约束(第 05 章讲过):getter 必须是 0 参数,setter 必须是 1 参数。这解释了 getBannerMode(Environment) 不被认作 getter。

5.2 ValueObjectBinder:构造器绑定

@Override
public <T> @Nullable T bind(ConfigurationPropertyName name, Bindable<T> target, Binder.Context context,
        DataObjectPropertyBinder propertyBinder, boolean fallbackToDefaultValue) {
    ValueObject<T> valueObject = ValueObject.get(target, context, this.constructorProvider, Discoverer.LENIENT);
    if (valueObject != null) {
        Class<?> targetType = target.getType().resolve();
        Assert.state(targetType != null, "'targetType' must not be null");
        context.pushConstructorBoundTypes(targetType);
        List<ConstructorParameter> parameters = valueObject.getConstructorParameters();
        List<@Nullable Object> args = new ArrayList<>(parameters.size());
        boolean bound = false;
        for (ConstructorParameter parameter : parameters) {
            Object arg = parameter.bind(propertyBinder);
            bound = bound || arg != null;                    // ★ 只要有一个参数绑定成功
            arg = (arg != null) ? arg : getDefaultValue(context, parameter);
            args.add(arg);
        }
        context.clearConfigurationProperty();
        context.popConstructorBoundTypes();
        if (bound) {
            return valueObject.instantiate(args);
        }
    }
    if (fallbackToDefaultValue) {
        return getNewDefaultValueInstanceIfPossible(context, target.getType());
    }
    return null;
}

关键逻辑:bound = bound || arg != null

只要任何一个构造参数成功绑定,就构造实例。未绑定的参数用 getDefaultValue 填(可能是 null、原始类型默认值、或 @DefaultValue 指定的值)。

这解释了一个常见困惑:为什么 @ConfigurationProperties 的 record 类,即使只在 yaml 里配了一个字段,对象也能被创建?——因为 bound = true,其余字段用默认值。

反之:如果一个字段都没配,bound = false,返回 null → 整个 @ConfigurationProperties 对象是 null 或用 create() 创建全默认值实例。

pushConstructorBoundTypes / popConstructorBoundTypes:

context.pushConstructorBoundTypes(targetType);
// ...
context.popConstructorBoundTypes();

维护一个 Deque<Class<?>> constructorBindings,isNestedConstructorBinding() 判断是否在构造器绑定的嵌套中。

用途:DefaultBindConstructorProvider 用它来判断"这是不是一个嵌套的构造器绑定"——因为嵌套的构造器绑定不需要 @ConstructorBinding 注解(只有顶层需要显式指定)。

5.3 Kotlin 支持

import kotlin.reflect.KFunction;
import kotlin.reflect.KParameter;
import kotlin.reflect.jvm.ReflectJvmMapping;

以及:

import org.springframework.core.KotlinDetector;

ValueObjectBinder 显式支持 Kotlin 的 data class。因为 Kotlin 的构造参数默认值、可空性等元数据在 Java 反射里不可见,必须用 Kotlin 反射。

这是 Boot 对 Kotlin 一等公民支持的证据:不是"能跑",而是在绑定引擎内部专门处理 Kotlin 的反射模型。

5.4 @DefaultValue:构造参数的默认值

private <T> @Nullable T getDefaultValue(Binder.Context context, ConstructorParameter parameter) {
    ResolvableType type = parameter.getType();
    Annotation[] annotations = parameter.getAnnotations();
    for (Annotation annotation : annotations) {
        if (annotation instanceof DefaultValue defaultValueAnnotation) {
            String[] defaultValue = defaultValueAnnotation.value();
            if (defaultValue.length == 0) {
                return getNewDefaultValueInstanceIfPossible(context, type);
            }
            return convertDefaultValue(context.getConverter(), defaultValue, type, annotations);
        }
    }
    // ...
}

@DefaultValue 的用法:

@ConfigurationProperties("my")
public record MyProps(
        @DefaultValue("30s") Duration timeout,
        @DefaultValue({"a", "b"}) List<String> items,
        @DefaultValue ServerInfo server      // 无 value → 创建该类型的"默认实例"
) {}

三种形态:

  1. @DefaultValue("30s") — 字符串值,走 Converter
  2. @DefaultValue({"a", "b"}) — 数组,用于集合类型
  3. @DefaultValue(无 value)— 创建该类型的"空实例"(getNewDefaultValueInstanceIfPossible)

这是 Boot 的一个重要便利:不可变对象(record)也能有默认值,不需要写 default 分支或在构造器里做处理。

而且 defaultValue.length == 0 的判断说明:空 value 与不写 value 是一回事(String[] 默认空数组)。

5.5 Discoverer.LENIENT vs STRICT

ValueObject.get(target, context, this.constructorProvider, Discoverer.LENIENT);   // 正常绑定
// ...
ValueObject.get(target, context, this.constructorProvider, Discoverer.STRICT);    // 错误信息增强

两种"发现构造器"的严格度:

  • LENIENT:找不到合适的构造器就返回 null(继续尝试 JavaBeanBinder)
  • STRICT:找不到就抛异常(带详细原因)

onUnableToCreateInstance 用 STRICT 再跑一次,只为拿详细错误(3.1 节讲过)。


第 6 节 · 聚合绑定:三种写法的共存与冲突

6.1 IndexedElementsBinder:下标扫描的实现

ArrayBinder 和 CollectionBinder 都继承 IndexedElementsBinder:

abstract class IndexedElementsBinder<T> extends AggregateBinder<T> {

	private static final String[] INDEXES;
	static {
		INDEXES = new String[10];
		for (int i = 0; i < INDEXES.length; i++) {
			INDEXES[i] = "[" + i + "]";
		}
	}
	// ...
}

INDEXES 预生成 [0] 到 [9] —— 这是一个微优化,避免频繁拼接字符串。超过 9 的下标会走 appendIndex 动态生成。

6.2 三条绑定路径

bindIndexed(source, root, target, elementBinder, result, aggregateType, elementType):

private void bindIndexed(ConfigurationPropertySource source, ConfigurationPropertyName root, Bindable<?> target,
        AggregateElementBinder elementBinder, IndexedCollectionSupplier collection, ResolvableType aggregateType,
        ResolvableType elementType) {
    ConfigurationProperty property = source.getConfigurationProperty(root);
    if (property != null) {
        getContext().setConfigurationProperty(property);
        bindValue(target, collection.get(), aggregateType, elementType, property.getValue());   // ★ 路径一
    }
    else {
        bindIndexed(source, root, elementBinder, collection, elementType);                       // ★ 路径二
    }
}

路径一:直接值(my.list=a,b,c)

private void bindValue(Bindable<?> target, Collection<Object> collection, ResolvableType aggregateType,
        ResolvableType elementType, @Nullable Object value) {
    if (value == null || (value instanceof CharSequence charSequence && charSequence.isEmpty())) {
        return;
    }
    Object aggregate = convert(value, aggregateType, target.getAnnotations());
    ResolvableType collectionType = ResolvableType.forClassWithGenerics(collection.getClass(), elementType);
    Collection<Object> elements = convert(aggregate, collectionType);
    if (elements != null) {
        collection.addAll(elements);
    }
}

空字符串被跳过(charSequence.isEmpty())——所以 my.list= 得到空集合而不是含一个空字符串的集合。这是一个贴心的细节。

转换两步:

  1. convert(value, aggregateType, ...) —— 把 "a,b,c" 转成 List(用 DelimitedStringToCollectionConverter)
  2. convert(aggregate, collectionType) —— 再转成带正确元素类型的集合

为什么需要两步? 第一步的 aggregateType 可能不含完整的元素泛型(比如从 @Value 场景进来),第二步补上。这是"渐进式补全泛型信息"的写法。

路径二:下标扫描(my.list[0]=a、my.list[1]=b)

private void bindIndexed(ConfigurationPropertySource source, ConfigurationPropertyName root,
        AggregateElementBinder elementBinder, IndexedCollectionSupplier collection, ResolvableType elementType) {
    Set<String> knownIndexedChildren = new HashSet<>();
    if (source instanceof IterableConfigurationPropertySource iterableSource) {
        knownIndexedChildren = getKnownIndexedChildren(iterableSource, root);
    }
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        ConfigurationPropertyName name = appendIndex(root, i);
        Object value = elementBinder.bind(name, Bindable.of(elementType), source);
        if (value == null) {
            break;                                    // ★ 遇到第一个 null 就停
        }
        knownIndexedChildren.remove(name.getLastElement(Form.UNIFORM));
        collection.get().add(value);
    }
    if (source instanceof IterableConfigurationPropertySource iterableSource) {
        assertNoUnboundChildren(knownIndexedChildren, iterableSource, root);
    }
}

三个要点:

(a)for (int i = 0; i < Integer.MAX_VALUE; i++) + break

这个循环的上界是 Integer.MAX_VALUE,靠 value == null 来终止。这意味着:

  • 下标必须从 0 开始连续
  • 一旦中间断档([0]、[2] 但没有 [1]),[2] 不会被绑定

反直觉结论 #2:my.list[0]=a、my.list[2]=c 只会得到 [a]——c 被静默丢弃。

这是 Boot 配置里一个著名的坑。下标必须连续。如果不连续,用 assertNoUnboundChildren 会报错吗?

(b)assertNoUnboundChildren —— 断档检测

if (source instanceof IterableConfigurationPropertySource iterableSource) {
    assertNoUnboundChildren(knownIndexedChildren, iterableSource, root);
}

流程:

  1. getKnownIndexedChildren(iterableSource, root) —— 枚举出该 source 里所有形如 root[i] 的属性名
  2. 每成功绑定一个下标,从 knownIndexedChildren 里 remove
  3. 循环结束后,剩下的就是"存在但没被绑定"的下标 → assertNoUnboundChildren 抛异常

所以断档不会静默,会报错:

Property: my.list[2]
Origin: class path resource [application.yml]
Reason: ...(未绑定/下标不连续)

但前提是 source 是 IterableConfigurationPropertySource!对于非 enumerable 的 source(如某些自定义 source、安全受限的 systemEnvironment),knownIndexedChildren 为空,断档无法检测,会静默丢弃。

这是第 06 章"enumerable vs 非 enumerable"差异的又一个后果:非 enumerable 的 source 不仅宽松绑定降级,连"下标断档检测"也失效。

(c)isAllowRecursiveBinding

@Override
protected boolean isAllowRecursiveBinding(@Nullable ConfigurationPropertySource source) {
    return source == null || source instanceof IterableConfigurationPropertySource;
}

只有 enumerable source 才允许递归绑定(元素本身是复杂对象的情况)。非 enumerable 时不允许,防止不可控的递归。

6.3 多 source 的聚合

protected final void bindIndexed(ConfigurationPropertyName name, Bindable<?> target,
        AggregateElementBinder elementBinder, ResolvableType aggregateType, ResolvableType elementType,
        IndexedCollectionSupplier result) {
    for (ConfigurationPropertySource source : getContext().getSources()) {
        bindIndexed(source, name, target, elementBinder, result, aggregateType, elementType);
        if (result.wasSupplied() && result.get() != null) {
            return;                       // ★ 第一个成功的 source 就返回
        }
    }
}

遍历所有 source,第一个成功产出非空结果的 source 胜出。

这是一个极其重要的语义:集合不跨 source 合并。

# application.properties
my.list[0]=a
my.list[1]=b
# application-prod.yml(优先级更高)
my.list[0]=x

结果:my.list = [x],不是 [x, b]。

集合是"整体替换"而非"逐元素合并"。这与 Map 不同(见 6.4)。

实践陷阱:很多人想在 application.properties 里定义一个 list 的默认全量,然后在 profile 文件里"覆盖其中一项"。这做不到——profile 文件里的 list 会整体替换。

解法:用 Map 代替 List(my.map.a=1、my.map.b=2,Map 会合并),或者接受整体替换。

6.4 MapBinder:Map 会合并

MapBinder 不继承 IndexedElementsBinder(Map 不是顺序索引)。它的行为是:遍历所有 source,收集所有 root.<key> 形式的属性,合并成一个 Map(同 key 时高优先级 source 胜出)。

Map 与集合的关键差异:

  • List/Array:整体替换(第一个非空 source 胜出)
  • Map:逐 key 合并(高优先级 source 的 key 覆盖低优先级的同名 key)

这是"为什么用 Map 做配置比 List 灵活"的机制原因。

6.5 三种写法的优先级

对于目标是 List<String> 的 my.list:

配置写法走哪条路径结果
my.list=a,b,c路径一(直接值 + 逗号分隔)[a, b, c]
my.list[0]=a, my.list[1]=b路径二(下标扫描)[a, b]
两者都有路径一优先(property != null)[a, b, c],下标形式被忽略

注意最后一行:如果同一个 source 里既有 my.list= 又有 my.list[0]=,直接值胜出,下标形式完全被忽略(因为 property != null 走了 bindValue 分支)。

这会造成难以理解的 bug。实践规则:同一属性不要混用两种写法。

6.6 逗号分隔的转义

my.list=a,b,c

如果想让某个元素本身包含逗号:

my.list[0]=a\,b

或者在 yaml 里用列表语法:

my:
  list:
    - "a,b"
    - c

yaml 的列表语法会被解析成 my.list[0]、my.list[1](走路径二),每个元素的原样值。

推荐做法:复杂值用 yaml 列表语法,避免逗号分隔的转义问题。


第 7 节 · BindHandler:绑定的五个钩子

7.1 接口定义

public interface BindHandler {

	BindHandler DEFAULT = new BindHandler() { };    // 空实现

	default <T> @Nullable Bindable<T> onStart(ConfigurationPropertyName name, Bindable<T> target, BindContext context) {
		return target;
	}

	default @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			Object result) {
		return result;
	}

	default @Nullable Object onFailure(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			Exception error) throws Exception {
		throw error;
	}

	default void onFinish(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			@Nullable Object result) throws Exception {
	}

	default <T> @Nullable T onCreate(ConfigurationPropertyName name, Bindable<T> target, BindContext context,
			T result) {
		return result;
	}
}

五个钩子,全部有默认实现(default 方法),所以 BindHandler.DEFAULT 可以是一个空实现:

BindHandler DEFAULT = new BindHandler() { };

五个钩子的语义:

钩子时机返回值的作用典型用途
onStart绑定开始前返回 null 表示跳过这次绑定;返回新 Bindable 表示替换目标过滤、改写目标
onSuccess绑定成功可替换结果装饰、校验
onFailure绑定失败可返回替代值("救回"),或抛异常忽略错误、降级
onFinish无论如何(最后)无清理、统计
onCreatebindOrCreate 创建实例后可替换实例初始化

7.2 AbstractBindHandler:装饰器链

public abstract class AbstractBindHandler implements BindHandler {

	private final @Nullable BindHandler parent;

	protected AbstractBindHandler(@Nullable BindHandler parent) {
		this.parent = parent;
	}

	protected final @Nullable BindHandler getParent() {
		return this.parent;
	}
	// ... 每个方法默认委托给 parent
}

这是标准的装饰器/责任链模式。每个 handler 持有一个 parent,默认把所有调用委托给 parent,只覆写自己关心的。

组合方式:

BindHandler handler = new IgnoreErrorsBindHandler(
                     new NoUnboundElementsBindHandler(
                     new MyCustomHandler()));

这解释了 @ConfigurationProperties 的多重行为是怎么叠加的:ignoreUnknownFields=false → 加 NoUnboundElementsBindHandler;ignoreInvalidFields=true → 加 IgnoreErrorsBindHandler。它们不是 if-else 分支,而是装饰器链的组装。

7.3 三个内置 handler

IgnoreErrorsBindHandler
// handler/IgnoreErrorsBindHandler.java
public class IgnoreErrorsBindHandler extends AbstractBindHandler {
    // onFailure 返回 null(或默认值),吞掉错误
}

对应 @ConfigurationProperties(ignoreInvalidFields = true)。

IgnoreTopLevelConverterNotFoundBindHandler
// handler/IgnoreTopLevelConverterNotFoundBindHandler.java

名字很长但语义明确:只忽略"顶层"的 ConverterNotFoundException。

用在什么场景?当 @ConfigurationProperties 的目标是一个简单类型(如 String),而配置里没有对应的属性时,绑定会抛 ConverterNotFoundException。这个 handler 让这种情况不报错。

"TopLevel"的限定很关键:它只忽略顶层的(深度 0),嵌套属性里的转换错误照样报错。这避免了"忽略错误"的范围过大导致真正的配置错误被掩盖。

NoUnboundElementsBindHandler
public class NoUnboundElementsBindHandler extends AbstractBindHandler {

	private final Set<ConfigurationPropertyName> boundNames = new HashSet<>();
	private final Set<ConfigurationPropertyName> attemptedNames = new HashSet<>();
	private final Function<ConfigurationPropertySource, Boolean> filter;

	@Override
	public <T> @Nullable Bindable<T> onStart(ConfigurationPropertyName name, Bindable<T> target, BindContext context) {
		this.attemptedNames.add(name);
		return super.onStart(name, target, context);
	}

	@Override
	public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			Object result) {
		this.boundNames.add(name);
		return super.onSuccess(name, target, context, result);
	}

	@Override
	public @Nullable Object onFailure(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			Exception error) throws Exception {
		if (error instanceof UnboundConfigurationPropertiesException) {
			throw error;         // ★ 不吞这个异常
		}
		return super.onFailure(name, target, context, error);
	}

	@Override
	public void onFinish(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			@Nullable Object result) throws Exception {
		if (context.getDepth() == 0) {
			checkNoUnboundElements(name, context);
		}
	}
	// ...
}

两个集合 + 一个检查:

  • attemptedNames:onStart 时记录"尝试绑定的名字"
  • boundNames:onSuccess 时记录"实际绑定成功的名字"
  • onFinish(depth == 0,即顶层)时:检查"source 里有哪些属性没被绑定"
private void checkNoUnboundElements(ConfigurationPropertyName name, BindContext context) {
    Set<ConfigurationProperty> unbound = new TreeSet<>();
    for (ConfigurationPropertySource source : context.getSources()) {
        if (source instanceof IterableConfigurationPropertySource && this.filter.apply(source)) {
            collectUnbound(name, unbound, (IterableConfigurationPropertySource) source);
        }
    }
    if (!unbound.isEmpty()) {
        throw new UnboundConfigurationPropertiesException(unbound);
    }
}

关键约束:只对 IterableConfigurationPropertySource 检查。

又一次看到"enumerable"的重要性:如果 source 不可枚举,就无法知道"有哪些属性",也就无法检测未绑定属性。

所以:@ConfigurationProperties(ignoreUnknownFields=false) 只对可枚举的 source 生效。对非 enumerable 的 source(如安全的 systemEnvironment),未知字段不会被检测。

onFailure 里的特判:

if (error instanceof UnboundConfigurationPropertiesException) {
    throw error;
}

不吞 UnboundConfigurationPropertiesException —— 这是它自己抛的异常,必须传播(否则嵌套检查的结果会丢失)。

filter 的作用:

private final Function<ConfigurationPropertySource, Boolean> filter;

允许调用方指定"哪些 source 参与检查"。典型用途:只检查用户自己的配置文件,不检查系统环境变量(因为环境变量里可能有大量无关的属性,如 PATH、HOME)。

这是一个必要的过滤:如果不加 filter,绑定 my.* 时会去检查 systemEnvironment 里有没有 MY_XXX 未被绑定——而系统环境变量数量庞大且不可控,会产生大量误报。


第 8 节 · UnboundConfigurationPropertiesException:配置拼写错误的最后防线

8.1 异常的价值

my:
  timeotu: 30s      # ← 拼错了(timeout → timeotu)

如果没有检测,MyProps.timeout 会是 null,应用可能启动成功但行为异常——这是最难排查的一类 bug。

NoUnboundElementsBindHandler + UnboundConfigurationPropertiesException 把它变成启动失败:

Description:

Binding to target [Bindable@xxx type = MyProps, ...] failed:

    Property: my.timeotu
    Value: "30s"
    Origin: class path resource [application.yml] - 3:5
    Reason: The elements [my.timeotu] were left unbound.

Action:

Update your application's configuration

8.2 默认行为与开关

Boot 的默认:ignoreUnknownFields = true(即不启用 NoUnboundElementsBindHandler)。

要启用:

@ConfigurationProperties(prefix = "my", ignoreUnknownFields = false)
public class MyProps { ... }

实践建议:建议在生产项目里开启 ignoreUnknownFields = false。它能在启动期捕获配置拼写错误,代价几乎为零。

唯一的注意点:如果你的 @ConfigurationProperties 类的字段少于配置文件里的属性(比如配置文件里写了但代码里还没加字段),会启动失败。这在灰度发布配置时需要注意。

8.3 TreeSet 保证错误信息有序

Set<ConfigurationProperty> unbound = new TreeSet<>();

用 TreeSet 而非 HashSet —— 未绑定属性按自然顺序输出,错误信息可读性好。

这是一个小但体现功力的细节:错误信息的可读性被认真对待。随机顺序的 20 个未绑定属性会让人抓狂,有序的则一眼能看出规律。


第 9 节 · Context 的防递归与深度管理

9.1 dataObjectBindings Deque

private final Deque<Class<?>> dataObjectBindings = new ArrayDeque<>();

private <T> @Nullable T withDataObject(Class<?> type, Supplier<@Nullable T> supplier) {
    this.dataObjectBindings.push(type);
    try {
        return withIncreasedDepth(supplier);
    }
    finally {
        this.dataObjectBindings.pop();
    }
}

private boolean isBindingDataObject(Class<?> type) {
    return this.dataObjectBindings.contains(type);
}

而 bindDataObject 里:

if (!allowRecursiveBinding && context.isBindingDataObject(type)) {
    return null;
}

防止自我递归:绑定类型 A 的过程中不能再绑定 A(除非 allowRecursiveBinding)。

考虑:

public class Node {
    private Node parent;     // 自引用
    private String name;
}

绑定 my.parent.name 时,会递归绑定 parent(类型还是 Node)。如果没有防递归,会无限递归。

Deque.contains(type) 检测"当前绑定栈里是否已有这个类型" → 有则返回 null,切断递归。

allowRecursiveBinding 什么时候为 true?

  • 聚合元素绑定时:aggregateBinder.isAllowRecursiveBinding(source)(第 6.2 节:只有 enumerable source 才 true)
  • 所以:对于 enumerable source,允许一定程度的类型递归(配合下标深度限制);否则不允许。

9.2 depth 与 withIncreasedDepth

private int depth;

private <T> @Nullable T withIncreasedDepth(Supplier<@Nullable T> supplier) {
    increaseDepth();
    try {
        return supplier.get();
    }
    finally {
        decreaseDepth();
    }
}

depth 的用途:

  1. 短路优化(context.depth != 0,第 4.2 节)
  2. NoUnboundElementsBindHandler.onFinish 只在 depth == 0 时检查
  3. BindContext.getDepth() 暴露给 handler

9.3 constructorBindings Deque

private final Deque<Class<?>> constructorBindings = new ArrayDeque<>();

void pushConstructorBoundTypes(Class<?> value) {
    this.constructorBindings.push(value);
}

boolean isNestedConstructorBinding() {
    return !this.constructorBindings.isEmpty();
}

void popConstructorBoundTypes() {
    this.constructorBindings.pop();
}

ValueObjectBinder 用它判断"是否在构造器绑定的嵌套内"。用途:DefaultBindConstructorProvider 据此决定嵌套类型是否需要 @ConstructorBinding(第 5.2 节讲过)。

9.4 configurationProperty 的保存

private @Nullable ConfigurationProperty configurationProperty;

void setConfigurationProperty(ConfigurationProperty configurationProperty) {
    this.configurationProperty = configurationProperty;
}

void clearConfigurationProperty() {
    this.configurationProperty = null;
}

bindProperty 里 setConfigurationProperty(property),handleBindError 里读取它来构造 BindException。

而 ValueObjectBinder 在绑定完所有参数后:

context.clearConfigurationProperty();
context.popConstructorBoundTypes();

主动清除——因为构造器绑定阶段设置的 configurationProperty 在实例化后不应再影响后续的错误报告。


第 10 节 · isUnbindableBean:跳过无意义的绑定

private boolean isUnbindableBean(ConfigurationPropertyName name, Bindable<?> target, Context context) {
    for (ConfigurationPropertySource source : context.getSources()) {
        if (source.containsDescendantOf(name) == ConfigurationPropertyState.PRESENT) {
            // We know there are properties to bind so we can't bypass anything
            return false;
        }
    }
    Class<?> resolved = target.getType().resolve(Object.class);
    if (resolved.isPrimitive() || NON_BEAN_CLASSES.contains(resolved)) {
        return true;
    }
    return resolved.getName().startsWith("java.");
}

两段判断:

第一段:如果任何 source 明确表示"有后代属性"(PRESENT),不跳过。

第二段:类型是原始类型、NON_BEAN_CLASSES 之一、或 java. 开头的 → 跳过。

NON_BEAN_CLASSES 里的典型成员(Boot 定义):String、Number 系列的包装类、Date、Enum、Class、UUID、Locale 等——这些"看起来像对象但不应按 Bean 处理"的类型。

这解释了为什么 java.time.Duration 不会被当成 Bean 去绑 duration.seconds:java.time.Duration 的名字以 java. 开头 → 跳过 Bean 绑定 → 走 Converter。

这是一个重要的性能与正确性优化:没有它,Binder 会尝试把 Duration 当 JavaBean 处理(找它的 getter/setter),浪费时间且可能产生奇怪的结果。

反面推论:自定义类型(非 java. 开头)会被当 Bean 处理。这就是为什么你自定义的类型如果要走 Converter 而不是 Bean 绑定,需要确保它没有"看起来像属性"的 getter/setter,或者注册一个 Converter(Converter 优先于 Bean 绑定——见第 4.1 节的分支顺序)。


第 11 节 · 诊断:五类常见绑定错误

错误一:转换失败

Property: my.timeout
Value: "abc"
Origin: class path resource [application.yml] - 12:9
Reason: failed to convert java.lang.String to java.time.Duration

源码路径:bindProperty → context.getConverter().convert(...) 抛 ConversionFailedException → handleBindError → BindException。

关键点:Origin 来自 ConfigurationProperty(由 OriginTrackedMapPropertySource 提供)。

错误二:未绑定属性

The elements [my.timeotu] were left unbound.

源码路径:NoUnboundElementsBindHandler.onFinish(depth=0)→ checkNoUnboundElements → UnboundConfigurationPropertiesException。

前提:ignoreUnknownFields=false 且 source 是 enumerable。

错误三:下标断档

my.list[2] 未被绑定(下标不连续)

源码路径:IndexedElementsBinder.bindIndexed → assertNoUnboundChildren。

前提:source 是 IterableConfigurationPropertySource。

错误四:无法创建实例

Unable to create instance for com.example.MyProps
(suppressed: 构造器参数 port 缺少默认值 ...)

源码路径:handleBindResult 的 create 分支 → fromDataObjectBinders(...create) 全失败 → 抛 IllegalStateException 前先调 onUnableToCreateInstance 收集 suppressed 原因。

错误五:循环依赖

(自引用类型导致的绑定失败)

源码路径:isBindingDataObject 返回 true → bindDataObject 返回 null → 最终属性为 null 或绑定失败。


第 12 节 · 12 条反直觉结论汇总

  1. 聚合类型的判断优先于简单属性。my.list=a,b,c 和 my.list[0]=a 都由 CollectionBinder 处理,即使存在直接属性也先走聚合分支。

  2. 下标必须连续。my.list[0] + my.list[2] 只得到 [a],[2] 被丢弃(但 enumerable source 下会报错,非 enumerable 下静默)。

  3. 集合不跨 source 合并。application.properties 的 my.list[0..1] 与 application-prod.yml 的 my.list[0] 不会合并,后者整体替换。

  4. Map 会跨 source 逐 key 合并,与集合的"整体替换"语义相反。这是"用 Map 做配置更灵活"的机制原因。

  5. 同一属性混用"逗号分隔"和"下标"时,逗号分隔胜出,下标形式被完全忽略。

  6. java. 开头的类型和 NON_BEAN_CLASSES 不会被当 Bean 绑定。Duration 之所以走 Converter 而非 Bean 绑定,就是因为 isUnbindableBean 的这条规则。

  7. onFailure 的返回值能"救回"绑定。返回非 null 就等于绑定成功——这是 IgnoreErrorsBindHandler 的实现方式。

  8. NoUnboundElementsBindHandler 只对 enumerable source 生效。非 enumerable 的 source 里,未知字段不会被检测——即使你开了 ignoreUnknownFields=false。

  9. filter 让"未绑定检查"能排除系统环境变量。否则 PATH、HOME 之类的环境变量会造成大量误报。

  10. ValueObjectBinder 只要有一个构造参数绑定成功就构造实例(bound = bound || arg != null),其余用 @DefaultValue 或类型默认值。

  11. onUnableToCreateInstance 故意用 STRICT 模式再解析一次,只为拿到详细原因,然后 addSuppressed。正常绑定用 LENIENT。

  12. @DefaultValue 的三种形态:带字符串值(走 Converter)、带数组值(集合)、无值(创建该类型的空实例)。

补充三条:

  1. 绑定期间会覆盖 ConfigurationPropertyCaching 策略(try-with-resources),这是为大量 containsDescendantOf 调用做的优化。

  2. 聚合元素绑定时临时替换 source 列表(withSource),保证 my.list[2] 只从它所在的那个 source 读值。

  3. 空字符串的集合值被跳过:my.list= 得到空集合,而不是含一个空字符串的集合。


第 13 节 · 动手验证清单

// 1. 验证集合不跨 source 合并
//    application.properties: my.list[0]=a  my.list[1]=b
//    application-prod.yml:   my.list[0]=x
//    --spring.profiles.active=prod → my.list == [x](不是 [x, b])

// 2. 验证下标断档
//    my.list[0]=a  my.list[2]=c
//    → 报错(enumerable)或静默得到 [a](非 enumerable)

// 3. 验证逗号分隔 vs 下标
//    my.list=a,b,c  同时  my.list[0]=z
//    → [a, b, c](下标被忽略)

// 4. 验证 Map 合并
//    application.properties: my.map.a=1  my.map.b=2
//    application-prod.yml:   my.map.a=99
//    → {a=99, b=2}

// 5. 验证未绑定检测
@ConfigurationProperties(prefix = "my", ignoreUnknownFields = false)
//    my.timeotu=30s → 启动失败

// 6. 验证构造器绑定"一个字段即可"
public record MyProps(String a, String b) {}
//    my.a=hello → MyProps[a=hello, b=null](bound=true)

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

// 8. 验证 JavaBean vs 构造器优先级
//    一个类同时有无参构造器+setter 和 全参构造器
//    → 走构造器绑定

// 9. 验证 isUnbindableBean
//    目标是 Duration,配 my.timeout.seconds=30
//    → 不会绑定(java. 开头 → 跳过 Bean 绑定)

// 10. 验证 onFailure 救回
Binder binder = new Binder(sources);
BindResult<MyType> r = binder.bind("my", Bindable.of(MyType.class),
        new IgnoreErrorsBindHandler());
//    → 转换失败时不抛,返回 null/默认值
# 11. 打开绑定过程的调试(Binder 自身没有 TRACE,但可以)
java -jar app.jar --logging.level.org.springframework.boot.context.properties.bind=TRACE

# 12. 用 ApplicationContextRunner 做单元测试
new ApplicationContextRunner()
    .withUserConfiguration(MyConfig.class)
    .withPropertyValues("my.timeout=30s")
    .run(context -> assertThat(context).hasSingleBean(MyProps.class));

第 14 节 · 源码索引

主题文件
绑定引擎context/properties/bind/Binder.java
绑定目标context/properties/bind/Bindable.java
结果context/properties/bind/BindResult.java
绑定模式context/properties/bind/BindMethod.java
上下文Binder.Context(内部类)
处理器接口context/properties/bind/BindHandler.java
处理器基类context/properties/bind/AbstractBindHandler.java
未绑定检测context/properties/bind/handler/NoUnboundElementsBindHandler.java
忽略错误context/properties/bind/handler/IgnoreErrorsBindHandler.java
顶层转换失败忽略context/properties/bind/handler/IgnoreTopLevelConverterNotFoundBindHandler.java
JavaBean 绑定context/properties/bind/JavaBeanBinder.java
构造器绑定context/properties/bind/ValueObjectBinder.java
构造器提供者context/properties/bind/BindConstructorProvider.java、DefaultBindConstructorProvider.java
@ConstructorBindingcontext/properties/bind/ConstructorBinding.java
@DefaultValuecontext/properties/bind/DefaultValue.java
聚合基类context/properties/bind/AggregateBinder.java
索引聚合context/properties/bind/IndexedElementsBinder.java
Map / Collection / Arraycontext/properties/bind/{MapBinder,CollectionBinder,ArrayBinder}.java
转换器context/properties/bind/BindConverter.java
占位符context/properties/bind/PlaceholdersResolver.java、PropertySourcesPlaceholdersResolver.java
异常context/properties/bind/BindException.java、UnboundConfigurationPropertiesException.java
已绑定追踪context/properties/bind/BoundPropertiesTrackingBindHandler.java
属性名规范化context/properties/bind/DataObjectPropertyName.java
运行时 hintscontext/properties/bind/BindableRuntimeHintsRegistrar.java

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

  1. "构造器绑定优先于 setter"这一顺序未逐行核实构造代码(文中已声明,是从创建次序推断的)。
  2. ConfigurationPropertyCaching 的实现细节未展开。
  3. ValueObjectBinder 的参数名发现机制(是否依赖 -parameters 编译参数)未实测。
  4. ApplicationConversionService 注册的 Converter 全集未列出。
  5. BindHandler 链在异常路径上的行为未逐个验证。

下一章:第 09 章进入 @ConfigurationProperties 全生命周期——注册、绑定、校验与 AOT。


阶段性小结(写至第 14 节)

Binder 是 Spring Boot 配置体系的"最后一公里"——把 Environment 里的字符串变成 Java 对象。它的设计可以用三个词概括:

分派:bindObject 的四条分支(聚合 → 简单属性 → 降级对象 → 对象)决定了每种类型走哪条路径。理解这个分派顺序,就理解了"为什么这个配置这么绑"。

递归:嵌套对象、嵌套集合、嵌套 Map 都通过 DataObjectPropertyBinder / AggregateElementBinder 递归回 bind()。而 Context 的 depth、dataObjectBindings、constructorBindings 三个栈结构保证递归有界、可检测、可诊断。

可干预:BindHandler 的五个钩子 + 装饰器链,让"忽略错误""未绑定检测""自定义校验"这些横切关注点以组合而非分支的方式实现。

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

  • 集合不合并,Map 合并。想在多配置文件里"增量覆盖",用 Map 而不是 List。
  • ignoreUnknownFields=false 值得开,但要知道它只对 enumerable source 生效。
  • 未绑定检测、下标断档检测、短路优化——这三处能力都依赖 IterableConfigurationPropertySource。让你的 PropertySource 可枚举,否则会丢失一系列能力。

下一章(第 09 章)我们上升到 @ConfigurationProperties 本身:Binder 是被谁调用的?ConfigurationPropertiesBindingPostProcessor 如何工作?ConfigurationPropertiesBean 的注册与校验(JSR-303)如何接入?以及 4.x 里 @ConfigurationProperties 在 AOT 下的处理。

第 15 节 · BindConverter:转换的最后一环

15.1 类注释里的关键信息

/**
 * Utility to handle any conversion needed during binding. This class is not thread-safe
 * and so a new instance is created for each top-level bind.
 */
final class BindConverter {

"This class is not thread-safe and so a new instance is created for each top-level bind."

每次顶层绑定都新建一个 BindConverter。为什么?因为它持有 TypeConverterConversionService,而后者包装的 SimpleTypeConverter 有可变状态(当前正在转换的 PropertyEditor 等)。

这是"有状态转换器必须 per-operation 实例化"的必然结果。这也是为什么 Binder 本身不是设计为"全局单例 + 并发绑定"用的。

15.2 delegates 的构造顺序

private BindConverter(@Nullable List<ConversionService> conversionServices,
        @Nullable Consumer<PropertyEditorRegistry> propertyEditorInitializer) {
    List<ConversionService> delegates = new ArrayList<>();
    delegates.add(new TypeConverterConversionService(propertyEditorInitializer));     // ① PropertyEditor
    boolean hasApplication = false;
    if (!CollectionUtils.isEmpty(conversionServices)) {
        for (ConversionService conversionService : conversionServices) {
            delegates.add(conversionService);                                          // ② 用户传入的
            hasApplication = hasApplication || conversionService instanceof ApplicationConversionService;
        }
    }
    if (!hasApplication) {
        delegates.add(ApplicationConversionService.getSharedInstance());               // ③ 兜底
    }
    this.delegates = Collections.unmodifiableList(delegates);
}

三层委托链,顺序至关重要:

#delegate说明
①TypeConverterConversionServicePropertyEditor 体系(Spring 的老式转换机制)
②用户传入的 ConversionService通常是 ApplicationConversionService(Environment 的或 BeanFactory 的)
③ApplicationConversionService.getSharedInstance()兜底(不可变实例,第 06 章讲过)

反直觉结论 #16:PropertyEditor 的优先级高于 ConversionService。

这是历史兼容的必然:Spring 从 1.0 就有 PropertyEditor(java.beans 标准),ConversionService 是 Spring 3.0 才引入的。Boot 在绑定时必须两者都支持,且老机制优先——否则已有的 PropertyEditor 注册会失效。

实践含义:如果你注册了一个 PropertyEditor 和一个 Converter 来处理同一对类型,PropertyEditor 会赢。这通常不是你想要的。

TypeConverterConversionService 内置了什么
import org.springframework.beans.propertyeditors.CustomBooleanEditor;
import org.springframework.beans.propertyeditors.CustomNumberEditor;
import org.springframework.beans.propertyeditors.FileEditor;

至少这三个:

  • CustomBooleanEditor — "true"/"on"/"yes"/"1" → true
  • CustomNumberEditor — 数字
  • FileEditor — 字符串 → File

CustomBooleanEditor 的存在解释了:为什么 my.enabled=on、my.enabled=yes、my.enabled=1 都能绑到 boolean。这是 java.beans 的传统宽松语义,不是 Boot 的 Converter 做的。

15.3 convert 的容错遍历

private @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType,
        TypeDescriptor targetType) {
    ConversionException failure = null;
    for (ConversionService delegate : this.delegates) {
        try {
            if (delegate.canConvert(sourceType, targetType)) {
                return delegate.convert(source, sourceType, targetType);
            }
        }
        catch (ConversionException ex) {
            if (failure == null && ex instanceof ConversionFailedException) {
                failure = ex;         // ★ 只记录第一个 ConversionFailedException
            }
        }
    }
    if (source == null) {
        return null;
    }
    throw (failure != null) ? failure : new ConverterNotFoundException(sourceType, targetType);
}

三个设计点:

(a)canConvert 先判断,再 convert

避免对不支持的转换抛异常。

(b)转换失败不立即抛出,而是记录并继续尝试下一个 delegate

catch (ConversionException ex) {
    if (failure == null && ex instanceof ConversionFailedException) {
        failure = ex;
    }
}

只保留第一个 ConversionFailedException(failure == null 判断)。后续的失败被丢弃。

为什么只保留第一个? 因为第一个 delegate(PropertyEditor)通常是最"宽容"的,它失败往往是因为值真的有问题。而后续 delegate 的失败可能是"不支持这个类型"而非"值有问题"。

保留第一个 ConversionFailedException 能给出最有价值的错误信息(值有问题 vs 类型不支持)。

(c)source == null 时返回 null 而不抛异常

if (source == null) {
    return null;
}
throw (failure != null) ? failure : new ConverterNotFoundException(sourceType, targetType);

null 源值不触发转换失败。这与第 4 节的 bindProperty 配合——属性没找到时根本不会调 bindProperty。

15.4 ConverterNotFoundException 的特殊地位

回顾第 4.1 节的 bindObject:

if (property != null) {
    try {
        return bindProperty(target, context, property);
    }
    catch (ConverterNotFoundException ex) {
        // We might still be able to bind it using the recursive binders
        boolean fallbackToDefaultValue = ObjectUtils.isEmpty(property.getValue());
        Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding,
                fallbackToDefaultValue);
        if (instance != null) {
            return instance;
        }
        throw ex;
    }
}

只对 ConverterNotFoundException 降级,ConversionFailedException 不降级。

语义区分:

  • ConverterNotFoundException:没有合适的转换器 → 也许这个类型该按对象绑定(有后代属性)→ 试试
  • ConversionFailedException:有转换器但转换失败 → 值有问题 → 直接报错

这是一个精确的异常分类驱动的控制流。Boot 用异常类型来表达"失败的性质",并据此决定恢复策略。这比返回错误码或 null 更清晰。

15.5 get() 的共享优化

static BindConverter get(@Nullable List<ConversionService> conversionServices,
        @Nullable Consumer<PropertyEditorRegistry> propertyEditorInitializer) {
    boolean sharedApplicationConversionService = (conversionServices == null) || (conversionServices.size() == 1
            && conversionServices.get(0) == ApplicationConversionService.getSharedInstance());
    if (propertyEditorInitializer == null && sharedApplicationConversionService) {
        return getSharedInstance();
    }
    return new BindConverter(conversionServices, propertyEditorInitializer);
}

private static BindConverter getSharedInstance() {
    if (sharedInstance == null) {
        sharedInstance = new BindConverter(null, null);
    }
    return sharedInstance;
}

如果条件是"默认配置"(无自定义 ConversionService、无 PropertyEditor 初始化器),用一个共享实例。

这与类注释的"not thread-safe"看似矛盾——实际上共享实例只在无状态配置下使用(TypeConverterConversionService 的 PropertyEditor 都是无状态的内置编辑器)。

这是一个"条件性共享"的优化:有状态配置 → 每次新建;无状态配置 → 共享单例。判断条件精确地写在 get() 里。

15.6 ResolvableTypeDescriptor:把泛型信息带进 TypeDescriptor

private static class ResolvableTypeDescriptor extends TypeDescriptor {

    ResolvableTypeDescriptor(ResolvableType resolvableType, Annotation[] annotations) {
        super(resolvableType, null, annotations);
    }
}

TypeDescriptor 是 Spring 转换体系的类型描述(含泛型 + 注解)。ResolvableTypeDescriptor 让 Boot 的 ResolvableType(含完整泛型)能传给 ConversionService。

注解也被带进去(annotations)——这让 @ConfigurationPropertiesBinding、@DurationUnit、@DataSizeUnit 这类注解驱动的转换成为可能:

@ConfigurationProperties("my")
public class MyProps {
    @DurationUnit(ChronoUnit.MINUTES)     // ★ 这个注解通过 TypeDescriptor 传给 Converter
    private Duration timeout;
}

这是"注解驱动转换"的机制基础:注解不是被 Converter 读取的(Converter 拿不到字段),而是通过 TypeDescriptor.getAnnotations() 传递的。

所以自定义 Converter 要支持注解,得用 ConditionalGenericConverter 并在 matches 里检查 TypeDescriptor 的注解。


第 16 节 · PropertySourcesPlaceholdersResolver:占位符解析

16.1 实现

public class PropertySourcesPlaceholdersResolver implements PlaceholdersResolver {

	private final @Nullable Iterable<PropertySource<?>> sources;
	private final PropertyPlaceholderHelper helper;

	public PropertySourcesPlaceholdersResolver(@Nullable Iterable<PropertySource<?>> sources,
			@Nullable PropertyPlaceholderHelper helper) {
		this.sources = sources;
		this.helper = (helper != null) ? helper
				: new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX,
						SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR,
						SystemPropertyUtils.ESCAPE_CHARACTER, true);
	}

	@Override
	public @Nullable Object resolvePlaceholders(@Nullable Object value) {
		if (value instanceof String string) {
			return this.helper.replacePlaceholders(string, this::resolvePlaceholder);
		}
		return value;
	}

	protected @Nullable String resolvePlaceholder(String placeholder) {
		if (this.sources != null) {
			for (PropertySource<?> source : this.sources) {
				Object value = source.getProperty(placeholder);
				if (value != null) {
					return String.valueOf(value);
				}
			}
		}
		return null;
	}
}

16.2 五个 SystemPropertyUtils 常量

new PropertyPlaceholderHelper(
    SystemPropertyUtils.PLACEHOLDER_PREFIX,     // "${"
    SystemPropertyUtils.PLACEHOLDER_SUFFIX,     // "}"
    SystemPropertyUtils.VALUE_SEPARATOR,        // ":"(默认值分隔符)
    SystemPropertyUtils.ESCAPE_CHARACTER,       // 转义字符
    true)                                        // ignoreUnresolvablePlaceholders

第五个参数 true = ignoreUnresolvablePlaceholders。

这意味着:my.value=${not.exist} 在绑定时不会报错,占位符会被原样保留(或被替换成空,取决于实现)。

对比 Environment.resolvePlaceholders(text)(Spring 原生,默认不忽略)会抛 IllegalArgumentException。

这是一个重要的行为差异:@ConfigurationProperties 里的占位符解析失败不报错,而 @Value("${...}") 会报错。这是很多人困惑的来源。

16.3 只对 String 解析

if (value instanceof String string) {
    return this.helper.replacePlaceholders(string, this::resolvePlaceholder);
}
return value;

非 String 值直接返回。这避免了不必要的处理。

16.4 resolvePlaceholder 用原始名查找

for (PropertySource<?> source : this.sources) {
    Object value = source.getProperty(placeholder);
    // ...
}

注意:这里用的是 Spring 原生的 PropertySource.getProperty(String),不是 ConfigurationPropertySource.getConfigurationProperty(ConfigurationPropertyName)。

这意味着占位符里的名字享受不到宽松绑定!

${my.time-out} 和 ${my.timeout} 是不同的。占位符的 key 必须与配置文件里的 key 精确一致(或者是 systemProperties/environment 里的原始形式)。

这与第 06 章讲的"宽松绑定"形成对比:宽松绑定作用于 ConfigurationPropertyName 查找,而占位符解析走的是原始名查找。

16.5 ConfigDataEnvironmentContributorPlaceholdersResolver

第 07 章提到过一个特殊的 resolver:

PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
        contributors, activationContext, null, true, this.environment.getConversionService());

它用于在 ConfigData 处理的中间状态解析占位符——此时 Environment 还没最终成形,不能用 PropertySourcesPlaceholdersResolver(那会读到不完整的属性)。

这体现了 ConfigData 的"自包含"设计:它不能依赖 Environment(因为 Environment 正由它构建),所以有自己的占位符解析器,只在 contributor 集合里查找。


第 17 节 · BoundPropertiesTrackingBindHandler:Actuator 的数据来源

17.1 实现

public class BoundPropertiesTrackingBindHandler extends AbstractBindHandler {

	private final Consumer<ConfigurationProperty> consumer;

	public BoundPropertiesTrackingBindHandler(Consumer<ConfigurationProperty> consumer) {
		Assert.notNull(consumer, "'consumer' must not be null");
		this.consumer = consumer;
	}

	@Override
	public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
			Object result) {
		if (context.getConfigurationProperty() != null && name.equals(context.getConfigurationProperty().getName())) {
			this.consumer.accept(context.getConfigurationProperty());
		}
		return super.onSuccess(name, target, context, result);
	}
}

只有 6 行逻辑,但它是 Actuator /actuator/configprops 端点的数据来源。

17.2 那个 name.equals(...getName()) 判断

if (context.getConfigurationProperty() != null && name.equals(context.getConfigurationProperty().getName()))

只记录"名字完全匹配"的属性。

为什么?因为 onSuccess 会在嵌套绑定的每一层被调用。考虑:

my.server.host=localhost
my.server.port=8080

绑定 my(顶层)成功 → onSuccess(my, ...),此时 context.getConfigurationProperty() 可能还是上一次设置的(或 null) 绑定 my.server 成功 → onSuccess(my.server, ...) 绑定 my.server.host 成功 → onSuccess(my.server.host, ...),getConfigurationProperty().getName() == my.server.host ✓ 记录

所以过滤条件是:当前 context 里记录的那个 ConfigurationProperty,其名字正好等于本次绑定的名字 —— 即"这是一个真实的叶子属性绑定",而不是中间层的对象绑定。

这个判断是必要的:否则 my、my.server 这些中间层的名字也会被记录成"已绑定属性",造成 /actuator/configprops 输出大量无意义的条目。

17.3 用途

new BoundPropertiesTrackingBindHandler((property) -> boundProperties.add(property));

收集到的 ConfigurationProperty 含 Origin,所以 Actuator 能告诉你"这个配置值来自哪个文件哪一行"。

第 07 章讲的 Origin 追踪,最终在这里兑现为用户可见的功能。


第 18 节 · 4.x 相关:BindableRuntimeHintsRegistrar

第 05 章讲过它在 SpringApplication 上的使用:

@ImportRuntimeHints(...)

(具体注解形式以源码为准;可确认的是存在为 ApplicationProperties、SpringApplicationBannerPrinter 相关的 hints 注册。)

BindableRuntimeHintsRegistrar 的职责:为绑定类型登记 native 反射信息。

它递归扫描类型,登记:

  • 构造器(无参构造器 + 构造绑定的构造器)
  • setter 方法(JavaBean 绑定)
  • getter 方法
  • 嵌套类型的同样处理

第 05 章读过它的 handleConstructor / handleJavaBeanProperties 方法。

AOT 关键:@ConfigurationProperties 类在 native 下必须被登记反射,否则绑定失败(找不到 setter)。Boot 提供了自动登记机制(通过 ConfigurationPropertiesBean 的 AOT 处理),不需要手工写 hints——但编程式创建的 Binder 绑定可能需要手工登记。


第 19 节 · BindResult:绑定的返回值

public final class BindResult<T> {

	private final ConfigurationPropertyName name;
	private final @Nullable T value;

	public @Nullable T get() { ... }
	public T getOrElse(T other) { ... }
	public <U> BindResult<U> map(Function<? super T, ? extends U> mapper) { ... }
	public boolean isBound() { ... }
	public void ifBound(Consumer<? super T> consumer) { ... }
	public T orElseGet(Supplier<? extends T> other) { ... }
	public T orElse(T other) { ... }
	// ...
}

(方法清单以源码为准,语义如上。)

isBound() vs get() != null:

  • isBound():绑定发生了(哪怕结果是 null)
  • get() != null:绑定产出非 null 值

差异在 bindOrCreate 场景:即使没有配置,create 也会产出实例,此时 isBound() 为 true。

实践:判断"用户是否配置了这个属性"用 isBound(),而不是 get() != null。

第 07 章的 Profiles 里就用到了:

if (!type.isMergeWithEnvironmentProfiles() || !boundProfiles.isBound()) {
    return environmentProfiles;
}

以及:

binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST, ProfilesValidator.get(binder))
    .ifBound((includes) -> { ... });

ifBound 的语义是"只有绑定成功才执行"——比 isBound() + get() 更函数式。


第 20 节 · Binder 与 @Value 的对比

维度Binder / @ConfigurationProperties@Value
类型信息完整 ResolvableType(含泛型)只有 Class(注解无法表达泛型)
聚合类型List/Map/Array 全支持只支持简单类型 + SpEL
嵌套对象支持任意深度不支持
宽松绑定支持(ConfigurationPropertyName)不支持(原始名)
占位符失败不报错(ignoreUnresolvable=true)报错
转换器链PropertyEditor → ConversionService → sharedConversionService(BeanFactory 的)
未绑定检测支持(NoUnboundElementsBindHandler,需 enumerable)不支持
校验(JSR-303)支持(@Validated)不支持
构造器绑定支持不支持
元数据(IDE 补全)支持部分
AOT / native有 hints 支持有限
Origin 追踪支持不支持

结论:@Value 只适合"取一个简单标量"的场景。任何结构化配置都应该用 @ConfigurationProperties。

这不是风格偏好,是能力差异:@Value 拿不到泛型信息(因为注解的属性只能是 Class),所以 List<Duration> 这种类型它根本无法处理。

20.1 一个精确的例子

@Value("${my.timeouts}")           // 只能当 String,或简单类型
private List<Duration> timeouts;   // ❌ 无法正确绑定(泛型信息丢失)
@ConfigurationProperties("my")
public class MyProps {
    private List<Duration> timeouts;   // ✅ 正确绑定 [30s, 1m]
}

机制差异:前者走 TypeDescriptor.forObject(String) + 目标类型由字段声明但注解层面无泛型;后者通过 Bindable 携带完整 ResolvableType。


第 21 节 · 性能:Binder 的三处优化

21.1 ConfigurationPropertyCaching 覆盖

(第 2.4 节)绑定期间启用缓存,containsDescendantOf 结果被缓存。

21.2 containsNoDescendantOf 短路

(第 4.2 节)确定无后代属性时立即返回,不遍历字段。

21.3 isUnbindableBean 跳过

(第 10 节)java.* 类型和 NON_BEAN_CLASSES 不做 Bean 绑定。

21.4 三者的共同依赖

这三个优化都依赖 IterableConfigurationPropertySource:

  • containsDescendantOf 对非 enumerable source 返回 UNKNOWN → 无法短路
  • 缓存策略对不可枚举 source 保守
  • isUnbindableBean 的第一段(检查 PRESENT)对 UNKNOWN 不生效

可操作的优化建议:如果你的应用启动慢,且使用了自定义 PropertySource,让它实现 EnumerablePropertySource。这能让上述三个优化都生效。

第 06 章讲过 SpringConfigurationPropertySource.from() 会根据 isFullEnumerable 选择 SpringIterableConfigurationPropertySource(全宽松)还是 SpringConfigurationPropertySource(降级)。两者的能力差异在这一章体现为性能与正确性差异。


第 22 节 · 补充的 6 条结论

  1. PropertyEditor 优先于 ConversionService(TypeConverterConversionService 是 delegates 链的第一个)。历史兼容的代价。

  2. BindConverter 不是线程安全的,每次顶层绑定新建;但在"无状态配置"下用共享实例(get() 的条件判断)。

  3. 只对 ConverterNotFoundException 降级到对象绑定,ConversionFailedException 不降级。异常类型驱动控制流。

  4. 占位符解析用原始名查找,享受不到宽松绑定。${my.time-out} ≠ ${my.timeout}。

  5. 占位符解析失败在绑定时不报错(ignoreUnresolvablePlaceholders=true),而 @Value 会报错。这是行为差异。

  6. /actuator/configprops 的数据来自 BoundPropertiesTrackingBindHandler,它靠 name.equals(getConfigurationProperty().getName()) 过滤掉中间层的对象绑定。

补充:

  1. 注解驱动的转换靠 ResolvableTypeDescriptor 传递注解(@DurationUnit、@DataSizeUnit),Converter 通过 TypeDescriptor.getAnnotations() 读取。

  2. isBound() 与 get() != null 不同——判断"用户是否配置了"用 isBound()。

  3. 三处性能优化都依赖 enumerable source——自定义 PropertySource 应实现 EnumerablePropertySource。


第 23 节 · 实战:两个完整的扩展示例

23.1 自定义 Converter(支持注解)

package com.example;

// 目标:把 "RED,GREEN" 这样的字符串转成 Set<MyEnum>,且支持 @MySeparator 指定分隔符
public final class MyEnumSetConverter implements ConditionalGenericConverter {

    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        return Set.of(new ConvertiblePair(String.class, Set.class));
    }

    @Override
    public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 只在元素类型是 MyEnum 时才匹配
        ResolvableType resolvable = targetType.getResolvableType();
        return MyEnum.class.equals(resolvable.as(Set.class).getGeneric(0).resolve());
    }

    @Override
    public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        if (source == null) {
            return null;
        }
        // ★ 从 TypeDescriptor 读注解
        MySeparator annotation = targetType.getAnnotation(MySeparator.class);
        String separator = (annotation != null) ? annotation.value() : ",";
        String[] parts = source.toString().split(Pattern.quote(separator));
        return Arrays.stream(parts)
            .map(String::trim)
            .filter(StringUtils::hasText)
            .map(MyEnum::valueOf)
            .collect(Collectors.toCollection(LinkedHashSet::new));
    }
}

注册(只在配置绑定中生效,不影响 MVC):

@Bean
@ConfigurationPropertiesBinding
public MyEnumSetConverter myEnumSetConverter() {
    return new MyEnumSetConverter();
}

三个要点:

  1. ConditionalGenericConverter 的 matches 保证不误吃其他类型转换
  2. targetType.getResolvableType() 拿到泛型信息判断元素类型
  3. targetType.getAnnotation(...) 读注解(第 15.6 节的机制)
  4. @ConfigurationPropertiesBinding 限定只用于配置绑定(第 06 章讲过 qualifier 机制)

23.2 自定义 BindHandler(审计所有绑定)

package com.example;

public class AuditingBindHandler extends AbstractBindHandler {

    private static final Log logger = LogFactory.getLog(AuditingBindHandler.class);

    private final Set<String> sensitivePrefixes = Set.of("password", "secret", "token", "key");

    public AuditingBindHandler(@Nullable BindHandler parent) {
        super(parent);
    }

    @Override
    public <T> @Nullable Bindable<T> onStart(ConfigurationPropertyName name, Bindable<T> target,
            BindContext context) {
        if (context.getDepth() == 0) {
            logger.info("Binding configuration prefix: " + name);
        }
        return super.onStart(name, target, context);
    }

    @Override
    public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
            Object result) {
        // ★ 敏感属性脱敏后记录
        if (isSensitive(name)) {
            logger.info("Bound " + name + " = ******");
        }
        return super.onSuccess(name, target, context, result);
    }

    private boolean isSensitive(ConfigurationPropertyName name) {
        String last = name.getLastElement(ConfigurationPropertyName.Form.UNIFORM);
        return this.sensitivePrefixes.stream().anyMatch(last::contains);
    }
}

使用(编程式):

Binder binder = Binder.get(environment);
MyProps props = binder.bindOrCreate("my", Bindable.of(MyProps.class),
        new AuditingBindHandler(null));

或者组合:

new AuditingBindHandler(
    new NoUnboundElementsBindHandler(null, (source) -> source.getName().contains("application")))

组合的力量:审计 + 未绑定检测 + 忽略错误可以任意组合,不需要改 Binder 本身。这就是装饰器链的价值。


第 24 节 · 本章边界与未覆盖内容

以下内容我没有逐行核实,明确标注:

  • NON_BEAN_CLASSES 的完整成员清单(只确认它存在且含 String 等)
  • DefaultBindConstructorProvider 的完整规则(只确认"单构造器自动使用"的方向)
  • MapBinder 的内部实现细节(只确认"逐 key 合并"的结论,依据是与集合 bindIndexed 的对比)
  • BindMethod 枚举的确切常量名
  • BindResult 的完整方法清单

以上会在后续章节(第 09 章 @ConfigurationProperties)中部分补齐。


阶段性小结(写至第 24 节)

Binder 是 Spring Boot 配置体系的"最后一公里"。它把 Environment 里的字符串变成结构化的 Java 对象,其设计可以用四组词概括:

分派:bindObject 的四条分支(聚合 → 简单属性 → 降级对象 → 对象绑定),由 getAggregateBinder 和 isUnbindableBean 精确控制。

递归:嵌套结构通过 DataObjectPropertyBinder / AggregateElementBinder 递归回 bind();Context 的三个栈(depth、dataObjectBindings、constructorBindings)保证递归有界、可检测。

可干预:BindHandler 的五个钩子 + 装饰器链,让横切关注点以组合方式实现。onFailure 能"救回"绑定,onFinish 支撑未绑定检测。

可诊断:ConfigurationProperty(含 Origin)贯穿整个链路,最终出现在 BindException 里,让错误能定位到文件行号。

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

  • 集合不跨 source 合并,Map 会合并 —— 想在多配置文件里增量覆盖,用 Map。
  • 下标必须连续,断档会丢失(enumerable source 下报错)。
  • PropertyEditor 优先级高于 ConversionService —— 这是历史兼容的代价。
  • 占位符解析用原始名,享受不到宽松绑定;且失败时不报错(与 @Value 相反)。
  • 三处性能优化都依赖 IterableConfigurationPropertySource —— 自定义 PropertySource 应实现 EnumerablePropertySource。

下一章(第 09 章)上升到 @ConfigurationProperties 本身:Binder 是被谁调用的?ConfigurationPropertiesBindingPostProcessor 的生命周期?ConfigurationPropertiesBean 的注册与 JSR-303 校验接入?以及 4.x 中 @ConfigurationProperties 在 AOT 下的处理与 ConfigurationPropertiesScan。

第 25 节 · 端到端追踪:一次完整的嵌套绑定

把本章所有机制串起来。假设:

配置(application.yml):

my:
  name: demo
  timeout: 30s
  servers:
    - host: a.example.com
      port: 8080
    - host: b.example.com
      port: 9090
  labels:
    env: prod
    team: infra

目标类型(构造器绑定):

@ConfigurationProperties("my")
public record MyProps(
        String name,
        Duration timeout,
        List<Server> servers,
        Map<String, String> labels) {

    public record Server(String host, int port) {}
}

追踪

调用入口(第 09 章详述,这里只关心 Binder 层):

Bindable<MyProps> bindable = Bindable.of(MyProps.class);
BindResult<MyProps> result = binder.bind("my", bindable, handler);
// 或 binder.bindOrCreate("my", bindable, handler)

第 1 步:顶层 bind("my", ...)

  • onStart("my", MyProps) → 记录 attemptedNames
  • bindObject("my", ...):
    • findProperty("my", ...) → 没有 my= 这样的直接属性 → null
    • context.depth == 0 → 不短路(顶层即使无属性也要继续,因为可能要 create)
    • getAggregateBinder(MyProps) → 不是 Map/Collection/Array → null
    • property == null → 走 bindDataObject

第 2 步:bindDataObject("my", MyProps)

DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> bind(name.append(propertyName),
        propertyTarget, handler, context, false, false);
  • isUnbindableBean?MyProps 不是 java.*、不在 NON_BEAN_CLASSES → false
  • dataObjectBindings.push(MyProps)
  • fromDataObjectBinders(null, ...) → ValueObjectBinder.bind(...) 先试

第 3 步:ValueObjectBinder 处理四个构造参数

对每个参数调 parameter.bind(propertyBinder),即 bind("my.name", ...)、bind("my.timeout", ...) 等。

参数 name(String):

  • bind("my.name") → bindObject
    • findProperty("my.name") → 找到 "demo" ✓
    • getAggregateBinder(String) → null
    • property != null → bindProperty
      • setConfigurationProperty(property)(含 Origin: application.yml:3:8)
      • resolvePlaceholders("demo") → 无占位符,原样
      • convert("demo", String) → "demo"
    • onSuccess → boundNames.add("my.name")
    • 返回 "demo"

参数 timeout(Duration):

  • 同样路径,convert("30s", Duration) → BindConverter 遍历 delegates
    • ① TypeConverterConversionService.canConvert(String, Duration)?无对应 PropertyEditor → false
    • ② ApplicationConversionService(Environment 的) → StringToDurationConverter ✓ → Duration.ofSeconds(30)

参数 servers(List<Server>):

  • bind("my.servers") → bindObject
    • getAggregateBinder(List) → CollectionBinder ✓ → bindAggregate
  • bindAggregate:
    • withIncreasedDepth
    • CollectionBinder.bind(...) → IndexedElementsBinder.bindIndexed(...)
    • 遍历 sources,对第一个 source:
      • source.getConfigurationProperty("my.servers") → 直接属性?yaml 列表语法不产生 my.servers=... → null
      • 走 bindIndexed(source, root, elementBinder, collection, elementType)
        • getKnownIndexedChildren → {[0], [1]}
        • i=0:appendIndex("my.servers", 0) = my.servers[0]
          • elementBinder.bind("my.servers[0]", Bindable.of(Server), source)
          • → context.withSource(source, ...) 隔离 source
          • → bind("my.servers[0]", Bindable.of(Server))
          • → bindDataObject → ValueObjectBinder → 绑定 my.servers[0].host、my.servers[0].port
          • → Server[host=a.example.com, port=8080]
          • knownIndexedChildren.remove("[0]")
        • i=1:同理得到 Server[host=b.example.com, port=9090]
        • i=2:value == null → break
        • assertNoUnboundChildren(empty, ...) ✓ 无剩余
    • result.wasSupplied() && result.get() != null → return(不继续遍历其他 source)

参数 labels(Map<String,String>):

  • getAggregateBinder(Map) → MapBinder ✓ → bindAggregate
  • MapBinder 遍历所有 source,收集所有 my.labels.* 的 key,合并
  • → {env=prod, team=infra}

第 4 步:实例化

bound = bound || arg != null;    // name != null → bound = true

四个参数都非 null → valueObject.instantiate(args)

context.clearConfigurationProperty();
context.popConstructorBoundTypes();
return valueObject.instantiate(args);

第 5 步:收尾

  • handleBindResult:onSuccess → convert(可能无需转换)→ onFinish
  • onFinish(depth == 0):NoUnboundElementsBindHandler 检查未绑定属性 → 无 → 不抛

最终结果:

MyProps[
  name=demo,
  timeout=PT30S,
  servers=[Server[host=a.example.com, port=8080], Server[host=b.example.com, port=9090]],
  labels={env=prod, team=infra}
]

追踪中的关键观察

  1. 顶层不短路(depth == 0),保证 bindOrCreate 能创建实例
  2. 聚合元素绑定时 source 被隔离(withSource),my.servers[0] 只从它所在的 source 读
  3. 下标循环靠 null 终止,且 enumerable source 下会检测断档
  4. 集合不跨 source 合并——第一个产出非空结果的 source 胜出
  5. bound 只要一个参数成功就为 true

第 26 节 · BindMethod 与 4.x 的相关变化

26.1 BindMethod 的确切定义(实测确认)

/**
 * Configuration property binding methods.
 *
 * @author Andy Wilkinson
 * @since 3.0.8
 */
public enum BindMethod {

	/**
	 * Java Bean using getter/setter binding.
	 */
	JAVA_BEAN,

	/**
	 * Value object using constructor binding.
	 */
	VALUE_OBJECT

}

@since 3.0.8 —— 这是一个小版本加入的枚举,说明它是为修复某个具体问题而引入的(推测:为 @ConfigurationProperties 提供显式指定绑定模式的能力,解决"类同时有 setter 和多个构造器"时的歧义)。

这修正了第 1.4 节的"(枚举常量以实际源码为准)" —— 现在可以写死:BindMethod 有且仅有 JAVA_BEAN 和 VALUE_OBJECT 两个常量。

26.2 Binder 在 Boot 内部的使用规模(实测)

Binder.get(...) / new Binder(...) 在 src/main/java 中出现 38 次

模块分布(文件数):

模块文件数
core/spring-boot14
core/spring-boot-autoconfigure3
module/spring-boot-actuator-autoconfigure2
module/spring-boot-actuator2
其他(webservices、security-*、r2dbc、ldap、jdbc、http-client、groovy-templates、graphql 等)各 1

这个分布说明 Binder 不只是 @ConfigurationProperties 的内部实现,它是 Boot 各模块读取配置的通用工具。

典型用法如:模块在自动配置里用 Binder 读一段配置、判断是否启用某特性。

// 典型模式
Binder binder = Binder.get(environment);
MyModuleProperties props = binder.bindOrCreate("spring.mymodule", Bindable.of(MyModuleProperties.class));

26.3 4.x 的相关演进

特性版本说明
BindMethod3.0.8显式指定绑定模式
ApplicationConversionService.addBeans 的 qualifier3.5.0@ConfigurationPropertiesBinding 限定符
PropertySourceInfo4.0.0影响 SpringConfigurationPropertySource 的缓存策略
NoUnboundElementsBindHandler 的 filter2.0+(沿用)—
@DefaultValue2.2+(沿用)—

4.x 对 Binder 本身的改动不大——它是 2.0 引入、2.4 扩展后相对稳定的组件。4.x 的主要变化是周边:PropertySourceInfo(缓存策略)、包迁移、AOT hints。


第 27 节 · 补充验证清单(端到端)

# 完整测试用例(application.yml)
my:
  # 1. 简单属性
  name: demo
  # 2. 带单位的类型
  timeout: 30s
  size: 10MB
  # 3. 集合(yaml 列表 → 下标形式)
  servers:
    - host: a
      port: 8080
    - host: b
      port: 9090
  # 4. Map(合并语义)
  labels:
    env: prod
  # 5. 占位符(注意:用原始名,非宽松)
  derived: ${my.name}-suffix
  # 6. 逗号分隔(与下标二选一)
  simple: a,b,c
@ConfigurationProperties("my")
public record MyProps(
        String name,
        Duration timeout,
        DataSize size,
        List<Server> servers,
        Map<String, String> labels,
        String derived,
        List<String> simple,
        @DefaultValue("fallback") String missing) {

    public record Server(String host, int port) {}
}

验证点:

assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(props.size()).isEqualTo(DataSize.ofMegabytes(10));
assertThat(props.servers()).hasSize(2);
assertThat(props.servers().get(1).port()).isEqualTo(9090);
assertThat(props.labels()).containsEntry("env", "prod");
assertThat(props.derived()).isEqualTo("demo-suffix");   // 占位符用原始名
assertThat(props.simple()).containsExactly("a", "b", "c");
assertThat(props.missing()).isEqualTo("fallback");      // @DefaultValue
# 验证 PropertyEditor 优先
# 注册一个 String→MyType 的 PropertyEditor 和一个 Converter
# → PropertyEditor 胜出

# 验证 ignoreUnresolvablePlaceholders
my.notexist=${does.not.exist}     # → 不报错(@ConfigurationProperties)
@Value("${does.not.exist}")       # → 报错

# 验证下标断档
my.list[0]=a
my.list[2]=c                      # → enumerable source 下报错

# 验证集合不合并
# application.properties: my.list[0]=a my.list[1]=b
# application-prod.yml:   my.list[0]=x
# → [x]

# 验证 Map 合并
# → {env=prod, ...} 合并而非替换

# 验证宽松绑定不适用于占位符
my.a-b: hello
other: ${my.a-b}                  # ✓ 原始名匹配
other2: ${my.ab}                  # ✗ 宽松形式不匹配

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

以下内容我没有逐行核实,明确标注:

  • NON_BEAN_CLASSES 的完整成员清单
  • MapBinder 的内部实现("逐 key 合并"的结论来自与 IndexedElementsBinder.bindIndexed 的对比推理,非直接阅读 MapBinder 源码)
  • CollectionBinder 的逗号分隔处理细节(读的是基类 IndexedElementsBinder.bindValue)
  • DefaultBindConstructorProvider 的完整规则
  • ConfigurationPropertiesBindingPostProcessor 的工作方式(第 09 章主题)
  • BindResult 的完整方法清单(语义已确认,方法名以源码为准)

凡涉及"我推断"的部分,都在正文里做了标注,不与实证混淆。


第 29 节 · 本章在全书中的位置

配置体系的四层(第 06-09 章)

第 06 章  Environment          ← 属性存在哪、怎么查、什么优先级
   ↓
第 07 章  ConfigData           ← 属性怎么从文件/远程加载进 Environment
   ↓
第 08 章  Binder(本章)        ← 属性怎么从字符串变成 Java 对象
   ↓
第 09 章  @ConfigurationProperties  ← Binder 被谁调用、Bean 如何注册、校验如何接入

四层是严格分层的:

  • Environment 不知道 ConfigData(ConfigData 通过 EnvironmentPostProcessor 往里塞)
  • Binder 不知道 ConfigData(只读 ConfigurationPropertySource)
  • @ConfigurationProperties 不知道 Environment 的细节(只用 Binder)

这种分层是 Boot 配置体系可扩展的根本原因:Spring Cloud Config 只需实现 ConfigDataLocationResolver;自定义配置源只需实现 PropertySource;自定义类型转换只需实现 Converter。每一层都有明确的 SPI,且互不影响。


本章小结

Binder 是 Spring Boot 配置体系的"最后一公里"。四组关键词:

分派——bindObject 的四条分支(聚合 → 简单属性 → 降级对象 → 对象绑定),由 getAggregateBinder 和 isUnbindableBean 精确控制。理解这个顺序就理解了"为什么这个配置这么绑"。

递归——嵌套结构通过 DataObjectPropertyBinder / AggregateElementBinder 递归回 bind();Context 的三个栈(depth、dataObjectBindings、constructorBindings)保证递归有界、可检测、可诊断。

可干预——BindHandler 的五个钩子 + 装饰器链。onFailure 能"救回"绑定,onFinish 支撑未绑定检测,onSuccess 支撑 Actuator 的数据收集。

可诊断——ConfigurationProperty(含 Origin)贯穿全链路,最终出现在 BindException 里,让错误能定位到文件行号。

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

  1. 集合不跨 source 合并,Map 会合并 —— 想在多配置文件里增量覆盖,用 Map。
  2. 下标必须连续 —— 断档会丢失(enumerable source 下报错,非 enumerable 下静默)。
  3. PropertyEditor 优先级高于 ConversionService —— 历史兼容的代价,也可能是自定义 Converter "不生效"的原因。
  4. 占位符解析用原始名 —— 享受不到宽松绑定;且失败时不报错(与 @Value 相反)。
  5. 三处性能优化都依赖 IterableConfigurationPropertySource —— 自定义 PropertySource 应实现 EnumerablePropertySource。
  6. onFailure 返回非 null 就等于绑定成功 —— 这是"忽略错误"类 handler 的实现基础,也是排查"配置错误为何没报错"的线索。

下一章(第 09 章)上升到 @ConfigurationProperties 本身:ConfigurationPropertiesBindingPostProcessor 的生命周期、ConfigurationPropertiesBean 的注册与扫描(@ConfigurationPropertiesScan)、JSR-303 校验的接入点、以及 4.x 下 @ConfigurationProperties 在 AOT 中的处理。