Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序

Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序、ConversionService 与属性优先级

本章定位

"我明明在 application.yml 里配了 server.port=9090,为什么启动还是 8080?" "为什么 -Dspring.profiles.active=prod 生效,而 SPRING_PROFILES_ACTIVE=prod 有时不生效?" "为什么 MYAPP_SERVER_PORT 能覆盖 server.port,但 myapp.server.port 不能?" "为什么我在 @PostConstruct 里读到的值和 @Value("${...}") 读到的不一样?"

这些问题的答案全部在同一个地方:Environment。

但绝大多数讲 Environment 的文章只做两件事:给你一张 PropertySource 优先级表格(背下来),然后讲 application.properties 的加载位置(抄文档)。这些内容你查官方文档 5 分钟就能得到,而且它们解释不了上面任何一个问题。

本章要做的是把 Environment 拆到源码层面,回答三个真正的机制问题:

  1. 次序是怎么被"物理地"建立起来的 ------不是"谁优先级高",而是"谁在什么时候被 addFirst / addLast / addBefore / replace 进了 MutablePropertySources 的 CopyOnWriteArrayList"。
  2. Environment 里有几套并存的属性查找机制 ------PropertySourcesPropertyResolver(原始名查找)、ConfigurationPropertySourcesPropertyResolver(规范化名查找)、Binder(绑定查找)。它们查找同一个 PropertySource 时的结果可能不同,这是"配置不生效"的真正根源。
  3. ConversionService 何时被安装、有几个实例、为什么 Environment 里的和 BeanFactory 里的不是同一个 ------这决定了 ${timeout:30s} 能不能转成 Duration。

全部基于本仓库 core/spring-boot 的 4.1.1 源码逐行核实。


第 1 节 · Environment 的三重身份与 Spring Boot 的三次改造

1.1 Spring Framework 原生的三层抽象

Environment 在 Spring Framework 里不是一个类,而是一条接口继承链。要理解 Boot 做了什么,必须先看清这条链:

java 复制代码
// org.springframework.core.env(Spring Framework)
public interface PropertyResolver {          // ① 纯查询能力
    boolean containsProperty(String key);
    @Nullable String getProperty(String key);
    String getProperty(String key, String defaultValue);
    @Nullable <T> T getProperty(String key, Class<T> targetType);
    <T> T getProperty(String key, Class<T> targetType, T defaultValue);
    String getRequiredProperty(String key);
    <T> T getRequiredProperty(String key, Class<T> targetType);
    String resolvePlaceholders(String text);
    String resolveRequiredPlaceholders(String text);
}

public interface Environment extends PropertyResolver {   // ② + profile 能力
    String[] getActiveProfiles();
    String[] getDefaultProfiles();
    @Deprecated boolean acceptsProfiles(String... profiles);
    boolean acceptsProfiles(Profiles profiles);
    boolean matchesProfiles(String... profileExpressions);
}

public interface ConfigurableEnvironment extends Environment, ConfigurablePropertyResolver {  // ③ + 可变能力
    void setActiveProfiles(String... profiles);
    void addActiveProfile(String profile);
    void setDefaultProfiles(String... profiles);
    MutablePropertySources getPropertySources();
    Map<String, Object> getSystemProperties();
    Map<String, Object> getSystemEnvironment();
    void merge(ConfigurableEnvironment parent);
}

三层的能力边界非常清楚:

层 增加的能力 典型持有者
PropertyResolver 按 key 查值、解析 ${} PropertySourcesPropertyResolver
Environment 增加 profile 语义 StandardEnvironment
ConfigurableEnvironment 增加写能力(增删 PropertySource、改 profile) StandardEnvironment、ApplicationEnvironment

注意一个容易混淆的点:ConfigurablePropertyResolver (ConfigurableEnvironment 继承的)也定义了 setConversionService / setPlaceholderPrefix / setValueSeparator / setIgnoreUnresolvablePlaceholders。也就是说,占位符的前后缀和 ConversionService 是挂在"解析器"上,而不是挂在"PropertySource 集合"上。

这个区分在实战里很重要:

environment.getProperty("a.b") 走的是 Environment 内部持有的那个 ConfigurablePropertyResolver; 而 new PropertySourcesPropertyResolver(sources).getProperty("a.b") 走的是另一个新建的解析器,没有 ConversionService ,所以 getProperty("timeout", Duration.class) 会失败。

1.2 Spring Boot 的 ApplicationEnvironment:三处覆写

Boot 没有直接用 StandardEnvironment,而是定义了一个包级私有子类:

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/ApplicationEnvironment.java
package org.springframework.boot;

class ApplicationEnvironment extends StandardEnvironment {

	@Override
	protected @Nullable String doGetActiveProfilesProperty() {
		return null;
	}

	@Override
	protected @Nullable String doGetDefaultProfilesProperty() {
		return null;
	}

	@Override
	protected ConfigurablePropertyResolver createPropertyResolver(MutablePropertySources propertySources) {
		return ConfigurationPropertySources.createPropertyResolver(propertySources);
	}

}

只有三个方法,但每一个都是关键改造。

1.2.1 覆写一与二:doGetActiveProfilesProperty() 返回 null

先看父类 AbstractEnvironment 里这两个方法的原始语义:

java 复制代码
// org.springframework.core.env.AbstractEnvironment(Spring Framework)
protected String doGetActiveProfilesProperty() {
    return getProperty(ACTIVE_PROFILES_PROPERTY_NAME);  // "spring.profiles.active"
}

protected String doGetDefaultProfilesProperty() {
    return getProperty(DEFAULT_PROFILES_PROPERTY_NAME); // "spring.profiles.default"
}

它们被用在 AbstractEnvironment 的 getActiveProfiles() 里:

java 复制代码
public String[] getActiveProfiles() {
    return doGetActiveProfiles();
}

protected Set<String> doGetActiveProfiles() {
    synchronized (this.activeProfiles) {
        if (this.activeProfiles.isEmpty()) {
            String profiles = doGetActiveProfilesProperty();
            if (StringUtils.hasText(profiles)) {
                setActiveProfiles(StringUtils.commaDelimitedListToStringArray(
                        StringUtils.trimAllWhitespace(profiles)));
            }
        }
        return this.activeProfiles;
    }
}

AbstractEnvironment 的默认行为是:从 spring.profiles.active 属性里读激活的 profile。

Boot 把它关掉了 (返回 null)。

为什么?因为 Boot 的 profile 激活不是 简单读一个属性,而是一整套 ConfigData + Profiles 的决策流程:

  1. spring.profiles.active 可能来自命令行、环境变量、application.properties、application-{profile}.properties 里的 spring.profiles.active......
  2. 而且 spring.profiles.active 在 ConfigData 里还能被 spring.profiles.group.* 展开成一组
  3. 更关键的是:Boot 支持 spring.config.activate.on-profile,这让 profile 的判定变成一个"配置数据树"上的迭代过程(第 07 章详述)

如果保留 AbstractEnvironment 的默认实现,getActiveProfiles() 会在任意时刻 被调用时偷偷触发一次 getProperty("spring.profiles.active")------而这时 ConfigData 可能还没加载完,读到的值是残缺的,然后被缓存进 activeProfiles 集合 里(if (this.activeProfiles.isEmpty()) 只判断一次),后面再也无法修正。

反直觉结论 #1 :ApplicationEnvironment 覆写 doGetActiveProfilesProperty() 返回 null,不是 "Boot 不支持 spring.profiles.active",而是把 profile 解析的控制权从 AbstractEnvironment 手里夺回来 ,交给 ConfigDataEnvironment 统一处理。Boot 通过 Profiles 类(core/spring-boot/src/main/java/org/springframework/boot/context/config/Profiles.java)读取这些属性,然后在正确的时机 调用 environment.setActiveProfiles(...) 显式写入。

这也是为什么 spring.profiles.active 在 ConfigData 里的处理是"特殊的"------它不是被 Environment 自己读的,而是被 ConfigDataEnvironment 读的。

1.2.2 覆写三:createPropertyResolver 换成 ConfigurationPropertySources 版本
java 复制代码
@Override
protected ConfigurablePropertyResolver createPropertyResolver(MutablePropertySources propertySources) {
    return ConfigurationPropertySources.createPropertyResolver(propertySources);
}

AbstractEnvironment 的原始实现是:

java 复制代码
protected ConfigurablePropertyResolver createPropertyResolver(MutablePropertySources propertySources) {
    return new PropertySourcesPropertyResolver(propertySources);
}

Boot 换成了 ConfigurationPropertySourcesPropertyResolver。

这意味着:ApplicationEnvironment 的每一次 getProperty(...) 调用,走的都是支持 ConfigurationPropertyName 的解析器 。这是 Boot "宽松绑定(relaxed binding)"能生效的基础设施之一------environment.getProperty("server.port") 和 environment.getProperty("SERVER_PORT") 都能命中,靠的就是这个替换。

关于 ConfigurationPropertySourcesPropertyResolver 的具体行为,见第 4 节。

1.2.3 一个必须注意的边界:什么时候用的是 ApplicationEnvironment?

ApplicationEnvironment 是包级私有的,而且只在两处被实例化:

java 复制代码
// SpringApplication.java
private ConfigurableEnvironment getOrCreateEnvironment() {
    if (this.environment != null) {
        return this.environment;
    }
    ConfigurableEnvironment environment = this.applicationContextFactory.createEnvironment(this.webApplicationType);
    if (environment == null) {
        environment = getEnvironment();   // ...
    }
    return environment;
}

以及 DefaultApplicationContextFactory:

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/DefaultApplicationContextFactory.java
@Override
public ConfigurableEnvironment createEnvironment(WebApplicationType webApplicationType) {
    return switch (webApplicationType) {
        case SERVLET -> new ApplicationServletEnvironment();
        case REACTIVE -> new ApplicationReactiveEnvironment();
        default -> new ApplicationEnvironment();
    };
}

(这两个类也是包级私有的 ApplicationServletEnvironment / ApplicationReactiveEnvironment,同样继承并覆写。)

重要的实践含义 :如果你自己 new StandardEnvironment() 然后 application.setEnvironment(env),你就丢掉了这三个覆写。后果是:

  • spring.profiles.active 会被 AbstractEnvironment 用残缺时机读取并缓存(可能读到 ConfigData 之前的命令行值,之后不再更新)
  • getProperty 走普通 PropertySourcesPropertyResolver,宽松绑定降级

这在自定义 SpringApplication 子类或做测试时是个隐蔽的坑。正确做法是 new ApplicationEnvironment()(但它包级私有,所以其实你也没法 new)------所以结论是:不要手工设置 Environment,除非你知道自己在做什么。

1.3 EnvironmentConverter:为什么 Environment 对象会被整个换掉

第 05 章讲过 prepareEnvironment 末尾的 convertEnvironmentIfNecessary。这里从 Environment 的视角补充它的必要性。

createEnvironment(webApplicationType) 是在构造期推断 的 webApplicationType 基础上做的。而 spring.main.web-application-type 可能在 application.properties 里被改写成 SERVLET。于是:

  • 构造期推断:NONE → 创建 ApplicationEnvironment(标准)
  • 绑定后:SERVLET → 需要 ApplicationServletEnvironment

两者的差别不只是类名:

java 复制代码
// ApplicationServletEnvironment(示意)
class ApplicationServletEnvironment extends StandardServletEnvironment {
    // StandardServletEnvironment 会注册 servletContextInitParams / servletConfigInitParams / jndiProperties
}

StandardServletEnvironment 的 customizePropertySources 会额外注册 servlet 相关的 PropertySource。所以需要换对象 ,EnvironmentConverter 负责搬运:

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/EnvironmentConverter.java
ConfigurableEnvironment convertEnvironmentIfNecessary(ConfigurableEnvironment environment,
        Class<? extends ConfigurableEnvironment> type) {
    if (type.equals(environment.getClass())) {
        return environment;
    }
    return convertEnvironment(environment, type);
}

private ConfigurableEnvironment convertEnvironment(ConfigurableEnvironment environment,
        Class<? extends ConfigurableEnvironment> type) {
    ConfigurableEnvironment result = createEnvironment(type);
    if (result == null) {
        return createDefaultEnvironment();
    }
    result.setActiveProfiles(environment.getActiveProfiles());
    ConversionService existingConversionService = environment.getConversionService();
    if (existingConversionService != null) {
        result.setConversionService(existingConversionService);
    }
    copyPropertySources(environment, result);
    return result;
}

private void copyPropertySources(ConfigurableEnvironment source, ConfigurableEnvironment target) {
    removeAllPropertySources(target.getPropertySources());
    for (PropertySource<?> propertySource : source.getPropertySources()) {
        if (!isServletEnvironmentPropertySource(propertySource)) {
            target.getPropertySources().addLast(propertySource);
        }
    }
}

private boolean isServletEnvironmentPropertySource(PropertySource<?> source) {
    String name = source.getName();
    return name.equals(StandardServletEnvironment.SERVLET_CONTEXT_PROPERTY_SOURCE_NAME)
            || name.equals(StandardServletEnvironment.SERVLET_CONFIG_PROPERTY_SOURCE_NAME)
            || name.equals(StandardServletEnvironment.JNDI_PROPERTY_SOURCE_NAME);
}

注意 result.setConversionService(existingConversionService) :ConversionService 被搬运而不是重建。这保证了绑定过程中已经注册的任何自定义转换器不丢。

而 copyPropertySources 排除三个 servlet source 的理由是:它们持有 ServletContext / ServletConfig 的具体实例引用 。如果搬运到新 Environment,就会造成"新 Environment 引用了旧 servlet 上下文"。它们会在 ApplicationContext 初始化时(ConfigurableWebApplicationContext.initPropertySources)被重新注入。


第 2 节 · PropertySource 次序的物理建立过程

这一节是本章的核心。我们要逐行跟踪 MutablePropertySources 里那个 CopyOnWriteArrayList 是怎么从空变成 15+ 个元素的。

2.1 MutablePropertySources 的数据结构

java 复制代码
// org.springframework.core.env.MutablePropertySources(Spring Framework)
public class MutablePropertySources implements PropertySources {

	private final List<PropertySource<?>> propertySourceList = new CopyOnWriteArrayList<>();
    // ...
}

CopyOnWriteArrayList 的选择值得说一句:

  • 读多写少 :属性查询是热路径(每次 getProperty 都要遍历),而增删 PropertySource 只在启动期发生几次
  • 遍历时安全 :EnvironmentPostProcessor 可能在遍历中增删
  • 迭代顺序 = 优先级顺序 :越靠前优先级越高,第一个命中的胜出

