Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:spring.config.activate、ConfigTree、多文档 YAML、环境变量映射
源码根:
spring-boot-4.1.1主要包:org.springframework.boot.context.config、org.springframework.boot.env、org.springframework.boot.context.properties.source版本:Spring Boot 4.1.1 / Spring Framework 7.0.9
0. 本章在整套体系中的位置
第 07 章我们拆开了 ConfigData 的骨架:位置 → 解析 → 加载 → contributor 树 → 应用。第 08 章拆开了 Binder,第 09 章拆开了 @ConfigurationProperties。
但第 07 章留了四个"只说了机制、没说透语义"的口子:
- profile 到底是什么时候、按什么规则求出来的? 为什么说"profile 求解是一次不动点迭代"?
spring.config.activate.on-profile到底绑定在谁的身上? 多文档 YAML 里第二个文档写了on-profile: prod,它是怎么"只对自己生效"的?- 一个文件怎么变成多个 PropertySource? YAML 的
---好理解,但.properties文件也能多文档? SPRING_PROFILES_ACTIVE这种大写下划线变量,是怎么变成spring.profiles.active的? 为什么MY_THE_BAR和MY_THEBAR都能命中?
本章打通前三个口子(第四个------环境变量映射------连同 ConfigTree 一起归入第 11 章)。它们是同一件事的四个切面:Boot 如何把"一堆来源各异的字符串"收敛成"一组有序、可判定、可追溯的属性"。
这一章的核心方法论仍然是第 07 章总结的第四定律:顺序语义常藏在 Javadoc 里。但本章会再加一条:
第六定律:语义的"作用域"常藏在构造参数里。 想知道一个属性是"全局判定"还是"只对当前文档生效",去看绑定它时传进去的
ConfigurationPropertySource集合------ 是Collections.singleton(...)还是Iterable<...>。这一字之差,就是spring.config.activate.on-profile与spring.profiles.active的根本区别。
1. Profile 的三重身份
"Profile" 这个词在 Boot 里被用在三个完全不同的层面,混用会导致大量误判。先做术语切分:
| 层面 | 名称 | 载体 | 谁消费 | 判定时机 |
|---|---|---|---|---|
| A. 运行时激活集合 | active profiles | Environment.getActiveProfiles()、spring.profiles.active |
@Profile、条件注解、用户代码 |
上下文刷新期(Environment 已定型) |
| B. 文件变体选择 | profile-specific file | application-{profile}.yaml |
StandardConfigDataLocationResolver |
ConfigData 解析期(已有 profiles) |
| C. 文档级开关 | spring.config.activate.on-profile |
单个 PropertySource |
ConfigDataProperties.Activate |
属性源应用期(逐源判定) |
三者不是同一件事:
- A 是"状态" :一个字符串集合,最终写进
Environment。 - B 是"命名约定" :文件名里带
-{profile},由 resolver 按 profile 字符串拼出额外的候选路径。 - C 是"布尔判定":每个属性源独立回答"我是否应当参与"。
最容易犯的错误是把 B 和 C 当成等价物。它们看起来都能做到"只在 prod 生效",但:
- B 只能按文件名切分,粒度是"整个文件"。
- C 可以按文档 切分,粒度是"文件里的一个
---片段",而且支持&、|、!、括号表达式。
另一个高频误解是以为 A 决定 C。实际上 C 判定 A (读 Profiles 对象),而 A 又反过来由 C 过滤后的属性源贡献------这是一个环。第 07 章说过的"不动点迭代"就是为破这个环而存在的。本章第 4 节把它彻底讲清楚。
2. Profiles 类:一次求解,三处输出
2.1 类的定位
java
// core/spring-boot/src/main/java/org/springframework/boot/context/config/Profiles.java
public class Profiles implements Iterable<String> {
public static final String INCLUDE_PROFILES_PROPERTY_NAME = "spring.profiles.include";
static final ConfigurationPropertyName INCLUDE_PROFILES =
ConfigurationPropertyName.of(Profiles.INCLUDE_PROFILES_PROPERTY_NAME);
private static final Bindable<MultiValueMap<String, String>> STRING_STRINGS_MAP = Bindable
.of(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class));
private static final Bindable<Set<String>> STRING_SET = Bindable.setOf(String.class);
private final MultiValueMap<String, String> groups;
private final List<String> activeProfiles;
private final List<String> defaultProfiles;
...
}
三个字段,全部 final,构造完成后不再变化。Profiles 是不可变快照 ------这一点非常重要:ConfigData 处理过程中会多次调用 Profiles 的构造,每次都是基于当时的 Binder 重新算一遍,不存在"增量更新"。
implements Iterable<String> 迭代的是 getAccepted()(第 2.7 节),不是 active。这是第一个容易踩的坑:
java
@Override
public Iterator<String> iterator() {
return getAccepted().iterator();
}
所以 for (String p : profiles) 拿到的可能是 default profiles。
2.2 构造过程的三条线
java
Profiles(Environment environment, Binder binder, @Nullable Collection<String> additionalProfiles) {
ProfilesValidator validator = ProfilesValidator.get(binder);
if (additionalProfiles != null) {
validator.validate(additionalProfiles,
() -> "Invalid profile property value found in additional profiles");
}
this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP, validator)
.orElseGet(LinkedMultiValueMap::new);
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));
this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder, validator));
}
执行顺序是固定的、不可交换的:
- 先取
ProfilesValidator(它自己也要读spring.profiles.validate,见 2.6) - 校验
additionalProfiles(来自SpringApplication.setAdditionalProfiles(...)) - 绑定
spring.profiles.group→MultiValueMap<String, String>,同时用 validator 校验 - 求 active profiles,然后
expandProfiles - 求 default profiles,然后
expandProfiles
注意 groups 必须先于 active/default 绑定 ,因为 expandProfiles 要用 this.groups。如果你在 application.properties 里同时写 spring.profiles.group.prod=prod-db,prod-mq 和 spring.profiles.active=prod,那么 active 会被展开成 [prod, prod-db, prod-mq]------这个顺序第 2.5 节细讲。
2.3 Type 枚举:ACTIVE 与 DEFAULT 的不对称
这是 Profiles 里最容易被忽略、但影响最大的一处设计:
java
private enum Type {
ACTIVE(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME, // "spring.profiles.active"
Environment::getActiveProfiles,
true, // mergeWithEnvironmentProfiles
Collections.emptySet()), // defaultValue
DEFAULT(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME, // "spring.profiles.default"
Environment::getDefaultProfiles,
false, // mergeWithEnvironmentProfiles ← 注意
Collections.singleton("default"));
private final Function<Environment, String[]> getter;
private final boolean mergeWithEnvironmentProfiles;
private final String name;
private final Set<String> defaultValue;
...
}
四个字段逐一看:
| 字段 | ACTIVE | DEFAULT | 含义 |
|---|---|---|---|
name |
spring.profiles.active |
spring.profiles.default |
绑定的属性名 |
getter |
Environment::getActiveProfiles |
Environment::getDefaultProfiles |
读"编程式设置"的值 |
mergeWithEnvironmentProfiles |
true |
false |
编程式设置与配置属性是否合并 |
defaultValue |
{}(空) |
{"default"} |
什么都没有时的兜底 |
mergeWithEnvironmentProfiles 的不对称是刻意的行为差异:
- 若你在代码里写了
springApplication.setAdditionalProfiles("a"),同时在application.properties里写了spring.profiles.active=b,结果是[b, a]------两者合并(注意顺序,additional 在前,见 2.4)。 - 但对 default 不适用:一旦 default 被"编程式设置"过,配置属性就不再参与合并。
这个不对称在源码里只有一处体现,就在 getProfiles() 里:
java
if (hasProgrammaticallySetProfiles(type, environmentPropertyValue, environmentPropertyProfiles,
environmentProfiles)) {
if (!type.isMergeWithEnvironmentProfiles() || !boundProfiles.isBound()) {
return environmentProfiles; // 直接返回编程式设置,忽略配置属性
}
return boundProfiles.map((bound) -> merge(environmentProfiles, bound)).get();
}
return boundProfiles.orElse(type.getDefaultValue());
翻译成人话:
arduino
若 profile 被"编程式设置"过:
若 该 Type 不合并(即 DEFAULT) 或 配置属性根本没绑定上:
→ 只用编程式设置的值
否则(ACTIVE 且配置属性绑定成功):
→ 编程式 ∪ 配置属性
否则(没被编程式设置):
→ 用配置属性的值,没绑上就用 defaultValue
2.4 hasProgrammaticallySetProfiles:怎么判断"被编程式设置过"
这段是纯逻辑推理,没有魔法,但非常绕:
java
private boolean hasProgrammaticallySetProfiles(Type type, @Nullable String environmentPropertyValue,
Set<String> environmentPropertyProfiles, Set<String> environmentProfiles) {
if (!StringUtils.hasLength(environmentPropertyValue)) {
return !type.getDefaultValue().equals(environmentProfiles);
}
if (type.getDefaultValue().equals(environmentProfiles)) {
return false;
}
return !environmentPropertyProfiles.equals(environmentProfiles);
}
先明确四个入参:
environmentPropertyValue:environment.getProperty("spring.profiles.active")的原始字符串(未切分)environmentPropertyProfiles:把上面那个字符串按逗号切分、去空白后的集合environmentProfiles:new LinkedHashSet<>(Arrays.asList(type.get(environment))),即environment.getActiveProfiles()的数组转集合
判断逻辑分三种情形:
情形 1:属性原始值为空 (environment.getProperty(...) 返回 null 或空串)
此时只有一种可能说明"编程式设置过":environment.getActiveProfiles() 不等于 defaultValue。
- 对 ACTIVE,defaultValue 是
{}。所以只要getActiveProfiles()非空 → 编程式设置过。典型场景:setAdditionalProfiles("x")或setActiveProfiles("x")。 - 对 DEFAULT,defaultValue 是
{"default"}。所以只要getDefaultProfiles()不是["default"]→ 编程式设置过。典型场景:setDefaultProfiles("base")。
情形 2:属性值非空,且 environmentProfiles 恰好等于 defaultValue → 返回 false,即"不算编程式设置"。
这是一个防御性分支 :防止属性值被 environment.getProperty() 读到、但 getActiveProfiles() 仍返回默认数组时,被误判成"编程式设置"从而跳过配置属性绑定。
- ACTIVE:属性有值但
getActiveProfiles()是空数组 → 不算编程式 → 正常绑定(最常见的正常路径)。 - DEFAULT:属性有值但
getDefaultProfiles()是["default"]→ 不算编程式 → 正常绑定,这保证了spring.profiles.default=base能被读到。
情形 3:属性值非空,且 environmentProfiles != defaultValue → 比较"切分后的属性集合"与"Environment 数组集合":
- 相等 → 二者同源(Environment 的值就是从属性来的)→ 不算编程式设置。
- 不等 → Environment 上有额外的值(编程式塞进去的)→ 算。
Environment::getActiveProfiles 在 Spring Framework 里会读 spring.profiles.active 属性,但 Boot 的 ApplicationEnvironment(见第 06 章)重写 doGetActiveProfilesProperty() 返回 null,剥夺了 AbstractEnvironment 自己读属性的能力 ------所以 Boot 里 getActiveProfiles() 只反映编程式设置,情形 3 的"不等"判断才成立。
方法论提示 :这段代码之所以绕,是因为它要同时兼容 Boot 的
ApplicationEnvironment和任意第三方Environment实现。
2.5 expandProfiles:group 展开的真实顺序
java
private List<String> expandProfiles(@Nullable List<String> profiles) {
Deque<String> stack = new ArrayDeque<>();
asReversedList(profiles).forEach(stack::push);
Set<String> expandedProfiles = new LinkedHashSet<>();
while (!stack.isEmpty()) {
String current = stack.pop();
if (expandedProfiles.add(current)) {
asReversedList(this.groups.get(current)).forEach(stack::push);
}
}
return asUniqueItemList(expandedProfiles);
}
这是一个显式栈的深度优先展开 (而不是递归,避免栈溢出也便于控制顺序)。要理解它,必须同时看 asReversedList:
java
private List<String> asReversedList(@Nullable List<String> list) {
if (CollectionUtils.isEmpty(list)) {
return Collections.emptyList();
}
List<String> reversed = new ArrayList<>(list);
Collections.reverse(reversed);
return reversed;
}
为什么反转两次? 这是第 05 章说的"逆序插入 = 后声明者优先"惯用法的又一例:
-
输入
[a, b, c],反转 →[c, b, a],依次 push 到栈顶 → 栈从顶到底是a, b, c。 -
弹出顺序:
a, b, c✓(保持原始顺序) -
若
a的 group 是[a1, a2],反转 →[a2, a1],push → 栈顶是a1。 -
此时栈从上到下是
a1, a2, b, c,弹出a1,然后把a1的 group 压栈......
结论 :展开是深度优先 ,a 的子 profile 完全展开完才会轮到 b。
举个具体例子。配置:
yaml
spring:
profiles:
group:
prod: prod-db,prod-mq
prod-db: prod-pool
active: prod
输入 active = [prod]:
ini
栈(顶→底): prod
pop prod → expanded = [prod]
groups[prod] = [prod-db, prod-mq],反转 → [prod-mq, prod-db],push
栈(顶→底): prod-db, prod-mq
pop prod-db → expanded = [prod, prod-db]
groups[prod-db] = [prod-pool],反转 → [prod-pool],push
栈(顶→底): prod-pool, prod-mq
pop prod-pool → expanded = [prod, prod-db, prod-pool]
groups[prod-pool] = null
栈(顶→底): prod-mq
pop prod-mq → expanded = [prod, prod-db, prod-pool, prod-mq]
最终 [prod, prod-db, prod-pool, prod-mq]。注意 prod-mq 排在 prod-pool 后面------深度优先的产物。
expandedProfiles.add(current) 的返回值被用作去重与环保护:如果某个 profile 已经在集合里(本次或更早),就不再展开它的 group。这天然处理了:
- 重复声明(
active: prod,prod) - group 环(
a: b且b: a)
注意集合是 LinkedHashSet,所以第一次出现的顺序被保留。
2.6 ProfilesValidator:字符集规则的边界
java
final class ProfilesValidator implements BindHandler {
private static final String ALLOWED_CHARS = "-_.+@";
private final boolean validate;
@Override
public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
validate(result);
return result;
}
void validate(Object value, Supplier<String> wrappedExceptionMessage) {
try {
validate(value);
}
catch (IllegalStateException ex) {
throw new IllegalStateException(wrappedExceptionMessage.get(), ex);
}
}
private void validate(@Nullable Object value) {
if (!this.validate) {
return;
}
if (value instanceof Collection<?> list) {
list.forEach(this::validate);
return;
}
if (value instanceof Map<?, ?> map) {
map.forEach((k, v) -> validate(v));
return;
}
String profile = (value != null) ? value.toString() : null;
Assert.state(StringUtils.hasText(profile), "Invalid empty profile");
for (int i = 0; i < profile.length(); i++) {
int codePoint = profile.codePointAt(i);
boolean isAllowedChar = ALLOWED_CHARS.indexOf(codePoint) != -1;
Assert.state(isAllowedChar || Character.isLetterOrDigit(codePoint),
() -> "Profile '%s' must contain a letter, digit or allowed char (%s)".formatted(profile, ...));
Assert.state((i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint),
() -> "Profile '%s' must start and end with a letter or digit".formatted(profile));
}
}
static ProfilesValidator get(Binder binder) {
return new ProfilesValidator(binder.bind("spring.profiles.validate", Boolean.class).orElse(true));
}
}
六个必须记住的事实:
-
它是一个
BindHandler,不是独立的校验器 。它通过onSuccess钩子在绑定成功后校验------这意味着只有在绑定成功时才会校验。如果一个 profile 属性值存在但因类型转换失败而没绑定上,校验不会跑。 -
允许的额外字符只有 4 个 :
-、_、.、+、@------等等,ALLOWED_CHARS = "-_.+@"是 5 个字符。逐个拆:-、_、.、+、@。空格、逗号、斜杠、冒号都不允许 (逗号被当作分隔符,在StringUtils.commaDelimitedListToSet层就切掉了)。 -
首尾必须是字母或数字 。条件
(i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint):中间位置(i不是首尾)允许特殊字符;首尾位置必须是字母数字。prod-1✓-prod✗(首字符-)prod_✗(尾字符_)
-
Map分支校验 value 不校验 key :map.forEach((k, v) -> validate(v))------key 是 group 名,value 是成员。group 名本身不被校验 。所以spring.profiles.group.prod-x=a,b里prod-x不会被这个校验器检查(它会在expandProfiles后作为 active profile 被校验------如果它被激活的话)。这是一个真实的可钻空子点:未被激活的 group 名可能不合法 。我在此标注为由源码推演,未在 CI 测试中确认 ,因为
Profiles的groups绑定走的是STRING_STRINGS_MAP,validator 的onSuccess在绑定MultiValueMap时会命中Map分支,只校验 value。 -
可以整体关闭 :
spring.profiles.validate=false。这个属性在官方元数据里没有列出(我在additional-spring-configuration-metadata.json中未检索到spring.profiles.validate条目),属于"存在但未被元数据收录"的属性。 -
get(Binder)每次都新建一个实例 ,且它自己用 binder 读属性。在Profiles构造里它被创建一次,然后传给三处绑定(groups / active / default)和一处手动校验(additionalProfiles)。
2.7 getAccepted():active 优先,回退 default
java
public List<String> getAccepted() {
return (!this.activeProfiles.isEmpty()) ? this.activeProfiles : this.defaultProfiles;
}
public boolean isAccepted(String profile) {
return getAccepted().contains(profile);
}
一句话:有 active 就用 active,否则用 default。
这解释了那个经典的"反直觉"现象:
yaml
# application.yaml
spring:
profiles:
default: base
active: dev
isAccepted("base") 返回 false------因为 active 非空,default 被完全屏蔽。
而:
yaml
spring:
profiles:
default: base
isAccepted("base") 返回 true。
isAccepted 是 spring.config.activate.on-profile 的最终判定入口 (见第 5 节 Activate.matchesActiveProfiles(profiles::isAccepted))。所以:
spring.config.activate.on-profile: default只在没有任何 active profile 时生效 。 一旦有人激活了dev,on-profile: default的文档就不再生效------即使你同时写了spring.profiles.default: default。
这条在 ConfigDataEnvironmentPostProcessorIntegrationTests 里有直接用例(源码行 198:spring.config.activate.on-profile: default)。
2.8 Profiles 的 toString 与调试
java
@Override
public String toString() {
ToStringCreator creator = new ToStringCreator(this);
creator.append("active", getActive().toString());
creator.append("default", getDefault().toString());
creator.append("accepted", getAccepted().toString());
return creator.toString();
}
调试 ConfigData 时,把 logging.level.org.springframework.boot.context.config=TRACE 打开,ConfigDataActivationContext 的 toString() 会打印 profiles(因为它也用 ToStringCreator 追加了 profiles)。这是定位 profile 求解问题最直接的手段。
3. spring.profiles.* 全套属性的语义矩阵
把 Profiles 用到的属性连同相关属性摆在一张表里:
| 属性 | 类型 | 读取方 | 语义 | 是否参与 group 展开 |
|---|---|---|---|---|
spring.profiles.active |
Set<String> |
Profiles.Type.ACTIVE |
激活的 profile | ✅ 是 |
spring.profiles.default |
Set<String> |
Profiles.Type.DEFAULT |
无 active 时的回退 | ✅ 是 |
spring.profiles.include |
List<String> |
ConfigDataEnvironment.getIncludedProfiles() |
追加激活 | ❌ 否(第 4 节解释) |
spring.profiles.group.* |
MultiValueMap |
Profiles 构造 |
profile 的展开规则 | --- |
spring.profiles.validate |
Boolean |
ProfilesValidator.get() |
是否校验名字合法性 | --- |
spring.config.activate.on-profile |
String[] |
ConfigDataProperties.Activate |
文档级开关 | ❌ 否 |
spring.config.activate.on-cloud-platform |
CloudPlatform |
ConfigDataProperties.Activate |
云平台开关 | --- |
spring.main.cloud-platform |
String |
CloudPlatform.isEnforced |
强制指定云平台 | --- |
3.1 spring.profiles.include 为什么"不参与 group 展开"
看 Profiles 构造:additionalProfiles 是构造参数 ,直接并进 activeProfiles:
java
private List<String> getActivatedProfiles(Environment environment, Binder binder,
ProfilesValidator validator, @Nullable Collection<String> additionalProfiles) {
return asUniqueItemList(getProfiles(environment, binder, validator, Type.ACTIVE), additionalProfiles);
}
private List<String> asUniqueItemList(Collection<String> profiles, @Nullable Collection<String> additional) {
LinkedHashSet<String> uniqueItems = new LinkedHashSet<>();
if (!CollectionUtils.isEmpty(additional)) {
uniqueItems.addAll(additional); // additional 先加
}
uniqueItems.addAll(profiles); // 后加 active
return Collections.unmodifiableList(new ArrayList<>(uniqueItems));
}
additional 排在前面 。而 ConfigDataEnvironment.withProfiles() 里:
java
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles); // SpringApplication.setAdditionalProfiles
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext)); // spring.profiles.include
Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);
所以顺序是:SpringApplication.setAdditionalProfiles(...) → spring.profiles.include 收集值 → spring.profiles.active。
include 的值被当作 additionalProfiles 传入,然后整体参与 expandProfiles------等等,这里要澄清:
java
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));
getActivatedProfiles 返回的合并结果 (include + active)会一起传给 expandProfiles。所以**include 的值其实是参与 group 展开的**。
那我为什么说"不参与"?准确的表述应该是:
spring.profiles.include与spring.profiles.active合并后再统一展开 group ,二者在展开层面没有区别。 区别在于收集时机 :include是在ConfigDataEnvironment里、逐个 contributor 单独绑定收集的(第 4 节),而active是在Profiles里、用聚合 Binder 绑定的。
这个区别带来一个真实的行为差异,我在第 4.2 节讲。这里先给结论:spring.profiles.include 可以被写在任何 contributor 里(每个属性源单独绑定),而 spring.profiles.active 只能用聚合 Binder 从"所有活跃属性源"里读。
3.2 asUniqueItemList 的去重语义
LinkedHashSet + addAll 意味着先出现者胜 (add 对已存在元素返回 false,不覆盖)。所以:
setAdditionalProfiles("dev")+spring.profiles.active=dev,prod→[dev, prod](dev 只出现一次,位置在前)spring.profiles.include=dev+spring.profiles.active=dev→[dev]
这个"先出现者胜"与属性优先级(后者覆盖前者)是相反的。这里是集合合并,不是属性覆盖,不要混淆。
3.3 一个完整的求解示例
环境:
csharp
SpringApplication.setAdditionalProfiles("ops")
环境变量 SPRING_PROFILES_ACTIVE=dev
application.yaml:
spring.profiles.group.dev: dev-db
spring.profiles.include: metrics
spring.profiles.default: base
推演(按源码算法推演,未在本地实际运行验证):
-
ProfilesValidator.get(binder)→validate=true -
withProfiles()里additionalProfiles = {ops} ∪ {metrics} = {ops, metrics}(ops 来自SpringApplication,metrics 来自spring.profiles.include) -
groups = {dev: [dev-db]} -
getActivatedProfiles:environmentPropertyValue = "dev"(SPRING_PROFILES_ACTIVE在 systemEnvironment 里)environmentProfiles = {}(Boot 的ApplicationEnvironment不读属性,且无编程式设置)hasProgrammaticallySetProfiles→getDefaultValue()({}).equals({})→ 情形 2 →false- 走
boundProfiles.orElse(...)→ 绑定得{dev} asUniqueItemList({dev}, {ops, metrics})→[ops, metrics, dev](additional 在前)
-
expandProfiles([ops, metrics, dev]):ini栈(顶→底): ops, metrics, dev pop ops → [ops] pop metrics → [ops, metrics] pop dev → [ops, metrics, dev];push groups[dev]=[dev-db] pop dev-db → [ops, metrics, dev, dev-db] -
getDefaultProfiles:绑定spring.profiles.default→{base}(同理走情形 2) -
getAccepted()=[ops, metrics, dev, dev-db] -
isAccepted("base")= false
关键洞察 :spring.profiles.default: base 写了,但因为 active 非空,base 完全没有生效。这是生产事故高发区。
4. profile 求解的时机:不动点迭代的第四、五步
第 07 章讲过 processAndApply() 的六步。这里只聚焦与 profile 直接相关的第 4、5 步。
4.1 withProfiles:profile 的唯一求解点
java
private ConfigDataActivationContext withProfiles(ConfigDataEnvironmentContributors contributors,
ConfigDataActivationContext activationContext) {
this.logger.trace("Deducing profiles from current config data environment contributors");
Binder binder = contributors.getBinder(activationContext,
(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES),
BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
try {
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles);
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext));
Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);
return activationContext.withProfiles(profiles);
}
catch (BindException ex) {
if (ex.getCause() instanceof InactiveConfigDataAccessException inactiveException) {
throw inactiveException;
}
throw ex;
}
}
四处细节:
(1) contributor 过滤器 :(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)
带 IGNORE_PROFILES 选项的属性源完全不参与 profile 求解 。哪些源带这个选项?ConfigData.Option.IGNORE_PROFILES 是 public enum,任何自定义 ConfigDataLoader 都可以设置。Boot 内置用法我在源码中检索到的设置点集中在 ConfigDataEnvironmentContributors 之外------具体哪些内置 loader 设置了它,我未逐行核实 ,标注为待查。但语义明确:这是"我这个来源不应该影响 profile 决策"的声明位。
(2) BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE:见 4.3。
(3) additionalProfiles 的顺序 :SpringApplication 设置的值在前,spring.profiles.include 收集的在后。
(4) 异常包装 :BindException 的 cause 若是 InactiveConfigDataAccessException,则直接抛出 cause (剥掉 BindException 外壳)。这让错误信息更干净------用户看到的是 "Inactive property source 'xxx' cannot contain property 'spring.profiles.active'",而不是一层 BindException 包裹。
4.2 getIncludedProfiles:逐 contributor 单独绑定
java
private Collection<? extends String> getIncludedProfiles(ConfigDataEnvironmentContributors contributors,
ConfigDataActivationContext activationContext) {
PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
contributors, activationContext, null, true, this.environment.getConversionService());
Set<String> result = new LinkedHashSet<>();
for (ConfigDataEnvironmentContributor contributor : contributors) {
ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
Binder binder = new Binder(Collections.singleton(source), placeholdersResolver);
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST, ProfilesValidator.get(binder))
.ifBound((includes) -> {
if (!contributor.isActive(activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
Profiles.INCLUDE_PROFILES);
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
Profiles.INCLUDE_PROFILES.append("[0]"));
}
result.addAll(includes);
});
}
}
return result;
}
这段是本章"第六定律"(语义作用域藏在构造参数里)的最佳注脚:
new Binder(Collections.singleton(source), placeholdersResolver) ------每个 contributor 用自己的单元素 Binder 绑定 spring.profiles.include。
含义:
spring.profiles.include不能从别的属性源"继承" 。A 文件里没写spring.profiles.include,B 文件里写了,A 不会被算上 B 的值(但 B 自己会被算上,因为遍历到了 B)。- 这与
spring.profiles.active形成鲜明对比------后者用聚合 Binder(所有活跃源的集合)绑定,所以 active 可以来自任意一个活跃源。
ifBound + isActive 的组合:
java
binder.bind(...).ifBound((includes) -> {
if (!contributor.isActive(activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES);
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES.append("[0]"));
}
result.addAll(includes);
});
逻辑有点绕:
ifBound只在值存在时执行(绑定成功且有值)。- 进入后再检查这个 contributor 是否 active 。若不 active(比如它的
on-profile没匹配上),就抛异常。
为什么要检查两次名字(spring.profiles.include 和 spring.profiles.include[0])?因为 STRING_LIST 的绑定在 Binder 里会分别尝试 spring.profiles.include(整体,逗号切分)和 spring.profiles.include[0]、[1]...(索引形式)。throwIfPropertyFound 用的是 source.getConfigurationProperty(name),只精确查一个名字,所以要查两次才能覆盖"写成标量"和"写成数组第 0 项"两种写法。
这条规则的实质 :不允许在"永不生效的文档"里写 spring.profiles.include------因为它看起来会生效,实际不会,是典型的静默失效。
对应的错误消息(来自 InactiveConfigDataAccessException.getMessage):
python
Inactive property source 'Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/' (document #1)' imported from location '...' cannot contain property 'spring.profiles.include' [origin: ...]
ProfilesValidator.get(binder) 在这里又新建了一个 validator ------用的是单元素 Binder。这意味着 spring.profiles.validate 也会从该单源读取。如果 spring.profiles.validate 只写在某个源里,那么校验 include 时用的是"该源自己的视角"。这是一个极细微的行为差异,我标注为源码推演。
4.3 FAIL_ON_BIND_TO_INACTIVE_SOURCE 与 InactiveSourceChecker
java
private Binder getBinder(@Nullable ConfigDataActivationContext activationContext,
Predicate<ConfigDataEnvironmentContributor> filter, Set<BinderOption> options) {
boolean failOnInactiveSource = options.contains(BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
Iterable<ConfigurationPropertySource> sources = () -> getBinderSources(
filter.and((contributor) -> failOnInactiveSource || contributor.isActive(activationContext)));
PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
this.root, activationContext, null, failOnInactiveSource, this.conversionService);
BindHandler bindHandler = !failOnInactiveSource ? null : new InactiveSourceChecker(activationContext);
return new Binder(sources, placeholdersResolver, null, null, bindHandler);
}
关键 :当 failOnInactiveSource = true 时,sources 的过滤器是 filter.and(contributor -> true || ...)------即不过滤活跃性!所有 contributor(包括不活跃的)都进了 Binder 的源集合。
然后靠 InactiveSourceChecker 在 onSuccess 时回头检查:
java
private class InactiveSourceChecker implements BindHandler {
private final @Nullable ConfigDataActivationContext activationContext;
@Override
public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
for (ConfigDataEnvironmentContributor contributor : ConfigDataEnvironmentContributors.this) {
if (!contributor.isActive(this.activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, name);
}
}
return result;
}
}
为什么这样设计? 因为要产生准确的错误消息。若直接过滤掉不活跃源,绑定会"静默"用活跃源的值,用户永远不知道自己在不生效的文档里写了 spring.profiles.active。现在的设计是:先让它绑定成功,再检查"这个值是不是从不活跃的源来的",是就报错。
Profiles 求解用的是 DENY_INACTIVE_BINDING:
java
private static final BinderOption[] ALLOW_INACTIVE_BINDING = {};
private static final BinderOption[] DENY_INACTIVE_BINDING = { BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE };
processAndApply() 里的分布:
| 步骤 | Binder 选项 | 含义 |
|---|---|---|
registerBootstrapBinder(contributors, null, DENY_INACTIVE_BINDING)(初始) |
DENY | 还没有 activationContext,但已经拒绝 |
processInitial → registerBootstrapBinder(..., null, DENY) |
DENY | 同上 |
processWithoutProfiles → registerBootstrapBinder(..., activationContext, DENY) |
DENY | 有 cloudPlatform 无 profiles |
withProfiles 内部 |
DENY | profile 求解本身 |
processWithProfiles → registerBootstrapBinder(..., activationContext, ALLOW_INACTIVE_BINDING) |
ALLOW | profile 定完之后,允许从不活跃源绑定 |
最后一步切换成 ALLOW 是关键 :profile 定完之后,后续的绑定(比如 spring.config.import 的处理)不再因为"源不活跃"而报错------因为此时"不活跃"已经是正常的、预期内的状态(文档被 on-profile 关掉了),不应该再对用户报错。
4.4 registerBootstrapBinder 与 BootstrapRegistry
java
private void registerBootstrapBinder(ConfigDataEnvironmentContributors contributors,
@Nullable ConfigDataActivationContext activationContext, BinderOption... binderOptions) {
this.bootstrapContext.register(Binder.class,
InstanceSupplier.from(() -> contributors.getBinder(activationContext, binderOptions))
.withScope(Scope.PROTOTYPE));
}
每次 processXxx 之后都重新注册一次 Binder.class 到 bootstrap context。Scope.PROTOTYPE 意味着每次 get() 都会调 from(...) 的 supplier,拿到最新快照。
这是给"需要在 ConfigData 阶段读配置的基础设施"用的通道(典型的例子是 Spring Cloud Config Server 的早期引导,或者自定义 ConfigDataLocationResolver 要读属性)。第 11 章讲 BootstrapRegistry 时会展开。
4.5 不动点:为什么是三轮而不是两轮
回顾第 07 章的六步:
processInitial------ activationContext = null(无 cloudPlatform、无 profiles)createActivationContext------ 算出 cloudPlatform(还没 profiles)processWithoutProfiles------ 用 cloudPlatform 处理 importwithProfiles------ 算出 profilesprocessWithProfiles------ 用 cloudPlatform + profiles 处理 import(含 profile-specific)applyToEnvironment------ 应用并写回Environment
为什么第 3 步不能省? 因为 spring.config.activate.on-cloud-platform 需要 cloudPlatform 才能判定某些文档是否活跃,而 cloudPlatform 的强制值 spring.main.cloud-platform 可能写在被 import 进来的文件里。所以必须先(无 profile 地)处理一轮 import,拿到完整的"非 profile 相关"属性,才能算 cloudPlatform。
为什么第 5 步不能省? 因为 profile-specific 文件(application-{profile}.yaml)需要知道 profile 才能解析,而 profile 只有在第 4 步才算出来。
为什么不能在第 5 步之后再算一次 profile? 因为 Boot 明确规定:profile-specific 文件里不能 写 spring.profiles.active / spring.profiles.include / spring.profiles.default。这是硬约束,由 InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS 强制(见第 7.3 节)。既然禁止,就不需要再迭代------迭代必然收敛。
这就是"不动点"的完整含义 :不是"反复迭代直到不变",而是"结构上保证三轮必收敛,并通过禁写规则把收敛性钉死"。
这是一个非常漂亮的设计:用静态禁止 替代动态收敛判定。
5. spring.config.activate.*:文档级开关的完整语义
5.1 ConfigDataProperties:绑定入口
java
class ConfigDataProperties {
private static final ConfigurationPropertyName NAME = ConfigurationPropertyName.of("spring.config");
private static final Bindable<ConfigDataProperties> BINDABLE_PROPERTIES =
Bindable.of(ConfigDataProperties.class);
private final List<ConfigDataLocation> imports;
private final @Nullable Activate activate;
ConfigDataProperties(@Nullable @Name("import") List<ConfigDataLocation> imports, @Nullable Activate activate) {
this.imports = (imports != null) ? imports.stream().filter(ConfigDataLocation::isNotEmpty).toList()
: Collections.emptyList();
this.activate = activate;
}
boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
return this.activate == null || this.activate.isActive(activationContext);
}
ConfigDataProperties withoutImports() {
return new ConfigDataProperties(null, this.activate);
}
static @Nullable ConfigDataProperties get(Binder binder) {
return binder.bind(NAME, BINDABLE_PROPERTIES, new ConfigDataLocationBindHandler()).orElse(null);
}
...
}
三件事:
-
NAME = "spring.config":绑定的是整个spring.config前缀。import字段用@Name("import")指定(而不是靠字段名反射),activate靠字段名匹配。注意:
@Name注解来自org.springframework.boot.context.properties.bind.Name,是 Binder 的构造器参数命名机制(第 08 章讲ValueObjectBinder时提到过)。 -
imports会过滤空 location :filter(ConfigDataLocation::isNotEmpty)。ConfigDataLocation.of("")返回EMPTY,会被丢掉。所以spring.config.import=(空值)等价于没写。 -
isActive的短路 :activate == null→ true。没写spring.config.activate.*的文档永远活跃。
5.2 绑定的作用域:Collections.singleton
这是本章最核心的一段:
java
// ConfigDataEnvironmentContributor.withBoundProperties
ConfigDataEnvironmentContributor withBoundProperties(Iterable<ConfigDataEnvironmentContributor> contributors,
@Nullable ConfigDataActivationContext activationContext) {
ConfigurationPropertySource configurationPropertySource = getConfigurationPropertySource();
Assert.state(configurationPropertySource != null, "'configurationPropertySource' must not be null");
Iterable<ConfigurationPropertySource> sources = Collections.singleton(configurationPropertySource); // ← 只有自己
PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
contributors, activationContext, this, true, this.conversionService);
Binder binder = new Binder(sources, placeholdersResolver, null, null, null);
ConfigDataProperties properties = ConfigDataProperties.get(binder);
if (properties != null && this.configDataOptions.contains(ConfigData.Option.IGNORE_IMPORTS)) {
properties = properties.withoutImports();
}
return new ConfigDataEnvironmentContributor(Kind.BOUND_IMPORT, this.location, this.resource,
this.fromProfileSpecificImport, this.propertySource, this.configurationPropertySource, properties,
this.configDataOptions, null, this.conversionService);
}
sources = Collections.singleton(configurationPropertySource)------当前 contributor 自己的那一个源。
而 placeholdersResolver 用的是全部 contributors。
这两个参数的差异,决定了两个完全不同的语义:
| 机制 | 查找范围 | 含义 |
|---|---|---|
属性值绑定 (spring.config.import、spring.config.activate.*) |
仅当前文档 | 必须写在这个文档里 |
占位符解析 (${...}) |
全部 contributors | 可以引用别的文档/属性源的值 |
实战推论:
yaml
# application.yaml ------ 文档 #0
my:
host: db.internal
---
# 文档 #1
spring:
config:
import: optional:file:/etc/conf/${my.host}.yaml
这是可以的 :spring.config.import 写在文档 #1 里(自己的源),${my.host} 从文档 #0 解析(全局占位符)。
反过来:
yaml
# 文档 #0
spring:
config:
activate:
on-profile: prod
---
# 文档 #1 ------ 没有写 activate
文档 #1 不会 继承文档 #0 的 on-profile。它永远活跃。
这就是为什么多文档 YAML 里每个需要条件化的文档都必须自己 写 spring.config.activate.on-profile。
5.3 Activate.isActive:双条件 AND
java
static class Activate {
private final @Nullable CloudPlatform onCloudPlatform;
private final String @Nullable [] onProfile;
boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
if (activationContext == null) {
return false;
}
CloudPlatform cloudPlatform = activationContext.getCloudPlatform();
boolean activate = isActive((cloudPlatform != null) ? cloudPlatform : CloudPlatform.NONE);
activate = activate && isActive(activationContext.getProfiles());
return activate;
}
private boolean isActive(CloudPlatform cloudPlatform) {
return this.onCloudPlatform == null || this.onCloudPlatform == cloudPlatform;
}
private boolean isActive(@Nullable Profiles profiles) {
return ObjectUtils.isEmpty(this.onProfile)
|| (profiles != null && matchesActiveProfiles(profiles::isAccepted));
}
private boolean matchesActiveProfiles(Predicate<String> activeProfiles) {
Assert.state(this.onProfile != null, "'this.onProfile' must not be null");
return org.springframework.core.env.Profiles.of(this.onProfile).matches(activeProfiles);
}
}
四条规则:
(1) activationContext == null → false
这意味着:在 processInitial 阶段(还没有 activationContext),所有写了 spring.config.activate.* 的文档都不活跃。
为什么?因为 processInitial 时 ConfigDataEnvironmentContributor.isActive(null) 返回 this.properties == null || this.properties.isActive(null)。对于 UNBOUND_IMPORT 类型的 contributor,第 07 章讲过 isActive 有个特殊分支:
java
boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
if (this.kind == Kind.UNBOUND_IMPORT) {
return false; // ← 未绑定的 import 一律不活跃
}
return this.properties == null || this.properties.isActive(activationContext);
}
而 ConfigDataEnvironmentContributorPlaceholdersResolver.isActive 里对 UNBOUND_IMPORT 做了特殊处理:
java
private boolean isActive(ConfigDataEnvironmentContributor contributor) {
if (contributor == this.activeContributor) {
return true;
}
if (contributor.getKind() != Kind.UNBOUND_IMPORT) {
return contributor.isActive(this.activationContext);
}
return contributor.withBoundProperties(this.contributors, this.activationContext)
.isActive(this.activationContext);
}
即:临时绑定一下再判定 。这样即使还没进入 withProcessedImports 的正式绑定流程,占位符解析时也能正确判断活跃性。
(2) onCloudPlatform 的 NONE 兜底 :activationContext.getCloudPlatform() 返回 null 时用 CloudPlatform.NONE。所以写了 on-cloud-platform: none 只在"没探测到任何云平台"时生效。
(3) 两个条件是 AND :同时写 on-profile 和 on-cloud-platform,两个都要满足。
(4) ObjectUtils.isEmpty(this.onProfile) → profile 条件恒真 。只写 on-cloud-platform 的文档,profile 条件不设限。
5.4 profile 表达式:!、&、|、括号
matchesActiveProfiles 委托给 Spring Framework 的 org.springframework.core.env.Profiles.of(String...):
java
return org.springframework.core.env.Profiles.of(this.onProfile).matches(activeProfiles);
Boot 侧没有自己的解析器,全部交给 Framework。Boot 的 CI 测试锁定了具体语法(ConfigDataEnvironmentPostProcessorIntegrationTests):
java
@Test
@WithResource(name = "application.yaml", content = """
---
my:
property: fromyamlfile
---
spring.config.activate.on-profile: dev & other
my:
property: devandother
---
spring.config.activate.on-profile: (dev | other) & another
my:
property: devorotherandanother
""")
void runWhenHasAndProfileExpressionLoadsExpectedProperties() {
this.application.setAdditionalProfiles("dev", "other");
ConfigurableApplicationContext context = this.application.run();
assertThat(context.getEnvironment().getProperty("my.property")).isEqualTo("devandother");
}
从测试可确认的语法:
| 表达式 | 含义 | 测试证据 |
|---|---|---|
prod |
单一 profile | 处处可见 |
!other |
非(注意 YAML 需加引号:"!other") |
runWhenHasNegatedProfilesLoadsExpectedProperties |
dev & other |
且 | runWhenHasAndProfileExpressionLoadsExpectedProperties |
| `(dev | other) & another` | 括号 + 或 + 且 |
default |
匹配"无 active profile"的回退态 | spring.config.activate.on-profile: default(行 198) |
表达式不匹配时的行为 (runWhenProfileExpressionsDoNotMatchLoadsExpectedProperties):
java
this.application.setAdditionalProfiles("dev"); // 只有 dev
// 文档 #1: on-profile: dev & other → 不匹配
// 文档 #2: on-profile: (dev | other) & another → 不匹配
assertThat(context.getEnvironment().getProperty("my.property")).isEqualTo("fromyamlfile"); // 文档 #0 的值
即:不匹配的文档被完全跳过,回退到文档 #0 的值。不是"报错",不是"用默认值"。
取反的语法细节 :YAML 里 ! 是标签前缀(tag prefix),必须加引号:
yaml
spring.config.activate.on-profile: "!other"
不加引号会被 snakeyaml 当成标签解析。Boot 的测试用例明确写了 "!other"。
另外注意:
onProfile是String[]。Binder 对String[]支持逗号切分(第 08 章的 aggregate binding),所以on-profile: "dev,other"会变成["dev", "other"]两个元素,交给Profiles.of("dev", "other")表示"dev 或 other"(多参数 = 或);而on-profile: "dev & other"无逗号,是单元素["dev & other"]。这条差异是按 Binder 通用行为的推演 ------Boot 的测试只覆盖了
&、|、!、括号,没有直接覆盖逗号写法。实践建议:用&/|而非逗号,意图更明确。
5.5 on-cloud-platform 与 CloudPlatform
CloudPlatform 枚举(org.springframework.boot.cloud.CloudPlatform):
| 枚举 | 探测条件 | 备注 |
|---|---|---|
NONE |
isDetected 恒 false |
特殊:用于"禁用误判" |
CLOUD_FOUNDRY |
VCAP_APPLICATION 或 VCAP_SERVICES |
|
HEROKU |
DYNO |
|
SAP |
HC_LANDSCAPE |
|
NOMAD |
NOMAD_ALLOC_ID |
@since 3.1.0 |
KUBERNETES |
KUBERNETES_SERVICE_HOST + KUBERNETES_SERVICE_PORT,或任意 *_SERVICE_HOST + 同名 *_SERVICE_PORT |
最复杂的探测 |
AZURE_APP_SERVICE |
WEBSITE_SITE_NAME+WEBSITE_INSTANCE_ID+WEBSITE_RESOURCE_GROUP+WEBSITE_SKU 全部存在 |
allMatch |
AWS_ECS |
AWS_EXECUTION_ENV 以 AWS_ECS 开头 |
@since 4.0.0(4.x 新增) |
AWS_ECS 是 Spring Boot 4.0 新增的------这是 4.x 相对 3.x 的一个实质增强点。
探测入口有两个:
java
public boolean isActive(Environment environment) {
String platformProperty = environment.getProperty(PROPERTY_NAME); // "spring.main.cloud-platform"
return isEnforced(platformProperty) || (platformProperty == null && isDetected(environment));
}
public boolean isEnforced(Binder binder) {
return isEnforced(binder.bind(PROPERTY_NAME, String.class).orElse(null));
}
private boolean isEnforced(@Nullable String platform) {
return name().equalsIgnoreCase(platform);
}
public static @Nullable CloudPlatform getActive(@Nullable Environment environment) {
if (environment != null) {
for (CloudPlatform cloudPlatform : values()) {
if (cloudPlatform.isActive(environment)) {
return cloudPlatform;
}
}
}
return null;
}
而 ConfigDataActivationContext 的探测逻辑是两段式:
java
private @Nullable CloudPlatform deduceCloudPlatform(Environment environment, Binder binder) {
for (CloudPlatform candidate : CloudPlatform.values()) {
if (candidate.isEnforced(binder)) {
return candidate;
}
}
return CloudPlatform.getActive(environment);
}
先查"强制",再查"探测":
- 遍历所有枚举,看谁的
isEnforced(binder)为真------即spring.main.cloud-platform的值(大小写不敏感)等于枚举名。 - 若没有强制值,才走
getActive(environment)做环境变量探测。
为什么第一步用 Binder 而不是 environment.getProperty? 因为 spring.main.cloud-platform 可能写在被 import 进来的 application.yaml 里,而那个文件在 createActivationContext 时已经通过 processInitial 加载进 contributors 了。用 Binder(contributors 的聚合 Binder)能读到它;用 environment.getProperty 读不到(还没 addLast 到 Environment)。
这也是"为什么 processInitial 必须在 createActivationContext 之前"的真正原因。
注意 values() 的遍历顺序就是枚举声明顺序,NONE 在最前面。isEnforced 对 NONE 要求 spring.main.cloud-platform=none。
NONE 的特殊用途 :Javadoc 写得很清楚------"No Cloud platform. Useful when false-positives are detected."。比如你在 K8s 里跑,但 KUBERNETES_SERVICE_HOST 的存在导致误判,而你不想要 K8s 相关行为,就设 spring.main.cloud-platform=none。
5.6 ConfigDataLocationBindHandler:给 import 位置补 Origin
java
class ConfigDataLocationBindHandler extends AbstractBindHandler {
@Override
public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
Object result) {
OriginMapper originMapper = new OriginMapper(context.getConfigurationProperty());
if (result instanceof ConfigDataLocation location) {
return originMapper.map(location);
}
if (result instanceof List<?> locations) {
return locations.stream().map(originMapper::mapIfPossible)
.collect(Collectors.toCollection(ArrayList::new));
}
if (result instanceof ConfigDataLocation[] locations) {
return Arrays.stream(locations).map(originMapper::mapIfPossible).toArray(ConfigDataLocation[]::new);
}
return result;
}
private record OriginMapper(@Nullable ConfigurationProperty property) {
@Nullable Object mapIfPossible(@Nullable Object object) {
return (object instanceof ConfigDataLocation location) ? map(location) : object;
}
@Nullable ConfigDataLocation map(@Nullable ConfigDataLocation location) {
if (location == null) {
return null;
}
Origin origin = Origin.from(location);
return (origin != null) ? location : location.withOrigin(Origin.from(property()));
}
}
}
作用:绑定出来的 ConfigDataLocation 对象默认没有 Origin(它只是个字符串包装)。这个 handler 在 onSuccess 时把绑定来源属性的 Origin(即"这个 import 写在哪个文件第几行")贴上去。
ConfigDataLocation.of(...) 创建的实例 origin 为 null,所以 Origin.from(location) 返回 null,走 withOrigin(Origin.from(property()))。
这带来什么? 当 spring.config.import 指向的文件不存在时,错误消息能告诉你"是 application.yaml 第 12 行的那个 import 找不到",而不是干巴巴一句 "Config data location not found"。
ConfigDataLocationNotFoundException 的 message 会带上 origin。这是 Boot 错误诊断质量高的重要一环。
5.7 IGNORE_IMPORTS 选项与 withoutImports
java
if (properties != null && this.configDataOptions.contains(ConfigData.Option.IGNORE_IMPORTS)) {
properties = properties.withoutImports();
}
带 IGNORE_IMPORTS 的 contributor,即使写了 spring.config.import 也会被清空。withoutImports() 保留 activate,只清 imports------所以 spring.config.activate.* 仍然生效。
EMPTY_LOCATION 类型的 contributor 用这个选项构造:
java
private static final ConfigData.Options EMPTY_LOCATION_OPTIONS =
ConfigData.Options.of(ConfigData.Option.IGNORE_IMPORTS);
即:"这个位置合法但没内容"的占位 contributor,不参与 import。
6. 多文档文件:一个文件 → 多个 PropertySource
6.1 YAML 多文档:OriginTrackedYamlLoader
java
public class YamlPropertySourceLoader implements PropertySourceLoader {
@Override
public String[] getFileExtensions() {
return new String[] { "yml", "yaml" };
}
@Override
public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
if (!ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", getClass().getClassLoader())) {
throw new IllegalStateException(
"Attempted to load " + name + " but snakeyaml was not found on the classpath");
}
List<Map<String, Object>> loaded = new OriginTrackedYamlLoader(resource).load();
if (loaded.isEmpty()) {
return Collections.emptyList();
}
List<PropertySource<?>> propertySources = new ArrayList<>(loaded.size());
for (int i = 0; i < loaded.size(); i++) {
String documentNumber = (loaded.size() != 1) ? " (document #" + i + ")" : "";
propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
Collections.unmodifiableMap(loaded.get(i)), true));
}
return propertySources;
}
}
六个可确认的事实:
-
Spring Boot 4.1.1 仍然用 snakeyaml 解析 YAML 配置文件 (不是 Jackson YAML)。这一点值得强调------4.x 把 Jackson 升到 3(用于 JSON 编解码),但配置文件的 YAML 解析没有切换。
另外
YamlPropertySourceLoader只实现了两参的load(name, resource),未实现三参的load(name, resource, encoding),所以 YAML 不支持指定编码 (而PropertiesPropertySourceLoader支持)。三参方法在接口中应有默认转发实现,但我未核实PropertySourceLoader接口的具体声明,标注待查。 -
snakeyaml 缺失时报错而不是静默跳过 :
ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", ...)。这解释了为什么 "snakeyaml not found" 是个常见的启动错误。 -
文档号只在多于一个文档时才加 :
(loaded.size() != 1) ? " (document #" + i + ")" : ""。所以单文档 YAML 的 PropertySource 名是干净的Config resource '...' via location '...',多文档则带(document #0)、(document #1)。调试技巧 :
/actuator/env或 TRACE 日志里看到(document #2)就知道这是第三个 YAML 文档。 -
每个文档一个
OriginTrackedMapPropertySource,且true表示"可展开 origin"(构造参数)。 -
返回的 List 顺序 = 文档顺序 (
i从 0 递增)。结合第 07 章的asContributors逆序插入:javafor (int i = data.getPropertySources().size() - 1; i >= 0; i--) { contributors.add(ConfigDataEnvironmentContributor.ofUnboundImport(location, resource, profileSpecific, data, i, this.conversionService, this.environmentUpdateListener)); }逆序遍历 +
addLast,最终 contributor 顺序 = 文档顺序。而ConfigData.getPropertySources()的 Javadoc 说"ascending priority order, later wins"------所以 文档 #2 覆盖文档 #1 覆盖文档 #0。这解释了 Boot 测试的
runWhenMultipleActiveProfilesWithMultiDocumentFilesLoadsInOrderOfDocument:java// 文档 #1: on-profile: dev → my.property: fromdevprofile // 文档 #2: on-profile: other → my.property: fromotherprofile this.application.setAdditionalProfiles("other", "dev"); assertThat(...getProperty("my.property")).isEqualTo("fromotherprofile"); // 文档 #2 胜出注意 :是"文档顺序"决定胜负,不是 profile 的激活顺序(
other在dev之前传给setAdditionalProfiles)。测试名字里 "InOrderOfDocument" 就是在强调这一点。 -
loaded.isEmpty()返回空 List ,此时asContributors走ofEmptyLocation分支。
6.2 OriginTrackedYamlLoader 的 snakeyaml 配置
java
@Override
protected Yaml createYaml() {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
loaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE);
loaderOptions.setAllowRecursiveKeys(true);
loaderOptions.setCodePointLimit(Integer.MAX_VALUE);
return createYaml(loaderOptions);
}
private Yaml createYaml(LoaderOptions loaderOptions) {
BaseConstructor constructor = new OriginTrackingConstructor(loaderOptions);
DumperOptions dumperOptions = new DumperOptions();
Representer representer = new Representer(dumperOptions);
NoTimestampResolver resolver = new NoTimestampResolver();
return new Yaml(constructor, representer, dumperOptions, loaderOptions, resolver);
}
四项 LoaderOptions 配置的含义:
| 配置 | 值 | 含义与影响 |
|---|---|---|
allowDuplicateKeys |
false |
YAML 里同一个 key 写两次会报错。这是 Boot 刻意加严的(snakeyaml 默认是 true,后者覆盖前者) |
maxAliasesForCollections |
Integer.MAX_VALUE |
解除别名数量限制(防止 YAML 炸弹的默认防护被关掉) |
allowRecursiveKeys |
true |
允许递归 key |
codePointLimit |
Integer.MAX_VALUE |
解除输入大小限制 |
安全提示 :maxAliasesForCollections 和 codePointLimit 都被设成了 Integer.MAX_VALUE,意味着Boot 关闭了 snakeyaml 自带的"十亿笑声"(billion laughs)防护 。这是因为配置文件是可信输入 (你自己写的),不是网络输入。但如果你做了"允许用户上传 YAML 配置文件并用 YamlPropertySourceLoader 加载"的功能,这就是一个真实的 DoS 面。
NoTimestampResolver:
java
private static final class NoTimestampResolver extends Resolver {
@Override
public void addImplicitResolver(Tag tag, Pattern regexp, String first, int limit) {
if (tag == Tag.TIMESTAMP) {
return;
}
super.addImplicitResolver(tag, regexp, first, limit);
}
}
禁用 TIMESTAMP 隐式解析 。含义:YAML 里的 2024-01-01 不会被 自动解析成 java.util.Date,而是保持字符串。这是为了避免"配置值类型意外变化"------如果 Boot 把它变成 Date,绑定给 String 字段时还要再转一次,且会丢失原始格式。
这对用户的直接影响 :my.date: 2024-01-01 绑定到 String 就是 "2024-01-01";绑定到 LocalDate 由 ConversionService 转,不是由 YAML 解析器转。
6.3 OriginTrackingConstructor:Origin 是怎么记下来的
java
private class OriginTrackingConstructor extends SafeConstructor {
@Override
public @Nullable Object getData() throws NoSuchElementException {
Object data = super.getData();
if (data instanceof CharSequence charSequence && charSequence.isEmpty()) {
return null;
}
return data;
}
@Override
protected Object constructObject(Node node) {
if (node instanceof SequenceNode sequenceNode && sequenceNode.getValue().isEmpty()) {
return constructTrackedObject(node, "");
}
if (node instanceof ScalarNode) {
if (!(node instanceof KeyScalarNode)) {
return constructTrackedObject(node, super.constructObject(node));
}
}
if (node instanceof MappingNode mappingNode) {
replaceMappingNodeKeys(mappingNode);
}
return super.constructObject(node);
}
...
private Object constructTrackedObject(Node node, Object value) {
Origin origin = getOrigin(node);
return OriginTrackedValue.of(getValue(value), origin);
}
private Object getValue(@Nullable Object value) {
return (value != null) ? value : "";
}
private Origin getOrigin(Node node) {
Mark mark = node.getStartMark();
Location location = new Location(mark.getLine(), mark.getColumn());
return new TextResourceOrigin(OriginTrackedYamlLoader.this.resource, location);
}
}
三个 constructObject 分支:
-
空 SequenceNode →
constructTrackedObject(node, ""),即空列表变成空字符串 的 OriginTrackedValue。这样my.list:(空)绑定成List<String>时是空列表,绑定成 String 时是""。 -
ScalarNode 且不是 KeyScalarNode → 包装成
OriginTrackedValue。 -
MappingNode → 把它的 key 节点替换成
KeyScalarNode,然后交给父类。
KeyScalarNode 的作用 :标记"这个 ScalarNode 是 key,不是 value"。这样在 constructObject 里遇到 key 时不会包装成 OriginTrackedValue(key 不需要 origin,它是名字不是值)。
java
private void replaceMappingNodeKeys(MappingNode node) {
List<NodeTuple> value = node.getValue();
List<NodeTuple> newValue = new ArrayList<>(value.size());
for (NodeTuple tuple : value) {
newValue.add(KeyScalarNode.get(tuple));
}
node.setValue(newValue);
}
getValue(value) 把 null 转成 ""------YAML 里 key:(无值)会绑定成空字符串而不是 null。这是一个高频陷阱:
yaml
my:
password:
@Value("${my.password}") 得到 "",不是 null,@ConfigurationProperties 的 String password 也是 ""。
6.4 Properties 多文档:#--- 分隔符(很多人不知道)
这是本章最容易让人意外的发现:.properties 文件也支持多文档。
java
// core/spring-boot/src/main/java/org/springframework/boot/env/PropertiesPropertySourceLoader.java
@Override
public List<PropertySource<?>> load(String name, Resource resource, @Nullable Charset encoding) throws IOException {
List<Map<String, ?>> properties = loadProperties(resource, encoding);
if (properties.isEmpty()) {
return Collections.emptyList();
}
List<PropertySource<?>> propertySources = new ArrayList<>(properties.size());
for (int i = 0; i < properties.size(); i++) {
String documentNumber = (properties.size() != 1) ? " (document #" + i + ")" : "";
propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
Collections.unmodifiableMap(properties.get(i)), true));
}
return propertySources;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private List<Map<String, ?>> loadProperties(Resource resource, @Nullable Charset encoding) throws IOException {
String filename = resource.getFilename();
List<Map<String, ?>> result = new ArrayList<>();
if (filename != null && filename.endsWith(XML_FILE_EXTENSION)) { // ".xml"
if (encoding == null) {
result.add((Map) PropertiesLoaderUtils.loadProperties(resource));
}
else {
result.add((Map) PropertiesLoaderUtils.loadProperties(new EncodedResource(resource, encoding)));
}
}
else {
List<Document> documents = new OriginTrackedPropertiesLoader(resource).load(encoding);
documents.forEach((document) -> result.add(document.asMap()));
}
return result;
}
PropertiesPropertySourceLoader.getFileExtensions() 返回 {"properties", "xml"} ------.xml 也支持(Java 的 XML properties 格式),但 XML 分支只有一个文档 (result.add(...) 单次调用),且不追踪 Origin (用 PropertiesLoaderUtils.loadProperties 转成裸 Map)。
6.5 OriginTrackedPropertiesLoader:手写字符级解析器
OriginTrackedPropertiesLoader 没有用 java.util.Properties,而是自己写了字符级解析。原因是要记录 Origin(行号列号)和支持多文档与列表展开。
主循环:
java
List<Document> load(@Nullable Charset encoding, boolean expandLists) throws IOException {
List<Document> documents = new ArrayList<>();
Document document = new Document();
StringBuilder buffer = new StringBuilder();
try (CharacterReader reader = new CharacterReader(this.resource, encoding)) {
while (reader.read()) {
if (reader.isCommentPrefixCharacter()) {
char commentPrefixCharacter = reader.getCharacter();
if (isNewDocument(reader)) {
if (!document.isEmpty()) {
documents.add(document);
}
document = new Document();
}
else {
if (document.isEmpty() && !documents.isEmpty()) {
document = documents.remove(documents.size() - 1); // ← 回溯!
}
reader.setLastLineCommentPrefixCharacter(commentPrefixCharacter);
reader.skipComment();
}
}
else {
reader.setLastLineCommentPrefixCharacter(-1);
loadKeyAndValue(expandLists, document, reader, buffer);
}
}
}
if (!document.isEmpty() && !documents.contains(document)) {
documents.add(document);
}
return documents;
}
三个值得深挖的设计:
(1) isNewDocument 的严格判定
java
private boolean isNewDocument(CharacterReader reader) throws IOException {
if (reader.isSameLastLineCommentPrefix()) {
return false;
}
boolean result = reader.getLocation().getColumn() == 0;
result = result && readAndExpect(reader, reader::isHyphenCharacter);
result = result && readAndExpect(reader, reader::isHyphenCharacter);
result = result && readAndExpect(reader, reader::isHyphenCharacter);
if (!reader.isEndOfLine()) {
reader.read();
reader.skipWhitespace();
}
return result && reader.isEndOfLine();
}
必须同时满足:
- 注释符在列 0(行首,无缩进)
- 后面紧跟三个
- - 三个
-之后,跳过空白就是行尾(不能有其他字符) - 注释符不能与上一行的注释符相同 (
isSameLastLineCommentPrefix)
所以:
| 写法 | 是否新文档 | 原因 |
|---|---|---|
#--- |
✅ | 列 0 + 三个 - + 行尾 |
!--- |
✅ | ! 也是注释符 |
# --- |
❌ | 第二个字符是空格,不是 - |
#--- |
❌ | 列不是 0(有缩进) |
#---- |
✅(前三 - 匹配后仍有 -) |
❌ ------ 三 - 后是 -,不是行尾 |
#--- comment |
❌ | 三 - 后有非空白内容 |
##--- |
❌ | 第二个字符是 # 不是 - |
isSameLastLineCommentPrefix 这一条很微妙 :它要求"本行的注释前缀字符 != 上一行记录的注释前缀字符"。这是为了防止把连续两行 # 注释误判成文档分隔。举例:
properties
#---
a=1
#---
b=2
第一行 #---:上一行没有注释前缀(初始 -1)→ 不同 → 判定 → 是新文档。 第三行 #---:上一行(#--- 那行)的前缀是 #,当前也是 # → 相同 → 返回 false!
等等,这样的话第三个 #--- 就判不出来了?让我再仔细看......
reader.setLastLineCommentPrefixCharacter(commentPrefixCharacter) 只在非新文档 的注释分支里调用。而在 else 分支(普通行)里调用 reader.setLastLineCommentPrefixCharacter(-1)。
所以:
- 第 1 行
#---→ 是新文档 → 不设 lastLineCommentPrefix(走document = new Document()分支) - 第 2 行
a=1→ 普通行 →setLastLineCommentPrefixCharacter(-1) - 第 3 行
#---→isSameLastLineCommentPrefix()比较lastLine(-1)与'#'→ 不同 → 继续判定 → 是新文档 ✓
那么什么时候会命中 isSameLastLineCommentPrefix?当上一行也是注释行(非分隔行)时:
properties
# comment
#---
第 1 行 # comment → 非新文档分支 → setLastLineCommentPrefixCharacter('#') 第 2 行 #--- → isSameLastLineCommentPrefix():'#' == '#' → true → 返回 false,不算新文档!
这是一个真实的反直觉行为 :注释行紧邻的 #--- 不会被识别为文档分隔符。
我明确标注:这是我对源码逻辑的推演,未在 Boot 测试中找到直接用例验证。 读者可用下例自测:
properties
# comment
#---
a=1
#---
b=2
按推演,这会被解析成一个文档 (第一条 #--- 因紧邻注释而失效,第二条生效)。若实际行为不同,说明我对 isSameLastLineCommentPrefix 的调用时机判断有误------也欢迎据此修正本文。
(2) 注释归属的回溯:documents.remove(documents.size() - 1)
java
if (document.isEmpty() && !documents.isEmpty()) {
document = documents.remove(documents.size() - 1);
}
语义 :如果当前文档还是空的(说明刚开始一个新文档),并且前面已经有文档了,那么把上一个文档拿回来继续用。
这是为了解决:
properties
a=1
#---
# 这是注释
b=2
a=1→ 文档 #0#---→ 判定为新文档 → 文档 #0 非空 →documents.add(文档#0)→document = new Document()# 这是注释→ 非新文档分支(不是---)→document.isEmpty()且documents非空 → 把文档 #0 拿回来 → 继续用文档 #0b=2→ 写入文档 #0
结果:a=1 和 b=2 在同一个文档里。
意图 :让"紧跟在分隔符后面的注释"归属到分隔符之前的那个文档。这符合直觉------注释是解释上面内容的。
但这带来一个陷阱:
properties
#---
# 注释
a=1
- 第 1 行
#---→ 新文档 →document(初始的空文档)为空 → 不 add →document = new Document() - 第 2 行
# 注释→document.isEmpty()且documents.isEmpty()(因为第一个空文档没 add)→ 不回溯 → 设 lastLine 前缀,skipComment - 第 3 行
a=1→ 写入当前 document
结果:一个文档,a=1。合理 ✓
(3) 列表展开:name[]=a,b,c
java
private void loadKeyAndValue(boolean expandLists, Document document, CharacterReader reader, StringBuilder buffer)
throws IOException {
String key = loadKey(buffer, reader).trim();
if (expandLists && key.endsWith("[]")) {
key = key.substring(0, key.length() - 2);
int index = 0;
do {
OriginTrackedValue value = loadValue(buffer, reader, true);
document.put(key + "[" + (index++) + "]", value);
if (!reader.isEndOfLine()) {
reader.read();
}
}
while (!reader.isEndOfLine());
}
else {
OriginTrackedValue value = loadValue(buffer, reader, false);
document.put(key, value);
}
}
my.list[]=a,b,c 展开成:
css
my.list[0]=a
my.list[1]=b
my.list[2]=c
这是 .properties 独有的语法糖,YAML 不需要(YAML 有原生列表)。
注意 expandLists 参数:load(encoding) 默认 true,load(encoding, boolean) 可关。PropertiesPropertySourceLoader 调用的是 load(encoding)(两参版本 → load(encoding, true)),所以默认开启。
loadValue 的 splitLists 参数 :为 true 时遇到 , 就停止读取(作为分隔符)。转义的 \, 不算(isListDelimiter 检查 !this.escaped)。
6.6 CharacterReader:转义与编码
java
private static final String[] ESCAPES = { "trnf", "\t\r\n\f" };
CharacterReader(Resource resource, @Nullable Charset encoding) throws IOException {
this.reader = new LineNumberReader(new InputStreamReader(resource.getInputStream(),
(encoding != null) ? encoding : StandardCharsets.ISO_8859_1));
}
默认编码是 ISO-8859-1 (不是 UTF-8)。这是 Java .properties 规范的历史遗留。所以:
- 中文必须写
\uXXXX转义,或者用[UTF-8]FileHint 指定编码(见 8.3)。 - 注意 Spring Boot 2.x 之后很多人以为 properties 默认 UTF-8 了------Boot 的
OriginTrackedPropertiesLoader仍然是 ISO-8859-1。
转义处理 :ESCAPES = { "trnf", "\t\r\n\f" }------两个字符串按索引对应:t→\t,r→\r,n→\n,f→\f。
java
private void readEscaped() throws IOException {
this.character = this.reader.read();
int escapeIndex = ESCAPES[0].indexOf(this.character);
if (escapeIndex != -1) {
this.character = ESCAPES[1].charAt(escapeIndex);
}
else if (this.character == '\n') {
this.columnNumber = -1;
read(); // 续行符:反斜杠+换行 → 继续读下一行
}
else if (this.character == 'u') {
readUnicode();
}
}
\t/\r/\n/\f→ 对应控制字符\+ 换行 → 续行(多行值)\uXXXX→ Unicode 转义,4 位十六进制
\u 解析的严格性:
java
private void readUnicode() throws IOException {
this.character = 0;
for (int i = 0; i < 4; i++) {
int digit = this.reader.read();
if (digit >= '0' && digit <= '9') { ... }
else if (digit >= 'a' && digit <= 'f') { ... }
else if (digit >= 'A' && digit <= 'F') { ... }
else {
throw new IllegalStateException("Malformed \\uxxxx encoding.");
}
}
}
不足 4 位 → IllegalStateException("Malformed \\uxxxx encoding.")。这个错误消息在排查"中文乱码/启动失败"时经常出现。
6.7 Document 的空 key 保护
java
static class Document {
private final Map<String, OriginTrackedValue> values = new LinkedHashMap<>();
void put(String key, OriginTrackedValue value) {
if (!key.isEmpty()) {
this.values.put(key, value);
}
}
boolean isEmpty() {
return this.values.isEmpty();
}
Map<String, OriginTrackedValue> asMap() {
return this.values;
}
}
空 key 被丢弃 。=value(没有 key)这样的行不会产生任何属性。
LinkedHashMap 保证插入顺序------这影响 getPropertyNames() 的顺序,进而影响 /actuator/env 的展示顺序(不影响优先级)。
6.8 多文档与 profile 的组合矩阵
把 6.1--6.7 与第 5 节合起来,得到"同一个文件内做条件化"的完整能力矩阵:
| 需求 | YAML 做法 | Properties 做法 |
|---|---|---|
| 分成 N 段 | --- |
#--- |
| 条件化第 k 段 | 第 k 段内写 spring.config.activate.on-profile |
同 |
| 按云平台条件化 | spring.config.activate.on-cloud-platform |
同 |
| 引用其它段的值做占位符 | ${...}(全局解析) |
同 |
| 后续段 import 其它文件 | 第 k 段内写 spring.config.import |
同 |
YAML 更常见是因为 --- 更醒目且不需要列 0 + 恰好三 - 的严格规则 。但 .properties 的多文档能力是真实存在且被 CI 覆盖的(Boot 有大量 .properties 多文档测试)。
7. profile-specific 文件:application-{profile}.yaml
7.1 解析入口
java
// StandardConfigDataLocationResolver
@Override
public List<StandardConfigDataResource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
ConfigDataLocation location, Profiles profiles) {
return resolve(getProfileSpecificReferences(context, location.split(), profiles));
}
private Set<StandardConfigDataReference> getProfileSpecificReferences(ConfigDataLocationResolverContext context,
ConfigDataLocation[] configDataLocations, Profiles profiles) {
Set<StandardConfigDataReference> references = new LinkedHashSet<>();
for (String profile : profiles) { // ← 外层是 profile
for (ConfigDataLocation configDataLocation : configDataLocations) { // ← 内层是 location
String resourceLocation = getResourceLocation(context, configDataLocation);
references.addAll(getReferences(configDataLocation, resourceLocation, profile));
}
}
return references;
}
循环嵌套顺序是"外层 profile,内层 location" ------这与非 profile-specific 的 getReferences(context, locations)(只有 location 一层循环)不同。
这带来一个优先级推论:
ini
locations = [L1, L2]
profiles = [p1, p2]
产生的 reference 顺序(LinkedHashSet 保序):
L1-p1, L2-p1, L1-p2, L2-p2
即按 profile 分组,而不是按 location 分组。
而 ConfigDataLocationResolvers.resolve 的合并:
java
private List<ConfigDataResolutionResult> resolve(ConfigDataLocationResolver<?> resolver,
ConfigDataLocationResolverContext context, ConfigDataLocation location, @Nullable Profiles profiles) {
List<ConfigDataResolutionResult> resolved = resolve(location, false, () -> resolver.resolve(context, location));
if (profiles == null) {
return resolved;
}
List<ConfigDataResolutionResult> profileSpecific = resolve(location, true,
() -> resolver.resolveProfileSpecific(context, location, profiles));
return merge(resolved, profileSpecific); // 非 profile-specific 在前,profile-specific 在后
}
merge(list1, list2) 就是简单拼接。所以profile-specific 资源排在普通资源之后 ------即"优先级更高"(因为 ConfigData.getPropertySources() 是升序、后者胜)。
7.2 Profiles 迭代的是什么
getProfileSpecificReferences 里 for (String profile : profiles) 用的是 Profiles.iterator() → getAccepted()。
所以 profile-specific 文件用的是 accepted 集合(active 非空则用 active,否则用 default)。
推论:
- 没有 active profile 时,
application-default.yaml会 被加载(因为 default 列表是["default"])。 - 激活
dev后,application-default.yaml不会被加载。
7.3 PROFILE_SPECIFIC 选项与"后置"语义
java
// StandardConfigDataLoader
private static final PropertySourceOptions PROFILE_SPECIFIC =
PropertySourceOptions.always(Option.PROFILE_SPECIFIC);
private static final PropertySourceOptions NON_PROFILE_SPECIFIC = PropertySourceOptions.ALWAYS_NONE;
@Override
public ConfigData load(ConfigDataLoaderContext context, StandardConfigDataResource resource)
throws IOException, ConfigDataNotFoundException {
if (resource.isEmptyDirectory()) {
return ConfigData.EMPTY;
}
ConfigDataResourceNotFoundException.throwIfDoesNotExist(resource, resource.getResource());
StandardConfigDataReference reference = resource.getReference();
Resource originTrackedResource = OriginTrackedResource.of(resource.getResource(),
Origin.from(reference.getConfigDataLocation()));
String name = String.format("Config resource '%s' via location '%s'", resource,
reference.getConfigDataLocation());
List<PropertySource<?>> propertySources = reference.getPropertySourceLoader()
.load(name, originTrackedResource, reference.getEncoding());
PropertySourceOptions options = (resource.getProfile() != null) ? PROFILE_SPECIFIC : NON_PROFILE_SPECIFIC;
return new ConfigData(propertySources, options);
}
resource.getProfile() != null → 带 PROFILE_SPECIFIC 标记。
这个标记的作用 在 ConfigDataEnvironmentContributor.withChildren 里:
java
ConfigDataEnvironmentContributor withChildren(ImportPhase importPhase,
List<ConfigDataEnvironmentContributor> children) {
Map<ImportPhase, List<ConfigDataEnvironmentContributor>> updatedChildren = new LinkedHashMap<>(this.children);
updatedChildren.put(importPhase, children);
if (importPhase == ImportPhase.AFTER_PROFILE_ACTIVATION) {
moveProfileSpecific(updatedChildren); // ← 只在 profile 激活后的阶段做
}
return new ConfigDataEnvironmentContributor(...);
}
java
private void moveProfileSpecific(Map<ImportPhase, List<ConfigDataEnvironmentContributor>> children) {
List<ConfigDataEnvironmentContributor> before = children.get(ImportPhase.BEFORE_PROFILE_ACTIVATION);
if (!hasAnyProfileSpecificChildren(before)) {
return;
}
List<ConfigDataEnvironmentContributor> updatedBefore = new ArrayList<>(before.size());
List<ConfigDataEnvironmentContributor> updatedAfter = new ArrayList<>();
for (ConfigDataEnvironmentContributor contributor : before) {
updatedBefore.add(moveProfileSpecificChildren(contributor, updatedAfter));
}
updatedAfter.addAll(children.getOrDefault(ImportPhase.AFTER_PROFILE_ACTIVATION, Collections.emptyList()));
children.put(ImportPhase.BEFORE_PROFILE_ACTIVATION, updatedBefore);
children.put(ImportPhase.AFTER_PROFILE_ACTIVATION, updatedAfter);
}
语义 :在 AFTER_PROFILE_ACTIVATION 阶段,把 BEFORE_PROFILE_ACTIVATION 阶段的 children 中带 PROFILE_SPECIFIC 标记的,全部搬移到 AFTER_PROFILE_ACTIVATION 阶段的 children 列表的开头。
为什么要搬?看 ContributorIterator 的遍历顺序:
java
private ContributorIterator() {
this.phase = ImportPhase.AFTER_PROFILE_ACTIVATION; // ← 先遍历 AFTER
this.children = getChildren(this.phase).iterator();
this.current = Collections.emptyIterator();
}
迭代器先遍历 AFTER_PROFILE_ACTIVATION 的子节点,再遍历 BEFORE_PROFILE_ACTIVATION 的,最后是自己。
而 applyContributor 用 addLast 顺序写入 Environment:
java
for (ConfigDataEnvironmentContributor contributor : contributors) {
PropertySource<?> propertySource = contributor.getPropertySource();
if (contributor.getKind() == Kind.BOUND_IMPORT && propertySource != null) {
if (!contributor.isActive(activationContext)) {
this.logger.trace(...);
}
else {
propertySources.addLast(propertySource);
...
}
}
}
addLast + 遍历顺序 → 先遍历到的排在 Environment 前面(低优先级),后遍历到的排在后面(高优先级)。
所以:
AFTER_PROFILE_ACTIVATION的 children 先被遍历 → 先addLast→ 低优先级BEFORE_PROFILE_ACTIVATION的 children 后被遍历 → 高优先级
这正好是反的! profile-specific 文件本该优先级更高。
moveProfileSpecific 就是来修正这个的:把 PROFILE_SPECIFIC 的 contributor 从 BEFORE 列表搬到 AFTER 列表的开头 ------这样它们变成 AFTER 列表里最先被遍历的,也就是整体最低优先级?
不对,让我重新推。
moveProfileSpecific:
java
updatedAfter.addAll(children.getOrDefault(AFTER, [])); // 把原 AFTER 的内容追加到 updatedAfter 尾部
children.put(AFTER, updatedAfter); // updatedAfter = [搬来的 PROFILE_SPECIFIC..., 原 AFTER...]
所以新的 AFTER 列表 = [被搬来的 profile-specific 们, 原来的 AFTER 内容]。
迭代器先遍历 AFTER → 顺序是 [profile-specific..., 原 AFTER 内容],然后遍历 BEFORE(已移除 profile-specific)→ [剩余 BEFORE],最后是 root 自己。
addLast 顺序 = 遍历顺序。Environment 里 addLast 越晚 = 优先级越高。
所以优先级从低到高:
sql
profile-specific(最低) → 原 AFTER 内容 → 剩余 BEFORE → root(最高)
这与"profile-specific 文件应该优先级更高"完全相反!
这里我必须停下来做一个严谨声明。
我在第 07 章做过类似的推导并一度陷入死锁,最终靠 ConfigData.getPropertySources() 的 Javadoc 解开。这次的情况不同:我手上有完整的 moveProfileSpecific 代码和 ContributorIterator 代码,但推导出的结论与官方文档"profile-specific 文件覆盖非 profile-specific 文件"相矛盾。
可能的解释(均为推测,未核实):
-
我对
ContributorIterator的方向理解反了 。fetchIfNecessary()里:javaif (this.phase == ImportPhase.AFTER_PROFILE_ACTIVATION) { this.phase = ImportPhase.BEFORE_PROFILE_ACTIVATION; this.children = getChildren(this.phase).iterator(); return fetchIfNecessary(); } if (this.phase == ImportPhase.BEFORE_PROFILE_ACTIVATION) { this.phase = null; this.next = ConfigDataEnvironmentContributor.this; // 最后是 self return this.next; }顺序确实是 AFTER → BEFORE → self。这一点我有把握。
-
applyContributor的addLast语义 。Environment 的MutablePropertySources.addLast是加到列表尾部,而PropertySources的查找是"从前往后找到第一个就返回"。所以越靠前优先级越高,不是越靠后!这才是关键。我在上面写反了。
Spring 的
MutablePropertySources内部是List<PropertySource<?>>,getProperty(name)遍历 list 返回第一个非 null。addLast= 加到尾部 = 最低优先级 。addFirst= 最高优先级。所以:先遍历到的先
addLast→ 排在前面 → 优先级更高。重推:
AFTERchildren 先遍历、先addLast→ 排在 Environment 前面 → 高优先级BEFOREchildren 后遍历 → 低优先级
✓ 这与"AFTER_PROFILE_ACTIVATION 阶段导入的东西优先级更高"一致(profile 激活后导入的,通常更具体)。
而
moveProfileSpecific把 profile-specific 从BEFORE搬到AFTER的开头 → 它们在 AFTER 列表里最先被遍历 → 优先级最高 ✓这就对了!
application-{profile}.yaml覆盖application.yaml✓
修正后的完整优先级链(从高到低):
sql
AFTER_PROFILE_ACTIVATION children(其中 profile-specific 排在最前)
↓
BEFORE_PROFILE_ACTIVATION children(已移除 profile-specific)
↓
root self
教训:这是本章第二次踩"顺序方向"的坑。把结论记牢:
MutablePropertySources:addFirst= 高优先级,addLast= 低优先级。ConfigData.getPropertySources():升序,后者胜(所以列表尾部 = 高优先级)。 这两个"尾"的含义相反 :Environment 的尾部是低优先级,ConfigData 列表的尾部是高优先级。 之所以不矛盾,是因为applyContributor遍历 contributor 树时,addLast的顺序 = 优先级降序。
我在这里把推导过程完整保留,是因为这个坑太容易踩,而且第 07 章的第五定律(逆序插入)在这里有第二个应用实例。
7.4 fromProfileSpecificImport 与禁写规则
java
static void throwIfPropertyFound(ConfigDataEnvironmentContributor contributor) {
ConfigurationPropertySource propertySource = contributor.getConfigurationPropertySource();
if (propertySource != null) {
ERRORS.forEach((name, replacement) -> {
ConfigurationProperty property = propertySource.getConfigurationProperty(name);
if (property != null) {
throw new InvalidConfigDataPropertyException(property, false, replacement, contributor.getResource());
}
});
if (contributor.isFromProfileSpecificImport()
&& !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
PROFILE_SPECIFIC_ERRORS.forEach((name) -> {
ConfigurationProperty property = propertySource.getConfigurationProperty(name);
if (property != null) {
throw new InvalidConfigDataPropertyException(property, true, null, contributor.getResource());
}
});
}
}
}
两张错误表:
表 1(ERRORS)------ 永远禁止,并给出替代写法:
| 禁止的属性 | 替代为 |
|---|---|
spring.profiles |
spring.config.activate.on-profile |
spring.profiles[0] |
spring.config.activate.on-profile |
即:spring.profiles 这个"裸"属性名是被禁止的 (它是 2.4 之前的 spring.profiles 语义残留),必须写成 spring.config.activate.on-profile。
表 2(PROFILE_SPECIFIC_ERRORS)------ profile-specific 文件中禁止:
java
errors.add(Profiles.INCLUDE_PROFILES); // spring.profiles.include
errors.add(Profiles.INCLUDE_PROFILES.append("[0]")); // spring.profiles.include[0]
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME)); // spring.profiles.active
errors.add(ConfigurationPropertyName.of(... + "[0]")); // spring.profiles.active[0]
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME)); // spring.profiles.default
errors.add(ConfigurationPropertyName.of(... + "[0]")); // spring.profiles.default[0]
application-prod.yaml 里不能写 spring.profiles.active------这就是第 4.5 节说的"用静态禁止替代动态收敛判定"。
错误消息:
vbnet
Property 'spring.profiles.active' imported from location '...' is invalid in a profile specific resource [origin: ...]
IGNORE_PROFILES 是豁免位:带这个选项的 contributor 即使来自 profile-specific import,也不做这套检查。
7.5 isSkippable:为什么找不到 application-prod.yaml 不报错
java
// StandardConfigDataReference
boolean isSkippable() {
return this.configDataLocation.isOptional() || this.directory != null || this.profile != null;
}
带 profile 的 reference 天然可跳过 。所以 application-prod.yaml 不存在时静默跳过,不报错------不需要写 optional:。
这是"profile-specific 文件是可选的"这一直觉的源码依据。
对比 getReferencesForFile 对于不带 profile 且不是目录的 location:
java
if (configDataLocation.isOptional()) {
return Collections.emptySet();
}
// 否则抛异常
所以 spring.config.import=file:/etc/nope.yaml(不带 optional)会报错。
8. spring.config.* 位置属性与 profile 的交互
8.1 四个位置属性的注册顺序
java
// ConfigDataEnvironment
static final String LOCATION_PROPERTY = "spring.config.location";
static final String ADDITIONAL_LOCATION_PROPERTY = "spring.config.additional-location";
static final String IMPORT_PROPERTY = "spring.config.import";
static final String ON_NOT_FOUND_PROPERTY = "spring.config.on-not-found";
static final ConfigDataLocation[] DEFAULT_SEARCH_LOCATIONS;
static {
List<ConfigDataLocation> locations = new ArrayList<>();
locations.add(ConfigDataLocation.of("optional:classpath:/;optional:classpath:/config/"));
locations.add(ConfigDataLocation.of("optional:file:./;optional:file:./config/;optional:file:./config/*/"));
DEFAULT_SEARCH_LOCATIONS = locations.toArray(new ConfigDataLocation[0]);
}
private List<ConfigDataEnvironmentContributor> getInitialImportContributors(Binder binder) {
List<ConfigDataEnvironmentContributor> initialContributors = new ArrayList<>();
addInitialImportContributors(initialContributors, binder, IMPORT_PROPERTY, EMPTY_LOCATIONS, false);
addInitialImportContributors(initialContributors, binder, ADDITIONAL_LOCATION_PROPERTY, EMPTY_LOCATIONS, true);
addInitialImportContributors(initialContributors, binder, LOCATION_PROPERTY, DEFAULT_SEARCH_LOCATIONS, true);
return initialContributors;
}
private void addInitialImportContributors(List<ConfigDataEnvironmentContributor> initialContributors, Binder binder,
String propertyName, ConfigDataLocation[] defaultValue, boolean registerIndividually) {
ConfigDataLocation[] locations = binder.bind(propertyName, CONFIG_DATA_LOCATION_ARRAY).orElse(defaultValue);
if (registerIndividually) {
for (int i = locations.length - 1; i >= 0; i--) { // ← 逆序!
addInitialImportContributors(initialContributors, List.of(locations[i]));
}
}
else {
addInitialImportContributors(initialContributors, List.of(locations));
}
}
关键差异 :registerIndividually 参数。
| 属性 | registerIndividually |
含义 |
|---|---|---|
spring.config.import |
false |
所有 import 位置打包成一个 contributor |
spring.config.additional-location |
true |
每个位置单独一个 contributor |
spring.config.location |
true |
每个位置单独一个 contributor |
为什么 import 不拆分? 因为 spring.config.import 的语义是"在当前位置导入",它们属于同一个"导入批次",应该作为一个整体参与 contributor 树。而 location / additional-location 是"独立的搜索位置",每个都要形成自己的子树。
for (int i = locations.length - 1; i >= 0; i--) ------第五定律的又一例:逆序注册,使得"数组中靠后的位置"在 contributor 列表中靠前,进而在 Environment 中"先 addLast"→ 高优先级。
所以:
javascript
spring.config.location=classpath:/a/,classpath:/b/,classpath:/c/
逆序注册 → contributor 顺序 [c, b, a] → Environment 顺序 [c, b, a](c 在前)→ c 优先级最低?
等等,又要小心方向。根据 7.3 节的修正:先遍历到的先 addLast → 排在 Environment 前面 → 优先级更高。
- 逆序循环:先加
c的 contributor,再加b,再加a。 - contributor 列表 =
[c, b, a] - 遍历 → 先
c先addLast→ Environment =[c, b, a] - Environment 从前向后查找 →
c最高优先级
✓ 符合直觉:spring.config.location 里后面的位置优先级更高。这也与官方文档一致("locations are searched in order, with later locations taking precedence")。
这正是"逆序注册"的目的------把"数组顺序"翻译成"优先级顺序"。
8.2 DEFAULT_SEARCH_LOCATIONS 的两组结构
java
locations.add(ConfigDataLocation.of("optional:classpath:/;optional:classpath:/config/"));
locations.add(ConfigDataLocation.of("optional:file:./;optional:file:./config/;optional:file:./config/*/"));
两个元素,每个元素内部用 ; 分隔多个位置。
- 元素 0(classpath 组):
optional:classpath:/、optional:classpath:/config/ - 元素 1(file 组):
optional:file:./、optional:file:./config/、optional:file:./config/*/
每个元素会作为一个 ConfigDataLocation 参与 split()(在 StandardConfigDataLocationResolver.resolve 里 location.split())。
按 8.1 的推导,元素 1(file 组)优先级高于元素 0(classpath 组)→ classpath 外/当前目录的配置覆盖 jar 包内的配置 ✓
组内:逆序 → ./config/*/ 最高,然后 ./config/,然后 ./;classpath:/config/ 高于 classpath:/ ✓
完整默认优先级(从低到高):
bash
classpath:/application.{yml,properties}
classpath:/config/application.{yml,properties}
file:./application.{yml,properties}
file:./config/application.{yml,properties}
file:./config/*/application.{yml,properties}
这与官方文档的"配置位置优先级"表一致 ✓
optional: 前缀 :DEFAULT_SEARCH_LOCATIONS 里每个都带 optional:。所以默认的搜索位置全部是"找不到就跳过"。
./config/*/ :这是一个通配符目录 。StandardConfigDataLocationResolver.resolvePattern 会被调用(this.resourceLoader.isPattern(...))。它的用途是"子模块/子目录各自放一份 config"的场景(Boot 2.4 引入)。
8.3 optional: 前缀与 FileHint
java
// ConfigDataLocation
public static final String OPTIONAL_PREFIX = "optional:";
public static ConfigDataLocation of(@Nullable String location) {
boolean optional = location != null && location.startsWith(OPTIONAL_PREFIX);
String value = (location != null && optional) ? location.substring(OPTIONAL_PREFIX.length()) : location;
return (StringUtils.hasText(value)) ? new ConfigDataLocation(optional, value, null) : EMPTY;
}
optional: 只在最前面 识别一次。所以 optional:optional:file:./x.yaml 的 value 是 optional:file:./x.yaml(第二个 optional: 不再被当作前缀)。
FileHint:方括号语法,给 location 附加属性:
java
static FileHint from(String value) {
if (!hasBrackets(value)) {
return NONE;
}
List<String> betweenBrackets = findBetweenBrackets(value);
Map<String, String> attributes = new HashMap<>();
for (String entry : betweenBrackets) {
String cleaned = entry.trim();
if (cleaned.isEmpty()) {
continue;
}
int equals = cleaned.indexOf('=');
if (equals == -1) {
attributes.put("extension", cleaned); // 没有 = → 当作 extension
}
else {
attributes.put(cleaned.substring(0, equals).trim(), cleaned.substring(equals + 1).trim());
}
}
return new FileHint(Collections.unmodifiableMap(attributes));
}
两个属性:extension 和 encoding。
用法:
arduino
spring.config.import=file:/etc/conf/extra[.yaml]
spring.config.import=file:/etc/conf/cn[UTF-8]
spring.config.import=file:/etc/conf/extra[.yaml][UTF-8]
[.yaml]→extension = ".yaml"[UTF-8]→encoding = "UTF-8"- 无
=的项 → 当 extension(必须以.开头 ,否则getExtension()返回 null)
java
@Nullable String getExtension() {
String extension = this.attributes.get("extension");
if (extension == null) {
return null;
}
if (!extension.startsWith(".")) {
return null;
}
return extension;
}
[yaml](不带点)是无效的。
removeFrom 把所有方括号内容从路径里剥掉:
java
static String removeFrom(String value) {
if (!hasBrackets(value)) {
return value;
}
StringBuilder result = new StringBuilder();
int brackets = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '[') { brackets++; continue; }
if (c == ']') { brackets--; continue; }
if (brackets <= 0) {
result.append(c);
}
}
return result.toString();
}
嵌套括号支持 :brackets 计数,brackets > 0 时不输出。findBetweenBrackets 也支持嵌套(brackets > 0 时把 [/] 当普通字符)。
典型用途:非标准扩展名的配置文件
arduino
spring.config.import=file:/etc/app/config[.properties]
这里 /etc/app/config 这个文件本身没有扩展名,用 [.properties] 告诉 Boot 用 properties loader 解析它。
典型用途 2:指定 properties 编码(中文)
arduino
spring.config.import=file:./conf/cn[UTF-8].properties
8.4 spring.config.name 与多配置名
java
private static final String CONFIG_NAME_PROPERTY = "spring.config.name";
static final String[] DEFAULT_CONFIG_NAMES = { "application" };
private String[] getConfigNames(Binder binder) {
String[] configNames = binder.bind(CONFIG_NAME_PROPERTY, String[].class).orElse(DEFAULT_CONFIG_NAMES);
for (String configName : configNames) {
validateConfigName(configName);
}
return configNames;
}
private void validateConfigName(String name) {
Assert.state(!name.contains("*"), () -> "Config name '" + name + "' cannot contain '*'");
}
spring.config.name 是 String[],可以指定多个名字。
getReferencesForDirectory 遍历所有 configNames:
java
private Set<StandardConfigDataReference> getReferencesForDirectory(ConfigDataLocation configDataLocation,
String directory, @Nullable String profile) {
Set<StandardConfigDataReference> references = new LinkedHashSet<>();
for (String name : this.configNames) {
Deque<StandardConfigDataReference> referencesForName = getReferencesForConfigName(name, configDataLocation,
directory, profile);
references.addAll(referencesForName);
}
return references;
}
注意这里是正序 (不是逆序),且 configNames 的循环在外层,扩展名循环在内层。
getReferencesForConfigName 里对扩展名用了 addFirst:
java
private Deque<StandardConfigDataReference> getReferencesForConfigName(String name,
ConfigDataLocation configDataLocation, String directory, @Nullable String profile) {
Deque<StandardConfigDataReference> references = new ArrayDeque<>();
for (PropertySourceLoader propertySourceLoader : this.propertySourceLoaders) {
for (String extension : propertySourceLoader.getFileExtensions()) {
StandardConfigDataReference reference = new StandardConfigDataReference(configDataLocation, directory,
directory + name, profile, extension, propertySourceLoader, null);
if (!references.contains(reference)) {
references.addFirst(reference);
}
}
}
return references;
}
Deque + addFirst = 逆序堆积。
propertySourceLoaders 来自 SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, ...),顺序由 META-INF/spring.factories 决定。Boot 的 spring-boot/src/main/resources/META-INF/spring.factories 里注册了 PropertiesPropertySourceLoader 和 YamlPropertySourceLoader(具体顺序我未在本次阅读中核实)。
若顺序是 [Properties(properties, xml), Yaml(yml, yaml)],则遍历产生:
matlab
properties, xml, yml, yaml
addFirst 后 deque 从顶到底是:yaml, yml, xml, properties。
references.addAll(referencesForName) 按 deque 迭代顺序(从顶到底)→ [yaml, yml, xml, properties]。
再结合 7.3 的"先遍历 = 高优先级"...... 这里 references 是"资源集合",它们被 resolve 成 resource 后进入 ConfigData 的 propertySources 列表,而 ConfigData.getPropertySources() 是升序、后者胜。
所以 properties 在最后 → .properties 优先级高于 .yaml ✓
这与官方文档一致 :同名文件,application.properties 覆盖 application.yaml(Boot 2.4+ 的行为;2.4 之前相反)。
我在此标注:propertySourceLoaders 的实际顺序我未核实 ,但".properties 覆盖 .yaml"这一结论与官方文档一致,可反推 loader 顺序使 deque 迭代顺序为 [yaml, yml, xml, properties]。
8.5 spring.config.on-not-found
java
this.notFoundAction = binder.bind(ON_NOT_FOUND_PROPERTY, ConfigDataNotFoundAction.class)
.orElse(ConfigDataNotFoundAction.FAIL);
ConfigDataNotFoundAction 枚举(源码未在本章读取,但语义可从使用处推知):FAIL / IGNORE。
作用点在 ConfigDataImporter.getNotFoundAction:
java
private ConfigDataNotFoundAction getNotFoundAction(ConfigDataLocation location,
@Nullable ConfigDataResource resource) {
if (location.isOptional() || (resource != null && resource.isOptional())) {
return ConfigDataNotFoundAction.IGNORE;
}
return this.notFoundAction;
}
optional: 前缀优先于 on-not-found 。所以 spring.config.on-not-found=ignore 不会让"非 optional 的 location"变成可选------它只是把默认行为从 FAIL 改成 IGNORE,而 optional 永远 IGNORE。
8.6 checkMandatoryLocations:最后一道防线
java
private void checkMandatoryLocations(ConfigDataEnvironmentContributors contributors,
ConfigDataActivationContext activationContext, Set<ConfigDataLocation> loadedLocations,
Set<ConfigDataLocation> optionalLocations) {
Set<ConfigDataLocation> mandatoryLocations = new LinkedHashSet<>();
for (ConfigDataEnvironmentContributor contributor : contributors) {
if (contributor.isActive(activationContext)) {
mandatoryLocations.addAll(getMandatoryImports(contributor));
}
}
for (ConfigDataEnvironmentContributor contributor : contributors) {
if (contributor.getLocation() != null) {
mandatoryLocations.remove(contributor.getLocation());
}
}
mandatoryLocations.removeAll(loadedLocations);
mandatoryLocations.removeAll(optionalLocations);
if (!mandatoryLocations.isEmpty()) {
for (ConfigDataLocation mandatoryLocation : mandatoryLocations) {
this.notFoundAction.handle(this.logger, new ConfigDataLocationNotFoundException(mandatoryLocation));
}
}
}
四步过滤:
- 收集所有活跃 contributor 的非 optional import
- 移除"已经被某个 contributor 的 location 覆盖的"(即成功加载的)
- 移除
importer.getLoadedLocations() - 移除
importer.getOptionalLocations()
剩下的就是"声明了 mandatory 但没加载上"的位置 → 按 notFoundAction 处理。
注意第 1 步的 contributor.isActive(activationContext) :不活跃的 contributor 声明的 mandatory import 不算数。所以:
yaml
spring.config.activate.on-profile: never-match
spring.config.import: /etc/must-exist.yaml # 非 optional
这个 import 不会导致启动失败------因为文档不活跃。
这是一个非常合理的"连带失效"设计。
9. 反直觉结论(本章十六条)
按"最容易踩坑"排序。
9.1 spring.profiles.default 在有任何 active profile 时完全失效
Profiles.getAccepted() = active 非空 ? active : default。写了 spring.profiles.default: base 又激活了 dev,base 就彻底不生效------不是"优先级低",是"根本不在判定集合里"。
连带影响:spring.config.activate.on-profile: default 也只在"零 active profile"时匹配。
9.2 spring.profiles.include 与 spring.profiles.active 的读取作用域不同
include:每个 contributor 单独绑定 (new Binder(Collections.singleton(source), ...)),不能跨源继承。active:聚合 Binder 绑定,可以来自任意一个活跃源。
副作用:写在文档 #1 的 spring.profiles.include 不会让文档 #0 变成"包含"状态;但写在文档 #1 的 spring.profiles.active 会被全局读到。
9.3 在不活跃的文档里写 spring.profiles.include 会直接报错
不是静默失效,是 InactiveConfigDataAccessException。这是 Boot 少数"主动拦截用户误用"的地方------因为 include 的失效太隐蔽。
9.4 spring.config.activate.on-profile 不会向下传播到后续文档
每个文档必须自己写。判定依据是 withBoundProperties 里的 Collections.singleton(configurationPropertySource)。
9.5 但 ${...} 占位符是全局解析的
同一个 Binder 里,属性值绑定用单源,占位符解析用全 contributors(ConfigDataEnvironmentContributorPlaceholdersResolver(contributors, ...))。
所以"文档 #1 用 ${my.host} 引用文档 #0 的值"是可行的,而"文档 #1 继承文档 #0 的 on-profile"不可行。二者机制不同,不要类比。
9.6 ACTIVE 与 DEFAULT 的合并行为不对称
Type.ACTIVE.mergeWithEnvironmentProfiles = true,Type.DEFAULT = false。编程式设置的 active 会与配置属性合并;编程式设置的 default 不会。
9.7 Profiles.iterator() 迭代的是 accepted 而不是 active
for (String p : profiles) 拿到的可能是 default profiles。StandardConfigDataLocationResolver.getProfileSpecificReferences 用的正是这个迭代器。
9.8 group 展开是深度优先,不是广度优先
expandProfiles 用显式栈 + 两次反转。prod → [prod-db, prod-mq] 且 prod-db → [prod-pool] 时,结果是 [prod, prod-db, prod-pool, prod-mq],prod-mq 排在 prod-pool 后面。
9.9 group 名(key)不被 ProfilesValidator 校验
validate(Map) 分支只校验 value。未激活的 group 名即使非法也不会报错------源码推演,未实测。
9.10 profile-specific 文件天然 optional
StandardConfigDataReference.isSkippable() 里 this.profile != null → 可跳过。application-prod.yaml 不存在不报错,不需要 optional: 前缀。
9.11 profile-specific 文件里禁止写 spring.profiles.*
由 InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS 强制。这不是"风格建议",是硬异常。
这条规则同时是"不动点迭代只需三轮"的收敛性保证------因为禁止在 profile-specific 文件里改 profile,第 5 步之后不需要再算一遍 profile。
9.12 .properties 文件支持多文档(#---)
判定极其严格:注释符在列 0 、紧跟恰好三个 -、之后到行尾只有空白、且上一行不是同类型注释行。
最后一条(isSameLastLineCommentPrefix)是我对源码逻辑的推演,未找到直接测试用例------如果实际行为不符,请以此为准修正。
9.13 .properties 的默认编码是 ISO-8859-1
OriginTrackedPropertiesLoader.CharacterReader 里写死 StandardCharsets.ISO_8859_1。中文必须 \uXXXX 或用 [UTF-8] FileHint。
这不是 Boot 的 bug,是 Java .properties 规范。但很多人误以为 Boot 已经默认 UTF-8。
9.14 YAML 里 key:(空值)绑定成空字符串而不是 null
OriginTrackingConstructor.getValue(null) 返回 ""。所以 @Value("${my.password}") 得到 ""。
9.15 snakeyaml 的 DoS 防护被 Boot 主动关闭
maxAliasesForCollections = Integer.MAX_VALUE、codePointLimit = Integer.MAX_VALUE。配置文件是可信输入所以没问题,但不要用它加载用户上传的 YAML。
9.16 MutablePropertySources.addLast = 低优先级,ConfigData 列表尾部 = 高优先级
两个"尾"的含义相反。之所以不矛盾,是因为 applyContributor 遍历 contributor 树时用 addLast,而遍历顺序本身就是优先级降序。
这是我本章第二次踩的坑(第一次在第 07 章)。记牢判据:
Spring
Environment的MutablePropertySources:前面优先(先找到先返回)。ConfigData.getPropertySources():后面优先(Javadoc 明说 later wins)。
9.17(补)spring.config.on-not-found=ignore 与 optional: 不是一回事
ConfigDataImporter.getNotFoundAction 里 optional: 直接返回 IGNORE,优先级高于 on-not-found 的值。
10. 动手验证清单
10.1 观察 profile 求解全过程
bash
# 1) 打开 ConfigData 全量 TRACE
java -jar app.jar \
--logging.level.org.springframework.boot.context.config=TRACE \
--spring.profiles.active=dev
第四行之后出现的 Adding imported property source '...(document #1)' 就是 profile 激活后才导入的。
10.2 验证"default 被 active 屏蔽"
application.yaml:
yaml
spring:
profiles:
default: base
active: dev
my:
key: value
application-base.yaml:
yaml
my:
key: from-base
启动后 my.key 应为 value(来自 application.yaml),不是 from-base。 去掉 active: dev 后应为 from-base。
10.3 验证多文档优先级与 profile 表达式
| 激活 | 期望值 | 依据 |
|---|---|---|
| 无 | base |
两个表达式都不匹配(profiles 为 default 集合) |
dev |
base |
dev & other 缺 other;`(dev |
dev,other |
dev-and-other |
第一个匹配 |
dev,another |
dev-or-other-and-another |
第二个匹配 |
dev,other,another |
dev-or-other-and-another |
两者都匹配,文档顺序后者胜 |
最后一行是重点:文档顺序决定胜负,与 profile 激活顺序无关。
10.4 验证 spring.profiles.include 的单源绑定
yaml
# 文档 #0
spring:
profiles:
include: metrics
---
# 文档 #1(无 on-profile,永远活跃)
my:
other: x
启动时 active = [metrics](外加任何 spring.profiles.active)。
反例(应报错):
yaml
spring.config.activate.on-profile: never
spring.profiles.include: metrics
应抛 InactiveConfigDataAccessException。
10.5 验证 .properties 多文档
application.properties:
properties
a=1
#---
b=2
/actuator/env 或 TRACE 日志应出现两个 PropertySource:
bash
Config resource '...' via location '...' (document #0)
Config resource '...' via location '...' (document #1)
再验证边界:
properties
a=1
# ---
b=2
# ---(第二字符是空格)不应产生第二个文档。
10.6 验证 group 展开顺序
yaml
spring:
profiles:
group:
prod: prod-db,prod-mq
prod-db: prod-pool
active: prod
打印 environment.getActiveProfiles() → 期望 [prod, prod-db, prod-pool, prod-mq]。
注意 prod-mq 在 prod-pool 之后(深度优先)。
10.7 验证 profile 名校验
10.8 验证云平台强制优先于探测
bash
KUBERNETES_SERVICE_HOST=1.2.3.4 KUBERNETES_SERVICE_PORT=443 \
java -jar app.jar --spring.main.cloud-platform=none
ConfigDataActivationContext.deduceCloudPlatform 先查 isEnforced(binder),none 命中 → 返回 NONE,探测结果被忽略。
配合:
yaml
spring.config.activate.on-cloud-platform: kubernetes
my:
k8s-only: yes
在强制 none 时 my.k8s-only 应不存在。
10.9 验证 mandatory import 的连带失效
yaml
spring.config.activate.on-profile: never-match
spring.config.import: /etc/definitely-not-exist.yaml
不应报错 (contributor 不活跃 → 其 mandatory import 不参与 checkMandatoryLocations)。
对照:去掉 on-profile → 应报 ConfigDataLocationNotFoundException。
10.10 验证默认搜索位置的优先级
在 src/main/resources/application.yaml 写 my.k=classpath-root,在 src/main/resources/config/application.yaml 写 my.k=classpath-config,在 ./config/application.yaml 写 my.k=file-config。期望 my.k=file-config。
11. 本章源码索引
按阅读优先级排列,全部位于 core/spring-boot/src/main/java/org/springframework/boot/。
11.1 核心(必读)
| 文件 | 行数量级 | 本章关注点 |
|---|---|---|
context/config/Profiles.java |
~270 | 全文,尤其是 Type 枚举与 expandProfiles |
context/config/ConfigDataProperties.java |
~150 | Activate 内部类 |
context/config/ConfigDataEnvironmentContributor.java |
~605 | withBoundProperties、isActive、withChildren/moveProfileSpecific、ContributorIterator、Kind |
context/config/ConfigDataEnvironment.java |
~416 | withProfiles、getIncludedProfiles、applyToEnvironment、checkMandatoryLocations |
env/OriginTrackedPropertiesLoader.java |
~353 | load、isNewDocument、CharacterReader |
env/OriginTrackedYamlLoader.java |
~188 | createYaml、OriginTrackingConstructor、NoTimestampResolver |
11.2 支撑
| 文件 | 关注点 |
|---|---|
context/config/ProfilesValidator.java |
ALLOWED_CHARS、首尾规则、get(Binder) |
context/config/InvalidConfigDataPropertyException.java |
ERRORS 与 PROFILE_SPECIFIC_ERRORS 两张表 |
context/config/InactiveConfigDataAccessException.java |
消息构造 |
context/config/ConfigDataEnvironmentContributorPlaceholdersResolver.java |
isActive 对 UNBOUND_IMPORT 的特殊处理 |
context/config/ConfigDataEnvironmentContributors.java |
getBinder、InactiveSourceChecker、asContributors 逆序 |
context/config/StandardConfigDataLocationResolver.java |
getProfileSpecificReferences、getReferencesForConfigName、resolveEmptyDirectories |
context/config/StandardConfigDataLoader.java |
PROFILE_SPECIFIC 选项 |
context/config/StandardConfigDataReference.java |
isSkippable |
context/config/ConfigDataLocation.java |
OPTIONAL_PREFIX、split()、of() |
context/config/FileHint.java |
from、removeFrom、getExtension |
context/config/ConfigDataLocationBindHandler.java |
Origin 回填 |
context/config/ConfigDataImporter.java |
load 逆序、getNotFoundAction |
context/config/ConfigDataLocationResolvers.java |
resolve 的 merge、reorder |
cloud/CloudPlatform.java |
各平台探测条件、isEnforced(Binder) |
env/YamlPropertySourceLoader.java |
document #N 命名 |
env/PropertiesPropertySourceLoader.java |
.xml 分支、document 循环 |
env/ConfigData.java(即 context/config/ConfigData.java) |
Option 枚举、getPropertySources() Javadoc |
11.3 测试(行为规格的权威来源)
| 文件 | 关注点 |
|--------------------------------------------------------------------------|-------------------------------------------------------|----------------------|
| context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java | profile 表达式(&、` | 、!`、括号)、多文档顺序、云平台 |
| context/config/ConfigDataEnvironmentContributorTests.java | on-cloud-platform、ContributorIterator 顺序断言 |
| context/config/ConfigDataEnvironmentContributorsTests.java | spring.config.activate.on-profile 的多 contributor 行为 |
| context/config/ProfilesTests.java | group 展开、include、校验器的单元断言 |
| context/properties/source/SystemEnvironmentPropertyMapperTests.java | 环境变量映射的四候选(第 11 章详述) |
测试文件清单中
ProfilesTests.java一项我未打开确认其存在与内容------标注为待核实。
12. 本章与全系列的接口
本章解决了第 07 章留下的四个口子中的三个:
| 口子 | 解决位置 | 状态 |
|---|---|---|
| ① profile 何时、按何规则求解 | §2--§4 | ✅ 已解决 |
② spring.config.activate.* 绑定在谁身上 |
§5 | ✅ 已解决 |
| ③ 一个文件 → 多个 PropertySource | §6 | ✅ 已解决 |
④ SPRING_PROFILES_ACTIVE 如何变成 spring.profiles.active |
--- | ⏳ 第 11 章 |
同时本章产出了两条新的方法论:
- 第六定律:语义的"作用域"常藏在构造参数里 (
Collections.singleton(...)vsIterable<...>) - 方向判据 :
MutablePropertySources前面优先,ConfigData列表后面优先
13. 下一章
第 11 章 · ConfigTree、env: 位置与环境变量映射:非文件型配置源的完整机制
本章讲的是"文件内容怎么被切分、怎么被条件化"。第 11 章讲"文件之外的东西怎么变成配置":
- ConfigTree :
configtree:/etc/config/------ Kubernetes ConfigMap/Secret 的标准接入方式。目录树 → 属性名(.分隔)、文件内容 → 属性值(Value既是CharSequence又是InputStreamSource)、AUTO_TRIM_TRAILING_NEW_LINE的"只 trim 单行"逻辑。 env:位置 :SystemEnvironmentConfigDataLocationResolver------ 把一个环境变量的值 当作一份完整配置文件来解析(env:APP_CONFIG[.yaml])。- 环境变量映射 :
SystemEnvironmentPropertyMapper------SPRING_PROFILES_ACTIVE→spring.profiles.active的四个候选名、HOST_0→host[0]的数字索引规则、LEGACY_SYSTEM_ENVIRONMENT的历史包袱。 PropertySourceInfo与前缀 :4.x 新增的isImmutable()/getPrefix(),以及它如何支撑"多应用共用一台机器"的属性隔离。
第 12 章再回到主线:事件总线与 Bootstrap 上下文 ------SpringApplicationRunListener、BootstrapRegistry、CRaC。
附:本章遗留悬念
spring.profiles.validate 未在配置元数据中收录(第 15 章);BootstrapRegistry 的 Scope.PROTOTYPE(第 12 章);ConfigDataEnvironmentUpdateListener 的 onPropertySourceAdded / onSetProfiles(第 12 章);AOT 阶段的 ConfigData(第 27 章)。
本章未逐行核实 / 待你实测的项
spring.profiles.active与ApplicationEnvironment的交互细节未逐行读。- 多文档解析在 YAML 与 Properties 下的差异只做了推演(文中已标注)。
spring.config.activate.*的全部键未穷举。- profile 求解不动点迭代的收敛边界未实测。
- AOT 下 profile 固化(
__EnvironmentPostProcessor)的行为在第 28 章。