Spring Framework 7.0.9 深度剖析-15-Environment 与属性管理

6 阅读27分钟

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 版本事实,它们是本章深度的"题眼",也是与大量网上旧资料不一致的地方:

  1. @PropertySource 的处理器在 6.0 之后已经不叫 PropertySourceAnnotationProcessor,也不在 spring-context 包内。7.0.9 仓库里的真实类是 org.springframework.core.io.support.PropertySourceProcessor(位于 spring-core),spring-context 的 ConfigurationClassParser 通过 PropertySourceRegistry 委托给它。凡是还在讲"PropertySourceAnnotationProcessor"的资料,描述的都是 5.x 时代。
  2. 占位符解析的核心算法在 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);
	}
}

逐行解读:

  1. synchronized (this.propertySourceList)——锁的是底层 list 对象本身,而不是 MutablePropertySources 实例。因为 CopyOnWriteArrayList 的写操作内部虽然线程安全,但"先 removeIfPresent 再 add(0)"这两步必须作为复合操作原子完成,否则两个并发线程可能各自插入同一个源,顺序错乱。
  2. removeIfPresent(propertySource)——按 equals(即按 name)先移除已存在的同名源。这意味着 addFirst 不是"简单头插",而是"先去重再头插":如果这个源已经在列表里任何位置,它会被搬到头位而不是留下两份。这正是"同名源只保留一份"语义的落地。
  3. 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;
}

逐段解读,这里每一行都是考点:

  1. for (PropertySource<?> propertySource : this.propertySources)——这个迭代顺序就是 MutablePropertySources.propertySourceList 的顺序,也就是我们 3.3 节用 addFirst/addLast/addBefore/addAfter 精心编排出来的顺序。列表越靠前的源越先被遍历到。

  2. "先到先得"在 if (value != null) { ... return ... }——只要某个源返回了非 null 值,立即 return,后面的源再也不会被问。这就是 PropertySource 优先级的全部秘密:不是"合并所有源的值",而是"排在最前面、且能给出非 null 值的那个源赢"。即使后面的源也有这个 key,也被遮蔽掉。

  3. 嵌套占位符的二次解析。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" 里即便嵌着占位符也不解析(因为数字转换更重要)。

  4. convertValueIfNecessary(value, targetValueType)——找到值之后,如果要的不是 String,就用 ConversionService 转换。这就是为什么 env.getProperty("server.port", Integer.class) 能拿到 Integer。

  5. 遍历完所有源都没有非 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;
}

这段逻辑的匹配顺序是:

  1. 先用原始名 spring.profiles.active 直接查;
  2. 查不到,checkPropertyName 依次试:原名 → 点替换成下划线 spring_profiles_active → 横线替换成下划线 spring-profiles-active(把 - 换成 _)→ 点和横线都换成下划线;
  3. 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 行)。逐行解读这个懒加载逻辑:

  1. synchronized (this.activeProfiles)——对 activeProfiles 集合加锁,保证并发下懒加载只发生一次。
  2. 只有当集合还是空的时才去查属性。这意味着一旦你手工 setActiveProfiles(...) 激活过 profile,这个属性就再也不会被自动读取了——编程式 API 优先级高于配置文件里的 spring.profiles.active。
  3. doGetActiveProfilesProperty() 走的是完整的 getProperty("spring.profiles.active")——也就是 3.4 节那套顺序遍历。所以这个属性可以来自 systemProperties(-Dspring.profiles.active=...)、可以来自 systemEnvironment(SPRING_PROFILES_ACTIVE,靠 3.6 节的名重写)、也可以来自任何 @PropertySource。
  4. 拿到字符串后,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);
		}
	}
}

三段:

  1. PropertySource 合并:遍历父环境所有源,凡是子环境里还没有同名的,addLast 追加到子环境末尾。同名的(比如两边都有的 systemProperties/systemEnvironment)子环境已有的保留,父的丢弃——这正是 2.4 节 javadoc 承诺的"child instance is to be preserved"。
  2. activeProfiles 合并:父激活的 profile 追加进子的集合。
  3. 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);
}