PropertySourcesPropertyResolver.getProperty 的查找逻辑:

java 复制代码
protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
    if (this.propertySources != null) {
        for (PropertySource<?> propertySource : this.propertySources) {
            if (logger.isTraceEnabled()) { ... }
            Object value = propertySource.getProperty(key);
            if (value != null) {
                if (resolveNestedPlaceholders && value instanceof String stringValue) {
                    value = resolveNestedPlaceholders(stringValue);
                }
                logKeyFound(key, propertySource, value);
                return convertValueIfNecessary(value, targetValueType);
            }
        }
    }
    // ...
    return null;
}

第一个非 null 就返回------这就是"优先级"的全部物理实现。没有权重计算,没有打分,只有数组下标。

排障启示:当你怀疑"配置没生效"时,最有效的手段不是猜,而是把这个 list 打出来。第 9 节给出完整的可执行代码。

2.2 阶段一:构造时的两个(systemEnvironment、systemProperties)

new ApplicationEnvironment() → StandardEnvironment → AbstractEnvironment 构造器:

java 复制代码
// AbstractEnvironment
public AbstractEnvironment() {
    this(new MutablePropertySources());
}

protected AbstractEnvironment(MutablePropertySources propertySources) {
    this.propertySources = propertySources;
    this.propertyResolver = createPropertyResolver(propertySources);
    customizePropertySources(propertySources);
}

StandardEnvironment.customizePropertySources:

java 复制代码
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
    propertySources.addLast(new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME,
            (Properties) System.getProperties()));
    propertySources.addLast(new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
            (Map<String, Object>) System.getenv()));
}

注意是 addLast 两次,所以顺序是:

csharp 复制代码
[0] systemProperties
[1] systemEnvironment

systemProperties 在 systemEnvironment 之前 → -Dfoo=bar 覆盖 FOO=bar。

System.getProperties() 被强制转成 Properties 并包装为 PropertiesPropertySource------注意它不是 快照,而是直接持有 System.getProperties() 这个对象 。这意味着运行期 System.setProperty(...) 会立刻反映到 Environment 里。

实践陷阱 :在单元测试里用 System.setProperty("xxx", ...) 设置属性是可行的,因为 systemProperties PropertySource 持有的是活对象。但 System.getenv() 在大多数 JVM 上返回的是不可变 Map,所以环境变量在运行期改不了。

2.3 阶段二:configurePropertySources 的三个动作

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java:513
protected void configurePropertySources(ConfigurableEnvironment environment, String[] args) {
    MutablePropertySources sources = environment.getPropertySources();

    // ① defaultProperties → addLast
    if (!CollectionUtils.isEmpty(this.defaultProperties)) {
        DefaultPropertiesPropertySource.addOrMerge(this.defaultProperties, sources);
    }

    // ② commandLineArgs → addFirst(或替换已有)
    if (this.addCommandLineProperties && args.length > 0) {
        String name = CommandLinePropertySource.COMMAND_LINE_PROPERTY_SOURCE_NAME;  // "commandLineArgs"
        PropertySource<?> source = sources.get(name);
        if (source != null) {
            CompositePropertySource composite = new CompositePropertySource(name);
            composite.addPropertySource(
                new SimpleCommandLinePropertySource("springApplicationCommandLineArgs", args));
            composite.addPropertySource(source);
            sources.replace(name, composite);
        }
        else {
            sources.addFirst(new SimpleCommandLinePropertySource(args));
        }
    }

    // ③ applicationInfo → addLast
    environment.getPropertySources().addLast(new ApplicationInfoPropertySource(this.mainApplicationClass));
}
动作 ①:defaultProperties 的 addOrMerge
java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/env/DefaultPropertiesPropertySource.java
public static void addOrMerge(Map<String, Object> source, MutablePropertySources sources) {
    if (!CollectionUtils.isEmpty(source)) {
        Map<String, Object> resultingSource = new HashMap<>();
        DefaultPropertiesPropertySource propertySource = new DefaultPropertiesPropertySource(resultingSource);
        if (sources.contains(NAME)) {
            mergeIfPossible(source, sources, resultingSource);
            sources.replace(NAME, propertySource);
        }
        else {
            resultingSource.putAll(source);
            sources.addLast(propertySource);
        }
    }
}

defaultProperties 来自 SpringApplication.setDefaultProperties(...) 或 SpringApplicationBuilder.properties(...)。

它是 addLast,所以优先级最低 ------在 systemEnvironment 之后。

反直觉结论 #2 :很多人以为 SpringApplicationBuilder.properties("server.port=9090") 是"程序里硬编码的,优先级应该很高"。恰恰相反,它是所有来源里优先级最低的(除了后面会被移到更末尾的位置)。这正是它叫 "default" 的原因:它是兜底默认值,任何外部配置都能覆盖它。

mergeIfPossible 处理的是"重复调用"场景:SpringApplicationBuilder 链式调用 .properties(...) 多次时,后一次不是替换,而是合并(且同名 key 后者覆盖前者)。

动作 ②:命令行参数的 Composite 分支

这是 4.x 里一段有意思的代码。正常情况(sources 里还没有 commandLineArgs):

java 复制代码
sources.addFirst(new SimpleCommandLinePropertySource(args));

addFirst → 命令行参数排到最前面,优先级最高。这是众所周知的。

但那个 if (source != null) 分支是给谁用的?

场景:SpringApplicationBuilder 构建父子上下文,或多个 SpringApplication 共享一个 Environment 。第一次 run 时 addFirst 了一个 commandLineArgs;第二次 run 时,那个 source 已经在了,此时:

java 复制代码
CompositePropertySource composite = new CompositePropertySource(name);
composite.addPropertySource(new SimpleCommandLinePropertySource("springApplicationCommandLineArgs", args));
composite.addPropertySource(source);
sources.replace(name, composite);

把新的 命令行参数放在 Composite 的前面,旧的在后面,然后用 Composite 替换掉原来的单 source。

CompositePropertySource 的查找逻辑:

java 复制代码
@Override
public @Nullable Object getProperty(String name) {
    for (PropertySource<?> propertySource : this.propertySources) {
        Object candidate = propertySource.getProperty(name);
        if (candidate != null) {
            return candidate;
        }
    }
    return null;
}

也是"第一个命中胜出"。所以:springApplicationCommandLineArgs > 原有 commandLineArgs。

这解释了 SpringApplicationBuilder 的一个行为 :子上下文 run(innerArgs) 时,内层 args 覆盖外层 args,但外层 args 里的其他参数依然可见。不是替换,是叠加。

动作 ③:ApplicationInfoPropertySource
java 复制代码
environment.getPropertySources().addLast(new ApplicationInfoPropertySource(this.mainApplicationClass));

提供 spring.application.pid、spring.application.version、spring.application.name 等(第 05 章详述)。同样是 addLast。

此时 list 的样子:

csharp 复制代码
[0] commandLineArgs           ← addFirst
[1] systemProperties          ← 构造期
[2] systemEnvironment         ← 构造期
[3] defaultProperties         ← ①
[4] applicationInfo            ← ③

2.4 阶段三:ConfigurationPropertySources.attach

java 复制代码
ConfigurationPropertySources.attach(environment);
java 复制代码
public static void attach(Environment environment) {
    Assert.isInstanceOf(ConfigurableEnvironment.class, environment);
    MutablePropertySources sources = ((ConfigurableEnvironment) environment).getPropertySources();
    PropertySource<?> attached = getAttached(sources);
    if (!isUsingSources(attached, sources)) {
        attached = new ConfigurationPropertySourcesPropertySource(ATTACHED_PROPERTY_SOURCE_NAME,
                new SpringConfigurationPropertySources(sources));
    }
    sources.remove(ATTACHED_PROPERTY_SOURCE_NAME);
    sources.addFirst(attached);
}

configurationProperties 被放在最前面(下标 0)。

但等等------它排第一,会不会让所有查询都走它?答案是:会,而且这正是设计意图。

ConfigurationPropertySourcesPropertySource 是一个适配器 :它自己是 PropertySource<Iterable<ConfigurationPropertySource>>,getProperty(name) 的实现是把 name 转成 ConfigurationPropertyName,然后在底层的其他所有 PropertySource 里查。

关键在 SpringConfigurationPropertySources 的构造:它持有 sources(即 MutablePropertySources)的引用 ,所以它是动态视图 ,不是快照。而 isIncluded 过滤掉了自己:

java 复制代码
private static boolean isIncluded(PropertySource<?> source) {
    return !(source instanceof StubPropertySource)
            && !(source instanceof ConfigurationPropertySourcesPropertySource);
}

这是防止无限递归的关键 ------configurationProperties 适配的是"除自己以外的所有 source"。

反直觉结论 #3 :configurationProperties 排在第一位并不改变优先级语义,它是一个代理 。真正的优先级仍由它内部遍历 MutablePropertySources 的顺序决定(即 commandLineArgs → systemProperties → ...)。

但它改变 了查找能力:它是唯一支持 ConfigurationPropertyName 规范化的路径。所以对于一个 key,environment.getProperty(key) 有两套解析结果------先命中 configurationProperties(规范化查找),若返回 null,再继续往后遍历到 commandLineArgs、systemProperties 等(原始名查找)。

这就是为什么 Spring Boot 的"宽松绑定"会表现出一些诡异的中间态。第 4 节详述。

2.5 阶段四:environmentPrepared 事件与 ConfigData 的插入

java 复制代码
listeners.environmentPrepared(bootstrapContext, environment);

这一步触发 EnvironmentPostProcessorApplicationListener,进而执行一串 EnvironmentPostProcessor。其中最重要的 ConfigDataEnvironmentPostProcessor 会插入大量 PropertySource。

ConfigData 的插入策略不是 addLast,而是 addFirst + 后续的精确排序 ,或者是 addBefore/addAfter 相对定位。具体规则在第 07 章完整展开。

这里只需要知道结果:ConfigData 加载的 application.properties 等会被插入到比 systemEnvironment 更高优先级的位置吗?

答案是:不。 经典规则是:

scss 复制代码
commandLineArgs
  > systemProperties (-D)
    > systemEnvironment
      > application-{profile}.properties (jar 外)
      > application-{profile}.properties (jar 内)
      > application.properties (jar 外)
      > application.properties (jar 内)
        > defaultProperties

即 ConfigData 插在 systemEnvironment 之后。

但 Boot 4.x 的 ConfigData 支持 spring.config.import,且支持在每个 ConfigData location 上单独指定优先级------这就复杂了,第 07 章完整讲。

2.6 阶段五:moveToEnd 的两次修正

java 复制代码
ApplicationInfoPropertySource.moveToEnd(environment);
DefaultPropertiesPropertySource.moveToEnd(environment);

为什么需要这两行?因为 configurePropertySources 里它们是 addLast,但之后 ConfigData 又往末尾后面插入了东西 (ConfigData 的 PropertySource 会被放在 defaultProperties 之前还是之后?)。

实际上 moveToEnd 保证的是:无论中间发生了什么插入,这两个始终在最末尾。

java 复制代码
// DefaultPropertiesPropertySource
public static void moveToEnd(MutablePropertySources propertySources) {
    PropertySource<?> propertySource = propertySources.remove(NAME);
    if (propertySource != null) {
        propertySources.addLast(propertySource);
    }
}

remove 再 addLast------简单粗暴但正确。

设计洞察 :这两行体现了 Boot 对"PropertySource 次序"的态度------不假设、事后修正 。因为 EnvironmentPostProcessor 是开放的扩展点,Boot 无法预知第三方会往哪里插。与其约束扩展者,不如在关键节点强制执行不变式。这是"开放扩展 + 关键不变式后置校验"的典型手法。

2.7 完整的最终次序表(Boot 4.1.1,Servlet 应用)

把上述全部串起来,加上 ApplicationContext 初始化时注入的 servlet source,一个典型 Spring Boot 4.1.1 Servlet 应用的 MutablePropertySources 最终次序是:

# PropertySource 名 来源 何时加入
0 configurationProperties 适配器 attach(environment)
1 commandLineArgs 命令行 configurePropertySources
2 servletConfigInitParams ServletConfig ApplicationContext.initPropertySources
3 servletContextInitParams ServletContext 同上
4 jndiProperties JNDI 同上(若可用)
5 systemProperties -D StandardEnvironment 构造
6 systemEnvironment 环境变量 同上
7 random RandomValuePropertySource RandomValuePropertySourceEnvironmentPostProcessor
8 cachedrandom AOT 缓存(4.x) native 场景
9..N ConfigData 各 location application*.properties/yml ConfigDataEnvironmentPostProcessor
N+1 defaultProperties builder.properties() configurePropertySources(后 moveToEnd)
N+2 applicationInfo PID/版本 configurePropertySources(后 moveToEnd)

注:2/3/4 三个 servlet source 只在 ApplicationServletEnvironment(即 webApplicationType=SERVLET)下存在;如果是 EnvironmentConverter 换出来的对象,它们在 copyPropertySources 里被排除,随后由 ConfigurableWebApplicationContext 重新注入。所以最终次序里它们的位置可能与上表略有出入,实际以运行时打印为准(第 9 节给代码)。

random 在 systemEnvironment 之后 ------这意味着 RANDOM_SERVER_PORT=8080 环境变量不能 覆盖 random.server-port。反过来说,random.* 也不会被环境变量意外污染。

2.8 一个常被误解的点:@PropertySource 的位置

@PropertySource("classpath:custom.properties") 加载的 PropertySource 加在哪里?

答案:在 ApplicationContext 刷新阶段 (ConfigurationClassPostProcessor 处理 @PropertySource 时),通过 environment.getPropertySources().addLast(...) 加入。

java 复制代码
// ConfigurationClassParser.processPropertySource(Spring Framework)
MutablePropertySources propertySources = this.environment.getPropertySources();
// ...
if (propertySourceNames.contains(name)) { ... }
else {
    // addLast
    propertySources.addLast(propertySource);
}

所以 @PropertySource 的优先级低于 application.properties(因为 application.properties 由 ConfigData 在更早阶段加入,且位置在 @PropertySource 之前)。

反直觉结论 #4 :@PropertySource 引入的配置不能覆盖 application.properties 里的同名配置;反过来,application.properties 能覆盖 @PropertySource。这与很多人的直觉("@PropertySource 是我显式声明的,应该优先")相反。

若确实需要 @PropertySource 优先级更高,只能手工调整:

