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拆到源码层面,回答三个真正的机制问题:
- 次序是怎么被"物理地"建立起来的 ------不是"谁优先级高",而是"谁在什么时候被
addFirst/addLast/addBefore/replace进了MutablePropertySources的CopyOnWriteArrayList"。Environment里有几套并存的属性查找机制 ------PropertySourcesPropertyResolver(原始名查找)、ConfigurationPropertySourcesPropertyResolver(规范化名查找)、Binder(绑定查找)。它们查找同一个PropertySource时的结果可能不同,这是"配置不生效"的真正根源。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 的决策流程:
spring.profiles.active可能来自命令行、环境变量、application.properties、application-{profile}.properties里的spring.profiles.active......- 而且
spring.profiles.active在 ConfigData 里还能被spring.profiles.group.*展开成一组 - 更关键的是: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", ...)设置属性是可行的,因为systemPropertiesPropertySource 持有的是活对象。但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 实例:
environment.getConversionService()------ 服务Environment.getProperty和早期绑定beanFactory.getConversionService()------ 服务@Value、@ConfigurationProperties的后处理器、SpEL 等
实践后果 :如果你注册了一个自定义
ConverterBean,它会被ApplicationConversionService.addBeans(...)加到实例 2 (BeanFactory 的),但不会自动加到实例 1(Environment 的)。后果:
@Value("${x}")和@ConfigurationProperties能用你的转换器,但environment.getProperty("x", MyType.class)在启动早期不能。要把转换器加到 Environment 的实例,需要
EnvironmentPostProcessor:
javapublic 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_PORTLEGACY_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;
}
流程:
- 遍历
mappers(普通 source 只有DefaultPropertyMapper;systemEnvironment有SystemEnvironmentPropertyMapper+DefaultPropertyMapper两个) - 每个 mapper 生成若干候选名
- 依次尝试,第一个非空返回
注意那个 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 打日志,会:
- 输出到错误的 appender
- 或者丢失
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
五个必须注意的约束:
- 不能用
@Component------ 因为 EnvironmentPostProcessor 运行时ApplicationContext还不存在 - 不能用普通 Log ------ 用
DeferredLog,否则日志位置错乱 - 不能依赖 Bean ------ 同上
- AOT 下要额外登记 ------ 如果这个类在 native 下需要被反射实例化,要加 hints(实际上它是通过
spring.factories+BeanUtils.instantiateClass直接调用的,需要ReflectionHints) - 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_PORTadapt 成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):
attached != null✅ConfigurationPropertyName.of("server.port", true)→ 合法 ✅attached.findConfigurationProperty(server.port):- 遍历
commandLineArgs的适配器 →SimpleCommandLinePropertySource是 enumerable?SimpleCommandLinePropertySource继承EnumerablePropertySource(其CommandLineArgs可被枚举)→ 用缓存:server.port↔server.port→ 命中--server.port→ 返回 1111
- 遍历
结果:1111 ✅(正确,命令行优先级最高)
查询 environment.getProperty("SERVER_PORT"):
attached != null✅ConfigurationPropertyName.of("SERVER_PORT", true)→ adapt 小写化 →server.port✅ 合法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):
attached != null✅ConfigurationPropertyName.of("my.old@key", true)→ 非法字符@→ 返回 null- 走路径 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)) 会:
- 枚举所有
ConfigurationPropertySource - 对每个 source,找出所有以
server.开头的属性 - 合并(按 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 包。
已确认迁移的类:
EnvironmentPostProcessorApplicationListenerRandomValuePropertySourceEnvironmentPostProcessorSpringApplicationJsonEnvironmentPostProcessorSystemEnvironmentPropertySourceEnvironmentPostProcessor
迁移成本提示 :如果你在 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 条反直觉结论汇总
-
ApplicationEnvironment关掉了AbstractEnvironment的 profile 自动读取 (doGetActiveProfilesProperty()返回null)。不是不支持spring.profiles.active,而是把控制权交给ConfigDataEnvironment。副作用:手工new StandardEnvironment()会导致 profile 被错误缓存。 -
defaultProperties是优先级最低的来源 。SpringApplicationBuilder.properties(...)设置的默认值被所有外部配置覆盖,与"程序内硬编码优先级高"的直觉相反。 -
configurationProperties排第一但不改变优先级 。它是适配器(代理到底层 sources),改变的是查找能力(宽松绑定)。 -
@PropertySource不能覆盖application.properties。它在 refresh 阶段addLast加入,位置在 ConfigData 之后。 -
宽松绑定不是"任意风格互认" 。camelCase 写的 key(
myApp.x)无法被 kebab 形式(my-app.x)读到,因为ConfigurationPropertyName.adapt只小写化、不在单词边界插-。properties/yaml 里一律用 kebab-case 是功能约束,不是风格偏好。 -
小写下划线的环境变量(
spring_datasource_url)能生效,靠的是 Boot 的兜底而不是 Spring Framework 。SpringConfigurationPropertySource.getSystemEnvironmentProperty最后的toLowerCase(Locale.ROOT)。换掉 Environment 实现类就会丢失这个能力。 -
对于合法的配置属性名,
getProperty的结果完全由configurationProperties决定,不回退到原生查找 (findPropertyValue里return无条件)。只有非法名字或异常时才走原生路径。 -
环境变量必须用下划线分隔层级,不能用连字符 。
MY-APP-NAME映射不到my.app.name(-是元素内合法字符,不是分隔符)。Kubernetes ConfigMap 的常见错误。 -
Environment的ConversionService和BeanFactory的不是同一个实例 。自定义ConverterBean 只进后者;要让前者也生效,必须写EnvironmentPostProcessor。此外还有一个不可变的getSharedInstance()。 -
random.*在 native 下是构建期常量 (cachedrandom),同一镜像多次运行得到相同值。而且它是非 enumerable 的,无法被列出/补全。
补充三条:
-
ConfigTreePropertySource返回的是多态Value(同时实现InputStreamSource和CharSequence),由绑定目标类型决定解释为文本还是字节------不预先决定。 -
EnvironmentPostProcessor里必须用DeferredLog,否则日志会丢失或错位(ApplicationFailedEvent时也会switchOverAll()补输出)。 -
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 节 · 本章未逐行核实 / 待你实测的项
ConfigurationPropertySources的完整查找规则未逐行核实。SystemEnvironmentPropertySource宽松绑定降级的精确边界未实测。- ConfigData 的插入策略(
addFirst/addBefore)在第 07 章,本章只给了结论。 PropertySourceInfo的实现类全集未统计。- 两个
ConversionService实例在 AOT 下的差异未验证。
下一章:第 07 章进入 ConfigData 体系------位置解析、profile 迭代与 contributor 树的完整机制。
本章小结
Environment 不是一个"装配置的容器",它是三套查找机制 + 一个有序列表 + 两个 ConversionService 实例的组合体:
- 有序列表 (
MutablePropertySources的CopyOnWriteArrayList)提供优先级语义------"第一个命中胜出",没有权重计算。 - 三套查找机制 :原生
PropertySourcesPropertyResolver(精确)、ConfigurationPropertySourcesPropertyResolver(路径 A:宽松 / 路径 B:精确回退)、Binder(前缀遍历 + 未绑定检测)。它们对同一个 key 可能给出不同答案。 - 两个 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 团队自己称为"最复杂的类之一"的实现。