关键点:

  1. servletConfig / servletContext 两个源在构造时塞的是 StubPropertySource(3.1 节那个永远返回 null 的占位对象)。因为此刻 ServletContext 对象根本还没准备好(Web 容器还没把上下文挂上来),只能先占住位置。
  2. JNDI 源要先判断 JNDI_PRESENT(JDK9+ 下 javax.naming 是可选模块,先探类是否存在)且 isDefaultJndiEnvironmentAvailable() 为真,才注册。
  3. 最后才 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)));
}

两层语义:

  1. 先校验 profile 名合法(不能 ! 开头);
  2. 若显式激活的集合 contains(profile),true;
  3. 若没有任何显式激活的 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);
}

逐段拆解这个状态机:

  1. Context 枚举有三个值:NONE、NEGATE、PARENTHESIS。它记录"当前正在解析什么结构内部"。
  2. 遇到 (:递归调用自己去解析括号内部(Context.PARENTHESIS),直到遇到配对的 ) 才返回。如果当前处于 NEGATE(即 ! 后面紧跟 (),直接返回括号内容——取反是在外层 not(...) 包裹时统一做的。
  3. 遇到 &:先 assertWellFormed(expression, operator == null || operator == Operator.AND),然后记下 operator = AND。
  4. 遇到 |:同理,但记成 OR。
  5. assertWellFormed 那行是硬规则:operator == null || operator == Operator.AND 表示——& 只能出现在"还没有操作符"(表达式开头或刚完一个元素)或"已经在 AND 链里"的位置。它不允许 & 和 | 在同一层混用。比如 a & b | c,解析到 | 时 operator 已经是 AND,断言 operator==null || operator==OR 失败,直接抛 "Malformed profile expression"。这正是 javadoc 强调的:& 与 | 必须用括号显式分组((a & b) | c)。
  6. 遇到 !:elements.add(not(parseTokens(..., Context.NEGATE)))。! 后面递归解析出一个 Profiles,用 not(...) 包一层取反。NEGATE context 下,default 分支会直接 return value——即 ! 只作用于紧跟的那一个原子 profile 或括号组。
  7. 遇到 ):把当前 elements 列表用 merge(expression, elements, operator) 合并成一个 Profiles。如果处于 PARENTHESIS context,直接 return(括号解析完毕);否则(这是顶层 )?其实顶层不该有 ),这里处理的是某种边界情况),清空 elements、把合并结果作为新元素。
  8. 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);
}

三步走:

  1. parse(value, false) 把整段文本解析成 Part 列表;返回 null 表示压根没有占位符,直接返回原字符串——这是热路径快速跳过,避免无占位符文本走任何构造。
  2. 包成 ParsedValue(一个 record:原文 + parts)。
  3. 构造 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;
}

算法:

  1. 从 ${ 之后开始扫描;
  2. 遇到一个 }(suffix):若 withinNestedPlaceholder > 0,说明这个 } 是用来闭合内层占位符的,计数减一、跳过;否则(计数为 0)这才是当前这层的结束,返回下标;
  3. 遇到一个 {(simplePrefix):说明又开了一层占位符,计数加一、跳过;
  4. 其他字符逐个前进。

用 ${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;
}

逐行:

  1. resolvePlaceholder(key) 回调用户 resolver(即 PropertySourcesPropertyResolver 取原始值)。拿不到 → 返回 null(交给上层走默认值或抛异常)。
  2. 拿到值后,立刻 resolutionContext.parse(resolvedValue) 看这个值内部是否还含占位符。没有(返回 null)→ 直接返回这个值,递归终止。
  3. 值内部还含占位符 → 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");
	}
}

几个要点:

  1. attributesForRepeatable 同时处理 @PropertySource 与可重复注解 @PropertySources(注解上标了 @Repeatable(PropertySources.class)),开发者可以在一个类上直接写多个 @PropertySource,Java 会把它们聚合进 @PropertySources。

  2. 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 做不到。

  3. 这个处理发生在配置类解析期,早于 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;
			}
		}
	}
}