java 复制代码
@Bean
public ApplicationContextInitializer<ConfigurableApplicationContext> propertySourceInitializer() {
    return context -> context.getEnvironment().getPropertySources()
        .addFirst(new ResourcePropertySource("custom", "classpath:custom.properties"));
}

第 3 节 · ConversionService:两个实例的故事

3.1 configureEnvironment 的第一步就是装 ConversionService

java 复制代码
protected void configureEnvironment(ConfigurableEnvironment environment, String[] args) {
    if (this.addConversionService) {
        environment.setConversionService(new ApplicationConversionService());
    }
    configurePropertySources(environment, args);
    configureProfiles(environment, args);
}

addConversionService 是一个 SpringApplication 字段:

java 复制代码
private boolean addConversionService = true;

可以通过 SpringApplication.setAddConversionService(false) 关闭(罕见)。

注意时机 :ConversionService 在 configurePropertySources 之前安装。这很重要------因为后续的占位符解析、属性绑定都需要它。

3.2 ApplicationConversionService 里到底有什么

ApplicationConversionService extends FormattingConversionService。它的静态 configure 方法:

java 复制代码
public static void configure(FormatterRegistry registry) {
    DefaultConversionService.addDefaultConverters(registry);
    DefaultFormattingConversionService.addDefaultFormatters(registry);
    addApplicationFormatters(registry);
    addApplicationConverters(registry);
}

public static void addApplicationConverters(ConverterRegistry registry) {
    addDelimitedStringConverters(registry);
    registry.addConverter(new StringToDurationConverter());
    registry.addConverter(new DurationToStringConverter());
    registry.addConverter(new NumberToDurationConverter());
    registry.addConverter(new DurationToNumberConverter());
    registry.addConverter(new StringToPeriodConverter());
    registry.addConverter(new PeriodToStringConverter());
    registry.addConverter(new NumberToPeriodConverter());
    registry.addConverter(new StringToDataSizeConverter());
    registry.addConverter(new NumberToDataSizeConverter());
    registry.addConverter(new StringToFileConverter());
    registry.addConverter(new InputStreamSourceToByteArrayConverter());
    registry.addConverterFactory(new LenientStringToEnumConverterFactory());
    registry.addConverterFactory(new LenientBooleanToEnumConverterFactory());
    if (registry instanceof ConversionService conversionService) {
        addApplicationConverters(registry, conversionService);
    }
}

private static void addApplicationConverters(ConverterRegistry registry, ConversionService conversionService) {
    registry.addConverter(new CharSequenceToObjectConverter(conversionService));
}

public static void addApplicationFormatters(FormatterRegistry registry) {
    registry.addFormatter(new CharArrayFormatter());
    registry.addFormatter(new InetAddressFormatter());
    registry.addFormatter(new IsoOffsetFormatter());
}

Boot 特有的类型支持(这是 Boot 与裸 Spring Framework 的关键差异):

目标类型 转换器 效果
Duration StringToDurationConverter "30s" → Duration.ofSeconds(30);30(无单位)→ 毫秒
Period StringToPeriodConverter "1y2m" → Period
DataSize StringToDataSizeConverter "10MB" → DataSize
File / Path StringToFileConverter 字符串 → 文件
byte[] InputStreamSourceToByteArrayConverter ConfigTree 的 Value → byte\[\]
InetAddress InetAddressFormatter 主机名 → InetAddress
char[] CharArrayFormatter 密码字段常用
IsoOffsetFormatter --- ISO-8601 偏移格式
枚举(宽松) LenientStringToEnumConverterFactory "cloud-foundry" → CLOUD_FOUNDRY
LenientStringToEnumConverterFactory 的"宽松"到底宽在哪
java 复制代码
// org.springframework.boot.convert.LenientStringToEnumConverterFactory(示意)
// 匹配策略依次尝试:
// 1. 原样匹配("CLOUD_FOUNDRY")
// 2. 大写化后匹配("cloud-foundry" → "CLOUD-FOUNDRY" → 替换 - 为 _ → "CLOUD_FOUNDRY")
// 3. 去掉所有非字母数字后比较

这就是为什么 spring.main.cloud-platform=cloud-foundry(连字符)在通过 Binder 绑定时能工作。

但第 05 章讲过:CloudPlatform 的激活不走 Binder,而是 environment.getProperty(...) + name().equalsIgnoreCase(),所以连字符形式会静默失效。

这个类不对称性值得记住 :同一个 spring.main.cloud-platform 属性,在"绑定 ApplicationProperties"路径和"CloudPlatform.getActive()"路径上的转换规则不同。前者宽松(连字符 OK),后者严格(连字符不行)。

这说明了:"配置属性 → 值"的转换路径不止一条,且它们不共享转换规则。

3.3 Environment 里的 vs BeanFactory 里的:不是同一个实例

这是一个非常关键但少有人讲的点。

Environment 的 ConversionService :由 configureEnvironment 创建,是一个 new ApplicationConversionService() 实例。

BeanFactory 的 ConversionService :是 applicationContext.getBeanFactory().getConversionService(),默认名字为 conversionService 的 Bean。

两者不是同一个对象 ,除非......我们来看 ApplicationContext 的刷新流程。

在 AbstractApplicationContext.finishBeanFactoryInitialization:

java 复制代码
if (beanFactory.containsBean(CONVERSION_SERVICE_BEAN_NAME) &&
        beanFactory.isTypeMatch(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class)) {
    beanFactory.setConversionService(
            beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class));
}

而那个名为 conversionService 的 Bean 从哪来?看 ConversionServiceAutoConfiguration(在 module/spring-boot-autoconfigure-classic-modules/... 或类似模块里):

java 复制代码
@AutoConfiguration
@ConditionalOnMissingBean(name = ConfigurableApplicationContext.CONVERSION_SERVICE_BEAN_NAME)
public class ConversionServiceAutoConfiguration {

	@Bean(name = ConfigurableApplicationContext.CONVERSION_SERVICE_BEAN_NAME)
	@ConditionalOnMissingBean
	public ApplicationConversionService conversionService() {
		return new ApplicationConversionService();
	}
}

这是第二个实例。

所以运行期有至少两个 ApplicationConversionService 实例:

  1. environment.getConversionService() ------ 服务 Environment.getProperty 和早期绑定
  2. beanFactory.getConversionService() ------ 服务 @Value、@ConfigurationProperties 的后处理器、SpEL 等

实践后果 :如果你注册了一个自定义 Converter Bean,它会被 ApplicationConversionService.addBeans(...) 加到实例 2 (BeanFactory 的),但不会自动加到实例 1(Environment 的)。

后果:@Value("${x}") 和 @ConfigurationProperties 能用你的转换器,但 environment.getProperty("x", MyType.class) 在启动早期不能。

要把转换器加到 Environment 的实例,需要 EnvironmentPostProcessor:

java 复制代码
public class MyConverterEnvironmentPostProcessor implements EnvironmentPostProcessor {
    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        ConfigurableConversionService cs = (ConfigurableConversionService) environment.getConversionService();
        cs.addConverter(new MyConverter());
    }
}

注册到 META-INF/spring.factories:org.springframework.boot.EnvironmentPostProcessor=...

3.4 getSharedInstance():第三个实例与它的不可变性

java 复制代码
private static volatile @Nullable ApplicationConversionService sharedInstance;

public static ConversionService getSharedInstance() {
    ApplicationConversionService sharedInstance = ApplicationConversionService.sharedInstance;
    if (sharedInstance == null) {
        synchronized (ApplicationConversionService.class) {
            sharedInstance = ApplicationConversionService.sharedInstance;
            if (sharedInstance == null) {
                sharedInstance = new ApplicationConversionService(null, true);   // ★ unmodifiable = true
                ApplicationConversionService.sharedInstance = sharedInstance;
            }
        }
    }
    return sharedInstance;
}

双重检查锁定(DCL)的单例,且 unmodifiable = true。

所有 addXxx 方法都被覆写加了 assertModifiable():

java 复制代码
private void assertModifiable() {
    if (this.unmodifiable) {
        throw new UnsupportedOperationException("This ApplicationConversionService cannot be modified");
    }
}

为什么需要第三个、不可变的实例?

因为有些场景需要一个"只读的、确定的、可被全局共享的"转换服务:

  • 配置元数据(configuration metadata)的处理
  • Spring Boot CLI / 构建期工具
  • 任何不该被用户代码污染的转换路径

unmodifiable 确保了:任何拿到 getSharedInstance() 的代码都无法污染全局状态。

这是一个很好的防御性设计范式:当一个对象要被全局共享时,提供不可变版本。否则"某处加了转换器 → 全局行为改变"的幽灵 bug 极难排查。

3.5 isConvertViaObjectSourceType:一个为元数据服务的小方法

java 复制代码
public boolean isConvertViaObjectSourceType(TypeDescriptor sourceType, TypeDescriptor targetType) {
    GenericConverter converter = getConverter(sourceType, targetType);
    Set<ConvertiblePair> pairs = (converter != null) ? converter.getConvertibleTypes() : null;
    if (pairs != null) {
        for (ConvertiblePair pair : pairs) {
            if (Object.class.equals(pair.getSourceType())) {
                return true;
            }
        }
    }
    return false;
}

@since 2.4.3。它的用途是:判断某个转换是否通过 ObjectToXxxConverter 完成。

为什么在意这个?因为 CharSequenceToObjectConverter(Boot 加的)会声明 Object 作为 source type。当配置元数据的 IDE 插件要判断"这个属性到底该提示什么类型的值"时,如果转换是"从任意 Object 出发"的,就无法推断输入类型,提示会退化。

3.6 addBeans:把容器里的转换器 Bean 收集进来

java 复制代码
public static Map<String, Object> addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory,
        @Nullable String qualifier) {
    ConfigurableListableBeanFactory configurableBeanFactory = getConfigurableListableBeanFactory(beanFactory);
    Map<String, Object> beans = getBeans(beanFactory, qualifier);
    beans.forEach((beanName, bean) -> {
        BeanDefinition beanDefinition = (configurableBeanFactory != null)
                ? configurableBeanFactory.getMergedBeanDefinition(beanName) : null;
        ResolvableType type = (beanDefinition != null) ? beanDefinition.getResolvableType() : null;
        addBean(registry, bean, type);
    });
    return beans;
}

private static Map<String, Object> getBeans(ListableBeanFactory beanFactory, @Nullable String qualifier) {
    Map<String, Object> beans = new LinkedHashMap<>();
    beans.putAll(getBeans(beanFactory, Printer.class, qualifier));
    beans.putAll(getBeans(beanFactory, Parser.class, qualifier));
    beans.putAll(getBeans(beanFactory, Formatter.class, qualifier));
    beans.putAll(getBeans(beanFactory, Converter.class, qualifier));
    beans.putAll(getBeans(beanFactory, ConverterFactory.class, qualifier));
    beans.putAll(getBeans(beanFactory, GenericConverter.class, qualifier));
    return beans;
}

收集顺序是固定的六种类型。qualifier 参数(@since 3.5.0)支持按限定符过滤------用于 @ConfigurationPropertiesBinding 注解标记的转换器只参与配置绑定,不参与 MVC 绑定。

java 复制代码
// 典型用法
@Bean
@ConfigurationPropertiesBinding
public Converter<String, MyType> myTypeConverter() { ... }

这个 qualifier 机制解决了" MVC 参数绑定和配置属性绑定需要不同转换规则"的问题。


第 4 节 · ConfigurationPropertySources:Environment 之上的第二套查找系统

这一节解释本章开头提出的第二个核心问题:同一个 PropertySource,不同查找路径的结果可能不同。

4.1 为什么需要两套查找

Spring Framework 的 PropertySource 语义是精确名字匹配:

java 复制代码
// MapPropertySource
@Override
public @Nullable Object getProperty(String name) {
    return this.source.get(name);
}

source 是一个 Map。查 server.port 就是 map.get("server.port")。如果 map 里的 key 是 SERVER_PORT,查不到就是查不到。

但 Spring Boot 想要的是:

用户写法 期望命中的 key
server.port server.port、server-port、serverPort、SERVER_PORT
spring.datasource.url(yaml 里) SPRING_DATASOURCE_URL(环境变量里)
my.list[0].name MY_LIST_0_NAME(环境变量)

这就需要一套**规范化(canonicalization)**机制:把各种写法统一到一个中间表示,再用中间表示生成候选名去查。

这个中间表示就是 ConfigurationPropertyName。

4.2 ConfigurationPropertyName:Boot 的属性名规范形式

ConfigurationPropertyName(core/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java)是 Boot 自 2.0 引入的核心类。它的关键设计:

(1)元素(element)结构

一个名字被拆成若干元素:server.port → 元素 ["server", "port"]。

(2)"有效字符"约束

java 复制代码
// 只允许小写字母 a-z、数字 0-9、以及 '-'
// 大写会在 adapt 时被小写化,其他字符('_'、'.' 除外)在 adapt 时按分隔符处理
static boolean isValidChar(char ch) {
    return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-';
}

(3)四种外部表示形式(Form)

java 复制代码
public enum Form {
    UNIFORM,               // server.port
    DASHED,                // server.port(每个元素内部保留 '-')
    SYSTEM_ENVIRONMENT,    // SERVER_PORT
    LEGACY_SYSTEM_ENVIRONMENT // SERVER_PORT 但对含 '-' 的元素做特殊处理
}

SYSTEM_ENVIRONMENT 与 LEGACY_SYSTEM_ENVIRONMENT 的区别:

  • SYSTEM_ENVIRONMENT:server.port → SERVER_PORT
  • LEGACY_SYSTEM_ENVIRONMENT:对含连字符的元素(my.server-port),旧版 Boot(1.x/2.x 早期)生成的是 MY_SERVERPORT(去掉连字符)而不是 MY_SERVER_PORT。为了向后兼容,两种都要尝试。

看 SystemEnvironmentPropertyMapper.map:

java 复制代码
@Override
public List<String> map(ConfigurationPropertyName configurationPropertyName) {
    List<String> mapped = new ArrayList<>(4);
    addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.SYSTEM_ENVIRONMENT, true));
    addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.LEGACY_SYSTEM_ENVIRONMENT, true));
    addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.SYSTEM_ENVIRONMENT, false));
    addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.LEGACY_SYSTEM_ENVIRONMENT, false));
    return mapped;
}

四个候选名 (true/false 是 prefixed 参数,与 [index] 元素有关)。这就是为什么环境变量映射需要 4 次尝试。

(4)adapt 的两条路径

java 复制代码
// 从外部字符串适配成 ConfigurationPropertyName
ConfigurationPropertyName.adapt(String name, char separator)
ConfigurationPropertyName.adapt(String name, char separator, Function<CharSequence, CharSequence> elementValueProcessor)

