Spring Framework 7.0.9 深度剖析-15-Environment 与属性管理
1. 本章导读
任何一个稍有规模的 Spring 应用,最终都要面对同一个问题:配置从哪里来?以什么顺序生效?占位符 ${db.url} 到底是被谁、在哪一步、用哪份配置替换掉的? 这个问题看起来简单,一旦涉及 JVM 系统属性、操作系统环境变量、web.xml 的上下文参数、JNDI、 properties 文件、YAML 多文档、命令行参数等多种来源,再叠加 @PropertySource、PropertySourcesPlaceholderConfigurer、@Value("${...}")、SpEL #{...} 等入口,"到底哪份配置赢了"就成了 Spring 生态里最常见的疑难杂症。本章的目标,就是回到 Spring Framework 7.0.9 的真实源码,把这套机制从接口定义到运行时链路彻底拆开讲透。
Environment 抽象在 Spring 3.1 引入,由两条主线构成:
- 属性(properties)解析主线:
PropertyResolver→ConfigurablePropertyResolver→Environment→ConfigurableEnvironment→AbstractEnvironment,底层挂着一个可变的PropertySource列表MutablePropertySources; - Profile 条件主线:
activeProfiles/defaultProfiles两个集合,配合Profiles谓词与ProfilesParser解析出的& | !布尔表达式,决定哪些 bean 定义该被注册。
本章涉及的模块分布很广,读者在阅读时可以按模块索引:
spring-core的org.springframework.core.env包:接口族、PropertySource家族、MutablePropertySources、AbstractEnvironment、StandardEnvironment、Profiles、ProfilesParser;spring-core的org.springframework.util包:PropertyPlaceholderHelper与 6.2 起新引入的PlaceholderParser、StringValueResolver;spring-core的org.springframework.core.io.support包:6.0 起从 spring-context 迁来的PropertySourceProcessor、PropertySourceFactory、DefaultPropertySourceFactory、ResourcePropertySource;spring-beans的org.springframework.beans.factory.config包:PlaceholderConfigurerSupport、PropertyResourceConfigurer、EmbeddedValueResolver、YamlProcessor、YamlMapFactoryBean、YamlPropertiesFactoryBean;spring-context:PropertySourcesPlaceholderConfigurer、@PropertySource/@PropertySources、ConfigurationClassParser对@PropertySource的解析入口;spring-web的org.springframework.web.context.support:StandardServletEnvironment。
需要特别提醒两个 7.0.9 版本事实,它们是本章深度的"题眼",也是与大量网上旧资料不一致的地方:
@PropertySource的处理器在 6.0 之后已经不叫PropertySourceAnnotationProcessor,也不在 spring-context 包内。7.0.9 仓库里的真实类是org.springframework.core.io.support.PropertySourceProcessor(位于 spring-core),spring-context 的ConfigurationClassParser通过PropertySourceRegistry委托给它。凡是还在讲"PropertySourceAnnotationProcessor"的资料,描述的都是 5.x 时代。- 占位符解析的核心算法在 6.2 被整体重写。7.0.9 里
PropertyPlaceholderHelper本身只是个空壳,真正干活的是org.springframework.util.PlaceholderParser——它把过去那个递归下降的parseStringValue单方法,改造成了"先解析成Part列表、再惰性 resolve"的两阶段模型。本章第 6 节会逐行拆解这个新模型。
读完本章,读者应当能够:徒手画出一次 ${db.url} 从 @Value 注解到最终字符串替换的完整调用栈;说清 MutablePropertySources 里每一个 PropertySource 的先后顺序与优先级语义;解释为什么 SPRING_PROFILES_ACTIVE 这种写法能匹配上 spring.profiles.active;定位 "Could not resolve placeholder" 异常在源码中到底是哪一行抛出来的。
2. 核心概念:接口族逐个定义
Environment 子体系的接口设计有一个鲜明特点:接口沿着"只读能力 → 可配置能力 → Profile 能力"三层递进,每一层都只加方法、不加状态。下面逐个读 7.0.9 的真实接口文件。
2.1 PropertyResolver:最底层的属性读取契约
PropertyResolver 定义在 spring-core/src/main/java/org/springframework/core/env/PropertyResolver.java,它是整个属性体系的根接口,只做一件事——"根据 key 取值"。它的方法族可以分成三组:
第一组是"取不取得到"与"取什么类型"。boolean containsProperty(String key) 的语义被注释限定得很死:key 对应的值不为 null 就算可用。这一点很重要,它意味着 containsProperty 并不是"key 在某个 Map 里存在",而是"解析出来不是 null"——这两个概念在后面 SystemEnvironmentPropertySource 的名重写语义下会产生微妙差别。
第二组是 getProperty 的重载家族:
@Nullable String getProperty(String key);
String getProperty(String key, String defaultValue);
<T> @Nullable T getProperty(String key, Class<T> targetType);
<T> T getProperty(String key, Class<T> targetType, T defaultValue);
String getRequiredProperty(String key) throws IllegalStateException;
<T> T getRequiredProperty(String key, Class<T> targetType) throws IllegalStateException;
注意这里的设计:带 defaultValue 的重载返回值不再是 null(找到了用找到的,找不到用默认值);带 targetType 的重载承担类型转换(比如把 "8080" 转成 Integer);getRequiredProperty 系列则是"必须找到"的严格版本,找不到直接抛 IllegalStateException。"可空 / 有默认值 / 必须"三档,覆盖了所有调用方对"找不到 key"的不同容忍度。
第三组是占位符:
String resolvePlaceholders(String text);
String resolveRequiredPlaceholders(String text) throws IllegalArgumentException;
两者的差别在 javadoc 里写得非常直白:resolvePlaceholders 遇到解析不了的占位符且没有默认值时,原样保留、直接放行("Unresolvable placeholders with no default value are ignored and passed through unchanged");resolveRequiredPlaceholders 则会抛 IllegalArgumentException。一个"宽松"一个"严格",正好对应后面 AbstractPropertyResolver 里那两个 nonStrictHelper / strictHelper。
2.2 ConfigurablePropertyResolver:可配置化
ConfigurablePropertyResolver(同包)继承 PropertyResolver,把"怎么解析"这件事开放成可配置项。它的方法全部是 setter:
ConfigurableConversionService getConversionService();
void setConversionService(ConfigurableConversionService conversionService);
void setPlaceholderPrefix(String placeholderPrefix);
void setPlaceholderSuffix(String placeholderSuffix);
void setValueSeparator(@Nullable String valueSeparator);
void setEscapeCharacter(@Nullable Character escapeCharacter); // @since 6.2
void setIgnoreUnresolvableNestedPlaceholders(boolean ignoreUnresolvableNestedPlaceholders); // @since 3.2
void setRequiredProperties(String... requiredProperties);
void validateRequiredProperties() throws MissingRequiredPropertiesException;
这里有几个值得点出的版本细节:
setEscapeCharacter是 6.2 才加入的(javadoc 上标了@since 6.2)。在此之前占位符是不能转义的;6.2 之后默认用反斜杠\作为转义符,于是\${name}会被当字面量输出。7.0.9 里这个默认值还可以通过 JVM 系统属性spring.placeholder.escapeCharacter.default全局改写(见AbstractPropertyResolver的DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME,@since 6.2.7)。setIgnoreUnresolvableNestedPlaceholders(3.2 引入)管的是"属性值内部嵌套的占位符解析不了时怎么办"。注意它和resolvePlaceholders的宽松语义是两个维度:前者管getProperty返回值内部的嵌套占位符,后者管整段文本的解析策略。setRequiredProperties+validateRequiredProperties是一个"启动自检"机制:注册一组必须存在的 key,在容器启动的某个时机统一校验,缺任何一个就抛MissingRequiredPropertiesException。
2.3 Environment:挂上 Profile 能力
Environment 接口(同包)继承 PropertyResolver,javadoc 开宗明义:它对应用环境建模两个关键方面——profiles 与 properties,属性访问能力全部来自 PropertyResolver 父接口。它新增的方法围绕 profile:
String[] getActiveProfiles();
String[] getDefaultProfiles();
default boolean matchesProfiles(String... profileExpressions) {
return acceptsProfiles(Profiles.of(profileExpressions));
} // @since 5.3.28
@Deprecated(since = "5.1")
boolean acceptsProfiles(String... profiles);
boolean acceptsProfiles(Profiles profiles); // @since 5.1
注意这个接口在 7.0.9 里的方法演进史:5.1 之前只有老的 acceptsProfiles(String...),它只支持"逗号分隔多个 profile、! 取反、整体或"的简陋布尔逻辑;5.1 引入了 Profiles 这个函数式谓词,把老方法标了 @Deprecated(since = "5.1");5.3.28 又补了一个 matchesProfiles(String...) 的 default 方法作为便捷入口。读者在源码里看到三个名字相似的方法时,要知道它们指向的是同一件事的三个历史阶段,本章第 5 节会讲到它们最终都汇聚到 Profiles.matches。
2.4 ConfigurableEnvironment:可变 PropertySource 与 Profile 设置
ConfigurableEnvironment(同包)同时继承 Environment 与 ConfigurablePropertyResolver,是应用代码真正会拿到的接口类型(ApplicationContext.getEnvironment() 返回值就是它)。它新增的能力分三块:
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); // @since 3.1.2
getPropertySources()返回的是MutablePropertySources而不是只读的PropertySources——这是整个体系里"优先级可动态编排"的总闸门。javadoc 专门演示了addFirst/addLast/addBefore/addAfter四个细粒度插入方法,并强调:对 PropertySource 的任何增删改,必须在refresh()之前完成,否则容器启动早期(包括PropertySourcesPlaceholderConfigurer)就读不到。merge(parent)用于父子容器(AbstractApplicationContext.setParent)场景:把父环境的 active/default profile、以及父环境里子环境尚不存在的同名 PropertySource 追加合并进来,且同名时保留子环境的、丢弃父环境的——避免子容器重复搜索 systemEnvironment 这种两边都有的公共源。
到这里,接口族的全貌就清楚了:
PropertyResolver (取属性 + 解析占位符)
▲
ConfigurablePropertyResolver (ConversionService / 前缀后缀 / 转义符 / 必填校验)
▲
Environment (+ activeProfiles / defaultProfiles / acceptsProfiles)
▲
ConfigurableEnvironment (+ setActiveProfiles / getPropertySources / merge)
▲
AbstractEnvironment (唯一的抽象骨架实现)
▲
StandardEnvironment / StandardServletEnvironment (默认 PropertySource 注册)
这个继承链的每一环都只做一件事:接口定义契约,AbstractEnvironment 持有两个核心状态——activeProfiles、defaultProfiles 两个 LinkedHashSet,以及一个 MutablePropertySources 和一个 ConfigurablePropertyResolver。下一节我们就从这两个状态的容器本身讲起。
3. 源码剖析(一):PropertySource 与 MutablePropertySources 的优先级容器
3.1 PropertySource:身份只由 name 决定
PropertySource<T>(spring-core/src/main/java/org/springframework/core/env/PropertySource.java)是所有配置源的抽象基类,泛型 T 表示底层真实载体——可以是 Properties、Map<String,Object>、ServletContext、ServletConfig,甚至一个纯粹的数据库查询。它的字段极其简单:
public abstract class PropertySource<T> {
protected final Log logger = LogFactory.getLog(getClass());
protected final String name;
protected final T source;
public PropertySource(String name, T source) {
Assert.hasText(name, "Property source name must contain at least one character");
Assert.notNull(source, "Property source must not be null");
this.name = name;
this.source = source;
}
只有 name 与 source 两个 final 字段。真正决定它在集合里身份的不是 source 的内容,而是 name——这一点在 equals/hashCode 上体现得淋漓尽致:
@Override
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof PropertySource<?> that &&
ObjectUtils.nullSafeEquals(getName(), that.getName())));
}
@Override
public int hashCode() {
return Objects.hashCode(getName());
}
两个 PropertySource 只要名字相同就被视为同一个,底层 source 里装了什么完全不参与相等性判断。这个设计直接服务于 MutablePropertySources 的"按名增删改"模型:你想替换某个源,只需要知道它的名字(比如 "systemProperties"),而不需要持有它的引用。javadoc 里还给了一个配套的静态工厂 named(String name),专门造出一个"只用于集合比较"的哑对象——它除了 equals/hashCode/toString 之外所有方法都抛 UnsupportedOperationException:
public static PropertySource<?> named(String name) {
return new ComparisonPropertySource(name);
}
这个哑对象就是后面 MutablePropertySources.indexOf(PropertySource.named("xxx")) 用来在列表里按名定位的探针。
抽象方法只有一个:
public abstract @Nullable Object getProperty(String name);
即"给我 key,返回值或 null"。containsProperty 的默认实现很简单粗暴:
public boolean containsProperty(String name) {
return (getProperty(name) != null);
}
这再次印证了 2.1 节强调的语义——"存在"等价于"取值非 null"。
类里还埋了一个很关键的内部类 StubPropertySource:
public static class StubPropertySource extends PropertySource<Object> {
public StubPropertySource(String name) {
super(name);
}
@Override
public @Nullable String getProperty(String name) {
return null;
}
}
它永远返回 null。存在意义是:在容器启动早期,某些真正的 PropertySource(比如基于 ServletContext 的源)还拿不到底层对象,这时先塞一个 stub 进去占住它应有的位置/顺序,等上下文 refresh 时再用真实源把它 replace 掉。StandardServletEnvironment 就是这么干的,第 4 节会看到。
3.2 EnumerablePropertySource:能枚举 key 的源
不是所有源都能"列出所有 key"——最典型的反例是 JNDI(你无法在不实际查询的前提下枚举出 JNDI 树上有哪些绑定)。于是 PropertySource 派生出 EnumerablePropertySource<T>(同包),多了一个抽象方法:
public abstract class EnumerablePropertySource<T> extends PropertySource<T> {
@Override
public boolean containsProperty(String name) {
return ObjectUtils.containsElement(getPropertyNames(), name);
}
public abstract String[] getPropertyNames();
}
它把 containsProperty 的默认实现从"调一次 getProperty"改成了"在 getPropertyNames() 返回的数组里线性查找"。javadoc 解释了动机:对于一个可能很昂贵的 getProperty(比如要跨网络查数据库),用一次廉价的全量枚举来做存在性判断更划算。我们日常见到的 PropertiesPropertySource、MapPropertySource、SystemEnvironmentPropertySource、ResourcePropertySource 都是它的子类;而 JndiPropertySource 直接继承 PropertySource 而非 EnumerablePropertySource,正是这个道理。
3.3 MutablePropertySources:优先级容器的逐行解剖
MutablePropertySources(spring-core/src/main/java/org/springframework/core/env/MutablePropertySources.java)实现 PropertySources 接口(一个可迭代的 PropertySource 集合),是整个属性体系的"顺序账本"。它的内部结构与旧版资料描述的 LinkedList 已经不同了,7.0.9 的真实字段是:
public class MutablePropertySources implements PropertySources {
private final List<PropertySource<?>> propertySourceList = new CopyOnWriteArrayList<>();
注意:7.0.9 用的是 CopyOnWriteArrayList,而不是老代码里常见的 LinkedList。这是一个有意识的选择:PropertySource 列表在容器启动期会被增删改,但启动完成后基本是只读遍历(PropertySourcesPropertyResolver 每次 getProperty 都要从头遍历一遍)。CopyOnWriteArrayList 读不加锁、写时复制的特性,正好匹配"读多写少且写集中在启动期"的访问模式——解析属性的热路径遍历完全无锁,而增删操作加一把 synchronized(propertySourceList) 保证复制过程一致。
它的构造器支持从另一个 PropertySources 拷贝并保持顺序:
public MutablePropertySources() {
}
public MutablePropertySources(PropertySources propertySources) {
this();
for (PropertySource<?> propertySource : propertySources) {
addLast(propertySource);
}
}
查询方法 contains / get 都是线性扫描按 name 匹配:
@Override
public boolean contains(String name) {
for (PropertySource<?> propertySource : this.propertySourceList) {
if (propertySource.getName().equals(name)) {
return true;
}
}
return false;
}
@Override
public @Nullable PropertySource<?> get(String name) {
for (PropertySource<?> propertySource : this.propertySourceList) {
if (propertySource.getName().equals(name)) {
return propertySource;
}
}
return null;
}
真正体现"优先级可编排"的是四个插入方法。先看 addFirst:
public void addFirst(PropertySource<?> propertySource) {
synchronized (this.propertySourceList) {
removeIfPresent(propertySource);
this.propertySourceList.add(0, propertySource);
}
}
逐行解读:
synchronized (this.propertySourceList)——锁的是底层 list 对象本身,而不是MutablePropertySources实例。因为CopyOnWriteArrayList的写操作内部虽然线程安全,但"先 removeIfPresent 再 add(0)"这两步必须作为复合操作原子完成,否则两个并发线程可能各自插入同一个源,顺序错乱。removeIfPresent(propertySource)——按 equals(即按 name)先移除已存在的同名源。这意味着addFirst不是"简单头插",而是"先去重再头插":如果这个源已经在列表里任何位置,它会被搬到头位而不是留下两份。这正是"同名源只保留一份"语义的落地。add(0, ...)插到索引 0——列表越靠前,被PropertySourcesPropertyResolver遍历到时越早命中,优先级越高。
addLast 结构完全对称,只是去掉了索引:
public void addLast(PropertySource<?> propertySource) {
synchronized (this.propertySourceList) {
removeIfPresent(propertySource);
this.propertySourceList.add(propertySource);
}
}
再看按相对位置插入的 addBefore / addAfter:
public void addBefore(String relativePropertySourceName, PropertySource<?> propertySource) {
assertLegalRelativeAddition(relativePropertySourceName, propertySource);
synchronized (this.propertySourceList) {
removeIfPresent(propertySource);
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index, propertySource);
}
}
public void addAfter(String relativePropertySourceName, PropertySource<?> propertySource) {
assertLegalRelativeAddition(relativePropertySourceName, propertySource);
synchronized (this.propertySourceList) {
removeIfPresent(propertySource);
int index = assertPresentAndGetIndex(relativePropertySourceName);
addAtIndex(index + 1, propertySource);
}
}
assertLegalRelativeAddition防止你把一个源插到它自己前面:相对位置源的名字等于新源自己的名字时抛IllegalArgumentException。assertPresentAndGetIndex(relativePropertySourceName)用PropertySource.named(...)探针在列表里定位:
private int assertPresentAndGetIndex(String name) {
int index = this.propertySourceList.indexOf(PropertySource.named(name));
if (index == -1) {
throw new IllegalArgumentException("PropertySource named '" + name + "' does not exist");
}
return index;
}
这里就能体会 3.1 节那个"哑探针" named() 的妙处了:indexOf 依赖 equals 按 name 比较,所以 named("systemProperties") 这个空壳对象能在真实列表里精准命中同名源,而它自己永远不会被误当成真实源存进去。
addBefore插到index,addAfter插到index+1。addAtIndex内部又做了一次removeIfPresent:
private void addAtIndex(int index, PropertySource<?> propertySource) {
removeIfPresent(propertySource);
this.propertySourceList.add(index, propertySource);
}
注意前面 addBefore 已经 removeIfPresent 过一次了,这里又 remove 一次——这是防御性的:万一计算 index 的过程与插入之间列表被别的线程动过(虽然有 synchronized 保护,但 addAtIndex 是 private 工具方法,要保证它自身被独立调用时也正确),重复 remove 是幂等的,不会出错。
删除与替换:
public @Nullable PropertySource<?> remove(String name) {
synchronized (this.propertySourceList) {
int index = this.propertySourceList.indexOf(PropertySource.named(name));
return (index != -1 ? this.propertySourceList.remove(index) : null);
}
}
public void replace(String name, PropertySource<?> propertySource) {
synchronized (this.propertySourceList) {
int index = assertPresentAndGetIndex(name);
this.propertySourceList.set(index, propertySource);
}
}
replace 用 list.set(index, ...) 原地替换、保持原位置不变——这正是 StandardServletEnvironment 把 stub 换成真实 ServletContextPropertySource 时用的方法:占位时占好的顺序(比如 servletContext 必须排在 jndiProperties 之前)在替换后原封不动。
最后还有一个查询优先级的工具方法:
public int precedenceOf(PropertySource<?> propertySource) {
return this.propertySourceList.indexOf(propertySource);
}
返回该源在列表里的下标(-1 表示不存在)。这个方法在日常业务代码里用得少,但在排查"到底谁覆盖了谁"时非常好用——打印一下两个同名 key 所在源的 precedence,谁小谁赢一目了然。
把这一节串起来:MutablePropertySources 就是一个按 name 去重、可按名定位、可相对插入、写时复制的有序列表,列表顺序就是搜索优先级。而真正按这个顺序去"先到先得"地取值的,是下一节的 PropertySourcesPropertyResolver。
3.4 PropertySourcesPropertyResolver:顺序遍历、先到先得
有了有序的 MutablePropertySources,"按优先级取值"的逻辑就落在 PropertySourcesPropertyResolver(spring-core/src/main/java/org/springframework/core/env/PropertySourcesPropertyResolver.java)里。它持有一个 PropertySources 引用:
public class PropertySourcesPropertyResolver extends AbstractPropertyResolver {
private final @Nullable PropertySources propertySources;
public PropertySourcesPropertyResolver(@Nullable PropertySources propertySources) {
this.propertySources = propertySources;
}
containsProperty 的实现就是"逐个问":
@Override
public boolean containsProperty(String key) {
if (this.propertySources != null) {
for (PropertySource<?> propertySource : this.propertySources) {
if (propertySource.containsProperty(key)) {
return true;
}
}
}
return false;
}
真正的核心是这个三参 getProperty(第 73-100 行):
protected <T> @Nullable T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
if (this.propertySources != null) {
for (PropertySource<?> propertySource : this.propertySources) {
if (logger.isTraceEnabled()) {
logger.trace("Searching for key '" + key + "' in PropertySource '" +
propertySource.getName() + "'");
}
Object value = propertySource.getProperty(key);
if (value != null) {
if (resolveNestedPlaceholders) {
if (value instanceof String string) {
value = resolveNestedPlaceholders(string);
}
else if ((value instanceof CharSequence cs) && (String.class.equals(targetValueType) ||
CharSequence.class.equals(targetValueType))) {
value = resolveNestedPlaceholders(cs.toString());
}
}
logKeyFound(key, propertySource, value);
return convertValueIfNecessary(value, targetValueType);
}
}
}
if (logger.isTraceEnabled()) {
logger.trace("Could not find key '" + key + "' in any property source");
}
return null;
}
逐段解读,这里每一行都是考点:
-
for (PropertySource<?> propertySource : this.propertySources)——这个迭代顺序就是MutablePropertySources.propertySourceList的顺序,也就是我们 3.3 节用addFirst/addLast/addBefore/addAfter精心编排出来的顺序。列表越靠前的源越先被遍历到。 -
"先到先得"在
if (value != null) { ... return ... }——只要某个源返回了非 null 值,立即return,后面的源再也不会被问。这就是 PropertySource 优先级的全部秘密:不是"合并所有源的值",而是"排在最前面、且能给出非 null 值的那个源赢"。即使后面的源也有这个 key,也被遮蔽掉。 -
嵌套占位符的二次解析。
resolveNestedPlaceholders为 true 时(普通getProperty走这条路),如果命中的值本身是个String,还要再调一次resolveNestedPlaceholders(string)。这就解释了一个经典用法:app.dir=/opt/app app.log=${app.dir}/logs解析
app.log时拿到字符串${app.dir}/logs,不会直接返回,而是再走一遍占位符解析,把${app.dir}替换成/opt/app,最终得到/opt/app/logs。注意这里只对String/CharSequence类型且目标类型匹配时才做——如果目标类型是Integer,值"8080"里即便嵌着占位符也不解析(因为数字转换更重要)。 -
convertValueIfNecessary(value, targetValueType)——找到值之后,如果要的不是 String,就用ConversionService转换。这就是为什么env.getProperty("server.port", Integer.class)能拿到Integer。 -
遍历完所有源都没有非 null 值,返回
null。配合AbstractPropertyResolver里getRequiredProperty对 null 的判断,最终抛出 "Required key '...' not found"。
对比一下 getPropertyAsRawString:
@Override
protected @Nullable String getPropertyAsRawString(String key) {
return getProperty(key, String.class, false);
}
它把第三个参数 resolveNestedPlaceholders 显式传成 false。这个方法是被 AbstractPropertyResolver.doResolvePlaceholders 用作占位符解析器的——占位符引擎要的是"key 对应的原始字符串值",不能在取出来之后又触发一轮嵌套解析(否则会和占位符引擎自己的递归逻辑打架)。这是一个非常容易被忽略的设计细节:getProperty 本身会解析嵌套占位符,但占位符引擎内部取值走的是不嵌套解析的 raw 版本。
3.5 AbstractPropertyResolver:模板方法与两个 helper
AbstractPropertyResolver(spring-core/src/main/java/org/springframework/core/env/AbstractPropertyResolver.java)实现 ConfigurablePropertyResolver 的全部配置逻辑。它的字段区(第 88-104 行)值得逐一看:
private volatile @Nullable ConfigurableConversionService conversionService;
private @Nullable PropertyPlaceholderHelper nonStrictHelper;
private @Nullable PropertyPlaceholderHelper strictHelper;
private boolean ignoreUnresolvableNestedPlaceholders = false;
private String placeholderPrefix = SystemPropertyUtils.PLACEHOLDER_PREFIX;
private String placeholderSuffix = SystemPropertyUtils.PLACEHOLDER_SUFFIX;
private @Nullable String valueSeparator = SystemPropertyUtils.VALUE_SEPARATOR;
private @Nullable Character escapeCharacter = getDefaultEscapeCharacter();
private final Set<String> requiredProperties = new LinkedHashSet<>();
默认前缀/后缀/分隔符都来自 SystemPropertyUtils(即 ${、}、:)。注意 nonStrictHelper 与 strictHelper 是懒加载的两个占位符助手,分别对应 2.1 节的宽松与严格两种 resolve* 方法:
@Override
public String resolvePlaceholders(String text) {
if (this.nonStrictHelper == null) {
this.nonStrictHelper = createPlaceholderHelper(true);
}
return doResolvePlaceholders(text, this.nonStrictHelper);
}
@Override
public String resolveRequiredPlaceholders(String text) throws IllegalArgumentException {
if (this.strictHelper == null) {
this.strictHelper = createPlaceholderHelper(false);
}
return doResolvePlaceholders(text, this.strictHelper);
}
private PropertyPlaceholderHelper createPlaceholderHelper(boolean ignoreUnresolvablePlaceholders) {
return new PropertyPlaceholderHelper(this.placeholderPrefix, this.placeholderSuffix,
this.valueSeparator, this.escapeCharacter, ignoreUnresolvablePlaceholders);
}
private String doResolvePlaceholders(String text, PropertyPlaceholderHelper helper) {
return helper.replacePlaceholders(text, this::getPropertyAsRawString);
}
最后这一行是整个桥接的精髓:占位符引擎要怎么取 key 的值?用方法引用 this::getPropertyAsRawString 喂给它。也就是说,占位符 ${db.url} 在引擎内部被解出 key 是 db.url 之后,回调的就是 PropertySourcesPropertyResolver.getPropertyAsRawString("db.url"),再走一遍 3.4 节那套"顺序遍历 PropertySource"的逻辑。占位符引擎本身完全不知道 PropertySource 的存在,它只认一个 PlaceholderResolver 函数式接口——这种解耦让同一套解析算法能复用到 PropertySourcesPlaceholderConfigurer、SystemPropertyUtils 等所有场景。
嵌套占位符的开关在这里收口:
protected String resolveNestedPlaceholders(String value) {
if (value.isEmpty()) {
return value;
}
return (this.ignoreUnresolvableNestedPlaceholders ?
resolvePlaceholders(value) : resolveRequiredPlaceholders(value));
}
setIgnoreUnresolvableNestedPlaceholders(true) 时,属性值内部解析不了的占位符原样保留;false 时(默认)严格抛异常。
validateRequiredProperties 是启动自检:
@Override
public void validateRequiredProperties() {
MissingRequiredPropertiesException ex = new MissingRequiredPropertiesException();
for (String key : this.requiredProperties) {
if (getProperty(key) == null) {
ex.addMissingRequiredProperty(key);
}
}
if (!ex.getMissingRequiredProperties().isEmpty()) {
throw ex;
}
}
它把所有缺失的 key 收集到一个异常里一次性抛出,而不是遇到第一个就停——这样运维一次就能看到全部缺哪些配置,而不是改一个跑一遍又报下一个。
3.6 SystemEnvironmentPropertySource:大小写与点下划线的名重写
讲完通用的顺序遍历,必须单独讲一个"破坏"了这个简单模型的源——SystemEnvironmentPropertySource(spring-core/src/main/java/org/springframework/core/env/SystemEnvironmentPropertySource.java)。它继承 MapPropertySource,底层就是 System.getenv() 那张环境变量表。
问题在于:Bash 等 shell 不允许变量名里出现 . 和 -。所以 spring.profiles.active 这个 key 没法直接作为环境变量设置。Spring 的解法是:当你用 spring.profiles.active 去查环境变量源时,它帮你试一系列"等价名"。javadoc 列了四种等价写法:foo.bar、foo_bar、FOO.BAR、FOO_BAR。源码在 getProperty / resolvePropertyName / checkPropertyName:
@Override
public @Nullable Object getProperty(String name) {
String actualName = resolvePropertyName(name);
if (logger.isDebugEnabled() && !name.equals(actualName)) {
logger.debug("PropertySource '" + getName() + "' does not contain property '" + name +
"', but found equivalent '" + actualName + "'");
}
return super.getProperty(actualName);
}
protected final String resolvePropertyName(String name) {
Assert.notNull(name, "Property name must not be null");
String resolvedName = checkPropertyName(name);
if (resolvedName != null) {
return resolvedName;
}
String uppercasedName = name.toUpperCase(Locale.ROOT);
if (!name.equals(uppercasedName)) {
resolvedName = checkPropertyName(uppercasedName);
if (resolvedName != null) {
return resolvedName;
}
}
return name;
}
private @Nullable String checkPropertyName(String name) {
// Check name as-is
if (this.source.containsKey(name)) {
return name;
}
// Check name with just dots replaced
String noDotName = name.replace('.', '_');
if (!name.equals(noDotName) && this.source.containsKey(noDotName)) {
return noDotName;
}
// Check name with just hyphens replaced
String noHyphenName = name.replace('-', '_');
if (!name.equals(noHyphenName) && this.source.containsKey(noHyphenName)) {
return noHyphenName;
}
// Check name with dots and hyphens replaced
String noDotNoHyphenName = noDotName.replace('-', '_');
if (!noDotName.equals(noDotNoHyphenName) && this.source.containsKey(noDotNoHyphenName)) {
return noDotNoHyphenName;
}
// Give up
return null;
}
这段逻辑的匹配顺序是:
- 先用原始名
spring.profiles.active直接查; - 查不到,
checkPropertyName依次试:原名 → 点替换成下划线spring_profiles_active→ 横线替换成下划线spring-profiles-active(把-换成_)→ 点和横线都换成下划线; checkPropertyName返回 null 时,把整名toUpperCase(Locale.ROOT)成SPRING.PROFILES.ACTIVE,再走一遍上面四步。
所以你在 shell 里设 SPRING_PROFILES_ACTIVE=p1,应用里用 spring.profiles.active 居然能查到——就是这套名重写在起作用。注意 toUpperCase 用的是 Locale.ROOT,避免土耳其语等 locale 下 i 变大写的诡异问题。
这个"名重写"是把双刃剑,也是本章第 10 节"常见坑"的主角:当系统属性里同时存在 FOO_BAR=1 和环境变量里存在 foo.bar=2 时,由于 systemProperties 排在 systemEnvironment 前面,你以为查 foo.bar 会命中系统属性;但如果系统属性里没有 foo.bar,查询会穿透到环境变量源,后者把 foo.bar 重写成 FOO_BAR 命中——这种"隐式名匹配"在排查配置覆盖问题时极难发现。排查时把这个类的日志开到 DEBUG,就能看到那句 "does not contain property 'foo.bar', but found equivalent 'FOO_BAR'"。
3.7 其他几个 PropertySource 实现速览
PropertiesPropertySource:包一个Properties对象,getPropertyNames()返回props.stringPropertyNames(),是最常用的"properties 文件"源。MapPropertySource:包一个Map<String, Object>,本质与上面类似但载体是 Map。CompositePropertySource:把多个源合成一个逻辑上的源,内部维护自己的 List 并按顺序遍历。它在PropertySourceProcessor.addPropertySource里被用来合并同名@PropertySource(见第 7 节),避免同名源被简单覆盖。CommandLinePropertySource/SimpleCommandLineArgsParser:解析命令行参数(--foo=bar这种 opts 风格与非选项参数),通常作为最高优先级源加在addFirst。
这些都继承自 3.1 的抽象基类,行为完全一致,不再赘述。
3.8 AbstractEnvironment:状态、Profile 懒加载与构造钩子
AbstractEnvironment(spring-core/src/main/java/org/springframework/core/env/AbstractEnvironment.java)是 ConfigurableEnvironment 的唯一骨架实现。它的字段区(第 103-111 行)是整个类的状态核心:
protected final Log logger = LogFactory.getLog(getClass());
private final Set<String> activeProfiles = new LinkedHashSet<>();
private final Set<String> defaultProfiles = new LinkedHashSet<>(getReservedDefaultProfiles());
private final MutablePropertySources propertySources;
private final ConfigurablePropertyResolver propertyResolver;
四个 final 状态:
activeProfiles:显式激活的 profile,初始空集合;defaultProfiles:默认 profile,初始就塞了getReservedDefaultProfiles()——基类实现是一个Collections.singleton("default"),即保留名"default";propertySources:3.3 节那个可变有序列表;propertyResolver:3.4/3.5 节那个顺序遍历解析器(实际类型是PropertySourcesPropertyResolver)。
构造函数的设计是一个模板方法钩子,值得逐行读:
public AbstractEnvironment() {
this(new MutablePropertySources());
}
protected AbstractEnvironment(MutablePropertySources propertySources) {
this.propertySources = propertySources;
this.propertyResolver = createPropertyResolver(propertySources);
customizePropertySources(propertySources);
}
createPropertyResolver默认返回new PropertySourcesPropertyResolver(propertySources),5.3.4 起做成可被子类覆盖的工厂方法。customizePropertySources(propertySources)是留给子类注册默认 PropertySource 的钩子。基类实现是空的("The base implementation registers no property sources."),StandardEnvironment、StandardServletEnvironment都通过覆盖它来加自己的源。
这里有一个 javadoc 用加粗警告的"实例变量访问"陷阱:子类在覆盖 customizePropertySources 时,不要读取子类自己声明的、带默认初始值的实例变量。原因是 Java 对象创建顺序——new StandardServletEnvironment() 时先执行父类 AbstractEnvironment 的构造,在构造里回调 customizePropertySources,这一刻子类的实例变量初始化器还没跑,读到的全是 null。这个坑在日常写自定义 Environment 时很容易踩。
3.9 activeProfiles 的懒加载:spring.profiles.active 是怎么进来的
很多人以为 profile 是启动时一次性读好的,其实 activeProfiles 集合在构造时是空的,第一次查询时才懒加载。看 doGetActiveProfiles:
@Override
public String[] getActiveProfiles() {
return StringUtils.toStringArray(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;
}
}
protected @Nullable String doGetActiveProfilesProperty() {
return getProperty(ACTIVE_PROFILES_PROPERTY_NAME);
}
ACTIVE_PROFILES_PROPERTY_NAME 就是常量 "spring.profiles.active"(第 77 行)。逐行解读这个懒加载逻辑:
synchronized (this.activeProfiles)——对 activeProfiles 集合加锁,保证并发下懒加载只发生一次。- 只有当集合还是空的时才去查属性。这意味着一旦你手工
setActiveProfiles(...)激活过 profile,这个属性就再也不会被自动读取了——编程式 API 优先级高于配置文件里的spring.profiles.active。 doGetActiveProfilesProperty()走的是完整的getProperty("spring.profiles.active")——也就是 3.4 节那套顺序遍历。所以这个属性可以来自 systemProperties(-Dspring.profiles.active=...)、可以来自 systemEnvironment(SPRING_PROFILES_ACTIVE,靠 3.6 节的名重写)、也可以来自任何@PropertySource。- 拿到字符串后,
commaDelimitedListToStringArray按逗号切开、trimAllWhitespace去掉每段空白,逐个setActiveProfiles(内部会做validateProfile)。
defaultProfiles 的懒加载逻辑对称,但有一个差别:判断条件是"当前 defaultProfiles 还等于保留默认集"(即用户没改过它),而不是简单的 isEmpty——
protected Set<String> doGetDefaultProfiles() {
synchronized (this.defaultProfiles) {
if (this.defaultProfiles.equals(getReservedDefaultProfiles())) {
String profiles = doGetDefaultProfilesProperty();
if (StringUtils.hasText(profiles)) {
setDefaultProfiles(StringUtils.commaDelimitedListToStringArray(
StringUtils.trimAllWhitespace(profiles)));
}
}
return this.defaultProfiles;
}
}
为什么 defaultProfiles 不能用 isEmpty 判断?因为它构造时就被塞了 "default",永远非空。改用"是否仍是保留集"来判断,既保证用户一旦手工 setDefaultProfiles 就不再读属性,又能在用户没动过时让 spring.profiles.default 生效。
3.10 validateProfile:profile 名的合法性校验
setActiveProfiles / addActiveProfile / setDefaultProfiles 在往集合里加之前,都会先调 validateProfile:
@Override
public void setActiveProfiles(String... profiles) {
Assert.notNull(profiles, "Profile array must not be null");
if (logger.isDebugEnabled()) {
logger.debug("Activating profiles " + Arrays.toString(profiles));
}
synchronized (this.activeProfiles) {
this.activeProfiles.clear();
for (String profile : profiles) {
validateProfile(profile);
this.activeProfiles.add(profile);
}
}
}
校验规则在 validateProfile:
protected void validateProfile(String profile) {
if (!StringUtils.hasText(profile)) {
throw new IllegalArgumentException("Invalid profile [" + profile + "]: must contain text");
}
if (profile.charAt(0) == '!') {
throw new IllegalArgumentException("Invalid profile [" + profile + "]: must not begin with ! operator");
}
}
两条硬规则:profile 名不能为空/纯空白;不能以 ! 开头。第二条的原因是 ! 是 profile表达式里的取反操作符,它属于"@Profile 注解的表达式"语法,不属于"要被激活的 profile 名"。你不能把一个叫 !dev 的 profile 激活,语义上也讲不通。违反时直接 IllegalArgumentException。
注意 setActiveProfiles 会先 clear() 再批量加——它是"覆盖式设置";而 addActiveProfile 是"追加式":
@Override
public void addActiveProfile(String profile) {
if (logger.isDebugEnabled()) {
logger.debug("Activating profile '" + profile + "'");
}
validateProfile(profile);
doGetActiveProfiles();
synchronized (this.activeProfiles) {
this.activeProfiles.add(profile);
}
}
它还先调了一次 doGetActiveProfiles()——确保懒加载已经发生、把属性里的 profile 先收进来,再追加新的,避免追加完被后续懒加载覆盖掉。
3.11 merge:父子环境合并
AbstractEnvironment.merge(第 469-488 行)是父子容器场景的实现:
@Override
public void merge(ConfigurableEnvironment parent) {
for (PropertySource<?> ps : parent.getPropertySources()) {
if (!this.propertySources.contains(ps.getName())) {
this.propertySources.addLast(ps);
}
}
String[] parentActiveProfiles = parent.getActiveProfiles();
if (!ObjectUtils.isEmpty(parentActiveProfiles)) {
synchronized (this.activeProfiles) {
Collections.addAll(this.activeProfiles, parentActiveProfiles);
}
}
String[] parentDefaultProfiles = parent.getDefaultProfiles();
if (!ObjectUtils.isEmpty(parentDefaultProfiles)) {
synchronized (this.defaultProfiles) {
this.defaultProfiles.remove(RESERVED_DEFAULT_PROFILE_NAME);
Collections.addAll(this.defaultProfiles, parentDefaultProfiles);
}
}
}
三段:
- PropertySource 合并:遍历父环境所有源,凡是子环境里还没有同名的,
addLast追加到子环境末尾。同名的(比如两边都有的 systemProperties/systemEnvironment)子环境已有的保留,父的丢弃——这正是 2.4 节 javadoc 承诺的"child instance is to be preserved"。 - activeProfiles 合并:父激活的 profile 追加进子的集合。
- defaultProfiles 合并:先把保留的
"default"从子集合里删掉,再把父的 default 追加进来。为什么要删"default"?因为一旦父环境显式指定了 default profile,那个笼统的"default"就不再有意义了,避免它和父的具体 default 同时生效造成混淆。
3.12 StandardEnvironment:两个默认源的注册顺序
StandardEnvironment(spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java)覆盖了那个构造钩子:
public class StandardEnvironment extends AbstractEnvironment {
public static final String SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME = "systemEnvironment";
public static final String SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME = "systemProperties";
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
propertySources.addLast(
new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));
propertySources.addLast(
new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
}
}
注意两个源都用 addLast 且按固定顺序:先 systemProperties(JVM 系统属性,即 System.getProperties()),再 systemEnvironment(操作系统环境变量)。于是最终列表顺序为:
[0] systemProperties (JVM -D 参数、System.getProperties())
[1] systemEnvironment (操作系统 env,SystemEnvironmentPropertySource)
结合 3.4 节"先到先得",这意味着:同一个 key,JVM 系统属性赢过操作系统环境变量。javadoc 解释了这个设计动机——system properties 是 per-JVM 的,环境变量可能被一台机器上多个 JVM 共享;让 per-JVM 的 system properties 优先,允许你在单个 JVM 级别上覆盖环境变量。
getSystemEnvironment 还有一个安全开关:
@Override
public Map<String, Object> getSystemEnvironment() {
if (suppressGetenvAccess()) {
return Collections.emptyMap();
}
return (Map) System.getenv();
}
protected boolean suppressGetenvAccess() {
return SpringProperties.getFlag(IGNORE_GETENV_PROPERTY_NAME);
}
当 JVM 系统属性 spring.getenv.ignore=true(常量 IGNORE_GETENV_PROPERTY_NAME)时,整个环境变量源直接退化成空 Map,连 System.getenv() 都不调——这是给那些对 SecurityManager 访问环境变量敏感的老容器准备的逃生门。
3.13 StandardServletEnvironment:Web 环境的五源优先级
StandardServletEnvironment(spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java)继承 StandardEnvironment,再往上加 Web 相关的源:
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
propertySources.addLast(new StubPropertySource(SERVLET_CONFIG_PROPERTY_SOURCE_NAME));
propertySources.addLast(new StubPropertySource(SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));
if (JNDI_PRESENT && JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
propertySources.addLast(new JndiPropertySource(JNDI_PROPERTY_SOURCE_NAME));
}
super.customizePropertySources(propertySources);
}
关键点:
- servletConfig / servletContext 两个源在构造时塞的是
StubPropertySource(3.1 节那个永远返回 null 的占位对象)。因为此刻ServletContext对象根本还没准备好(Web 容器还没把上下文挂上来),只能先占住位置。 - JNDI 源要先判断
JNDI_PRESENT(JDK9+ 下javax.naming是可选模块,先探类是否存在)且isDefaultJndiEnvironmentAvailable()为真,才注册。 - 最后才
super.customizePropertySources(propertySources)——把父类的 systemProperties/systemEnvironment 追加到末尾。
于是 Web 环境最终的 PropertySource 列表顺序(也就是搜索优先级,从高到低)是:
[0] servletConfigInitParams (web.xml 里 <servlet> 的 init-param)
[1] servletContextInitParams (web.xml 里 <context-param>)
[2] jndiProperties (java:comp/env 下的 JNDI)
[3] systemProperties (JVM -D)
[4] systemEnvironment (操作系统环境变量)
这与 ConfigurableEnvironment javadoc 的总原则一致:与本应用越贴近的来源优先级越高,越全局的来源优先级越低。servlet 私有参数 > servlet 全局参数 > JNDI > JVM 系统属性 > 操作系统环境变量。占位的 stub 在 AbstractApplicationContext.initPropertySources() 被调用时,通过 WebApplicationContextUtils.initServletPropertySources 用 replace 原地换成真实的 ServletContextPropertySource / ServletConfigPropertySource——注意是 replace 不是 addFirst,所以替换后位置不变,优先级语义稳定。
下一节我们把视线从属性转到 Profile 表达式:@Profile("!dev & (prod | staging)") 这种字符串是怎么被解析成布尔谓词的。
3.14 Profiles 谓词与 ProfilesParser:& | ! 表达式的递归解析
3.14.1 从 acceptsProfiles(String...) 到 acceptsProfiles(Profiles)
回到 AbstractEnvironment,profile 判断有两个入口。老的(5.1 起废弃)直接在环境里做字符串判断:
@Override
@Deprecated(since = "5.1")
public boolean acceptsProfiles(String... profiles) {
Assert.notEmpty(profiles, "Must specify at least one profile");
for (String profile : profiles) {
if (StringUtils.hasLength(profile) && profile.charAt(0) == '!') {
if (!isProfileActive(profile.substring(1))) {
return true;
}
}
else if (isProfileActive(profile)) {
return true;
}
}
return false;
}
它只支持"多个 profile 整体或、单项 ! 取反"——"p1", "!p2" 表示 p1 激活或 p2 没激活。一旦你想写 p1 & p2(两个都激活),或者 (p1 & p2) | p3,这个简陋模型就力不从心了。
5.1 起的新入口把表达式解析独立成了 Profiles 谓词:
@Override
public boolean acceptsProfiles(Profiles profiles) {
Assert.notNull(profiles, "Profiles must not be null");
return profiles.matches(this::isProfileActive);
}
注意这里传入的是 this::isProfileActive——一个 Predicate<String>。Profiles 是个函数式接口:
@FunctionalInterface
public interface Profiles {
boolean matches(Predicate<String> isProfileActive);
static Profiles of(String... profileExpressions) {
return ProfilesParser.parse(profileExpressions);
}
}
整个设计是"把表达式解析成一个谓词闭包,运行时用环境提供的 isProfileActive 去喂它"。isProfileActive 的实现(AbstractEnvironment 第 407-412 行):
protected boolean isProfileActive(String profile) {
validateProfile(profile);
Set<String> currentActiveProfiles = doGetActiveProfiles();
return (currentActiveProfiles.contains(profile) ||
(currentActiveProfiles.isEmpty() && doGetDefaultProfiles().contains(profile)));
}
两层语义:
- 先校验 profile 名合法(不能
!开头); - 若显式激活的集合
contains(profile),true; - 若没有任何显式激活的 profile,则降级去看 defaultProfiles 是否包含它——这就是"不激活任何 profile 时,
@Profile("default")的 bean 会被注册"的实现根源。
3.14.2 ProfilesParser:一个手写的递归下降解析器
ProfilesParser(spring-core/src/main/java/org/springframework/core/env/ProfilesParser.java)是包内可见的 final 类,承担把字符串表达式编译成 Profiles 闭包的工作。入口:
static Profiles parse(String... expressions) {
Assert.notEmpty(expressions, "Must specify at least one profile expression");
Profiles[] parsed = new Profiles[expressions.length];
for (int i = 0; i < expressions.length; i++) {
parsed[i] = parseExpression(expressions[i]);
}
return new ParsedProfiles(expressions, parsed);
}
多个表达式之间是整体或关系——ParsedProfiles.matches 里任意一个 parsed[i].matches(...) 为 true 就 true。
单个表达式 parseExpression 用 StringTokenizer 把字符串按 ()&|! 切开,且把这些分隔符本身也作为 token 返回(第三个参数 true):
private static Profiles parseExpression(String expression) {
Assert.hasText(expression, () -> "Invalid profile expression [" + expression + "]: must contain text");
StringTokenizer tokens = new StringTokenizer(expression, "()&|!", true);
return parseTokens(expression, tokens);
}
核心递归函数 parseTokens(第 65-109 行)是整个解析器的心脏:
private static Profiles parseTokens(String expression, String tokens, Context context) {
List<Profiles> elements = new ArrayList<>();
Operator operator = null;
while (tokens.hasMoreTokens()) {
String token = tokens.nextToken().trim();
if (token.isEmpty()) {
continue;
}
switch (token) {
case "(" -> {
Profiles contents = parseTokens(expression, tokens, Context.PARENTHESIS);
if (context == Context.NEGATE) {
return contents;
}
elements.add(contents);
}
case "&" -> {
assertWellFormed(expression, operator == null || operator == Operator.AND);
operator = Operator.AND;
}
case "|" -> {
assertWellFormed(expression, operator == null || operator == Operator.OR);
operator = Operator.OR;
}
case "!" -> elements.add(not(parseTokens(expression, tokens, Context.NEGATE)));
case ")" -> {
Profiles merged = merge(expression, elements, operator);
if (context == Context.PARENTHESIS) {
return merged;
}
elements.clear();
elements.add(merged);
operator = null;
}
default -> {
Profiles value = equals(token);
if (context == Context.NEGATE) {
return value;
}
elements.add(value);
}
}
}
return merge(expression, elements, operator);
}
逐段拆解这个状态机:
Context枚举有三个值:NONE、NEGATE、PARENTHESIS。它记录"当前正在解析什么结构内部"。- 遇到
(:递归调用自己去解析括号内部(Context.PARENTHESIS),直到遇到配对的)才返回。如果当前处于NEGATE(即!后面紧跟(),直接返回括号内容——取反是在外层not(...)包裹时统一做的。 - 遇到
&:先assertWellFormed(expression, operator == null || operator == Operator.AND),然后记下operator = AND。 - 遇到
|:同理,但记成 OR。 assertWellFormed那行是硬规则:operator == null || operator == Operator.AND表示——&只能出现在"还没有操作符"(表达式开头或刚完一个元素)或"已经在 AND 链里"的位置。它不允许&和|在同一层混用。比如a & b | c,解析到|时 operator 已经是 AND,断言operator==null || operator==OR失败,直接抛 "Malformed profile expression"。这正是 javadoc 强调的:&与|必须用括号显式分组((a & b) | c)。- 遇到
!:elements.add(not(parseTokens(..., Context.NEGATE)))。!后面递归解析出一个 Profiles,用not(...)包一层取反。NEGATEcontext 下,default分支会直接return value——即!只作用于紧跟的那一个原子 profile 或括号组。 - 遇到
):把当前elements列表用merge(expression, elements, operator)合并成一个 Profiles。如果处于PARENTHESIScontext,直接 return(括号解析完毕);否则(这是顶层)?其实顶层不该有),这里处理的是某种边界情况),清空 elements、把合并结果作为新元素。 - default 分支:普通 token(profile 名)用
equals(token)包成一个原子谓词,加入 elements。
合并函数 merge:
private static Profiles merge(String expression, List<Profiles> elements, @Nullable Operator operator) {
assertWellFormed(expression, !elements.isEmpty());
if (elements.size() == 1) {
return elements.get(0);
}
Profiles[] profiles = elements.toArray(new Profiles[0]);
return (operator == Operator.AND ? and(profiles) : or(profiles));
}
- 只有一个元素时直接返回它(
a | b这种退化成a); - 多个元素按当前 operator 聚合:AND 用
allMatch,OR 用anyMatch。
四个基础谓词工厂(其实都是 lambda):
private static Profiles or(Profiles... profiles) {
return activeProfile -> Arrays.stream(profiles).anyMatch(isMatch(activeProfile));
}
private static Profiles and(Profiles... profiles) {
return activeProfile -> Arrays.stream(profiles).allMatch(isMatch(activeProfile));
}
private static Profiles not(Profiles profiles) {
return activeProfile -> !profiles.matches(activeProfile);
}
private static Profiles equals(String profile) {
return activeProfile -> activeProfile.test(profile);
}
最终的 ParsedProfiles(第 150-193 行)持有原始表达式字符串数组,matches 时遍历每个编译好的子谓词,任意一个命中即 true;它的 equals/hashCode 基于原始表达式字符串集合,所以 Profiles.of("a & b") 与 Profiles.of("a & b") 被视为相等、可作为缓存 key。
拿一个具体例子走一遍:Profiles.of("!dev & (prod | staging)")
- token 流:
!dev&(prod|staging) - 遇到
!:递归解析dev & (prod | staging)的剩余部分,但 context=NEGATE,所以 default 分支直接返回equals("dev"),elements.add(not(equals("dev"))); - 遇到
&:operator=AND; - 遇到
(:递归解析括号内prod | staging,得到or(equals("prod"), equals("staging")),加入 elements; - 顶层 elements =
[not(dev), or(prod, staging)],operator=AND,最终and(not(dev), or(prod, staging))。
运行时喂入 isProfileActive:dev 没激活 且(prod 或 staging 激活)时整体为 true。这就是 @Profile("!dev & (prod | staging)") 的完整语义。
3.14.3 Environment 视角的 acceptsProfiles 与 ConditionEvaluator 的关系
值得点明:@Profile 注解本身的判断最终就是调 Environment.acceptsProfiles(Profiles.of(...))。容器在注册 bean 定义前,会用条件评估器(ConditionEvaluator)去看 @Profile,它拿到注解里的表达式字符串,构造 Profiles 谓词,再委托给环境。本章不展开条件评估器本身(那是条件装配机制的职责),但要从 Environment 视角记住:profile 表达式的求值,本质就是"环境提供一个 Predicate<String> isProfileActive,表达式闭包用它做短路求值"。environment 完全不关心表达式长什么样,解析器也完全不关心环境里到底激活了哪些 profile——两者通过一个谓词函数解耦。这种设计让 profile 表达式可以单测、可以缓存、可以在不实例化容器的前提下预编译。
3.15 占位符解析引擎:PlaceholderParser 的两阶段模型(6.2 起重写)
这是本章最硬的一节。很多旧资料里讲的都是 PropertyPlaceholderHelper.parseStringValue 这个递归方法——7.0.9 里它已经不存在了。6.2 开始,整个占位符解析被重构成一个独立的 PlaceholderParser(spring-core/src/main/java/org/springframework/util/PlaceholderParser.java,javadoc 标 @since 6.2),PropertyPlaceholderHelper 退化成一个委托壳:
public class PropertyPlaceholderHelper {
private final PlaceholderParser parser;
public PropertyPlaceholderHelper(String placeholderPrefix, String placeholderSuffix,
@Nullable String valueSeparator, @Nullable Character escapeCharacter,
boolean ignoreUnresolvablePlaceholders) {
Assert.notNull(placeholderPrefix, "'placeholderPrefix' must not be null");
Assert.notNull(placeholderSuffix, "'placeholderSuffix' must not be null");
this.parser = new PlaceholderParser(placeholderPrefix, placeholderSuffix,
valueSeparator, escapeCharacter, ignoreUnresolvablePlaceholders);
}
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
Assert.notNull(value, "'value' must not be null");
return this.parser.replacePlaceholders(value, placeholderResolver);
}
}
真正的算法在 PlaceholderParser 里。它的核心思想是两阶段:先把字符串解析成一个 Part(片段)树,再对这棵树惰性求值。下面逐行拆。
3.15.1 构造期:simplePrefix 的推导
private static final Map<String, String> wellKnownSimplePrefixes = Map.of(
"}", "{",
"]", "[",
")", "("
);
PlaceholderParser(String prefix, String suffix, @Nullable String separator,
@Nullable Character escape, boolean ignoreUnresolvablePlaceholders) {
this.prefix = prefix;
this.suffix = suffix;
String simplePrefixForSuffix = wellKnownSimplePrefixes.get(this.suffix);
if (simplePrefixForSuffix != null && this.prefix.endsWith(simplePrefixForSuffix)) {
this.simplePrefix = simplePrefixForSuffix;
}
else {
this.simplePrefix = this.prefix;
}
this.separator = separator;
this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
this.escape = escape;
}
simplePrefix 是"短前缀"。默认前缀 ${、后缀 },那么嵌套检测时只需要找 {(而不是完整的 ${)来判断又遇到一层占位符开头。这个推导表写死了三对常见括号:}→{、]→[、)→(。只有当完整前缀以对应短前缀结尾时才用短的,否则退回完整前缀。这样 nextValidEndPrefix 在扫描嵌套时能用更短、更精确的 token 配对。
3.15.2 入口:replacePlaceholders
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
List<Part> parts = parse(value, false);
if (parts == null) {
return value;
}
ParsedValue parsedValue = new ParsedValue(value, parts);
PartResolutionContext resolutionContext = new PartResolutionContext(placeholderResolver,
this.prefix, this.suffix, this.ignoreUnresolvablePlaceholders,
candidate -> parse(candidate, false));
return parsedValue.resolve(resolutionContext);
}
三步走:
parse(value, false)把整段文本解析成Part列表;返回 null 表示压根没有占位符,直接返回原字符串——这是热路径快速跳过,避免无占位符文本走任何构造。- 包成
ParsedValue(一个 record:原文 + parts)。 - 构造
PartResolutionContext——它实现了PlaceholderResolver接口,持有用户传入的 resolver(实际就是AbstractPropertyResolver的this::getPropertyAsRawString)、以及一个二次解析函数candidate -> parse(candidate, false)。注意这个二次解析函数:当某个 key 的值里又含占位符时,它会把那个值当新字符串再 parse 一遍——这就是"值内嵌套占位符"的闭环。
3.15.3 parse:把字符串切成 Part 列表
private @Nullable List<Part> parse(String value, boolean inPlaceholder) {
int startIndex = nextStartPrefix(value, 0);
if (startIndex == -1) {
return null;
}
List<Part> parts = new ArrayList<>(4);
int position = 0;
while (startIndex != -1) {
int endIndex = nextValidEndPrefix(value, startIndex);
if (endIndex == -1) { // Not a valid placeholder, consume the prefix and continue
addText(value, position, startIndex + this.prefix.length(), parts);
position = startIndex + this.prefix.length();
startIndex = nextStartPrefix(value, position);
}
else if (isEscaped(value, startIndex)) { // Not a valid index, accumulate and skip the escape character
addText(value, position, startIndex - 1, parts);
addText(value, startIndex, startIndex + this.prefix.length(), parts);
position = startIndex + this.prefix.length();
startIndex = nextStartPrefix(value, position);
}
else { // Found valid placeholder, recursive parsing
addText(value, position, startIndex, parts);
String placeholder = value.substring(startIndex + this.prefix.length(), endIndex);
List<Part> placeholderParts = parse(placeholder, true);
if (placeholderParts == null) {
parts.add(createSimplePlaceholderPart(placeholder));
}
else {
parts.addAll(placeholderParts);
}
startIndex = nextStartPrefix(value, endIndex + this.suffix.length());
position = endIndex + this.suffix.length();
}
}
// Add rest of text if necessary
addText(value, position, value.length(), parts);
return (inPlaceholder ? List.of(createNestedPlaceholderPart(value, parts)) : parts);
}
这是一个线性扫描 + 递归函数。它在主串里反复做四件事:找下一个 ${(nextStartPrefix)、找配对的 }(nextValidEndPrefix)、处理三种情况、把夹在中间的纯文本累积成 TextPart。逐情况看:
endIndex == -1:这个${后面找不到配对的}(比如"${abc")。注释说"Not a valid placeholder"——把这个${当普通文本吃掉,继续往后找。也就是说未闭合的${原样保留为字面量。isEscaped(value, startIndex):这个${前面紧跟转义符(默认\)。isEscaped的实现是this.escape != null && index > 0 && value.charAt(index - 1) == this.escape。处理方式:把${之前的文本累加(注意addText(value, position, startIndex - 1, parts)——不包含那个反斜杠),再把${本身当文本累加,然后跳过${。最终效果是\${name}渲染成字面${name},反斜杠消失。- 正常占位符:
addText先把${之前的纯文本存成 TextPart;substring(startIndex + prefix.len, endIndex)截出占位符内部的内容(比如${a:${b}}内部是a:${b});递归parse(placeholder, true)——如果内部还含${,递归把它解析成嵌套 parts;如果内部没有占位符(placeholderParts == null),就建一个简单的SimplePlaceholderPart;否则直接展开嵌套 parts。 - 循环结束后把尾部剩余文本补成 TextPart。
inPlaceholder为 true 时(即这次 parse 是解析某个占位符内部的),把所有 parts 包成一个NestedPlaceholderPart;false 时直接返回 parts 列表。
3.15.4 nextValidEndPrefix:配对括号与嵌套计数
这是处理"嵌套占位符"的关键,它不是简单地找第一个 },而是要处理 ${a:${b}} 这种多层嵌套:
private int nextValidEndPrefix(String value, int startIndex) {
int index = startIndex + this.prefix.length();
int withinNestedPlaceholder = 0;
while (index < value.length()) {
if (StringUtils.substringMatch(value, index, this.suffix)) {
if (withinNestedPlaceholder > 0) {
withinNestedPlaceholder--;
index = index + this.suffix.length();
}
else {
return index;
}
}
else if (StringUtils.substringMatch(value, index, this.simplePrefix)) {
withinNestedPlaceholder++;
index = index + this.simplePrefix.length();
}
else {
index++;
}
}
return -1;
}
算法:
- 从
${之后开始扫描; - 遇到一个
}(suffix):若withinNestedPlaceholder > 0,说明这个}是用来闭合内层占位符的,计数减一、跳过;否则(计数为 0)这才是当前这层的结束,返回下标; - 遇到一个
{(simplePrefix):说明又开了一层占位符,计数加一、跳过; - 其他字符逐个前进。
用 ${a:${b}} 走一遍:startIndex 指 ${,index 从 a:${b}} 的 a 开始。扫到 a、:,遇到 {(simplePrefix)→ withinNestedPlaceholder=1;继续扫到内层 } → withinNestedPlaceholder>0,减成 0,跳过;继续扫到外层 } → 计数 0,返回——正好落在外层那个 } 上。这就是嵌套占位符能被正确配对的原因。
3.15.5 parseSection:默认值 : 的切分与转义
占位符内部可能带默认值 ${name:John}。createSimplePlaceholderPart 调 parseSection 把它切成 key 与 fallback:
private ParsedSection parseSection(String value) {
if (this.separator == null || !value.contains(this.separator)) {
return new ParsedSection(value, null);
}
int position = 0;
int index = value.indexOf(this.separator, position);
StringBuilder buffer = new StringBuilder();
while (index != -1) {
if (isEscaped(value, index)) {
// Accumulate, without the escape character.
buffer.append(value, position, index - 1);
buffer.append(value, index, index + this.separator.length());
position = index + this.separator.length();
index = value.indexOf(this.separator, position);
}
else {
buffer.append(value, position, index);
String key = buffer.toString();
String fallback = value.substring(index + this.separator.length());
return new ParsedSection(key, fallback);
}
}
buffer.append(value, position, value.length());
return new ParsedSection(buffer.toString(), null);
}
- 没有
:(separator)→ 整个当 key,fallback=null; - 找到
:后,判断它是不是被转义符保护的(isEscaped)。转义的:被当普通字符:把它之前(不含反斜杠)+ 这个:本身累进 buffer,继续往后找下一个:。这样${path:C\:\\data}这种 Windows 路径里的C:就不会被误判成默认值分隔符。 - 第一个未转义的
:才是 key 与 fallback 的分界:key 是它之前累积的内容,fallback 是它之后的全部剩余(fallback 本身可以再含占位符)。
3.15.6 求值:Part 树的 resolve 与循环检测
parse 完成后是一棵 Part 树,resolve 阶段在 AbstractPart.resolveRecursively:
protected @Nullable String resolveRecursively(PartResolutionContext resolutionContext, String key) {
String resolvedValue = resolutionContext.resolvePlaceholder(key);
if (resolvedValue == null) {
// Not found
return null;
}
// Let's check if we need to recursively resolve that value
List<Part> nestedParts = resolutionContext.parse(resolvedValue);
if (nestedParts == null) {
return resolvedValue;
}
resolutionContext.flagPlaceholderAsVisited(key);
String value = new ParsedValue(resolvedValue, nestedParts).resolve(resolutionContext);
resolutionContext.removePlaceholder(key);
return value;
}
逐行:
resolvePlaceholder(key)回调用户 resolver(即 PropertySourcesPropertyResolver 取原始值)。拿不到 → 返回 null(交给上层走默认值或抛异常)。- 拿到值后,立刻
resolutionContext.parse(resolvedValue)看这个值内部是否还含占位符。没有(返回 null)→ 直接返回这个值,递归终止。 - 值内部还含占位符 →
flagPlaceholderAsVisited(key)做循环检测,再把这个值当新字符串解析求值,最后removePlaceholder(key)出栈。
循环检测在 PartResolutionContext:
private @Nullable Set<String> visitedPlaceholders;
public void flagPlaceholderAsVisited(String placeholder) {
if (this.visitedPlaceholders == null) {
this.visitedPlaceholders = new HashSet<>(4);
}
if (!this.visitedPlaceholders.add(placeholder)) {
throw new PlaceholderResolutionException(
"Circular placeholder reference '%s'".formatted(placeholder), placeholder, null);
}
}
visitedPlaceholders 是一个 Set:对某个 key 求它的值、值里又出现这个 key 本身时,add 会返回 false,于是立刻抛 "Circular placeholder reference"。求值完一层 removePlaceholder 出栈,允许"A 依赖 B、B 依赖 C、C 不依赖任何"这种链式解析正常进行,只禁止真循环(A→B→A)。这就是 ${a} 的值是 ${b}、${b} 的值又是 ${a} 时立即报错的源码位置。
两种 Part 的 resolve 逻辑:
// SimplePlaceholderPart:key=简单名,fallback=默认值
@Override
public String resolve(PartResolutionContext resolutionContext) {
String value = resolveRecursively(resolutionContext);
if (value != null) {
return value;
}
else if (this.fallback != null) {
return this.fallback;
}
return resolutionContext.handleUnresolvablePlaceholder(this.key, this.text);
}
// NestedPlaceholderPart:key 和 fallback 都是 Part 列表
@Override
public String resolve(PartResolutionContext resolutionContext) {
String resolvedKey = Part.resolveAll(this.keyParts, resolutionContext);
String value = resolveRecursively(resolutionContext, resolvedKey);
if (value != null) {
return value;
}
else if (this.defaultParts != null) {
return Part.resolveAll(this.defaultParts, resolutionContext);
}
return resolutionContext.handleUnresolvablePlaceholder(resolvedKey, this.text);
}
未解析占位符的处理在 handleUnresolvablePlaceholder:
public String handleUnresolvablePlaceholder(String key, String text) {
if (this.ignoreUnresolvablePlaceholders) {
return toPlaceholderText(key);
}
String originalValue = (!key.equals(text) ? toPlaceholderText(text) : null);
throw new PlaceholderResolutionException(
"Could not resolve placeholder '%s'".formatted(key), key, originalValue);
}
- 宽松模式(
ignoreUnresolvablePlaceholders=true):把${key}原样拼回去; - 严格模式:抛
PlaceholderResolutionException,消息 "Could not resolve placeholder 'xxx'"。这就是运维天天见到的 "Could not resolve placeholder" 异常的发源地。ParsedValue.resolve会在抛异常前用ex.withValue(this.text)把完整原始文本附到异常上,所以报错信息里能看到是哪个字符串解析失败的。
把这一节压缩成一句话:7.0.9 的占位符引擎是"parse 成 Part 树(处理嵌套配对、转义、默认值切分)→ resolve 惰性求值(回调取值、值内再 parse、Set 做循环检测、严格/宽松决定抛异常还是放行)"的两阶段模型,比旧版单方法递归更清晰、也支持了 6.2 新增的转义符能力。
3.16 @PropertySource 的处理链:从注解到 Environment
3.16.1 时机:ConfigurationClassPostProcessor 解析阶段
@PropertySource 不是在 bean 实例化时处理的,而是在 ConfigurationClassPostProcessor 这个 BeanFactoryPostProcessor 解析配置类的阶段就处理掉了。入口在 ConfigurationClassParser(spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java)第 314-319 行:
// Process any @PropertySource annotations
for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), org.springframework.context.annotation.PropertySource.class,
PropertySources.class, true)) {
if (this.propertySourceRegistry != null) {
this.propertySourceRegistry.processPropertySource(propertySource);
}
else {
logger.info("Ignoring @PropertySource annotation on [" + sourceClass.getMetadata().getClassName() +
"]. Reason: Environment must implement ConfigurableEnvironment");
}
}
几个要点:
-
attributesForRepeatable同时处理@PropertySource与可重复注解@PropertySources(注解上标了@Repeatable(PropertySources.class)),开发者可以在一个类上直接写多个@PropertySource,Java 会把它们聚合进@PropertySources。 -
this.propertySourceRegistry只有当 environment 是ConfigurableEnvironment时才非 null。构造函数里(第 159-160 行):this.propertySourceRegistry = (this.environment instanceof ConfigurableEnvironment ce ? new PropertySourceRegistry(new PropertySourceProcessor(ce, this.resourceLoader)) : null);如果环境不是
ConfigurableEnvironment(极端自定义场景),@PropertySource被忽略并打 info 日志——因为往 environment 里塞 PropertySource 需要MutablePropertySources这个可变接口,只读的Environment做不到。 -
这个处理发生在配置类解析期,早于 bean 实例化,也早于
PropertySourcesPlaceholderConfigurer这个 BeanFactoryPostProcessor 的postProcessBeanFactory。所以 properties 文件里的 key 在占位符替换发生时已经躺在 environment 里了。
3.16.2 两个名字必须澄清:PropertySourceRegistry 与 PropertySourceProcessor
7.0.9 已经没有 PropertySourceAnnotationProcessor 这个类了(那是 5.x 的东西)。现在这条链分成两段:
PropertySourceRegistry(spring-context/.../annotation/PropertySourceRegistry.java,包内可见,@since 6.0):只负责把@PropertySource注解属性读出来、组装成PropertySourceDescriptor这个 record,然后委托。它本身是个薄壳。PropertySourceProcessor(spring-core/src/main/java/org/springframework/core/io/support/PropertySourceProcessor.java,@since 6.0):真正干活的类——加载资源、创建 PropertySource、按顺序塞进 environment。
PropertySourceRegistry.processPropertySource:
void processPropertySource(AnnotationAttributes propertySource) throws IOException {
String name = propertySource.getString("name");
if (!StringUtils.hasLength(name)) {
name = null;
}
String encoding = propertySource.getString("encoding");
if (!StringUtils.hasLength(encoding)) {
encoding = null;
}
String[] locations = propertySource.getStringArray("value");
Assert.isTrue(locations.length > 0, "At least one @PropertySource(value) location is required");
boolean ignoreResourceNotFound = propertySource.getBoolean("ignoreResourceNotFound");
Class<? extends PropertySourceFactory> factoryClass = propertySource.getClass("factory");
Class<? extends PropertySourceFactory> factoryClassToUse =
(factoryClass != PropertySourceFactory.class ? factoryClass : null);
PropertySourceDescriptor descriptor = new PropertySourceDescriptor(Arrays.asList(locations),
ignoreResourceNotFound, name, factoryClassToUse, encoding);
this.propertySourceProcessor.processPropertySource(descriptor);
this.descriptors.add(descriptor);
}
它把注解的 name(空串转 null)、encoding、value(位置数组)、ignoreResourceNotFound、factory(默认 PropertySourceFactory.class 表示用默认工厂)原样打包成 descriptor。注意 factoryClassToUse 的判断:只有当用户显式指定了非默认工厂时才传 class,否则传 null——告诉 processor 用内置的 defaultPropertySourceFactory。
3.16.3 PropertySourceProcessor.processPropertySource:位置占位符解析与资源加载
真正加载在 PropertySourceProcessor.processPropertySource(第 79-108 行):
public void processPropertySource(PropertySourceDescriptor descriptor) throws IOException {
String name = descriptor.name();
String encoding = descriptor.encoding();
List<String> locations = descriptor.locations();
Assert.isTrue(locations.size() > 0, "At least one @PropertySource(value) location is required");
boolean ignoreResourceNotFound = descriptor.ignoreResourceNotFound();
PropertySourceFactory factory = (descriptor.propertySourceFactory() != null ?
instantiateClass(descriptor.propertySourceFactory()) : defaultPropertySourceFactory);
for (String location : locations) {
try {
String resolvedLocation = this.environment.resolveRequiredPlaceholders(location);
for (Resource resource : this.resourcePatternResolver.getResources(resolvedLocation)) {
addPropertySource(factory.createPropertySource(name, new EncodedResource(resource, encoding)));
}
}
catch (RuntimeException | IOException ex) {
// Placeholders not resolvable or resource not found when trying to open it
if (ignoreResourceNotFound && (ex instanceof PlaceholderResolutionException || isIgnorableException(ex) ||
isIgnorableException(ex.getCause()))) {
if (logger.isInfoEnabled()) {
logger.info("Properties location [" + location + "] not resolvable: " + ex.getMessage());
}
}
else {
throw ex;
}
}
}
}
逐行:
-
位置先过一遍占位符解析:
this.environment.resolveRequiredPlaceholders(location)。这意味着@PropertySource("classpath:/com/${my.placeholder:default/path}/app.properties")这种写法成立——${my.placeholder:default/path}会用已经注册到 environment 的源(systemProperties、systemEnvironment、之前处理的 @PropertySource)解析。注意用的是严格版resolveRequiredPlaceholders:如果占位符没默认值且解析不了,直接抛异常,不会"先空着路径"继续。 -
支持通配符:
this.resourcePatternResolver.getResources(resolvedLocation)——6.1 起classpath*:/config/*.properties这种通配位置也被支持(注解 javadoc 明确写了 "As of Spring Framework 6.1, resource location wildcards are also supported")。每个匹配到的 Resource 都会被加载成一个独立的 PropertySource。 -
工厂创建 PropertySource:
factory.createPropertySource(name, new EncodedResource(resource, encoding))。默认工厂是DefaultPropertySourceFactory:public PropertySource<?> createPropertySource(@Nullable String name, EncodedResource resource) throws IOException { return (name != null ? new ResourcePropertySource(name, resource) : new ResourcePropertySource(resource)); }即每个资源包成一个
ResourcePropertySource(它内部用PropertiesLoaderUtils把.properties或.xml读成Properties,再继承PropertiesPropertySource)。用户没写name时,名字从资源描述里派生(这就是为什么日志里能看到类似class path resource [app.properties]的源名)。 -
ignoreResourceNotFound 的精确语义:只有当异常是
PlaceholderResolutionException(占位符解析不了)、FileNotFoundException、UnknownHostException、SocketException(或它们的 cause)时才吞掉并打 info 日志;其他异常(比如 properties 文件格式错误)照样抛出。这个区分很重要——文件不存在可以选忽略,但文件存在却坏了必须报错。
3.16.4 addPropertySource:同名合并为 CompositePropertySource
加载出来的源怎么进 environment 的 MutablePropertySources?看 addPropertySource(第 110-144 行):
private void addPropertySource(PropertySource<?> propertySource) {
String name = propertySource.getName();
MutablePropertySources propertySources = this.environment.getPropertySources();
if (this.propertySourceNames.contains(name)) {
// We've already added a version, we need to extend it
PropertySource<?> existing = propertySources.get(name);
if (existing != null) {
PropertySource<?> newSource = (propertySource instanceof ResourcePropertySource rps ?
rps.withResourceName() : propertySource);
if (existing instanceof CompositePropertySource cps) {
cps.addFirstPropertySource(newSource);
}
else {
if (existing instanceof ResourcePropertySource rps) {
existing = rps.withResourceName();
}
CompositePropertySource composite = new CompositePropertySource(name);
composite.addPropertySource(newSource);
composite.addPropertySource(existing);
propertySources.replace(name, composite);
}
return;
}
}
if (this.propertySourceNames.isEmpty()) {
propertySources.addLast(propertySource);
}
else {
String lastAdded = this.propertySourceNames.get(this.propertySourceNames.size() - 1);
propertySources.addBefore(lastAdded, propertySource);
}
this.propertySourceNames.add(name);
}
这段有两个精妙设计:
- 同名源自动合成
CompositePropertySource。如果这个名字的源之前已经加过(propertySourceNames是 processor 自己维护的已加名列表),不简单覆盖,而是包成一个 CompositePropertySource:新源addFirstPropertySource(新的优先级更高)、旧源放后面。第一次出现同名时,还会把旧源withResourceName()重命名后再包进去——这样两个不同资源文件用同一个名字时,不会互相覆盖,而是合成一个内部有序的复合源,Composite 内部自己再按顺序遍历。 - 新 @PropertySource 的优先级高于已有的。看最后那段:如果一个名字都没加过,
addLast;否则插到"最近加的那个名字"的前面(addBefore(lastAdded, ...))。这意味着后处理的@PropertySource反而排在更前面、优先级更高。这与@PropertySourcejavadoc 里那句"the last @PropertySource annotation processed will 'win'"完全一致——配置类 B 的@PropertySource后于配置类 A 处理,B 里同名 key 覆盖 A 的。
把整条链串起来:
ConfigurationClassPostProcessor (BFPP)
└─ ConfigurationClassParser.parse 配置类
└─ 遍历 @PropertySource/@PropertySources
└─ PropertySourceRegistry.processPropertySource (读注解→descriptor)
└─ PropertySourceProcessor.processPropertySource
├─ environment.resolveRequiredPlaceholders(location) 解析路径占位符
├─ resourcePatternResolver.getResources(location) 支持通配
├─ DefaultPropertySourceFactory → ResourcePropertySource (读 .properties/.xml)
└─ addPropertySource: 同名合并 Composite / 新源 addBefore 老源
└─ environment.getPropertySources() (MutablePropertySources) 动态编排顺序
到这一步,配置文件里的 key 已经以正确的优先级躺在 environment 里。下一节讲占位符在 bean 定义与 @Value 里是怎么被替换掉的。
3.17 PropertySourcesPlaceholderConfigurer:把 environment 接到容器
3.17.1 postProcessBeanFactory:组装搜索源
@PropertySource 只负责把 key 放进 environment,但 bean 定义里的 ${db.url}、@Value("${...}") 又是另一套系统——它们发生在 bean 工厂层面,需要一个 BeanFactoryPostProcessor 把 environment 里的 key 接到容器。这就是 PropertySourcesPlaceholderConfigurer(spring-context/src/main/java/org/springframework/context/support/PropertySourcesPlaceholderConfigurer.java)。注意 7.0.9 它的继承链是:
PropertySourcesPlaceholderConfigurer
extends PlaceholderConfigurerSupport
extends PropertyResourceConfigurer
implements BeanFactoryPostProcessor
(javadoc 明确写它"Specialization of PlaceholderConfigurerSupport")。它的 postProcessBeanFactory(第 130-157 行)是入口:
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
if (this.propertySources == null) {
this.propertySources = new MutablePropertySources();
if (this.environment != null) {
PropertySource<?> environmentPropertySource =
(this.environment instanceof ConfigurableEnvironment configurableEnvironment ?
new ConfigurableEnvironmentPropertySource(configurableEnvironment) :
new FallbackEnvironmentPropertySource(this.environment));
this.propertySources.addLast(environmentPropertySource);
}
try {
PropertySource<?> localPropertySource =
new PropertiesPropertySource(LOCAL_PROPERTIES_PROPERTY_SOURCE_NAME, mergeProperties());
if (this.localOverride) {
this.propertySources.addFirst(localPropertySource);
}
else {
this.propertySources.addLast(localPropertySource);
}
}
catch (IOException ex) {
throw new BeanInitializationException("Could not load properties", ex);
}
}
processProperties(beanFactory, createPropertyResolver(this.propertySources));
this.appliedPropertySources = this.propertySources;
}
逐段:
- 用一个独立的
MutablePropertySources装本次解析要用的搜索源(不复用 environment 自己的那个,避免污染)。 - 把整个 environment 包成一个 PropertySource 加进来:
- 若是
ConfigurableEnvironment(常规场景),包成内部类ConfigurableEnvironmentPropertySource(6.2.7 引入)。这个源的getProperty会遍历 environment 内部所有 PropertySource,找到第一个非 null 值后用 environment 的 ConversionService 转成 String 返回。也就是说它是 environment 全体源的"代理壳"。 - 若只是只读的
Environment(非 Configurable),退化成FallbackEnvironmentPropertySource,直接调environment.getProperty(name)。
- 若是
- 再把 configurer 自己的本地 properties(通过
setLocation/setProperties等配置的,mergeProperties()合并出来)包成LOCAL_PROPERTIES_PROPERTY_SOURCE_NAME("localProperties")。 - 本地源的位置由
localOverride决定:默认false→addLast(本地源优先级最低,最后才查);localOverride=true→addFirst(本地源优先级最高,可以覆盖 environment)。这个localOverride是理解"properties 文件 vs 环境变量谁赢"的关键开关。 - 最后
processProperties(beanFactory, createPropertyResolver(...)),用一个全新的PropertySourcesPropertyResolver(6.0.12 起做成可覆盖的工厂方法createPropertyResolver)包着这个组装好的列表去干活。
3.17.2 processProperties:造出 StringValueResolver
protected void processProperties(ConfigurableListableBeanFactory beanFactoryToProcess,
ConfigurablePropertyResolver propertyResolver) throws BeansException {
propertyResolver.setPlaceholderPrefix(this.placeholderPrefix);
propertyResolver.setPlaceholderSuffix(this.placeholderSuffix);
propertyResolver.setValueSeparator(this.valueSeparator);
propertyResolver.setEscapeCharacter(this.escapeCharacter);
StringValueResolver valueResolver = strVal -> {
String resolved = (this.ignoreUnresolvablePlaceholders ?
propertyResolver.resolvePlaceholders(strVal) :
propertyResolver.resolveRequiredPlaceholders(strVal));
if (this.trimValues) {
resolved = resolved.trim();
}
return (resolved.equals(this.nullValue) ? null : resolved);
};
doProcessProperties(beanFactoryToProcess, valueResolver);
}
这里把占位符语法(前缀/后缀/分隔符/转义符)配置灌进 resolver,然后造一个 lambda 形式的 StringValueResolver:
- 宽松开关
ignoreUnresolvablePlaceholders决定走resolvePlaceholders(解析不了保留原样)还是resolveRequiredPlaceholders(严格抛异常); trimValues=true时对结果 trim;- 若结果字符串正好等于
nullValue(通过setNullValue("null")之类配置),返回真正的null——这就是"把字符串 'null' 转成空引用"的实现。
3.17.3 doProcessProperties:BeanDefinitionVisitor 遍历 + 注册嵌入式解析器
这个 lambda 被传给父类 PlaceholderConfigurerSupport.doProcessProperties(spring-beans/.../config/PlaceholderConfigurerSupport.java 第 238-263 行):
protected void doProcessProperties(ConfigurableListableBeanFactory beanFactoryToProcess,
StringValueResolver valueResolver) {
BeanDefinitionVisitor visitor = new BeanDefinitionVisitor(valueResolver);
String[] beanNames = beanFactoryToProcess.getBeanDefinitionNames();
for (String curName : beanNames) {
// Check that we're not parsing our own bean definition,
// to avoid failing on unresolvable placeholders in properties file locations.
if (!(curName.equals(this.beanName) && beanFactoryToProcess.equals(this.beanFactory))) {
BeanDefinition bd = beanFactoryToProcess.getBeanDefinition(curName);
try {
visitor.visitBeanDefinition(bd);
}
catch (Exception ex) {
throw new BeanDefinitionStoreException(bd.getResourceDescription(), curName, ex.getMessage(), ex);
}
}
}
// Resolve placeholders in alias target names and aliases as well.
beanFactoryToProcess.resolveAliases(valueResolver);
// Resolve placeholders in embedded values such as annotation attributes.
beanFactoryToProcess.addEmbeddedValueResolver(valueResolver);
}
三件事:
- 遍历所有 bean 定义,用
BeanDefinitionVisitor把PropertyValues、构造参数、bean 引用、集合元素里的字符串值挨个过一遍valueResolver,把${...}替换掉。唯独跳过自己的 bean 定义(curName.equals(this.beanName))——因为 configurer 自己的setLocation("${...}")里可能引用了系统属性,如果连自己也解析,一旦那个属性缺失就会在加载 properties 文件之前就报错,先有鸡还是先有蛋。 resolveAliases(valueResolver):bean 别名也做占位符替换。addEmbeddedValueResolver(valueResolver)——这是@Value能工作的关键。把这个 resolver 注册进 bean 工厂的嵌入式解析器列表。后面@Value("${...}")被处理时,就会用它来解析。
3.17.4 AbstractBeanFactory.resolveEmbeddedValue:嵌入式解析器列表
AbstractBeanFactory(spring-beans/.../support/AbstractBeanFactory.java)持有一个 CopyOnWriteArrayList<StringValueResolver>(第 149 行):
private final List<StringValueResolver> embeddedValueResolvers = new CopyOnWriteArrayList<>();
@Override
public void addEmbeddedValueResolver(StringValueResolver valueResolver) {
Assert.notNull(valueResolver, "StringValueResolver must not be null");
this.embeddedValueResolvers.add(valueResolver);
}
@Override
public @Nullable String resolveEmbeddedValue(@Nullable String value) {
if (value == null) {
return null;
}
String result = value;
for (StringValueResolver resolver : this.embeddedValueResolvers) {
result = resolver.resolveStringValue(result);
if (result == null) {
return null;
}
}
return result;
}
注意 resolveEmbeddedValue 是把所有注册的 resolver 串起来挨个过(不是命中即返回)。通常这个列表里只有 PropertySourcesPlaceholderConfigurer 注册的那一个,但理论上可以有多个 configurer 链式处理(第一个宽松放过的,第二个可能解析出来)。每个 resolver 返回 null 就整体结束。
3.17.5 @Value 的求值链路:AutowiredAnnotationBeanPostProcessor
@Value("${db.url}") 不是在 PropertySourcesPlaceholderConfigurer 阶段被替换的——那时 bean 还没实例化,注解属性在运行时才读。真正的链路是:
-
AutowiredAnnotationBeanPostProcessor在 bean 属性注入时,发现字段/参数上的@Value,取出它的字符串值; -
这个字符串先被交给
EmbeddedValueResolver(spring-beans/.../config/EmbeddedValueResolver.java):public class EmbeddedValueResolver implements StringValueResolver { private final BeanExpressionContext exprContext; private final @Nullable BeanExpressionResolver exprResolver; public EmbeddedValueResolver(ConfigurableBeanFactory beanFactory) { this.exprContext = new BeanExpressionContext(beanFactory, null); this.exprResolver = beanFactory.getBeanExpressionResolver(); } @Override public @Nullable String resolveStringValue(String strVal) { String value = this.exprContext.getBeanFactory().resolveEmbeddedValue(strVal); if (this.exprResolver != null && value != null) { Object evaluated = this.exprResolver.evaluate(value, this.exprContext); value = (evaluated != null ? evaluated.toString() : null); } return value; } }EmbeddedValueResolver.resolveStringValue先调beanFactory.resolveEmbeddedValue(strVal)——即上一节那个遍历embeddedValueResolvers的方法,走到PropertySourcesPlaceholderConfigurer注册的 lambda,用PropertySourcesPropertyResolver把${db.url}替换成真实值;然后再把结果交给BeanExpressionResolver(SpEL)求值。 -
这就解释了
${...}与#{...}为什么能混用:@Value("${app.name:#{systemProperties['user.name']}}")先由占位符引擎把${...}(含默认值#{...})处理掉,再由 SpEL 引擎对结果表达式求值。占位符与 SpEL 是两个先后衔接的阶段,不是同一套语法——这也是第 10 节"混用坑"的根源。
把这几节串成一条完整链路,就是下一节"运行机制"的内容。
3.18 YAML 支持:YamlProcessor 的 flatten 语义
properties 文件只能表达扁平的 key=value,而 YAML 天然支持嵌套结构。Spring 把 YAML 文档加载成 Properties 的桥接器是 YamlProcessor(spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java),配套的两个工厂 bean 是 YamlMapFactoryBean 与 YamlPropertiesFactoryBean(同包)。它依赖 SnakeYAML 2.0+(javadoc 明确 "Requires SnakeYAML 2.0 or higher")。
3.18.1 加载流程
核心入口 process(MatchCallback):
protected void process(MatchCallback callback) {
Yaml yaml = createYaml();
for (Resource resource : this.resources) {
boolean found = process(callback, yaml, resource);
if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND && found) {
return;
}
}
}
它对每个 Resource 调用内部的 process(callback, yaml, resource),后者用 yaml.loadAll(reader) 把一个 YAML 文件里所有用 --- 分隔的文档逐个读成 Map。createYaml 构造了一个带安全限制的 Yaml:
protected Yaml createYaml() {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
loaderOptions.setTagInspector(new SupportedTagInspector());
DumperOptions dumperOptions = new DumperOptions();
return new Yaml(new Constructor(loaderOptions), new Representer(dumperOptions),
dumperOptions, loaderOptions);
}
setAllowDuplicateKeys(false):YAML 文档里出现重复 key 直接报错(而不是静默后值覆盖前值);SupportedTagInspector:白名单式限制可以反序列化的 Java 类型——默认只允许 JDK 标准类型(SafeConstructor),只有用户通过setSupportedTypes显式登记的类才允许用!!标签实例化,否则抛ComposerException。这是一个安全边界,防止恶意 YAML 用任意类型标签搞反序列化攻击。
3.18.2 flatten:嵌套结构如何变成点分 key
YAML 是嵌套的,Properties 是扁平的,桥梁就是 getFlattenedMap / buildFlattenedMap(7.0.4 起新增了 includeEmpty 重载):
private void buildFlattenedMap(Map<String, Object> result, Map<String, Object> source, @Nullable String path,
boolean includeEmpty, @Nullable Object emptyValue) {
if (includeEmpty && source.isEmpty()) {
result.put(path, emptyValue);
return;
}
source.forEach((key, value) -> {
if (StringUtils.hasText(path)) {
if (key.startsWith("[")) {
key = path + key;
}
else {
key = path + '.' + key;
}
}
if (value instanceof String) {
result.put(key, value);
}
else if (value instanceof Map map) {
// Need a compound key
buildFlattenedMap(result, map, key, includeEmpty, emptyValue);
}
else if (value instanceof Collection collection) {
// Need a compound key
if (collection.isEmpty()) {
result.put(key, "");
}
else {
int count = 0;
for (Object object : collection) {
buildFlattenedMap(result, Collections.singletonMap(
"[" + (count++) + "]", object), key, includeEmpty, emptyValue);
}
}
}
else {
result.put(key, (value != null ? value : (includeEmpty ? emptyValue : "")));
}
});
}
flatten 的规则:
- 嵌套 Map 用
.连接 key:server: { port: 8080 }→server.port=8080; - Collection(List)展开成
[0]、[1]索引:servers: [a, b]→servers[0]=a、servers[1]=b; - 空集合写
key=(空字符串); - 7.0.4 新增的
includeEmpty控制是否把空 Map 也写成一个空值条目,以及空值用什么占位。
这就是为什么 YAML 里写 server.port: 8080,最后能用 ${server.port} 读到——它被 flatten 成了标准点分 key,与 properties 文件里的 server.port=8080 完全等价。
3.18.3 多文档与 DocumentMatcher
一个 YAML 文件可以用 --- 切成多个文档(典型的"dev 文档 + prod 文档")。process(map, callback) 先用 getFlattenedMap 把文档转成 Properties,再用 DocumentMatcher 决定这个文档要不要采用:
private boolean process(Map<String, Object> map, MatchCallback callback) {
Properties properties = CollectionFactory.createStringAdaptingProperties();
properties.putAll(getFlattenedMap(map));
if (this.documentMatchers.isEmpty()) {
callback.process(properties, map);
return true;
}
MatchStatus result = MatchStatus.ABSTAIN;
for (DocumentMatcher matcher : this.documentMatchers) {
MatchStatus match = matcher.matches(properties);
result = MatchStatus.getMostSpecific(match, result);
if (match == MatchStatus.FOUND) {
callback.process(properties, map);
return true;
}
}
if (result == MatchStatus.ABSTAIN && this.matchDefault) {
callback.process(properties, map);
return true;
}
return false;
}
没有 matcher 时所有文档都采用(按 ResolutionMethod 决定 OVERRIDE 还是 FIRST_FOUND);有 matcher 时,比如"挑 environment=prod 的那个文档",就只处理匹配的文档。ResolutionMethod 枚举三档:OVERRIDE(后面的覆盖前面的)、OVERRIDE_AND_IGNORE(忽略加载失败)、FIRST_FOUND(找到第一个能加载的资源就停)。
注意:Spring Framework 本体的 YamlPropertiesFactoryBean 只是把 YAML 加载成 Properties bean,不会根据当前激活的 profile 自动挑选文档——那是 Spring Boot 的 spring.config.* 机制,不在本仓库范围内。本章只讲 Framework 层的 YamlProcessor 语义。
4. 运行机制:一次 ${db.url} 占位符解析的完整链路
把前面散落在各节的源码串成一次端到端的调用。假设我们有一个 bean:
@Value("${db.url:jdbc:mysql://localhost/default}")
private String url;
且 db.url 定义在 classpath:db.properties 里,通过 @PropertySource("classpath:db.properties") 引入。完整时序如下:
阶段一:配置类解析(refresh 的 invokeBeanFactoryPostProcessors 早期)
ConfigurationClassPostProcessor触发ConfigurationClassParser;- 解析到
@PropertySource("classpath:db.properties"),调PropertySourceRegistry.processPropertySource; PropertySourceProcessor先environment.resolveRequiredPlaceholders("classpath:db.properties")解析路径(无占位符,原样);DefaultPropertySourceFactory把资源读成ResourcePropertySource,addPropertySource把它以合适优先级插入 environment 的MutablePropertySources(排在 systemProperties 之前、还是之后取决于处理顺序,新 @PropertySource 优先)。
阶段二:占位符 configurer 执行(仍是 invokeBeanFactoryPostProcessors 阶段)
PropertySourcesPlaceholderConfigurer.postProcessBeanFactory被调;- 它把整个 environment 包成
ConfigurableEnvironmentPropertySource,加上本地localProperties,组装成自己的MutablePropertySources; processProperties造出 lambdaStringValueResolver(严格模式);doProcessProperties遍历所有 beanDefinition,把 XML/注解方式定义的<property value="${db.url}">这类直接在 beanDefinition 里的字符串替换掉;同时addEmbeddedValueResolver(valueResolver)把这个 lambda 注册进AbstractBeanFactory.embeddedValueResolvers。
阶段三:bean 实例化与 @Value 注入(refresh 的 finishBeanFactoryInitialization)
- 创建该 bean,
AutowiredAnnotationBeanPostProcessor发现字段上的@Value; - 用
EmbeddedValueResolver(包着 beanFactory)处理注解值${db.url:jdbc:mysql://localhost/default}; EmbeddedValueResolver.resolveStringValue调beanFactory.resolveEmbeddedValue(...);resolveEmbeddedValue遍历embeddedValueResolvers,走到阶段二注册的 lambda;- lambda 调
propertyResolver.resolveRequiredPlaceholders(...),进入PropertySourcesPropertyResolver; - 内部是
PropertyPlaceholderHelper→PlaceholderParser,先 parse 成SimplePlaceholderPart(key="db.url", fallback="jdbc:mysql://localhost/default"); - resolve 时
resolveRecursively回调getPropertyAsRawString("db.url"); - 这个回调走
PropertySourcesPropertyResolver.getProperty("db.url", String.class, false),按 MutablePropertySources 顺序遍历:先查 environment 代理源 → 内部遍历 @PropertySource 加的 db.properties 源,找到db.url=jdbc:mysql://prod:3306/app,返回; - 假设没找到,
resolveRecursively返回 null,SimplePlaceholderPart.resolve走 fallback 分支返回jdbc:mysql://localhost/default; - 值回来后,
EmbeddedValueResolver再把它交给 SpELBeanExpressionResolver(本例值里没有#{...},直接原样返回); - 最终
url字段被注入解析后的字符串。
4.1 属性覆盖优先级总表(以 7.0.9 实际注册顺序为准)
一个标准 Servlet 环境下,同名 key 从高到低(先到先得)的完整顺序:
| 优先级 | PropertySource 名 | 来源 | 备注 |
|---|---|---|---|
| 最高(列表头) | 编程式 addFirst 的源 | 用户代码 / CommandLinePropertySource | 命令行参数通常插在这里 |
| ↑ | @PropertySource 后处理的源 | 各 @Configuration 类 | 后处理者覆盖先处理者 |
| ↑ | servletConfigInitParams | web.xml <servlet> init-param | 构造时是 stub,refresh 时 replace |
| ↑ | servletContextInitParams | web.xml <context-param> | 同上 |
| ↑ | jndiProperties | java:comp/env | 仅 JNDI 可用时注册 |
| ↑ | systemProperties | JVM -D 参数、System.getProperties() | per-JVM |
| 较低 | systemEnvironment | OS 环境变量 | SystemEnvironmentPropertySource 名重写 |
| 最低(列表尾) | localProperties | PropertySourcesPlaceholderConfigurer 本地 | localOverride=false 时最低,true 时最高 |
三个常被忽略的要点:
- 同名 key 是"先到先得",不是合并。排在前面的源给出非 null 值,后面的源被完全遮蔽,哪怕后面的源里这个 key 更"正确"。
@PropertySource的相对优先级由配置类注册顺序决定,component-scan 出来的顺序不保证稳定——javadoc 明确建议:如果覆盖关系很关键,就别依赖扫描顺序,改用编程式getPropertySources().addFirst/addBefore。PropertySourcesPlaceholderConfigurer的localOverride决定本地 properties 文件是压过 environment 还是垫底。默认 false(垫底),这意味着默认情况下@PropertySource(进 environment)比 configurer 自己setLocation的文件优先级高——很多人搞反。
5. 扩展用法与最佳实践
5.1 自定义 PropertySource:把数据库、加密配置接进来
PropertySource 是抽象类,扩展它只需要实现一个抽象方法 getProperty(String name)。最典型的自定义源是从数据库或 KMS 读配置:
public class JdbcPropertySource extends PropertySource<JdbcTemplate> {
public JdbcPropertySource(String name, JdbcTemplate source) {
super(name, source);
}
@Override
public Object getProperty(String name) {
try {
return getSource().queryForObject(
"SELECT prop_value FROM app_config WHERE prop_key = ?",
String.class, name);
}
catch (EmptyResultDataAccessException ex) {
return null; // 查不到必须返回 null,不能抛异常打断顺序遍历
}
}
}
两个实现要点:
- 查不到 key 必须返回 null。这是 3.4 节"先到先得"语义的硬要求——如果自定义源在"没有这个 key"时抛异常,整个
getProperty会被中断,后面低优先级的源也没机会兜底,这违背了 PropertySource 链的设计契约。只有"源本身坏了"(比如数据库连不上)才适合抛异常。 - 如果可以枚举 key,继承
EnumerablePropertySource并实现getPropertyNames()。这样containsProperty能走更高效的路径,也便于调试工具列出全部 key。
把自定义源接进环境,有两个时机选择:
- 在
refresh()之前(推荐):通过ApplicationContextInitializer或在new AnnotationConfigApplicationContext()之后、refresh()之前调env.getPropertySources().addFirst(new JdbcPropertySource("db", jdbcTemplate))。这样 configurer 阶段就能查到它。 - 动态增删:
MutablePropertySources本身是线程安全的(CopyOnWriteArrayList+ 写时 synchronized),运行期也能addFirst/addLast/remove/replace。但要注意:正在被解析的某次getProperty遍历的是当时的列表快照,运行期改列表不会打断进行中的解析,只会影响下一次查询。
加密配置源(JWT/KMS 解密)也是同一思路:getProperty 里把密文解密成明文返回。注意解密是按需触发的——只有真的被查询到时才解密,而不是启动时一次性解密全部配置,这样能减少密钥暴露面。
5.2 多环境 Profile 的组织建议
结合 3.9-3.14 节的源码,组织多环境时应遵循几条从实现倒推出来的规则:
- profile 激活是"懒加载 + 一次性"的。
doGetActiveProfiles只在 activeProfiles 为空时读一次spring.profiles.active。一旦编程式setActiveProfiles被调用,属性文件里的配置就失效了。所以同一个项目里不要既在代码里setActiveProfiles("dev")、又指望配置文件里的spring.profiles.active=prod生效——前者会赢。 - 命令行 > JVM 系统属性 > 环境变量 > 配置文件。这是由 3.12/3.13 的源顺序决定的:命令行源通常
addFirst到最前,systemProperties 在 systemEnvironment 之前,@PropertySource 在最后处理的优先级最高。部署时用--spring.profiles.active=prod(命令行)覆盖任何配置文件,是最可靠的外部化手段。 - 默认 profile
"default"的含义。没有任何显式激活的 profile 时,@Profile("default")的 bean 会被注册(3.14.2 的isProfileActive第二层)。这不是"某个叫 default 的环境",而是"没指定环境时的兜底"。不要把生产配置放进@Profile("default")。 - 表达式用括号显式分组。3.14.2 的
assertWellFormed禁止同层混用&和|。@Profile("dev & local | unit")会直接抛 "Malformed profile expression",必须写成@Profile("dev & (local | unit)")。 - profile 名不能以
!开头(validateProfile硬规则)。!只在表达式里用于"非",不能作为要激活的 profile 名。
5.3 占位符的默认值、嵌套与转义
结合 3.15 节的 PlaceholderParser:
- 默认值
::${db.url:jdbc:mysql://localhost/default},key 找不到时用冒号后的字符串。注意冒号只认第一个未转义的,所以默认值本身可以含冒号(如 URL 里的jdbc:mysql://...),引擎取的是第一个:后的全部内容。 - 嵌套占位符:
${log.dir:${user.home}/logs}——先解析整体,keylog.dir找不到时 fallback 是${user.home}/logs,fallback 本身还会被再解析一次(3.15.6 的resolveRecursively)。 - 转义:6.2 起默认反斜杠
\。\${name}输出字面${name};路径里的C:\data写成${path:C\:\\data}避免C:被误判为默认值分隔符。 - 循环检测:
a=${b}、b=${a}时直接抛 "Circular placeholder reference"。这是配置错误,不是引擎问题。
5.4 类型转换与 ConversionService
env.getProperty("server.port", Integer.class) 能自动转类型,靠的是 AbstractPropertyResolver.convertValueIfNecessary 里的 ConversionService。默认是 DefaultConversionService。需要自定义转换(比如把 "1s,200ms" 这种时长转成 Duration)时:
ConfigurableEnvironment env = applicationContext.getEnvironment();
env.getConversionService().addConverter(new MyDurationConverter());
注意 getConversionService() 返回的是 ConfigurableConversionService,直接往里 addConverter 即可,不需要整个替换。这比 2.2 节提到的 setConversionService 更轻量。
5.5 线程安全与性能注意
MutablePropertySources用CopyOnWriteArrayList,读(getProperty遍历)无锁、写时复制。启动期增删源之后,运行期读是廉价的;但如果在高并发下频繁运行期增删,复制开销会显现——PropertySource 列表应尽量在启动期定稿。AbstractEnvironment的 activeProfiles/defaultProfiles 操作各自加synchronized,profile 集合的并发修改是安全的;但 profile 一旦激活,建议不再运行期修改,否则行为不可预测。PropertySourcesPropertyResolver.getProperty每次都线性遍历所有源。如果 PropertySource 数量很多(几十个)且每次都查同一个热 key,建议把最常用的源放最前,让"先到先得"尽快命中、少遍历。
5.6 validateRequiredProperties:启动期配置自检
把必须存在的 key 注册进来,在 refresh 早期统一校验:
ConfigurableEnvironment env = applicationContext.getEnvironment();
env.setRequiredProperties("db.url", "db.username", "db.password");
env.validateRequiredProperties();
缺任何一个 key 都会抛 MissingRequiredPropertiesException,且一次性报出全部缺失(3.5 节),而不是跑一轮报一个。这比让应用启动到一半在某个 bean 里 NPE 要好得多——建议把"部署时必须由环境变量提供的密钥"都注册成 requiredProperties。
6. 常见问题与坑
6.1 "Could not resolve placeholder 'xxx'" 的源码级根因与排查
这是 Environment 体系最高频的异常。7.0.9 里它的抛出点非常明确——PlaceholderParser.PartResolutionContext.handleUnresolvablePlaceholder(3.15.6 节):
public String handleUnresolvablePlaceholder(String key, String text) {
if (this.ignoreUnresolvablePlaceholders) {
return toPlaceholderText(key);
}
String originalValue = (!key.equals(text) ? toPlaceholderText(text) : null);
throw new PlaceholderResolutionException(
"Could not resolve placeholder '%s'".formatted(key), key, originalValue);
}
抛异常的前提是:严格模式(ignoreUnresolvablePlaceholders=false,即 resolveRequiredPlaceholders)且这个 key 回调 resolver 返回了 null,且没有默认值。按"回调返回 null"往下追,根因只有几种:
- key 根本不在任何 PropertySource 里。用
env.getProperty("xxx")直接测——如果连直接查都返回 null,那就是配置没进 environment。常见原因:@PropertySource的路径写错(classpath 下找不到文件且没开ignoreResourceNotFound,但开了的话文件不存在就被静默吞了,key 自然没有);properties 文件没打进 jar;YAML 没 flatten 成预期 key(嵌套层级不对)。 - key 在某个源里,但被排在更前面的源"遮蔽"了一个 null 之外的值。不可能——先到先得只要非 null 就返回。但反过来说:如果你以为 key 在 A 源,实际排在 A 前面的 B 源里没有这个 key,查询会穿透到 A 源,这种"穿透"本身没问题。真正容易踩的是下面第 3 点。
- 大小写/名重写陷阱(3.6 节)。你在 properties 文件里写了
foo.bar,但 systemEnvironment 里有个FOO_BAR,查foo.bar时穿透到环境变量源会命中FOO_BAR。更隐蔽的是:你以为该用db.url,但实际环境变量设的是DB_URL,而 properties 里没有db.url,于是查询穿透命中DB_URL——值不是你期望的那个。排查方法:把SystemEnvironmentPropertySource的日志开到 DEBUG,看那句 "does not contain property 'foo.bar', but found equivalent 'FOO_BAR'"。 - configurer 没注册或顺序不对。
@Value("${...}")能解析的前提是PropertySourcesPlaceholderConfigurer把自己注册的 resolver 加进了embeddedValueResolvers(3.17.3)。如果用纯 Java 配置且没有<context:property-placeholder/>、也没有一个 static@Bean返回PropertySourcesPlaceholderConfigurer,那么embeddedValueResolvers是空的,@Value("${...}")里的占位符不会被解析(注意:这种情况通常不报 "Could not resolve",因为没人去解析它,字段里直接是字面${...})。这是与 XML 时代最大的区别——注解配置必须显式注册 configurer 或依赖 Spring Boot 自动配置。 - 严格模式误开。
resolveRequiredPlaceholders严格抛异常;resolvePlaceholders宽松保留原样。如果某个值解析不了但你希望它保留,检查PropertySourcesPlaceholderConfigurer.setIgnoreUnresolvablePlaceholders(true)。
排查的"黄金三步":
- 第一步:
System.out.println(env.getProperty("xxx"))直接查。null 就是配置没进 environment,非 null 就是优先级/遮蔽问题。 - 第二步:打印
env.getPropertySources()的完整顺序与每个源的名字,确认你的源在不在列表里、排第几。 - 第三步:把
PropertySourcesPropertyResolver日志开到 TRACE(3.4 节有那行 "Searching for key 'xxx' in PropertySource 'yyy'"),看它到底遍历了哪些源、在哪一步停下来的。
6.2 PropertySource 顺序误判
最常见的误判:以为 @PropertySource 加的源排在 systemProperties 之后。实际上根据 3.16.4,后处理的 @PropertySource 是 addBefore(lastAdded) 插到已有源前面的——@PropertySource 源通常排在 systemProperties 前面,优先级高于 -D 参数。如果想让命令行 -D 覆盖配置文件,要么用 CommandLinePropertySource 显式 addFirst,要么意识到优先级顺序与直觉相反。
另一个误判:MutablePropertySources.replace 是原地替换保持位置(3.3 节),不是插到最前。Web 环境 stub 换真实源靠的就是这个,替换后顺序不变。
6.3 SystemEnvironmentPropertySource 的大小写坑
环境变量名在 Linux 上通常全大写(DB_URL),properties 文件里是点分小写(db.url)。靠 3.6 节的名重写,db.url 能命中 DB_URL。但坑在于:重写是单向的——查 db.url 会试 DB_URL,但查 DB_URL 不会反过来试 db.url。更麻烦的是,如果 systemProperties 里恰好有个 DB_URL,它排在 systemEnvironment 前面,会先命中——你以为在改环境变量,实际一直被 -DDB_URL 盖着。排查同 6.1 第三步。
6.4 ${} 与 #{} 混用
3.17.5 已经点明:${...} 是占位符(PropertyResolver),#{...} 是 SpEL(BeanExpressionResolver),两者由 EmbeddedValueResolver 先后衔接——先解析占位符,再把结果当 SpEL 表达式求值。坑在于:
- 占位符的默认值里写 SpEL:
${app.name:#{systemProperties['user.name']}}。先占位符引擎处理:key 找不到时 fallback 是字符串#{systemProperties['user.name']},这个字符串再交给 SpEL 求值。顺序不能反——#{...${...}...}反过来不行,因为 SpEL 引擎不认${}。 - 如果占位符解析后的结果本身不含
#{},SpEL 引擎对字符串做原样 toString,不会出错;但如果你把 SpEL 表达式写错(比如${expr:#{T(Math).random()}),结果会是 SpEL 求值异常,错误信息容易和占位符异常混淆。 - 不要在
${}里写复杂 SpEL——占位符引擎只认key:default,不认 SpEL 语法。
6.5 profile 表达式语法错误
3.14.2 的 assertWellFormed:a & b | c 同层混用 & 和 | 直接抛 "Malformed profile expression"。必须加括号 (a & b) | c。另外:
!只作用于紧跟的原子或括号,!a & b解析为(!a) & b;- profile 名不能含
!开头(validateProfile); - 多个
@Profile值之间是"或"(ParsedProfiles.matches任意命中),不是"与"。
6.6 YAML 多文档解析
- Framework 的
YamlProcessor不会按当前 profile 自动挑选文档(那是 Spring Boot 的能力)。一个 YAML 文件里写了 dev/prod 两个文档,Framework 层默认会全部加载、后面的覆盖前面的(OVERRIDE),dev 和 prod 的 key 混在一起。要选文档必须配DocumentMatcher。 - 重复 key 直接报错(
setAllowDuplicateKeys(false)),不会静默覆盖。 - 嵌套 List 被 flatten 成
key[0]、key[1](3.18.2),用${key[0]}访问,而不是${key}。 - 7.0.4 之前空 Map 不会生成条目,
includeEmpty=false;新版本可通过getFlattenedMap(source, includeEmpty, emptyValue)控制。
6.7 其他高频坑
@PropertySource不生效:检查 environment 是不是ConfigurableEnvironment(3.16.1,不是则打 info 忽略);检查配置类有没有被组件扫描到。spring.profiles.active在application.properties里写了但不生效:因为 profile 是懒加载(3.9),而这个属性本身要被解析进 environment——如果它在 @PropertySource 里,时机上可能晚于某些条件评估。profile 激活最好用命令行/环境变量,而不是配置文件。- 运行期改 PropertySource 不影响已解析的 bean:beanDefinition 里的占位符在 configurer 阶段就被替换成字面量了,之后改源不会回溯。运行期动态配置刷新是另一套机制(Spring Cloud Refresh),不在本章范围。
7. 本章小结
本章从接口族出发,逐层剖开了 Spring 7.0.9 的 Environment 与属性管理体系:
- 接口四层递进:
PropertyResolver(取属性/解析占位符)→ConfigurablePropertyResolver(ConversionService/前缀后缀/转义符/必填校验)→Environment(profile)→ConfigurableEnvironment(可变 PropertySource/merge)。 - 优先级即列表顺序:
MutablePropertySources用CopyOnWriteArrayList维护一个"按 name 去重、可相对插入"的有序源列表;PropertySourcesPropertyResolver.getProperty线性遍历、先到先得、首个非 null 值胜出。 - AbstractEnvironment 持有 activeProfiles/defaultProfiles 两个懒加载集合,profile 通过
spring.profiles.active属性懒加载,validateProfile拒绝!开头的名字。 - ProfilesParser 是一个手写递归下降解析器,把
& | !与括号编译成Profiles谓词闭包,禁止同层混用&和|。 - 占位符引擎在 6.2 被重写为
PlaceholderParser:先 parse 成 Part 树(处理嵌套配对、转义、默认值切分),再惰性 resolve(回调取值、值内再 parse、Set 循环检测、严格/宽松决定抛异常)。 - @PropertySource 处理链在 6.0 后由
PropertySourceRegistry(spring-context)委托PropertySourceProcessor(spring-core io.support)完成,同名源自动合成CompositePropertySource,后处理的源优先级更高。 - 占位符接入容器靠
PropertySourcesPlaceholderConfigurer:组装 environment 代理源 + 本地源,用BeanDefinitionVisitor遍历 beanDefinition,再把StringValueResolver注册进AbstractBeanFactory.embeddedValueResolvers,供@Value通过EmbeddedValueResolver调用。 - 优先级总表:命令行/addFirst > @PropertySource > servletConfig > servletContext > jndi > systemProperties > systemEnvironment > configurer 本地(localOverride=false 时)。
本章关键类清单
| 类名 | 模块与相对路径 | 职责一句话 |
|---|---|---|
PropertyResolver | spring-core/src/main/java/org/springframework/core/env/PropertyResolver.java | 最底层属性读取与占位符解析契约 |
ConfigurablePropertyResolver | spring-core/src/main/java/org/springframework/core/env/ConfigurablePropertyResolver.java | 配置 ConversionService/前缀后缀/转义符/必填校验 |
Environment | spring-core/src/main/java/org/springframework/core/env/Environment.java | 挂接 active/default profile 与 acceptsProfiles |
ConfigurableEnvironment | spring-core/src/main/java/org/springframework/core/env/ConfigurableEnvironment.java | 暴露可变 PropertySource、profile 设置、merge |
AbstractPropertyResolver | spring-core/src/main/java/org/springframework/core/env/AbstractPropertyResolver.java | 模板方法:两个 helper、ConversionService、嵌套占位符开关 |
PropertySourcesPropertyResolver | spring-core/src/main/java/org/springframework/core/env/PropertySourcesPropertyResolver.java | 按 MutablePropertySources 顺序遍历、先到先得 |
PropertySource<T> | spring-core/src/main/java/org/springframework/core/env/PropertySource.java | 配置源抽象,身份只由 name 决定 |
EnumerablePropertySource | spring-core/src/main/java/org/springframework/core/env/EnumerablePropertySource.java | 可枚举 key 的源(getPropertyNames) |
MutablePropertySources | spring-core/src/main/java/org/springframework/core/env/MutablePropertySources.java | CopyOnWriteArrayList 实现的优先级容器 |
SystemEnvironmentPropertySource | spring-core/src/main/java/org/springframework/core/env/SystemEnvironmentPropertySource.java | 环境变量名重写(点/横线/大小写) |
AbstractEnvironment | spring-core/src/main/java/org/springframework/core/env/AbstractEnvironment.java | Environment 骨架:profile 集合、懒加载、merge |
StandardEnvironment | spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java | 注册 systemProperties + systemEnvironment |
StandardServletEnvironment | spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java | Web 五源(servletConfig/Context/JNDI + 上两者) |
Profiles | spring-core/src/main/java/org/springframework/core/env/Profiles.java | profile 表达式谓词函数式接口 |
ProfilesParser | spring-core/src/main/java/org/springframework/core/env/ProfilesParser.java | & | ! 与括号的递归下降解析器 |
PropertyPlaceholderHelper | spring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java | 占位符助手壳,委托 PlaceholderParser |
PlaceholderParser | spring-core/src/main/java/org/springframework/util/PlaceholderParser.java | 两阶段(parse 成 Part 树 + 惰性 resolve)占位符引擎 |
PropertySourceProcessor | spring-core/src/main/java/org/springframework/core/io/support/PropertySourceProcessor.java | @PropertySource 加载、同名合成 Composite |
PropertySourceRegistry | spring-context/src/main/java/org/springframework/context/annotation/PropertySourceRegistry.java | 注解元数据 → descriptor 的薄壳 |
DefaultPropertySourceFactory | spring-core/src/main/java/org/springframework/core/io/support/DefaultPropertySourceFactory.java | 默认工厂,包 ResourcePropertySource |
PropertySourcesPlaceholderConfigurer | spring-context/src/main/java/org/springframework/context/support/PropertySourcesPlaceholderConfigurer.java | 把 environment 接到容器的 BFPP |
PlaceholderConfigurerSupport | spring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.java | doProcessProperties:BeanDefinitionVisitor + 注册 resolver |
EmbeddedValueResolver | spring-beans/src/main/java/org/springframework/beans/factory/config/EmbeddedValueResolver.java | @Value 专用:resolveEmbeddedValue + SpEL |
AbstractBeanFactory | spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java | embeddedValueResolvers 列表与 resolveEmbeddedValue |
YamlProcessor | spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java | YAML 多文档加载与 flatten 成点分 key |