逐行:

  1. 位置先过一遍占位符解析:this.environment.resolveRequiredPlaceholders(location)。这意味着 @PropertySource("classpath:/com/${my.placeholder:default/path}/app.properties") 这种写法成立——${my.placeholder:default/path} 会用已经注册到 environment 的源(systemProperties、systemEnvironment、之前处理的 @PropertySource)解析。注意用的是严格版 resolveRequiredPlaceholders:如果占位符没默认值且解析不了,直接抛异常,不会"先空着路径"继续。

  2. 支持通配符:this.resourcePatternResolver.getResources(resolvedLocation)——6.1 起 classpath*:/config/*.properties 这种通配位置也被支持(注解 javadoc 明确写了 "As of Spring Framework 6.1, resource location wildcards are also supported")。每个匹配到的 Resource 都会被加载成一个独立的 PropertySource。

  3. 工厂创建 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] 的源名)。

  4. 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);
}

这段有两个精妙设计:

  1. 同名源自动合成 CompositePropertySource。如果这个名字的源之前已经加过(propertySourceNames 是 processor 自己维护的已加名列表),不简单覆盖,而是包成一个 CompositePropertySource:新源 addFirstPropertySource(新的优先级更高)、旧源放后面。第一次出现同名时,还会把旧源 withResourceName() 重命名后再包进去——这样两个不同资源文件用同一个名字时,不会互相覆盖,而是合成一个内部有序的复合源,Composite 内部自己再按顺序遍历。
  2. 新 @PropertySource 的优先级高于已有的。看最后那段:如果一个名字都没加过,addLast;否则插到"最近加的那个名字"的前面(addBefore(lastAdded, ...))。这意味着后处理的 @PropertySource 反而排在更前面、优先级更高。这与 @PropertySource javadoc 里那句"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;
}

逐段:

  1. 用一个独立的 MutablePropertySources 装本次解析要用的搜索源(不复用 environment 自己的那个,避免污染)。
  2. 把整个 environment 包成一个 PropertySource 加进来:
    • 若是 ConfigurableEnvironment(常规场景),包成内部类 ConfigurableEnvironmentPropertySource(6.2.7 引入)。这个源的 getProperty 会遍历 environment 内部所有 PropertySource,找到第一个非 null 值后用 environment 的 ConversionService 转成 String 返回。也就是说它是 environment 全体源的"代理壳"。
    • 若只是只读的 Environment(非 Configurable),退化成 FallbackEnvironmentPropertySource,直接调 environment.getProperty(name)。
  3. 再把 configurer 自己的本地 properties(通过 setLocation/setProperties 等配置的,mergeProperties() 合并出来)包成 LOCAL_PROPERTIES_PROPERTY_SOURCE_NAME("localProperties")。
  4. 本地源的位置由 localOverride 决定:默认 false → addLast(本地源优先级最低,最后才查);localOverride=true → addFirst(本地源优先级最高,可以覆盖 environment)。这个 localOverride 是理解"properties 文件 vs 环境变量谁赢"的关键开关。
  5. 最后 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);
}

三件事:

  1. 遍历所有 bean 定义,用 BeanDefinitionVisitor 把 PropertyValues、构造参数、bean 引用、集合元素里的字符串值挨个过一遍 valueResolver,把 ${...} 替换掉。唯独跳过自己的 bean 定义(curName.equals(this.beanName))——因为 configurer 自己的 setLocation("${...}") 里可能引用了系统属性,如果连自己也解析,一旦那个属性缺失就会在加载 properties 文件之前就报错,先有鸡还是先有蛋。
  2. resolveAliases(valueResolver):bean 别名也做占位符替换。
  3. 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 还没实例化,注解属性在运行时才读。真正的链路是:

  1. AutowiredAnnotationBeanPostProcessor 在 bean 属性注入时,发现字段/参数上的 @Value,取出它的字符串值;

  2. 这个字符串先被交给 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)求值。

  3. 这就解释了 ${...} 与 #{...} 为什么能混用:@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 早期)

  1. ConfigurationClassPostProcessor 触发 ConfigurationClassParser;
  2. 解析到 @PropertySource("classpath:db.properties"),调 PropertySourceRegistry.processPropertySource;
  3. PropertySourceProcessor 先 environment.resolveRequiredPlaceholders("classpath:db.properties") 解析路径(无占位符,原样);
  4. DefaultPropertySourceFactory 把资源读成 ResourcePropertySource,addPropertySource 把它以合适优先级插入 environment 的 MutablePropertySources(排在 systemProperties 之前、还是之后取决于处理顺序,新 @PropertySource 优先)。

阶段二:占位符 configurer 执行(仍是 invokeBeanFactoryPostProcessors 阶段)

  1. PropertySourcesPlaceholderConfigurer.postProcessBeanFactory 被调;
  2. 它把整个 environment 包成 ConfigurableEnvironmentPropertySource,加上本地 localProperties,组装成自己的 MutablePropertySources;
  3. processProperties 造出 lambda StringValueResolver(严格模式);
  4. doProcessProperties 遍历所有 beanDefinition,把 XML/注解方式定义的 <property value="${db.url}"> 这类直接在 beanDefinition 里的字符串替换掉;同时 addEmbeddedValueResolver(valueResolver) 把这个 lambda 注册进 AbstractBeanFactory.embeddedValueResolvers。

阶段三:bean 实例化与 @Value 注入(refresh 的 finishBeanFactoryInitialization)

  1. 创建该 bean,AutowiredAnnotationBeanPostProcessor 发现字段上的 @Value;
  2. 用 EmbeddedValueResolver(包着 beanFactory)处理注解值 ${db.url:jdbc:mysql://localhost/default};
  3. EmbeddedValueResolver.resolveStringValue 调 beanFactory.resolveEmbeddedValue(...);
  4. resolveEmbeddedValue 遍历 embeddedValueResolvers,走到阶段二注册的 lambda;
  5. lambda 调 propertyResolver.resolveRequiredPlaceholders(...),进入 PropertySourcesPropertyResolver;
  6. 内部是 PropertyPlaceholderHelper → PlaceholderParser,先 parse 成 SimplePlaceholderPart(key="db.url", fallback="jdbc:mysql://localhost/default");
  7. resolve 时 resolveRecursively 回调 getPropertyAsRawString("db.url");
  8. 这个回调走 PropertySourcesPropertyResolver.getProperty("db.url", String.class, false),按 MutablePropertySources 顺序遍历:先查 environment 代理源 → 内部遍历 @PropertySource 加的 db.properties 源,找到 db.url=jdbc:mysql://prod:3306/app,返回;
  9. 假设没找到,resolveRecursively 返回 null,SimplePlaceholderPart.resolve 走 fallback 分支返回 jdbc:mysql://localhost/default;
  10. 值回来后,EmbeddedValueResolver 再把它交给 SpEL BeanExpressionResolver(本例值里没有 #{...},直接原样返回);
  11. 最终 url 字段被注入解析后的字符串。

4.1 属性覆盖优先级总表(以 7.0.9 实际注册顺序为准)

一个标准 Servlet 环境下,同名 key 从高到低(先到先得)的完整顺序:

优先级PropertySource 名来源备注
最高(列表头)编程式 addFirst 的源用户代码 / CommandLinePropertySource命令行参数通常插在这里
↑@PropertySource 后处理的源各 @Configuration 类后处理者覆盖先处理者
↑servletConfigInitParamsweb.xml <servlet> init-param构造时是 stub,refresh 时 replace
↑servletContextInitParamsweb.xml <context-param>同上
↑jndiPropertiesjava:comp/env仅 JNDI 可用时注册
↑systemPropertiesJVM -D 参数、System.getProperties()per-JVM
较低systemEnvironmentOS 环境变量SystemEnvironmentPropertySource 名重写
最低(列表尾)localPropertiesPropertySourcesPlaceholderConfigurer 本地localOverride=false 时最低,true 时最高

三个常被忽略的要点:

  1. 同名 key 是"先到先得",不是合并。排在前面的源给出非 null 值,后面的源被完全遮蔽,哪怕后面的源里这个 key 更"正确"。
  2. @PropertySource 的相对优先级由配置类注册顺序决定,component-scan 出来的顺序不保证稳定——javadoc 明确建议:如果覆盖关系很关键,就别依赖扫描顺序,改用编程式 getPropertySources().addFirst/addBefore。
  3. 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,不能抛异常打断顺序遍历
		}
	}
}

两个实现要点:

  1. 查不到 key 必须返回 null。这是 3.4 节"先到先得"语义的硬要求——如果自定义源在"没有这个 key"时抛异常,整个 getProperty 会被中断,后面低优先级的源也没机会兜底,这违背了 PropertySource 链的设计契约。只有"源本身坏了"(比如数据库连不上)才适合抛异常。
  2. 如果可以枚举 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 节的源码,组织多环境时应遵循几条从实现倒推出来的规则:

  1. profile 激活是"懒加载 + 一次性"的。doGetActiveProfiles 只在 activeProfiles 为空时读一次 spring.profiles.active。一旦编程式 setActiveProfiles 被调用,属性文件里的配置就失效了。所以同一个项目里不要既在代码里 setActiveProfiles("dev")、又指望配置文件里的 spring.profiles.active=prod 生效——前者会赢。
  2. 命令行 > JVM 系统属性 > 环境变量 > 配置文件。这是由 3.12/3.13 的源顺序决定的:命令行源通常 addFirst 到最前,systemProperties 在 systemEnvironment 之前,@PropertySource 在最后处理的优先级最高。部署时用 --spring.profiles.active=prod(命令行)覆盖任何配置文件,是最可靠的外部化手段。
  3. 默认 profile "default" 的含义。没有任何显式激活的 profile 时,@Profile("default") 的 bean 会被注册(3.14.2 的 isProfileActive 第二层)。这不是"某个叫 default 的环境",而是"没指定环境时的兜底"。不要把生产配置放进 @Profile("default")。
  4. 表达式用括号显式分组。3.14.2 的 assertWellFormed 禁止同层混用 & 和 |。@Profile("dev & local | unit") 会直接抛 "Malformed profile expression",必须写成 @Profile("dev & (local | unit)")。
  5. profile 名不能以 ! 开头(validateProfile 硬规则)。! 只在表达式里用于"非",不能作为要激活的 profile 名。

5.3 占位符的默认值、嵌套与转义

结合 3.15 节的 PlaceholderParser:

  • 默认值 ::${db.url:jdbc:mysql://localhost/default},key 找不到时用冒号后的字符串。注意冒号只认第一个未转义的,所以默认值本身可以含冒号(如 URL 里的 jdbc:mysql://...),引擎取的是第一个 : 后的全部内容。
  • 嵌套占位符:${log.dir:${user.home}/logs}——先解析整体,key log.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"往下追,根因只有几种:

  1. key 根本不在任何 PropertySource 里。用 env.getProperty("xxx") 直接测——如果连直接查都返回 null,那就是配置没进 environment。常见原因:@PropertySource 的路径写错(classpath 下找不到文件且没开 ignoreResourceNotFound,但开了的话文件不存在就被静默吞了,key 自然没有);properties 文件没打进 jar;YAML 没 flatten 成预期 key(嵌套层级不对)。
  2. key 在某个源里,但被排在更前面的源"遮蔽"了一个 null 之外的值。不可能——先到先得只要非 null 就返回。但反过来说:如果你以为 key 在 A 源,实际排在 A 前面的 B 源里没有这个 key,查询会穿透到 A 源,这种"穿透"本身没问题。真正容易踩的是下面第 3 点。
  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'"。
  4. configurer 没注册或顺序不对。@Value("${...}") 能解析的前提是 PropertySourcesPlaceholderConfigurer 把自己注册的 resolver 加进了 embeddedValueResolvers(3.17.3)。如果用纯 Java 配置且没有 <context:property-placeholder/>、也没有一个 static @Bean 返回 PropertySourcesPlaceholderConfigurer,那么 embeddedValueResolvers 是空的,@Value("${...}") 里的占位符不会被解析(注意:这种情况通常不报 "Could not resolve",因为没人去解析它,字段里直接是字面 ${...})。这是与 XML 时代最大的区别——注解配置必须显式注册 configurer 或依赖 Spring Boot 自动配置。
  5. 严格模式误开。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 时)。

本章关键类清单

类名模块与相对路径职责一句话
PropertyResolverspring-core/src/main/java/org/springframework/core/env/PropertyResolver.java最底层属性读取与占位符解析契约
ConfigurablePropertyResolverspring-core/src/main/java/org/springframework/core/env/ConfigurablePropertyResolver.java配置 ConversionService/前缀后缀/转义符/必填校验
Environmentspring-core/src/main/java/org/springframework/core/env/Environment.java挂接 active/default profile 与 acceptsProfiles
ConfigurableEnvironmentspring-core/src/main/java/org/springframework/core/env/ConfigurableEnvironment.java暴露可变 PropertySource、profile 设置、merge
AbstractPropertyResolverspring-core/src/main/java/org/springframework/core/env/AbstractPropertyResolver.java模板方法:两个 helper、ConversionService、嵌套占位符开关
PropertySourcesPropertyResolverspring-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 决定
EnumerablePropertySourcespring-core/src/main/java/org/springframework/core/env/EnumerablePropertySource.java可枚举 key 的源(getPropertyNames)
MutablePropertySourcesspring-core/src/main/java/org/springframework/core/env/MutablePropertySources.javaCopyOnWriteArrayList 实现的优先级容器
SystemEnvironmentPropertySourcespring-core/src/main/java/org/springframework/core/env/SystemEnvironmentPropertySource.java环境变量名重写(点/横线/大小写)
AbstractEnvironmentspring-core/src/main/java/org/springframework/core/env/AbstractEnvironment.javaEnvironment 骨架:profile 集合、懒加载、merge
StandardEnvironmentspring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java注册 systemProperties + systemEnvironment
StandardServletEnvironmentspring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.javaWeb 五源(servletConfig/Context/JNDI + 上两者)
Profilesspring-core/src/main/java/org/springframework/core/env/Profiles.javaprofile 表达式谓词函数式接口
ProfilesParserspring-core/src/main/java/org/springframework/core/env/ProfilesParser.java& | ! 与括号的递归下降解析器
PropertyPlaceholderHelperspring-core/src/main/java/org/springframework/util/PropertyPlaceholderHelper.java占位符助手壳,委托 PlaceholderParser
PlaceholderParserspring-core/src/main/java/org/springframework/util/PlaceholderParser.java两阶段(parse 成 Part 树 + 惰性 resolve)占位符引擎
PropertySourceProcessorspring-core/src/main/java/org/springframework/core/io/support/PropertySourceProcessor.java@PropertySource 加载、同名合成 Composite
PropertySourceRegistryspring-context/src/main/java/org/springframework/context/annotation/PropertySourceRegistry.java注解元数据 → descriptor 的薄壳
DefaultPropertySourceFactoryspring-core/src/main/java/org/springframework/core/io/support/DefaultPropertySourceFactory.java默认工厂,包 ResourcePropertySource
PropertySourcesPlaceholderConfigurerspring-context/src/main/java/org/springframework/context/support/PropertySourcesPlaceholderConfigurer.java把 environment 接到容器的 BFPP
PlaceholderConfigurerSupportspring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.javadoProcessProperties:BeanDefinitionVisitor + 注册 resolver
EmbeddedValueResolverspring-beans/src/main/java/org/springframework/beans/factory/config/EmbeddedValueResolver.java@Value 专用:resolveEmbeddedValue + SpEL
AbstractBeanFactoryspring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.javaembeddedValueResolvers 列表与 resolveEmbeddedValue
YamlProcessorspring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.javaYAML 多文档加载与 flatten 成点分 key