DefaultPropertyMapper 用 adapt(name, '.'); SystemEnvironmentPropertyMapper 用 adapt(name, '_', this::processElementValue),其中:

java 复制代码
private CharSequence processElementValue(CharSequence value) {
    String result = value.toString().toLowerCase(Locale.ENGLISH);
    return isNumber(result) ? "[" + result + "]" : result;
}

private static boolean isNumber(String string) {
    return string.chars().allMatch(Character::isDigit);
}

数字元素 → 索引 :HOST_0 → host[0]。这让环境变量也能表达 List 的下标。

4.3 SpringConfigurationPropertySource:一次查找的完整过程

java 复制代码
@Override
public @Nullable ConfigurationProperty getConfigurationProperty(@Nullable ConfigurationPropertyName name) {
    if (name == null) {
        return null;
    }
    for (PropertyMapper mapper : this.mappers) {
        try {
            for (String candidate : mapper.map(name)) {
                Object value = getPropertySourceProperty(candidate);
                if (value != null) {
                    Origin origin = PropertySourceOrigin.get(this.propertySource, candidate);
                    return ConfigurationProperty.of(this, name, value, origin);
                }
            }
        }
        catch (Exception ex) {
            // Ignore
        }
    }
    return null;
}

流程:

  1. 遍历 mappers(普通 source 只有 DefaultPropertyMapper;systemEnvironment 有 SystemEnvironmentPropertyMapper + DefaultPropertyMapper 两个)
  2. 每个 mapper 生成若干候选名
  3. 依次尝试,第一个非空返回

注意那个 catch (Exception ex) { // Ignore }:

这是一个有争议的写法。它吞掉了所有异常,包括潜在的 SecurityException(安全限制的环境变量访问)、ClassCastException 等。

后果:如果某个 PropertySource 的 getProperty 抛异常,你会看到"属性神秘地找不到",而不是一个清晰的异常。

排障提示:如果你遇到"某个属性死活读不到,但看起来明明存在",考虑是否是这里吞掉了异常。可以临时在调试器里给这个 catch 打断点。

getPropertySourceProperty 的 systemEnvironment 特判
java 复制代码
protected final @Nullable Object getPropertySourceProperty(String name) {
    // Save calls to SystemEnvironmentPropertySource.resolvePropertyName(...) since we've already done the mapping
    PropertySource<?> propertySource = getPropertySource();
    return (!this.systemEnvironmentSource) ? propertySource.getProperty(name)
            : getSystemEnvironmentProperty(((SystemEnvironmentPropertySource) propertySource).getSource(), name);
}

@Nullable Object getSystemEnvironmentProperty(Map<String, Object> systemEnvironment, String name) {
    Object value = systemEnvironment.get(name);
    return (value != null) ? value : systemEnvironment.get(name.toLowerCase(Locale.ROOT));
}

绕过 SystemEnvironmentPropertySource.resolvePropertyName 是性能优化------因为 mapper 已经生成了候选名,不需要再走一次 Spring Framework 那套(它会做 name.replace('.', '_').toUpperCase() 等多次尝试)。

最后的 toLowerCase(Locale.ROOT) 兜底:处理 Server_Port 这种混合写法的环境变量。

注意用 Locale.ROOT 而不是默认 Locale。这是防止 Turkish locale bug (土耳其语里 I 小写化变成 ı 而不是 i,会导致 SERVER_PORT 匹配失败)。这是 Java 国际化的经典陷阱,Boot 在所有内部字符串大小写转换上都用 Locale.ROOT / Locale.ENGLISH。

4.4 SpringIterableConfigurationPropertySource:全宽松查找

SpringConfigurationPropertySource.from(source) 会分派:

java 复制代码
static SpringConfigurationPropertySource from(PropertySource<?> source) {
    Assert.notNull(source, "'source' must not be null");
    boolean systemEnvironmentSource = isSystemEnvironmentPropertySource(source);
    PropertyMapper[] mappers = (!systemEnvironmentSource) ? DEFAULT_MAPPERS : SYSTEM_ENVIRONMENT_MAPPERS;
    return (!isFullEnumerable(source))
            ? new SpringConfigurationPropertySource(source, systemEnvironmentSource, mappers)
            : new SpringIterableConfigurationPropertySource((EnumerablePropertySource<?>) source,
                    systemEnvironmentSource, mappers);
}

private static boolean isFullEnumerable(PropertySource<?> source) {
    PropertySource<?> rootSource = getRootSource(source);
    if (rootSource.getSource() instanceof Map<?, ?> map) {
        // Check we're not security restricted
        try {
            map.size();
        }
        catch (UnsupportedOperationException ex) {
            return false;
        }
    }
    return (source instanceof EnumerablePropertySource);
}

两类实现的能力差异是关键:

非 enumerable enumerable(全宽松)
典型来源 systemEnvironment(安全受限时)、自定义非枚举 source MapPropertySource、PropertiesPropertySource、ConfigData 的 OriginTrackedMapPropertySource
查找方式 用 mapper 生成候选名,逐个试 先 用 mapper 试,再用缓存的全量名字映射试
能否 stream() 所有属性 否 是
能否 containsDescendantOf(判断"有没有以 X 开头的属性") 只能返回 UNKNOWN 精确判断

SpringIterableConfigurationPropertySource.getConfigurationProperty:

java 复制代码
@Override
public @Nullable ConfigurationProperty getConfigurationProperty(@Nullable ConfigurationPropertyName name) {
    if (name == null) {
        return null;
    }
    ConfigurationProperty configurationProperty = super.getConfigurationProperty(name);
    if (configurationProperty != null) {
        return configurationProperty;
    }
    for (String candidate : getCache().getMapped(name)) {
        Object value = getPropertySourceProperty(candidate);
        if (value != null) {
            Origin origin = PropertySourceOrigin.get(getPropertySource(), candidate);
            return ConfigurationProperty.of(this, name, value, origin);
        }
    }
    return null;
}

它先走 mapper(父类逻辑),失败后走"全量名字映射缓存"。

那个缓存是 Cache.getMapped(name)------它会把 source 里所有 PropertySource 名字都 adapt 成 ConfigurationPropertyName,建一个 map,然后反查。这样即使原始 key 是 myWeird-Key.Name 这种 mapper 生成不出来的形式,也能被找到。

这解释了 Boot 文档的这句话:"Relaxed binding works best with enumerable property sources."

对于非枚举的 PropertySource(如某些自定义的、或安全受限的 systemEnvironment),宽松绑定降级为"只尝试 mapper 生成的有限候选名"。

缓存策略:SoftReferenceConfigurationPropertyCache
java 复制代码
private final SoftReferenceConfigurationPropertyCache<Cache> cache;

用软引用缓存。内存紧张时可被 GC 回收,之后重新计算。这是"缓存大对象但不肯付出 OOM 代价"的经典做法。

而且:

java 复制代码
SpringIterableConfigurationPropertySource(EnumerablePropertySource<?> propertySource,
        boolean systemEnvironmentSource, PropertyMapper... mappers) {
    super(propertySource, systemEnvironmentSource, mappers);
    assertEnumerablePropertySource();
    boolean immutable = isImmutablePropertySource();
    // ...
    this.cache = new SoftReferenceConfigurationPropertyCache<>(immutable);
    this.containsDescendantOfCache = (!systemEnvironmentSource) ? null : new ConcurrentReferenceHashMap<>();
}

immutable 来自 PropertySourceInfo.isImmutable()(4.x 新增接口,第 2 节提到过):

java 复制代码
public interface PropertySourceInfo {
    default boolean isImmutable() {
        return false;
    }
    default @Nullable String getPrefix() {
        return null;
    }
}

如果一个 PropertySource 声明 isImmutable() == true,缓存就可以永久持有(不必担心失效)。否则用软引用 + 可能的失效检查。

4.x 的演进 :PropertySourceInfo 是 4.0.0 新增的接口。它的两个方法(isImmutable、getPrefix)都是为缓存优化 和多应用隔离服务的。这体现了 Boot 4.x 在"启动性能"上的持续投入------ConfigData 的属性查找是启动期热点。

4.5 两套查找并存导致的不对称

现在我们可以回答本节开头的问题了。

ConfigurationPropertySourcesPropertyResolver(ApplicationEnvironment 用的)与 PropertySourcesPropertyResolver(原生)的差异在于:前者认识 configurationProperties 这个 PropertySource。

场景推演 :假设 application.yml 里有:

yaml 复制代码
myApp:
  endpoint: http://a

(注意是驼峰 myApp,不是 kebab my-app)

  • environment.getProperty("myApp.endpoint") → 原生查找 map.get("myApp.endpoint") → 命中 (因为 MapPropertySource 里 key 就是 myApp.endpoint)
  • environment.getProperty("my-app.endpoint") → 原生查找失败 → 但 configurationProperties 排在第 0 位,它会把 my-app.endpoint 规范化成元素 [my-app, endpoint],然后 toString() 得到 my-app.endpoint......还是不命中。

关键 :OriginTrackedMapPropertySource(ConfigData 用的)是 enumerable 的,所以走 SpringIterableConfigurationPropertySource,它的全量名字映射缓存 会把 myApp.endpoint adapt 成 myapp.endpoint(注意:大写被小写化,但没有插入连字符!)。

那么查 my-app.endpoint 时,规范化后的名字是 my-app.endpoint,而缓存里是 myapp.endpoint------还是不相等。

所以驼峰写的 myApp.endpoint 无法 通过 my-app.endpoint 读到。

反直觉结论 #5 :Boot 的宽松绑定不是"任意风格互认"。它的实际规则是:

  • 推荐写法 (kebab-case:my-app.endpoint)能被所有写法命中
  • 驼峰/下划线/大写写法 在 properties/yaml 里只能被"精确原样"命中,或者被对应的规范化形式命中(myApp.endpoint ↔ myapp.endpoint),但不能被 kebab 形式命中

原因:ConfigurationPropertyName.adapt 会小写化并去掉非法字符 ,但不会 在 camelCase 的单词边界插入 -。所以 myApp → myapp,而 my-app 保持 my-app。两者不等价。

实践规则 :在 properties/yaml 里一律用 kebab-case。这不是风格偏好,是功能约束。

由此产生的"中间态"bug

考虑这个真实场景:

yaml 复制代码
# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:a

环境变量:SPRING_DATASOURCE_URL=jdbc:mysql://...

  • 原生路径:systemEnvironment.getProperty("spring.datasource.url") → SystemEnvironmentPropertySource.resolvePropertyName 会尝试 SPRING_DATASOURCE_URL → 命中
  • 规范化路径:configurationProperties 里 systemEnvironment 适配出的 SpringIterableConfigurationPropertySource,用 SystemEnvironmentPropertyMapper 生成 SPRING_DATASOURCE_URL → 命中

两条路径都命中,结果一致。OK。

但如果环境变量是 spring_datasource_url(小写):

  • 原生路径:SystemEnvironmentPropertySource.resolvePropertyName("spring.datasource.url") 会尝试 spring.datasource.url、SPRING_DATASOURCE_URL 等,不尝试小写下划线 → 未命中
  • 规范化路径:getSystemEnvironmentProperty 最后有 systemEnvironment.get(name.toLowerCase(Locale.ROOT)),name 是 SPRING_DATASOURCE_URL,小写化是 spring_datasource_url → 命中

两条路径结果不同! 而由于 configurationProperties 排在第 0 位,最终结果是命中。

反直觉结论 #6 :小写下划线的环境变量(spring_datasource_url)能生效,靠的不是 Spring Framework 的 SystemEnvironmentPropertySource,而是 Boot 的 SpringConfigurationPropertySource.getSystemEnvironmentProperty 里的那一行 toLowerCase(Locale.ROOT) 兜底。

如果因为某种原因 configurationProperties 没有被 attach(比如你自己 new 了 StandardEnvironment),这个能力就消失了。这是"换掉 Environment 实现类"最隐蔽的代价之一。

4.6 ConfigurationPropertySourcesPropertyResolver 的额外能力

ApplicationEnvironment 用的解析器除了认识 configurationProperties,还提供了:

(1)containsProperty 的一致性

原生 PropertySourcesPropertyResolver.containsProperty(key) 只做原始名查找;ConfigurationPropertySources 版本会同时做规范化查找。这让 containsProperty 与 getProperty 的语义一致。

原生版本有个经典不一致:environment.containsProperty("a.b") 返回 false,但 environment.getProperty("a.b") 返回非 null(因为后者被 configurationProperties 处理了,而前者......实际上 containsProperty 也会遍历所有 source,包括 configurationProperties,所以也能命中)。

这个不一致在 Boot 里被缓解了,但在混用原生 Environment 时仍会出现。

(2)Origin 追踪

ConfigurationProperty.of(this, name, value, origin) 携带 PropertySourceOrigin。这让 Boot 能在报错时告诉你:

kotlin 复制代码
Property: spring.datasource.url
    Value: "jdbc:h2:mem:a"
    Origin: class path resource [application.yml] - 3:13
    Reason: ...

这个 Origin 信息在整个 Binder 链路上被传递,最终出现在 BindException 的错误消息里。这是 Boot 配置错误信息质量远超其他框架的技术基础。


第 5 节 · 特殊 PropertySource 逐个拆解

5.1 RandomValuePropertySource:random.* 的魔法

java 复制代码
public class RandomValuePropertySource extends PropertySource<Random> {

	public static final String RANDOM_PROPERTY_SOURCE_NAME = "random";

	private static final String PREFIX = "random.";

	public RandomValuePropertySource(String name) {
		super(name, new SecureRandom());
	}

	@Override
	public @Nullable Object getProperty(String name) {
		if (!name.startsWith(PREFIX)) {
			return null;
		}
		logger.trace(LogMessage.format("Generating random property for '%s'", name));
		return getRandomValue(name.substring(PREFIX.length()));
	}

	private Object getRandomValue(String type) {
		if (type.equals("int")) {
			return getSource().nextInt();
		}
		if (type.equals("long")) {
			return getSource().nextLong();
		}
		String range = getRange(type, "int");
		if (range != null) {
			return getNextIntInRange(Range.of(range, Integer::parseInt));
		}
		range = getRange(type, "long");
		if (range != null) {
			return getNextLongInRange(Range.of(range, Long::parseLong));
		}
		if (type.equals("uuid")) {
			return UUID.randomUUID().toString();
		}
		return getRandomBytes();
	}
	// ...
}

它继承 PropertySource<Random>,泛型参数是 Random ,而不是 Map。这意味着它不是 enumerable ------getPropertyNames() 无从枚举(随机值的名字空间是无限的)。

这带来一个直接后果:random.* 属性无法被"列出"。

  • Actuator 的 /actuator/configprops 不会显示
  • IDE 补全不会提示
  • ConfigurationPropertySources 的 stream() 不会包含它
范围语法的解析
java 复制代码
private @Nullable String getRange(String type, String prefix) {
    if (type.startsWith(prefix)) {
        int startIndex = prefix.length() + 1;
        if (type.length() > startIndex) {
            return type.substring(startIndex, type.length() - 1);
        }
    }
    return null;
}

文档说格式是 OPEN value (,max) CLOSE,其中 OPEN/CLOSE 是任意字符。

getRange 的实现印证了这一点:它只做 substring(startIndex, length-1)------去掉第一个分隔符字符和最后一个字符 ,中间的内容原样交给 Range.of 解析。

所以这些都是合法的:

properties 复制代码
my.secret=${random.int[1024,65536]}
my.number=${random.int(1024,65536)}
my.number=${random.int<1024,65536>}
my.uuid=${random.uuid}
my.value=${random.value}      # byte[],然后被 HexFormat 转成十六进制字符串

实践坑 :${random.int[1024,65536]} 在 yaml 里要加引号吗?不需要,但在 .properties 里 [] 没问题 。而在某些 shell 里传命令行参数时 [ 需要转义。

SecureRandom 的代价
java 复制代码
super(name, new SecureRandom());

用的是 SecureRandom,不是 Random。

SecureRandom 的 nextInt() 在首次调用时可能阻塞 (等待熵池)。在熵不足的环境(容器、虚拟机刚启动)里,random.uuid 可能造成启动卡顿。

生产提示 :如果启动耗时分析(/actuator/startup)显示卡在 random,考虑:

  • 改用 random.int 之外的方式生成(如在代码里生成)
  • 或确保 JVM 参数 -Djava.security.egd=file:/dev/./urandom
containsDescendantOf 的特判
java 复制代码
@Override
public ConfigurationPropertyState containsDescendantOf(ConfigurationPropertyName name) {
    PropertySource<?> source = getPropertySource();
    Object underlyingSource = source.getSource();
    if (underlyingSource instanceof Random) {
        return containsDescendantOfForRandom("random", name);
    }
    if (underlyingSource instanceof PropertySource<?> underlyingPropertySource
            && underlyingPropertySource.getSource() instanceof Random) {
        // Assume wrapped random sources use the source name as the prefix
        return containsDescendantOfForRandom(source.getName(), name);
    }
    return ConfigurationPropertyState.UNKNOWN;
}

private static ConfigurationPropertyState containsDescendantOfForRandom(String prefix, ConfigurationPropertyName name) {
    if (name.getNumberOfElements() > 1 && name.getElement(0, Form.DASHED).equals(prefix)) {
        return ConfigurationPropertyState.PRESENT;
    }
    return ConfigurationPropertyState.ABSENT;
}

因为 random 不是 enumerable,无法枚举,所以 Boot 特判 :如果查询的名字第一个元素是 random 且元素数 > 1,就返回 PRESENT。

这个特判是为了让 random.* 能被 Binder 正常识别为"存在的属性"。

注意第二个 if:支持包装过的 random source(比如 AOT 场景下的 cachedrandom,或 RandomValuePropertySource 被包在另一个 PropertySource 里)。此时用外层 source 的名字 作为前缀。这就是 4.x 里 cachedrandom 这个 PropertySource 的来历。

谁把它加进 Environment?

RandomValuePropertySourceEnvironmentPostProcessor(在 core/spring-boot/src/main/java/org/springframework/boot/env/ 下,注册于 spring.factories)。它的 postProcessEnvironment 里:

java 复制代码
// 示意
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
    RandomValuePropertySource.addToEnvironment(environment, application.getResourceLoader().getClassLoader());
}

而在 native/AOT 场景下,会额外加一个 cachedrandom------把构建期生成的随机值固化,避免 native 运行时生成不同值(或避免 SecureRandom 在 native 下的可达性问题)。

AOT 教训 :random.* 在 native 下是"构建期固化"的。这意味着同一个 native 镜像跑多次,得到的随机值相同 。如果你的应用依赖 random.uuid 生成实例 ID,native 下所有实例会有相同 ID。必须用运行期 API 生成,不能用 random.*。

5.2 DefaultPropertiesPropertySource:最低优先级的兜底

已在 2.3 节讲过。补充两点:

(1)addOrMerge 的 HashMap 新建

java 复制代码
Map<String, Object> resultingSource = new HashMap<>();
DefaultPropertiesPropertySource propertySource = new DefaultPropertiesPropertySource(resultingSource);
if (sources.contains(NAME)) {
    mergeIfPossible(source, sources, resultingSource);
    sources.replace(NAME, propertySource);
}

注意:每次都新建一个 HashMap 和新的 DefaultPropertiesPropertySource 实例 ,然后用 replace 换掉旧的。

为什么不直接改旧 map?因为旧 map 可能被 OriginTrackedMapPropertySource 之类的包装,或者是不可变 Map。新建 + replace 是最安全的。

代价:如果有代码持有 了旧 PropertySource 对象的引用(比如某个缓存了它的 ConfigurationPropertySource 适配器),会失效。这正是 4.4 节讲的软引用缓存需要处理的场景------SpringIterableConfigurationPropertySource 的缓存持有的是 PropertySource 对象引用,replace 后缓存会指向旧对象。

Boot 通过 SpringConfigurationPropertySources.isUsingSources(sources) 检测 sources 是否变化,从而决定是否需要重建适配器:

java 复制代码
private static boolean isUsingSources(@Nullable PropertySource<?> attached, MutablePropertySources sources) {
    return attached instanceof ConfigurationPropertySourcesPropertySource
            && ((SpringConfigurationPropertySources) attached.getSource()).isUsingSources(sources);
}

这也是 attach 可以重复调用的原因------它自己检测是否需要重建。

(2)hasMatchingName 与 ifNotEmpty 的用途

java 复制代码
public static boolean hasMatchingName(@Nullable PropertySource<?> propertySource) {
    return (propertySource != null) && propertySource.getName().equals(NAME);
}

被 ConfigData 用来识别并跳过 defaultProperties(ConfigData 不应覆盖它,或者说它的位置由 moveToEnd 保证)。

5.3 ApplicationInfoPropertySource

已在第 05 章讲过。这里补充它在 PropertySource 次序中的特殊性:

java 复制代码
@Override
public boolean isImmutable() {
    return true;   // 4.x:标记不可变
}

isImmutable() == true 让 SpringIterableConfigurationPropertySource 的缓存可以永久持有。因为 PID、版本号、jar 路径在进程生命周期内不变。

5.4 ConfigTreePropertySource:Kubernetes ConfigMap 挂载

java 复制代码
public class ConfigTreePropertySource extends EnumerablePropertySource<Path>
		implements PropertySourceInfo, OriginLookup<String> {

	private static final int MAX_DEPTH = 100;

	private final Map<String, PropertyFile> propertyFiles;
	private final String[] names;
	private final Set<Option> options;

	private ConfigTreePropertySource(String name, Path sourceDirectory, Set<Option> options) {
		super(name, sourceDirectory);
		Assert.isTrue(Files.exists(sourceDirectory),
				() -> "'sourceDirectory' [%s] must exist".formatted(sourceDirectory));
		Assert.isTrue(Files.isDirectory(sourceDirectory),
				() -> "'sourceDirectory' [%s] must be a directory".formatted(sourceDirectory));
		this.propertyFiles = PropertyFile.findAll(sourceDirectory, options);
		this.options = options;
		this.names = StringUtils.toStringArray(this.propertyFiles.keySet());
	}
	// ...
}

设计要点:

(1)泛型参数是 Path ,但它是 EnumerablePropertySource------因为目录在创建时就被扫描了一次 (PropertyFile.findAll),结果存在 propertyFiles map 里。

所以:

  • 目录结构在启动后不能变(加文件不会生效),注释明确写:"Directories are only scanned when the source is first created. The directory is not monitored for updates."
  • 文件内容可以变 ,但要看 Option:
    • 默认(无 option):文件内容在创建时读入内存
    • Option.ALWAYS_READ:每次 getProperty 都重新读文件

(2)嵌套目录用 . 而不是 / 分隔

a/b/c.txt → 属性名 a.b.c.txt。

(3)MAX_DEPTH = 100

防止符号链接循环导致的无限递归。

(4)Value 返回类型

属性值返回的是 Value 实例,它同时实现 InputStreamSource 和 CharSequence。配合 ApplicationConversionService 的 InputStreamSourceToByteArrayConverter,可以被转成 String 或 byte[]。

这是一个精妙的设计 :不预先决定"文件内容是字符串还是字节",而是返回一个多态的 Value,让绑定目标类型 决定如何解释。绑定 String 就当文本,绑定 byte[] 就当二进制(证书、密钥场景)。

(5)典型用法

properties 复制代码
spring.config.import=optional:configtree:/etc/config/

对应 Kubernetes 的 ConfigMap/Secret volume mount:

yaml 复制代码
volumes:
  - name: config
    configMap:
      name: my-config

/etc/config/ 下每个文件是一个属性。这比环境变量更适合存多行内容(如证书、yaml 片段)。

(6)implements PropertySourceInfo

作为 4.x 的新接口实现者,它提供 isImmutable()(取决于是否有 ALWAYS_READ option)和 getPrefix()。

5.5 OriginTrackedMapPropertySource:ConfigData 用的 PropertySource

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/env/OriginTrackedMapPropertySource.java
public class OriginTrackedMapPropertySource extends EnumerablePropertySource<Map<String, Object>>
		implements OriginLookup<String> {
	// ...
}

它比普通 MapPropertySource 多携带了 Origin 信息(每个 key 对应它在文件中的行号)。

这就是为什么 Boot 的错误消息能告诉你 application.yml:3:13。

在第 07 章讲 ConfigData 加载时会详细展开 OriginTrackedPropertiesLoader。


第 6 节 · EnvironmentPostProcessor:4.x 的新形态

6.1 4.x 的最大变化:从"事件监听器直接干"到"监听器 + PostProcessor 链"

在 Spring Boot 2.x/3.x,EnvironmentPostProcessor 的触发是 EnvironmentPostProcessorApplicationListener,但它在 org.springframework.boot.context.config 包下,且实现相对简单。

4.x 把它移到了 org.springframework.boot.support 包,并且增加了 AOT 支持。看类声明:

java 复制代码
package org.springframework.boot.support;

public class EnvironmentPostProcessorApplicationListener implements SmartApplicationListener, Ordered {

	static final String AOT_FEATURE_NAME = "EnvironmentPostProcessor";

	public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 10;

	private final DeferredLogs deferredLogs;
	private int order = DEFAULT_ORDER;
	private final Function<@Nullable ClassLoader, EnvironmentPostProcessorsFactory> postProcessorsFactory;
	// ...
}
关键字段一:DeferredLogs
java 复制代码
private final DeferredLogs deferredLogs;

这是一个延迟日志机制。为什么需要它?

因为在 EnvironmentPostProcessor 执行时,日志系统还没初始化完 (日志初始化本身依赖于 Environment 配置,如 logging.level.*)。此时如果 PostProcessor 打日志,会:

  1. 输出到错误的 appender
  2. 或者丢失

DeferredLogs 的做法:把日志暂存 起来,等 ApplicationPreparedEvent(此时日志系统已就绪)时 switchOverAll() 统一输出:

java 复制代码
@Override
public void onApplicationEvent(ApplicationEvent event) {
    if (event instanceof ApplicationEnvironmentPreparedEvent environmentPreparedEvent) {
        onApplicationEnvironmentPreparedEvent(environmentPreparedEvent);
    }
    if (event instanceof ApplicationPreparedEvent) {
        onApplicationPreparedEvent();     // → finish() → deferredLogs.switchOverAll()
    }
    if (event instanceof ApplicationFailedEvent) {
        onApplicationFailedEvent();        // → finish()
    }
}

private void finish() {
    this.deferredLogs.switchOverAll();
}

注意 ApplicationFailedEvent 也调用 finish()------启动失败时也要把暂存的日志吐出来,否则你会丢失最关键的诊断信息。

排障价值 :这就是为什么 Spring Boot 启动失败时,有时能看到"看似在很早阶段打的日志"出现在后面。它们是 DeferredLogs 补输出的。如果你写自定义 EnvironmentPostProcessor 且需要打日志,必须 通过 DeferredLog 而不是普通 Log,否则日志会丢或错位。

关键字段二:postProcessorsFactory 函数
java 复制代码
private final Function<@Nullable ClassLoader, EnvironmentPostProcessorsFactory> postProcessorsFactory;

public EnvironmentPostProcessorApplicationListener() {
    this(EnvironmentPostProcessorsFactory::fromSpringFactories);
}

public static EnvironmentPostProcessorApplicationListener with(
        EnvironmentPostProcessorsFactory postProcessorsFactory) {
    return new EnvironmentPostProcessorApplicationListener((classloader) -> postProcessorsFactory);
}

默认从 spring.factories(或 4.x 的 META-INF/spring/org.springframework.boot.EnvironmentPostProcessor)加载。

with(...) 工厂方法允许测试时注入固定的 PostProcessor 列表。

关键字段三:ORDER = HIGHEST_PRECEDENCE + 10
java 复制代码
public static final int DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 10;

Ordered.HIGHEST_PRECEDENCE 是 Integer.MIN_VALUE。+10 意味着:它几乎是第一个执行的监听器,但留了 10 个位置给更优先的 (比如日志系统的 LoggingApplicationListener 需要更早?实际上日志监听器有它自己的 order)。

为什么不是直接用 HIGHEST_PRECEDENCE? 因为 Boot 需要保证 EnvironmentPostProcessor 在某些更早的监听器之后运行(比如负责初始化 BootstrapContext 或日志基础设施的)。留 10 个槽位是"未来扩展的保险"。

6.2 AOT:生成的 __EnvironmentPostProcessor 类

这是 4.x 最值得讲的新增:

java 复制代码
private void addAotGeneratedEnvironmentPostProcessorIfNecessary(List<EnvironmentPostProcessor> postProcessors,
        SpringApplication springApplication) {
    if (AotDetector.useGeneratedArtifacts()) {
        ClassLoader classLoader = (springApplication.getResourceLoader() != null)
                ? springApplication.getResourceLoader().getClassLoader() : null;
        Class<?> mainApplicationClass = springApplication.getMainApplicationClass();
        Assert.state(mainApplicationClass != null, "mainApplicationClass not found");
        String postProcessorClassName = mainApplicationClass.getName() + "__" + AOT_FEATURE_NAME;
        if (ClassUtils.isPresent(postProcessorClassName, classLoader)) {
            postProcessors.add(0, instantiateEnvironmentPostProcessor(postProcessorClassName, classLoader));
        }
    }
}

机制:

  • AOT 构建期,Boot 分析出"这个应用的 Environment 最终会变成什么样"
  • 生成一个类,名字是 <主类全限定名>__EnvironmentPostProcessor
  • 运行期(native),如果这个类存在,就加到列表最前面 (add(0, ...))

它的作用是:把"本该在运行时通过扫描、解析、加载得到的 Environment 状态",直接固化。

对应的 AOT 贡献:

java 复制代码
static class EnvironmentBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {

    @Override
    public @Nullable BeanFactoryInitializationAotContribution processAheadOfTime(
            ConfigurableListableBeanFactory beanFactory) {
        Environment environment = beanFactory.getBean(ConfigurableApplicationContext.ENVIRONMENT_BEAN_NAME,
                Environment.class);
        String[] activeProfiles = environment.getActiveProfiles();
        String[] defaultProfiles = environment.getDefaultProfiles();
        if (!ObjectUtils.isEmpty(activeProfiles) && !Arrays.equals(activeProfiles, defaultProfiles)) {
            return new EnvironmentAotContribution(activeProfiles);
        }
        return null;
    }
}

它固化的是 active profiles。

这是一个重要的优化:native 下,spring.profiles.active 的解析、ConfigData 的 profile 迭代这些逻辑全部被跳过,直接写入构建期算出的 profiles。

AOT 的深刻含义 :profiles 在 native 下是构建期常量。这意味着:

  • 你不能 在运行时通过 SPRING_PROFILES_ACTIVE 环境变量改变 native 应用的 profile
  • 要改变 profile,必须重新构建镜像

这与 JVM 模式的行为根本不同。很多"native 下配置不生效"的问题根源在此。

而 EnvironmentAotContribution 生成的类大致是:

java 复制代码
// 生成的伪代码(示意)
public class MyApplication__EnvironmentPostProcessor implements EnvironmentPostProcessor {
    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        environment.setActiveProfiles("prod");   // 构建期算出的值
    }
}

注意条件 !Arrays.equals(activeProfiles, defaultProfiles)------如果没显式设置 profile(active 等于 default),就不生成贡献,运行期仍走正常逻辑。这是保守优化:只在有明确信息时才固化。

6.3 EnvironmentPostProcessor 接口本身

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/env/EnvironmentPostProcessor.java
@FunctionalInterface
public interface EnvironmentPostProcessor {

	void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application);

}

@FunctionalInterface ------ 可以用 lambda。但它没有 Ordered 支持。

那顺序怎么控制?看调用:

java 复制代码
List<EnvironmentPostProcessor> postProcessors = getEnvironmentPostProcessors(...);
for (EnvironmentPostProcessor postProcessor : postProcessors) {
    postProcessor.postProcessEnvironment(environment, application);
}

顺序由 EnvironmentPostProcessorsFactory 决定。而 EnvironmentPostProcessorsFactory.fromSpringFactories 会读取 spring.factories 里按 @Order 注解或 Ordered 接口排序。

注意 :EnvironmentPostProcessor 接口不继承 Ordered ,但实现类可以实现 Ordered 或用 @Order。排序由 factory 处理,不是由调用循环处理。这是一个"接口最小化 + 实现可选择增强"的设计。

6.4 内置的 EnvironmentPostProcessor 清单

从 core/spring-boot/src/main/resources/META-INF/spring.factories(或 META-INF/spring/*.imports)可以查到。

我用命令确认:

bash 复制代码
grep -A 20 "EnvironmentPostProcessor" core/spring-boot/src/main/resources/META-INF/spring.factories

典型的几个:

PostProcessor 作用
SystemEnvironmentPropertySourceEnvironmentPostProcessor 替换 systemEnvironment PropertySource 以支持 environmentPrefix
RandomValuePropertySourceEnvironmentPostProcessor 加 random
ConfigDataEnvironmentPostProcessor 加载 ConfigData(第 07 章)
SpringFactoriesEnvironmentPostProcessor(若有) ---
VcapApplicationListener 相关 云平台变量
SystemPropertiesEnvironmentPostProcessor(若有) ---

(具体清单以第 07 章实测为准。)

6.5 如何写自己的 EnvironmentPostProcessor

完整示例:

java 复制代码
package com.example;

public class MyEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {

    private static final DeferredLog logger = new DeferredLog();

    @Override
    public int getOrder() {
        return ConfigDataEnvironmentPostProcessor.ORDER + 1;  // 在 ConfigData 之后
    }

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        // 1. 此时的 environment 已包含:命令行、systemProperties、systemEnvironment、
        //    ConfigData(如果 order 在其后)
        String value = environment.getProperty("my.custom.key");
        logger.info("MyEnvironmentPostProcessor: my.custom.key=" + value);

        // 2. 添加自己的 PropertySource
        MutablePropertySources sources = environment.getPropertySources();
        sources.addLast(new MapPropertySource("mySource", Map.of("my.derived.key", "derived")));
    }
}

注册(META-INF/spring.factories):

properties 复制代码
org.springframework.boot.EnvironmentPostProcessor=\
com.example.MyEnvironmentPostProcessor

五个必须注意的约束:

  1. 不能用 @Component ------ 因为 EnvironmentPostProcessor 运行时 ApplicationContext 还不存在
  2. 不能用普通 Log ------ 用 DeferredLog,否则日志位置错乱
  3. 不能依赖 Bean ------ 同上
  4. AOT 下要额外登记 ------ 如果这个类在 native 下需要被反射实例化,要加 hints(实际上它是通过 spring.factories + BeanUtils.instantiateClass 直接调用的,需要 ReflectionHints)
  5. Order 要谨慎 ------ 在 ConfigDataEnvironmentPostProcessor 之前意味着你读不到 application.properties;之后意味着你能读到但也可能被后续覆盖

6.6 EnvironmentPostProcessor 与 ApplicationContextInitializer 的选型

两者都能改 Environment,怎么选?

EnvironmentPostProcessor ApplicationContextInitializer
触发时机 ApplicationEnvironmentPreparedEvent prepareContext 阶段(Context 已创建,未 refresh)
能拿到 Environment + SpringApplication ConfigurableApplicationContext
能拿到 BeanFactory ❌ ✅(可以注册 Bean 定义)
能改 Environment ✅ ✅
能加 PropertySource ✅ ✅
典型用途 早期属性源(如从远程配置中心拉取) 需要注册 Bean 的场景
注册方式 spring.factories spring.factories 或 application.addInitializers(...)

决策规则 :只需要改 Environment → EnvironmentPostProcessor(更早、更轻)。需要碰 BeanFactory 或 Context → ApplicationContextInitializer。


第 7 节 · 属性查找的真相:ConfigurationPropertySourcesPropertyResolver

本节是对第 4 节 4.5 小节的重要修正与深化。

我在 4.5 节的推演里假设:"configurationProperties 排在第 0 位,它返回 null 后,解析会继续往后遍历 到 commandLineArgs、systemProperties 等原生 PropertySource"。

读完 ConfigurationPropertySourcesPropertyResolver 的源码后,这个假设不成立。真实的分支逻辑比"依次遍历"要陡峭得多。下面是逐行实证。

7.1 完整源码与逐行解读

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/context/properties/source/
// ConfigurationPropertySourcesPropertyResolver.java
class ConfigurationPropertySourcesPropertyResolver extends AbstractPropertyResolver {

	private final MutablePropertySources propertySources;
	private final DefaultResolver defaultResolver;

	ConfigurationPropertySourcesPropertyResolver(MutablePropertySources propertySources) {
		this.propertySources = propertySources;
		this.defaultResolver = new DefaultResolver(propertySources);
	}

	@Override
	public boolean containsProperty(String key) {
		ConfigurationPropertySourcesPropertySource attached = getAttached();
		if (attached != null) {
			ConfigurationPropertyName name = ConfigurationPropertyName.of(key, true);
			if (name != null) {
				try {
					return attached.findConfigurationProperty(name) != null;
				}
				catch (Exception ex) {
					// Ignore
				}
			}
		}
		return this.defaultResolver.containsProperty(key);
	}
	// ...
	private @Nullable Object findPropertyValue(String key) {
		ConfigurationPropertySourcesPropertySource attached = getAttached();
		if (attached != null) {
			ConfigurationPropertyName name = ConfigurationPropertyName.of(key, true);
			if (name != null) {
				try {
					ConfigurationProperty configurationProperty = attached.findConfigurationProperty(name);
					return (configurationProperty != null) ? configurationProperty.getValue() : null;
				}
				catch (Exception ex) {
					// Ignore
				}
			}
		}
		return this.defaultResolver.getProperty(key, Object.class, false);
	}
}

7.2 三条互斥路径(而非一条遍历)

findPropertyValue 的实际控制流是:

kotlin 复制代码
if (attached != null) {                          // ① configurationProperties 是否已挂载?
    if (ConfigurationPropertyName.of(key, true) != null) {  // ② key 是否是合法的配置属性名?
        try {
            return attached.findConfigurationProperty(name) != null ? value : null;  // ③ 路径 A:就此返回
        } catch (Exception) {
            // fall through
        }
    }
}
return defaultResolver.getProperty(key, Object.class, false);  // 路径 B:原生查找

路径 A (attached != null 且 name != null):结果完全 由 configurationProperties 决定。无论返回 null 还是非 null,都直接 return ------不会回退到 defaultResolver。

路径 B (attached == null 或 name == null 或抛异常):走 DefaultResolver(即原生 PropertySourcesPropertyResolver)依次遍历所有 source。

这与"configurationProperties 只是排在第一位的普通 PropertySource"的直觉模型完全不同。它不是"第一个被问的人",它是**"如果它在场且能理解这个问题,就由它全权负责"**。

7.3 ConfigurationPropertyName.of(key, true):路径 B 的第一个触发条件

第二个参数 true 的含义是 returnNullIfInvalid。看签名:

java 复制代码
static @Nullable ConfigurationPropertyName of(CharSequence name, boolean returnNullIfInvalid)

哪些 key 会让它返回 null(从而走路径 B)?

ConfigurationPropertyName 的合法字符约束是 [a-z0-9-],元素间用 . 分隔,索引用 [n]。以下情况非法:

key 是否合法 原因
server.port ✅ 标准
SERVER_PORT ✅(会被小写化) 大写允许,adapt 时转换
spring.data.redis.host ✅
my.list[0].name ✅ 索引形式
my.list.0.name ⚠️ 视情况 0 作为元素是合法的,但语义不同
my.list[abc] ❌ 非数字索引
my..name ❌ 空元素
my.name. ❌ 尾部空元素
my@name ❌ 非法字符
``(空串) ❌

实践后果 #1 :一个含非法字符的属性名(比如从遗留系统继承的 my.old@key),不走 configurationProperties,只走原生查找。这意味着它享受不到宽松绑定和环境变量映射。

sympton:环境变量 MY_OLD@KEY 读不到,但 System.getProperty("my.old@key") 能读到。

这不是 bug,是 ConfigurationPropertyName 的词法约束。它保证了规范化过程的确定性。

7.4 attached.findConfigurationProperty(name):路径 A 内部的完整查找

ConfigurationPropertySourcesPropertySource.findConfigurationProperty:

java 复制代码
@Override
public @Nullable ConfigurationProperty findConfigurationProperty(ConfigurationPropertyName name) {
    if (name == null) {
        return null;
    }
    for (ConfigurationPropertySource configurationPropertySource : getSource()) {
        ConfigurationProperty configurationProperty = configurationPropertySource.getConfigurationProperty(name);
        if (configurationProperty != null) {
            return configurationProperty;
        }
    }
    return null;
}

getSource() 返回 SpringConfigurationPropertySources(一个 Iterable),它的迭代器会遍历底层 MutablePropertySources,但过滤掉 StubPropertySource 和 ConfigurationPropertySourcesPropertySource (isIncluded,见 2.4 节)。

所以路径 A 内部 依然是"按 MutablePropertySources 顺序,第一个命中胜出"。优先级语义得以保持。

关键差异在于"命中"的定义:

  • 路径 B(原生):propertySource.getProperty("server.port") → 精确 map 查找
  • 路径 A(适配):configurationPropertySource.getConfigurationProperty(name) → mapper 生成候选名 + (enumerable 时)全量名字缓存反查

同一个 MapPropertySource(内容是 {SERVER_PORT: 9090}):

  • 路径 B 查 server.port → map.get("server.port") → null
  • 路径 A 查 server.port → DefaultPropertyMapper.map(server.port) 生成 ["server.port"] → map.get("server.port") → null → 若 enumerable,走缓存反查:把 SERVER_PORT adapt 成 server.port → 命中,返回 9090

这就是"宽松绑定"的全部物理实现 :不在 PropertySource 里,不在 Environment 里,而在 SpringIterableConfigurationPropertySource 的名字映射缓存里。

7.5 一个精确的对照实验

假设 MutablePropertySources 内容如下(为简化只列三个):

css 复制代码
[0] configurationProperties   (适配器)
[1] commandLineArgs           (--server.port=1111)
[2] systemProperties          (空)
[3] appProps (MapPropertySource, {"SERVER_PORT": "2222"})
[4] defaultProperties         ({"server.port": "3333"})

查询 environment.getProperty("server.port") (ApplicationEnvironment → ConfigurationPropertySourcesPropertyResolver):

  1. attached != null ✅
  2. ConfigurationPropertyName.of("server.port", true) → 合法 ✅
  3. attached.findConfigurationProperty(server.port):
    • 遍历 commandLineArgs 的适配器 → SimpleCommandLinePropertySource 是 enumerable?SimpleCommandLinePropertySource 继承 EnumerablePropertySource(其 CommandLineArgs 可被枚举)→ 用缓存:server.port ↔ server.port → 命中 --server.port → 返回 1111

结果:1111 ✅(正确,命令行优先级最高)

查询 environment.getProperty("SERVER_PORT"):

  1. attached != null ✅
  2. ConfigurationPropertyName.of("SERVER_PORT", true) → adapt 小写化 → server.port ✅ 合法
  3. findConfigurationProperty(server.port) → 同上 → 1111

结果:1111

反直觉结论 #7(修正版) :在 ApplicationEnvironment 下,environment.getProperty("SERVER_PORT") 和 environment.getProperty("server.port") 返回完全相同的东西 。因为两者在第一步就被 ConfigurationPropertyName.of 归一化了。

归一化发生在查询入口,而不是在 PropertySource 内部。这是理解 Boot 宽松绑定的关键。

查询 environment.getProperty("my.old@key")(假设只有 systemProperties 里有 my.old@key):

  1. attached != null ✅
  2. ConfigurationPropertyName.of("my.old@key", true) → 非法字符 @ → 返回 null
  3. 走路径 B:defaultResolver.getProperty("my.old@key", Object.class, false) → 遍历 commandLineArgs → appProps → systemProperties → 命中

结果:能读到,但享受不到任何宽松绑定。

7.6 containsProperty 与 getProperty 的一致性

java 复制代码
@Override
public boolean containsProperty(String key) {
    ConfigurationPropertySourcesPropertySource attached = getAttached();
    if (attached != null) {
        ConfigurationPropertyName name = ConfigurationPropertyName.of(key, true);
        if (name != null) {
            try {
                return attached.findConfigurationProperty(name) != null;
            }
            catch (Exception ex) {
                // Ignore
            }
        }
    }
    return this.defaultResolver.containsProperty(key);
}

containsProperty 与 findPropertyValue 走完全相同的分支逻辑。这保证了:

environment.containsProperty(k) 为 true ⟺ environment.getProperty(k) 非 null

(在值本身非 null 的前提下。注意:如果属性值显式设为 null ------在某些 PropertySource 里可能------containsProperty 会返回 false,因为 findConfigurationProperty 的实现里 if (value != null) 才算命中。)

对比原生 PropertySourcesPropertyResolver:

java 复制代码
@Override
public boolean containsProperty(String key) {
    if (this.propertySources != null) {
        for (PropertySource<?> propertySource : this.propertySources) {
            if (propertySource.containsProperty(key)) {
                return true;
            }
        }
    }
    return false;
}

原生版本遍历所有 source 调 containsProperty。而 configurationProperties 的 containsProperty 实现......实际上 ConfigurationPropertySourcesPropertySource 没有覆写 containsProperty,所以走 PropertySource.containsProperty(name) → getProperty(name) != null。

在 Boot 里这两个方法的一致性是被刻意设计出来的 。这是"为什么 Boot 的 containsProperty 比原生可靠"的原因。

7.7 占位符解析的时机:resolveNestedPlaceholders 的两处差异

java 复制代码
private <T> @Nullable T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
    Object value = findPropertyValue(key);
    if (value == null) {
        return null;
    }
    if (resolveNestedPlaceholders && value instanceof String string) {
        value = resolveNestedPlaceholders(string);
    }
    try {
        return convertValueIfNecessary(value, targetValueType);
    }
    catch (ConversionFailedException ex) {
        Exception wrappedCause = new InvalidConfigurationPropertyValueException(key, value,
                "Failed to convert to type " + ex.getTargetType(), ex.getCause());
        throw new ConversionFailedException(ex.getSourceType(), ex.getTargetType(), ex.getValue(), wrappedCause);
    }
}

注意 findPropertyValue 里调 defaultResolver.getProperty(key, Object.class, false) 传的是 false:

java 复制代码
return this.defaultResolver.getProperty(key, Object.class, false);

为什么是 false? 因为占位符解析交给外层统一做一次。如果内层也做,就会双重解析:

bash 复制代码
${a} → 内层解析成 "value-of-a" → 外层又解析一次(如果 value-of-a 恰好含 ${...})

双重解析的后果是:字面量里的 ${...} 会被误解析,且 ${a:default} 的默认值语义会被破坏。

这是一个细但重要的正确性细节 :ConfigurationPropertySourcesPropertyResolver 显式避免了双重占位符解析。

转换失败的异常包装
java 复制代码
catch (ConversionFailedException ex) {
    Exception wrappedCause = new InvalidConfigurationPropertyValueException(key, value,
            "Failed to convert to type " + ex.getTargetType(), ex.getCause());
    throw new ConversionFailedException(ex.getSourceType(), ex.getTargetType(), ex.getValue(), wrappedCause);
}

InvalidConfigurationPropertyValueException 携带 key、原始 value、目标类型 三个上下文。这让最终的错误消息是:

vbnet 复制代码
Failed to convert to type java.time.Duration
Property: my.timeout
Value: "abc"

而不是裸的 ConversionFailedException。这个包装是为了让 FailureAnalyzer 能识别出"这是配置值转换失败"并给出可操作的建议。

7.8 getPropertyAsRawString:为什么要绕过占位符

java 复制代码
@Override
protected @Nullable String getPropertyAsRawString(String key) {
    return getProperty(key, String.class, false);
}

AbstractPropertyResolver.resolvePlaceholders → PropertyPlaceholderHelper 在遇到 ${a} 时会调 getPropertyAsRawString("a")。

如果不绕过(即传 true),解析 ${a} 时会把 a 的值再做一次占位符解析------同样导致双重解析。

getPropertyAsRawString 的存在本身就是"占位符解析必须只做一次"这一约束的证据。


第 8 节 · 属性优先级的完整规则体系(可操作版)

前面几节把机制拆开了。这一节给出可直接用于排障的判断树。

8.1 第一层:PropertySource 次序

标准 Boot 4.1.1 应用(非 AOT),从高到低:

ini 复制代码
 1. commandLineArgs                    --server.port=8081 / --spring.profiles.active=prod
 2. servletConfigInitParams            <init-param>(仅 SERVLET,且极少用)
 3. servletContextInitParams           <context-param>(仅 SERVLET)
 4. jndiProperties                     java:comp/env(仅 SERVLET 且可用时)
 5. systemProperties                   -Dserver.port=8081
 6. systemEnvironment                  SERVER_PORT=8081
 7. random                             random.* 前缀(不会被覆盖,只能被引用)
 8. ConfigData: application-{profile}.properties(jar 外,如 ./config/)
 9. ConfigData: application-{profile}.properties(classpath)
10. ConfigData: application.properties(jar 外)
11. ConfigData: application.properties(classpath)
12. @PropertySource 引入的源            addLast 加入
13. defaultProperties                  SpringApplicationBuilder.properties(...)
14. applicationInfo                    spring.application.pid/version

注意 12 的位置 :@PropertySource 在 refresh 阶段 addLast 加入,所以它在 ConfigData 之后、defaultProperties 之前(因为 defaultProperties 会被 moveToEnd 再挪一次)。但 moveToEnd 发生在 prepareEnvironment 阶段(早于 refresh),所以 refresh 时 addLast 的 @PropertySource 会排在 defaultProperties 之后。

实际次序以运行时 dump 为准(第 9 节)。这个细节说明:静态文档里的优先级表在 @PropertySource 参与时会失真,必须实测。

8.2 第二层:同一 PropertySource 内的 key 次序

ConfigData 内部:

  • 同一文件里,后面的覆盖前面的?不 。properties/yaml 是 Map,重复 key 时后者覆盖前者(加载时 put),但 Origin 会记录最后一次的位置。
  • 不同文件:application-{profile} 覆盖 application(由 ConfigData 的 contributor 排序保证)

8.3 第三层:查找路径(路径 A vs 路径 B)

如第 7 节所述,这是一个二元开关:

  • key 是合法 ConfigurationPropertyName → 路径 A(宽松绑定全开)
  • 否则 → 路径 B(精确匹配)

8.4 第四层:ConfigurationPropertyName 的等价类

在路径 A 下,以下写法完全等价 (会被归一化到同一个 ConfigurationPropertyName):

写法 归一化结果
server.port server.port
SERVER_PORT server.port(adapt 时 _ 作分隔符或小写化)
server-port server.port?(不是------- 是合法字符,保留)

等等,这里要小心。- 是合法字符,不会被当分隔符。所以:

  • server-port → 元素 ["server-port"],单元素
  • server.port → 元素 ["server", "port"],双元素

两者不等价!

关键澄清 :ConfigurationPropertyName 的元素分隔符只有 . (普通 source)和 _(systemEnvironment)。- 是元素内的合法字符。

所以:

  • server.port ≠ server-port ❌
  • my-app.name ≠ my.app.name ❌
  • MY_APP_NAME(环境变量)→ my.app.name ✅(下划线是分隔符)
  • MY-APP-NAME?在 systemEnvironment 下,_ 是分隔符,- 是元素内字符 → 元素 ["my-app-name"],单元素 → 与 my.app.name 不等价 ❌
    反直觉结论 #8 :环境变量里必须用下划线 分隔层级,不能用连字符。MY-APP-NAME 映射不到 my.app.name。这是 Kubernetes ConfigMap 里常见的错误------ConfigMap 的 key 只允许 [-._a-zA-Z0-9],很多人习惯用 -。

而 DefaultPropertyMapper 用 . 作分隔符,所以 properties 文件里必须用点。

8.5 第五层:Binder 的绑定规则(与 getProperty 不同)

Binder 是第 3 套查找系统(本章第 1 节提到的三套之一)。它不走 Environment.getProperty,而是直接遍历 ConfigurationPropertySource 并用 ConfigurationPropertyName 做前缀遍历。

这意味着 Binder.bind("server", Bindable.of(ServerProperties.class)) 会:

  1. 枚举所有 ConfigurationPropertySource
  2. 对每个 source,找出所有以 server. 开头的属性
  3. 合并(按 source 次序,先出现的胜出)

与 getProperty 的差异 :Binder 能看到"有哪些子属性存在"(通过 IterableConfigurationPropertySource.stream()),而 getProperty 只能逐个查。

这就是为什么 @ConfigurationProperties 能感知"未绑定的属性"(UnboundConfigurationPropertiesException)而 getProperty 不能。

第 09 章会完整展开 Binder。这里只需记住:"配置属性绑定"和"Environment 查询"不是同一套代码。

8.6 判断树:配置没生效时的排查顺序

bash 复制代码
配置值不对?
├─ 1. 打印 PropertySource 列表(第 9 节代码),确认目标 source 在不在
│     ├─ 不在 → 配置文件没被加载(路径/profile/import 问题)→ 第 07 章
│     └─ 在 → 继续
├─ 2. 打印每个 source 里该 key 的值,确认"第一个命中的"是谁
│     └─ 若更高优先级的 source 里有意外的值 → 找到了
├─ 3. 确认 key 是合法 ConfigurationPropertyName
│     ├─ 非法 → 走路径 B,宽松绑定失效
│     └─ 合法 → 继续
├─ 4. 确认用的是哪种读取方式
│     ├─ @ConfigurationProperties → Binder 路径(第 09 章)
│     ├─ @Value → BeanFactory 的 ConversionService(第 3 节)
│     └─ environment.getProperty → 本章路径 A/B
├─ 5. 确认 profile 是否如你所想(第 1.2.1 节:Boot 关掉了 AbstractEnvironment 的自动读取)
└─ 6. AOT/native 下:profiles 是构建期常量(第 6.2 节)

第 9 节 · 排障工具:把 Environment 的内部状态打出来

9.1 完整 dump 代码

java 复制代码
@Component
public class EnvironmentDumper implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        ConfigurableEnvironment env = (ConfigurableEnvironment) context.getEnvironment();
        System.out.println("========== PropertySources (优先级从高到低) ==========");
        int i = 0;
        for (PropertySource<?> ps : env.getPropertySources()) {
            System.out.printf("[%2d] %-40s %s%n", i++, ps.getName(), ps.getClass().getSimpleName());
        }

        System.out.println("\n========== 指定 key 在各 source 中的值 ==========");
        String key = "server.port";
        for (PropertySource<?> ps : env.getPropertySources()) {
            Object v = ps.getProperty(key);
            if (v != null) {
                System.out.printf("  %-40s => %s%n", ps.getName(), v);
            }
        }

        System.out.println("\n========== Environment 的最终解析 ==========");
        System.out.println("containsProperty: " + env.containsProperty(key));
        System.out.println("getProperty     : " + env.getProperty(key));
        System.out.println("ConversionService: " + env.getConversionService().getClass().getName());
    }
}

注意 :要在 ApplicationRunner 里跑(此时 Environment 已完全就绪),且需要注入 ApplicationContext 或 Environment。

9.2 更实用的方式:临时 EnvironmentPostProcessor

如果问题发生在启动早期(ApplicationRunner 都跑不到),用 PostProcessor:

java 复制代码
public class DumpEnvironmentPostProcessor implements EnvironmentPostProcessor {
    @Override
    public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
        System.out.println("--- after ConfigData? order 决定 ---");
        env.getPropertySources().forEach(ps ->
            System.out.println(ps.getName() + " : " + ps.getClass().getSimpleName()));
    }
}

配合不同 order 值,可以看到"ConfigData 之前"和"ConfigData 之后"的快照对比。

9.3 Actuator 的方式(生产环境)

/actuator/env 端点会返回 PropertySource 列表(但默认脱敏 )。/actuator/configprops 返回已绑定的 @ConfigurationProperties 结果。

注意:Actuator 的 /actuator/env 按优先级倒序显示(高优先级在后?实际上取决于实现版本)。要小心读。

9.4 用 -Dlogging.level 观察

Boot 在 TRACE 级别会打印属性查找:

properties 复制代码
logging.level.org.springframework.core.env=TRACE
logging.level.org.springframework.boot.context.properties.source=TRACE

PropertySourcesPropertyResolver.getProperty 里有:

java 复制代码
if (logger.isTraceEnabled()) {
    logger.trace("Searching for key '" + key + "' in PropertySource '" + propertySource.getName() + "'");
}

以及 RandomValuePropertySource 的:

java 复制代码
logger.trace(LogMessage.format("Generating random property for '%s'", name));

打开 TRACE 可以看到每一次查找的每一步。对于复杂问题这是最有效的手段(但输出量巨大)。


第 10 节 · 4.x 相对 3.x 的变化清单

方面 3.x 4.1.1
PropertySourceInfo 接口 无 4.0.0 新增 ,提供 isImmutable() / getPrefix()
EnvironmentPostProcessor 的监听类位置 org.springframework.boot.context.config 移到 org.springframework.boot.support
AOT 生成的 __EnvironmentPostProcessor 无 新增,固化 active profiles
DeferredLogs 机制 有(单 DeferredLog) 增强为 DeferredLogs(复数管理)
cachedrandom PropertySource 无 新增(AOT 下固化 random 值)
EnvironmentPostProcessorsFactory 无(直接 spring.factories 加载) 新增抽象 ,支持 with(...) 注入
OriginAwareSystemEnvironmentPropertySource 有 增强,带 environmentPrefix 支持
ConversionService.addBeans 的 qualifier 有(3.5.0 起) 沿用
SystemEnvironmentPropertySourceEnvironmentPostProcessor 位置 org.springframework.boot.env 移到 org.springframework.boot.support

包迁移是 4.x 最显著的变化 :一批"基础设施类"从 org.springframework.boot.env / org.springframework.boot.context.config 迁到了新的 org.springframework.boot.support 包。

已确认迁移的类:

  • EnvironmentPostProcessorApplicationListener
  • RandomValuePropertySourceEnvironmentPostProcessor
  • SpringApplicationJsonEnvironmentPostProcessor
  • SystemEnvironmentPropertySourceEnvironmentPostProcessor

迁移成本提示 :如果你在 3.x 里按全限定名引用 过这些类(比如在 spring.factories 里写完整类名,或 import 了它们),升级到 4.x 会直接失败。这是 4.x 升级的一个硬断点。

spring.factories 里如果写的是接口名 org.springframework.boot.EnvironmentPostProcessor,实现类全名写在值里------值里的全名必须改。

10.1 完整的 4.1.1 EnvironmentPostProcessor 注册清单(实测)

bash 复制代码
core/spring-boot/src/main/resources/META-INF/spring.factories

org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.cloud.CloudFoundryVcapEnvironmentPostProcessor,\
org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor,\
org.springframework.boot.support.RandomValuePropertySourceEnvironmentPostProcessor,\
org.springframework.boot.support.SpringApplicationJsonEnvironmentPostProcessor,\
org.springframework.boot.support.SystemEnvironmentPropertySourceEnvironmentPostProcessor

只有 5 个(核心 jar 内)。注意:

  • CloudFoundryVcapEnvironmentPostProcessor 仍在 org.springframework.boot.cloud(没有迁移到 support)
  • ConfigDataEnvironmentPostProcessor 仍在 org.springframework.boot.context.config(没有迁移)
  • 另外三个迁移了

这个不一致说明:迁移是按类的性质 进行的------"通用支撑设施"进 support,"领域特定"的留在原包。

另外注意同一文件里还有:

复制代码
org.springframework.boot.SpringApplicationRunListener 相关?
第 45 行:org.springframework.boot.support.EnvironmentPostProcessorApplicationListener

(这行是在 org.springframework.boot.context.ApplicationListener 或类似 key 下注册的。)


第 11 节 · AOT / Native 下的 Environment

11.1 三层固化

在 native 场景下,Environment 有三处被"烘焙":

(1)active profiles(第 6.2 节)

java 复制代码
if (!ObjectUtils.isEmpty(activeProfiles) && !Arrays.equals(activeProfiles, defaultProfiles)) {
    return new EnvironmentAotContribution(activeProfiles);
}

(2)random.* 值

cachedrandom PropertySource 把构建期生成的随机值固化。

(3)EnvironmentPostProcessor 列表本身

生成的 <App>__EnvironmentPostProcessor 类替代了 spring.factories 的运行时扫描。

11.2 后果:三类"native 下不生效"

现象 原因 解法
SPRING_PROFILES_ACTIVE 环境变量不起作用 profiles 是构建期常量,被 __EnvironmentPostProcessor 覆盖 重新构建镜像,或用 spring.profiles.active 在构建期指定
random.uuid 每次启动都一样 cachedrandom 固化 改用运行期 API
自定义的 EnvironmentPostProcessor 没跑 AOT 生成的类替换 了列表?不------它是 add(0, ...) 加在最前,不替换。但如果你的 PostProcessor 依赖运行时 classpath 扫描,native 下扫不到 登记 spring.factories 到 hints,或用 @ImportRuntimeHints

注意第三项的一个易错点 :addAotGeneratedEnvironmentPostProcessorIfNecessary 用的是 postProcessors.add(0, ...),不是替换 。所以运行时仍然会执行 spring.factories 里的 PostProcessor。

但 ConfigDataEnvironmentPostProcessor 在 native 下的行为不同 ------它会走 AOT 优化路径(跳过文件扫描,直接用构建期结果)。这是 ConfigDataEnvironmentPostProcessor 内部的分支,第 07 章展开。

11.3 AotDetector.useGeneratedArtifacts()

java 复制代码
if (AotDetector.useGeneratedArtifacts()) { ... }

这是 Spring Framework 6 提供的判定:true 表示当前运行在"使用了 AOT 生成产物的模式"下(native 或 spring.aot.enabled=true 的 JVM 模式)。

注意 :spring.aot.enabled=true 的 JVM 模式 也会走这条路径!这意味着上述三类固化的后果在 JVM + AOT 模式下同样存在。这对"在 JVM 上用 AOT 做测试"的人来说是个陷阱。


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

  1. ApplicationEnvironment 关掉了 AbstractEnvironment 的 profile 自动读取 (doGetActiveProfilesProperty() 返回 null)。不是不支持 spring.profiles.active,而是把控制权交给 ConfigDataEnvironment。副作用:手工 new StandardEnvironment() 会导致 profile 被错误缓存。

  2. defaultProperties 是优先级最低的来源 。SpringApplicationBuilder.properties(...) 设置的默认值被所有外部配置覆盖,与"程序内硬编码优先级高"的直觉相反。

  3. configurationProperties 排第一但不改变优先级 。它是适配器(代理到底层 sources),改变的是查找能力(宽松绑定)。

  4. @PropertySource 不能覆盖 application.properties 。它在 refresh 阶段 addLast 加入,位置在 ConfigData 之后。

  5. 宽松绑定不是"任意风格互认" 。camelCase 写的 key(myApp.x)无法被 kebab 形式(my-app.x)读到,因为 ConfigurationPropertyName.adapt 只小写化、不在单词边界插 -。properties/yaml 里一律用 kebab-case 是功能约束,不是风格偏好。

  6. 小写下划线的环境变量(spring_datasource_url)能生效,靠的是 Boot 的兜底而不是 Spring Framework 。SpringConfigurationPropertySource.getSystemEnvironmentProperty 最后的 toLowerCase(Locale.ROOT)。换掉 Environment 实现类就会丢失这个能力。

  7. 对于合法的配置属性名,getProperty 的结果完全由 configurationProperties 决定,不回退到原生查找 (findPropertyValue 里 return 无条件)。只有非法名字或异常时才走原生路径。

  8. 环境变量必须用下划线分隔层级,不能用连字符 。MY-APP-NAME 映射不到 my.app.name(- 是元素内合法字符,不是分隔符)。Kubernetes ConfigMap 的常见错误。

  9. Environment 的 ConversionService 和 BeanFactory 的不是同一个实例 。自定义 Converter Bean 只进后者;要让前者也生效,必须写 EnvironmentPostProcessor。此外还有一个不可变的 getSharedInstance()。

  10. random.* 在 native 下是构建期常量 (cachedrandom),同一镜像多次运行得到相同值。而且它是非 enumerable 的,无法被列出/补全。

补充三条:

  1. ConfigTreePropertySource 返回的是多态 Value (同时实现 InputStreamSource 和 CharSequence),由绑定目标类型决定解释为文本还是字节------不预先决定。

  2. EnvironmentPostProcessor 里必须用 DeferredLog ,否则日志会丢失或错位(ApplicationFailedEvent 时也会 switchOverAll() 补输出)。

  3. ConfigurationPropertySourcesPropertyResolver 显式避免双重占位符解析 (内层 defaultResolver.getProperty(..., false))。getPropertyAsRawString 的存在是这一约束的证据。


第 13 节 · 动手验证清单

bash 复制代码
# 1. 看 PropertySource 次序(需要写个 ApplicationRunner,见 9.1)
#    或在应用启动时加:
java -jar app.jar --debug 2>&1 | grep -i "propertysource"

# 2. 验证宽松绑定:三种等价写法
java -jar app.jar --server.port=1111
java -jar app.jar -Dserver.port=1111   # 注意:这个会被当作 JVM 参数
SERVER_PORT=1111 java -jar app.jar
SPRING_DATASOURCE_URL=xxx java -jar app.jar

# 3. 验证环境变量连字符不工作(应无效)
MY-APP-NAME=test java -jar app.jar     # 映射不到 my.app.name
MY_APP_NAME=test java -jar app.jar     # 有效

# 4. 验证 defaultProperties 优先级最低
#    SpringApplicationBuilder.properties("server.port=9999")
#    然后 --server.port=8081 → 结果是 8081

# 5. 验证 random
cat > /tmp/t.properties <<'EOF'
my.uuid=${random.uuid}
my.int=${random.int[100,200]}
EOF
java -jar app.jar --spring.config.location=/tmp/t.properties

# 6. 打开 TRACE 看查找过程
java -jar app.jar --logging.level.org.springframework.core.env=TRACE

# 7. 验证 ConversionService 实例不同
#    在代码里打印:
#    System.out.println(System.identityHashCode(env.getConversionService()));
#    System.out.println(System.identityHashCode(
#        ctx.getBeanFactory().getConversionService()));
#    → 两者不同

# 8. 验证 AOT 下 profiles 是常量
./mvnw -Pnative native:compile   # 或 gradle nativeCompile
SPRING_PROFILES_ACTIVE=dev ./app   # 无效,仍是构建期的 profile

# 9. 验证 @PropertySource 不能覆盖 application.properties
#    application.properties:  my.key=fromAppProps
#    custom.properties:       my.key=fromCustom
#    @PropertySource("classpath:custom.properties")
#    → 结果是 fromAppProps

# 10. 验证 camelCase 不能被 kebab 读到
#     application.yml:  myApp: { x: 1 }
#     env.getProperty("my-app.x")  → null
#     env.getProperty("myApp.x")   → 1
#     env.getProperty("myapp.x")   → 1(归一化后命中)

第 14 节 · 源码索引

主题 文件
ApplicationEnvironment core/spring-boot/src/main/java/org/springframework/boot/ApplicationEnvironment.java
EnvironmentConverter core/spring-boot/src/main/java/org/springframework/boot/EnvironmentConverter.java
configureEnvironment 三步 SpringApplication.java:498 / :513 / :543
ConfigurationPropertySources.attach context/properties/source/ConfigurationPropertySources.java
ConfigurationPropertySourcesPropertyResolver context/properties/source/ConfigurationPropertySourcesPropertyResolver.java
SpringConfigurationPropertySource context/properties/source/SpringConfigurationPropertySource.java
SpringIterableConfigurationPropertySource 同上目录
DefaultPropertyMapper 同上目录
SystemEnvironmentPropertyMapper 同上目录
ConfigurationPropertyName 同上目录
ApplicationConversionService convert/ApplicationConversionService.java
RandomValuePropertySource env/RandomValuePropertySource.java
DefaultPropertiesPropertySource env/DefaultPropertiesPropertySource.java
PropertySourceInfo env/PropertySourceInfo.java
ConfigTreePropertySource env/ConfigTreePropertySource.java
OriginTrackedMapPropertySource env/OriginTrackedMapPropertySource.java
EnvironmentPostProcessor env/EnvironmentPostProcessor.java
EnvironmentPostProcessorApplicationListener support/EnvironmentPostProcessorApplicationListener.java
SystemEnvironmentPropertySourceEnvironmentPostProcessor support/

第 15 节 · 本章新增定律

第十二定律:降级路径存在 = 这里不会崩,但可能静默偏离预期(第 6 章)

spring.main.cloud-platform=cloud-foundry(连字符形式)走 Binder 绑定时能工作------ ApplicationConversionService 会做"大写化 → 替换 - 为 _ → 去掉非字母数字"三级宽松匹配。 但 CloudPlatform.getActive() 走的是 environment.getProperty(...) + name().equalsIgnoreCase(), 不走 Binder,于是同一个值的连字符形式在这里静默失效(第 3 节)。

同一个属性、两条转换路径、两套规则------没有异常,没有 WARN,只有"有时生效有时不生效"。 第 4 节里 ConfigurationPropertySources 对"非枚举 PropertySource"的宽松绑定降级是同一类问题: 降级让最坏情况从"崩溃"变成了"结果不对"。

凡是"宽松路径"与"严格路径"并存的地方,都要在文档里写明边界------ 因为代码本身不会告诉你它刚刚静默地换了一条路。


第 16 节 · 本章未逐行核实 / 待你实测的项

  1. ConfigurationPropertySources 的完整查找规则未逐行核实。
  2. SystemEnvironmentPropertySource 宽松绑定降级的精确边界未实测。
  3. ConfigData 的插入策略(addFirst / addBefore)在第 07 章,本章只给了结论。
  4. PropertySourceInfo 的实现类全集未统计。
  5. 两个 ConversionService 实例在 AOT 下的差异未验证。

下一章:第 07 章进入 ConfigData 体系------位置解析、profile 迭代与 contributor 树的完整机制。


本章小结

Environment 不是一个"装配置的容器",它是三套查找机制 + 一个有序列表 + 两个 ConversionService 实例的组合体:

  1. 有序列表 (MutablePropertySources 的 CopyOnWriteArrayList)提供优先级语义------"第一个命中胜出",没有权重计算。
  2. 三套查找机制 :原生 PropertySourcesPropertyResolver(精确)、ConfigurationPropertySourcesPropertyResolver(路径 A:宽松 / 路径 B:精确回退)、Binder(前缀遍历 + 未绑定检测)。它们对同一个 key 可能给出不同答案。
  3. 两个 ConversionService 实例(Environment 的、BeanFactory 的)+ 一个不可变的共享实例。自定义转换器默认只进后者。

最容易被误解的三点是:

  • configurationProperties 不是"第一个 PropertySource",而是"全权代理"------对合法名字,它返回 null 就是 null,不回退。
  • 宽松绑定的等价类是有限的------kebab 是唯一的"通用写法",camelCase 不能被 kebab 读到。
  • @PropertySource 优先级低于 application.properties。

理解了这些,"配置不生效"就从玄学变成了可机械排查的问题:dump 列表 → 定位命中者 → 检查 key 合法性 → 确认读取路径。

下一章(第 07 章)我们进入 ConfigData------那个在 environmentPrepared 阶段往这个列表里插入 6~10 个 PropertySource 的复杂子系统。它是 Boot 配置体系里最庞大、也最容易出问题的部分:spring.config.location、spring.config.import、spring.config.activate.on-profile、profile 的迭代求解、以及 ConfigDataEnvironmentPostProcessor 那个被 Spring 团队自己称为"最复杂的类之一"的实现。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT
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·后端·面试