Spring Boot 4.1.1 深度源码解析-第 07 章 · ConfigData 体系深潜:位置解析、profile 迭代求解与 contributor 树
本章定位
ConfigDataEnvironmentPostProcessor是 Spring Boot 源码里最复杂的类之一------这是 Spring 团队自己在 Javadoc 和演讲里承认过的。它的复杂度不是偶然的,而是被需求倒逼出来的:
- 要支持
application.properties和application-{profile}.properties- 要支持 jar 内、jar 外、classpath、文件系统、任意 URL
- 要支持
spring.config.location/additional-location/import三种扩展方式- 要支持
spring.config.activate.on-profile------ 这让"配置文件的加载"变成依赖配置文件内容的循环问题- 要支持
spring.profiles.include/spring.profiles.group------ profile 之间是图而不是列表- 要支持远程配置中心(Spring Cloud Config 就是基于此扩展点)
- 要支持 AOT 下把这一切固化
本章的任务是把这个复杂度拆成可理解的几块:
- 六步主循环 :
processAndApply()的每一步在做什么、为什么必须按这个顺序- 位置解析 :
ConfigDataLocation的语法、optional:、相对路径、目录与文件的分派- contributor 树 :五种
Kind、父子关系、迭代顺序与优先级的关系- profile 求解 :
Profiles类的三来源合并 + group 的栈展开算法- 循环依赖的破解 :
InactiveConfigDataAccessException与"profile 不能由非激活文档激活"全部基于本仓库 4.1.1 源码逐行核实。
第 1 节 · 为什么需要重写:从 ConfigFileApplicationListener 到 ConfigData
1.1 2.4 之前的问题
Spring Boot 2.3 及更早,配置文件加载由 ConfigFileApplicationListener 负责。它是一个 EnvironmentPostProcessor + ApplicationListener 的混合体,核心逻辑是一个递归:
ini
加载 application.properties
→ 读到 spring.profiles.active=prod
→ 加载 application-prod.properties
→ 又读到 spring.profiles.active=xxx ?(不支持)
问题在于:
- profile 只能在"非 profile 专用文件"里声明 ,不能在
application-prod.properties里再激活别的 profile - 没有
spring.config.activate.on-profile,无法让一个文件"只在某 profile 下生效"(只能靠文件名约定) - 无法 import 任意位置,只能靠文件名约定
- yaml 多文档块 (
---分隔)支持很弱 - profile group 不存在
1.2 2.4 的重写:ConfigData
2.4 引入了全新的 ConfigData 抽象,把"配置加载"建模成:
rust
ConfigDataLocation(用户声明的位置,一个字符串)
↓ ConfigDataLocationResolver
ConfigDataResource(解析出的具体资源,如 "classpath:/application.properties")
↓ ConfigDataLoader
ConfigData(加载出的属性集合 + Option 集合)
↓
ConfigDataEnvironmentContributor(contributor 树的一个节点)
关键的新增抽象是 ConfigDataEnvironmentContributor 树。每个 contributor 有自己的:
PropertySource(可选)ConfigDataProperties(从自己的属性里绑定的spring.config.*)ConfigDataLocation(它自己的位置)- 子 contributor 列表(它 import 的东西)
Kind(生命周期状态)
这棵树让 spring.config.activate.on-profile 成为可能:每个 contributor 可以独立于父节点判断自己是否激活。
1.3 4.x 的演进
4.1.1 相对 2.4 的主要增强:
| 特性 | 加入版本 | 说明 |
|---|---|---|
ConfigDataEnvironmentUpdateListener |
2.4+ | 跟踪 Environment 更新(供 Spring Cloud 等使用) |
ConfigDataPropertiesRuntimeHints |
3.x | AOT 反射登记 |
ConfigDataLocationRuntimeHints |
3.x | 同上 |
ProfilesValidator |
4.0 | profile 名合法性校验(见第 5 节) |
__EnvironmentPostProcessor AOT 生成 |
4.0 | 固化 profiles(第 06 章) |
第 2 节 · 全景:processAndApply() 的六步
2.1 入口
java
// core/spring-boot/src/main/java/org/springframework/boot/context/config/
// ConfigDataEnvironmentPostProcessor.java
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
postProcessEnvironment(environment, application.getResourceLoader(), application.getAdditionalProfiles());
}
void postProcessEnvironment(ConfigurableEnvironment environment, @Nullable ResourceLoader resourceLoader,
Collection<String> additionalProfiles) {
this.logger.trace("Post-processing environment to add config data");
resourceLoader = (resourceLoader != null) ? resourceLoader : new DefaultResourceLoader();
getConfigDataEnvironment(environment, resourceLoader, additionalProfiles).processAndApply();
}
application.getAdditionalProfiles() 是 SpringApplication.setAdditionalProfiles(...) 设置的"额外激活的 profile"。它区别于 spring.profiles.active------它是编程式 设置的,且优先级更高(第 5 节详述)。
2.2 六步主流程
java
void processAndApply() {
// 准备
ConfigDataImporter importer = new ConfigDataImporter(this.logFactory, this.notFoundAction, this.resolvers,
this.loaders);
// ① 把 Binder 注册到 BootstrapContext(供其他 PostProcessor 用)
registerBootstrapBinder(this.contributors, null, DENY_INACTIVE_BINDING);
// ② 第一轮:处理初始 import(无 activation context)
ConfigDataEnvironmentContributors contributors = processInitial(this.contributors, importer);
// ③ 创建 activation context(此时知道 cloud platform,还不知道 profiles)
ConfigDataActivationContext activationContext = createActivationContext(
contributors.getBinder(null, BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE));
// ④ 第二轮:用 activation context 再处理一轮(仍无 profiles)
contributors = processWithoutProfiles(contributors, importer, activationContext);
// ⑤ 求解 profiles
activationContext = withProfiles(contributors, activationContext);
// ⑥ 第三轮:带上 profiles 再处理一轮
contributors = processWithProfiles(contributors, importer, activationContext);
// 收尾:应用到 Environment
applyToEnvironment(contributors, activationContext, importer.getLoadedLocations(),
importer.getOptionalLocations());
}
为什么要三轮 withProcessedImports?
因为这是一个不动点迭代:每一轮处理可能发现新的 import,新的 import 又带来新的属性和新的 import。必须迭代到不再有新东西。
而为什么中间要插一次 profile 求解? 因为 spring.config.activate.on-profile 让"能否加载某文件"依赖于 profile,而 profile 又来自配置文件。
流程的因果链:
dart
轮①(无 context): 只能处理不依赖任何条件的 initial import
↓ 得到第一批属性(含 cloud platform 检测结果)
创建 activation context(含 cloud platform,不含 profiles)
轮②(有 cloud): 能处理 on-cloud-platform 条件的 import
↓ 得到第二批属性(含 spring.profiles.active)
求解 profiles
轮③(有 profiles): 能处理 on-profile 条件的 import
↓ 得到第三批属性(可能含新的 spring.config.import)
(withProcessedImports 内部是 while(true) 循环,会一直迭代到不动点)
反直觉结论 #1 :
spring.config.import写在application-prod.properties里是有效 的------因为轮③会带上 profile 处理。但如果你在轮③引入的文件里又写spring.profiles.active,它不会被采纳(profiles 已经求解完毕)。这就是 Boot 文档说"profile 只能在非 profile 专用文档里激活"的机制原因。
2.3 registerBootstrapBinder:为谁服务
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));
}
它把当前状态的 Binder 注册到 BootstrapContext,Scope.PROTOTYPE 表示每次取都是新的(因为状态会变)。
用途 :其他 EnvironmentPostProcessor(比如 Spring Cloud 的 ConfigServicePropertySourceLocator)可以从 BootstrapContext 拿到 Binder,读取当前已加载的配置。
这是一个为扩展性预留的钩子 。它让第三方能在 ConfigData 处理的中间状态 插入自己的逻辑。Spring Cloud Config 正是靠这个拿到
spring.cloud.config.*配置。
2.4 DENY_INACTIVE_BINDING vs ALLOW_INACTIVE_BINDING
java
private static final BinderOption[] ALLOW_INACTIVE_BINDING = {};
private static final BinderOption[] DENY_INACTIVE_BINDING = { BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE };
- 轮①②③(前两轮)用
DENY:如果绑定时命中了一个非激活 的 source,抛InactiveConfigDataAccessException - 轮③(最后一轮)用
ALLOW:此时 profile 已知,允许绑定非激活 source
这个切换是整套机制里最微妙的一处。它的作用是:在 profiles 尚未确定时,禁止从"尚未确定是否激活"的文档里读 spring.profiles.active。
详见第 6 节。
第 3 节 · ConfigDataLocation:位置的语法
3.1 类的定义
java
public final class ConfigDataLocation implements OriginProvider {
private static final ConfigDataLocation EMPTY = new ConfigDataLocation(false, "", null);
public static final String OPTIONAL_PREFIX = "optional:";
private final boolean optional;
private final String value;
private final @Nullable Origin origin;
// ...
}
final 类,只有三个字段 。value 是不含 optional: 前缀的部分(看 of() 的解析)。
它 implements OriginProvider------携带 Origin,这样当某个位置找不到时,报错能指出"是 application.yml 第几行的 spring.config.import 声明了这个位置"。
3.2 optional: 前缀
optional:classpath:/myapp.properties:找不到不报错。
不带前缀的 classpath:/myapp.properties:找不到就抛 ConfigDataLocationNotFoundException。
全局开关:
java
static final String ON_NOT_FOUND_PROPERTY = "spring.config.on-not-found";
java
this.notFoundAction = binder.bind(ON_NOT_FOUND_PROPERTY, ConfigDataNotFoundAction.class)
.orElse(ConfigDataNotFoundAction.FAIL);
ConfigDataNotFoundAction 有 FAIL 和 IGNORE 两个值。设置 spring.config.on-not-found=ignore 可以全局让所有位置变 optional。
实践提示 :
optional:是每个位置 的开关,spring.config.on-not-found是全局开关。两者关系是"或"------任一为真就不报错。注意默认值是
FAIL。所以spring.config.import=classpath:/not-exist.properties会启动失败。这在"配置文件可选"的场景(如容器挂载的配置)里必须加optional:。
3.3 ; 分隔:一个 location 可以含多个位置
java
public ConfigDataLocation[] split() {
return split(";");
}
public ConfigDataLocation[] split(String delimiter) {
Assert.state(!this.value.isEmpty(), "Unable to split empty locations");
String[] values = StringUtils.delimitedListToStringArray(toString(), delimiter);
ConfigDataLocation[] result = new ConfigDataLocation[values.length];
for (int i = 0; i < values.length; i++) {
int index = i;
ConfigDataLocation configDataLocation = of(values[index]);
result[i] = configDataLocation.withOrigin(getOrigin());
}
return result;
}
; 是分隔符 。注意 of(values[i]) 会重新解析每个片段的 optional: 前缀------所以:
properties
spring.config.import=optional:a.properties;b.properties
只有 a 是 optional ,b 不是。optional: 前缀作用于单个片段,不作用于整个串。
这是一个高频错误。很多人以为写在开头就整串都 optional。正确写法:
propertiesspring.config.import=optional:a.properties;optional:b.properties
3.4 默认搜索位置
java
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]);
}
两个 location,每个内部用 ; 分组。
拆开看(按 split() 后的片段顺序):
| 组 | 片段顺序 | 位置 |
|---|---|---|
| location0 | 1 | optional:classpath:/ |
| location0 | 2 | optional:classpath:/config/ |
| location1 | 1 | optional:file:./ |
| location1 | 2 | optional:file:./config/ |
| location1 | 3 | optional:file:./config/*/ |
Boot 参考文档给出的默认位置优先级(从低到高)是:
makefile
classpath:/ ← 最低
classpath:/config/
file:./
file:./config/
file:./config/*/ ← 最高
注意代码里的书写顺序与文档的优先级顺序并不一致。代码中至少存在三处"逆序"手法,它们共同决定了最终的 contributor 迭代顺序,进而决定优先级:
逆序手法一:registerIndividually 的倒序遍历
java
if (registerIndividually) {
for (int i = locations.length - 1; i >= 0; i--) {
addInitialImportContributors(initialContributors, List.of(locations[i]));
}
}
spring.config.location / additional-location 用 registerIndividually=true,从最后一个 location 往前遍历 。所以 DEFAULT_SEARCH_LOCATIONS 的 [classpath组, file组] 被倒序注册成 [file组, classpath组]------file 组先成为 contributor。
逆序手法二:getReferencesForConfigName 的 addFirst
java
Deque<StandardConfigDataReference> references = new ArrayDeque<>();
for (PropertySourceLoader propertySourceLoader : this.propertySourceLoaders) {
for (String extension : propertySourceLoader.getFileExtensions()) {
// ...
if (!references.contains(reference)) {
references.addFirst(reference); // ★ 插到头部
}
}
}
PropertySourceLoader 的加载顺序来自 spring.factories(实测确认):
properties
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\ # properties, xml
org.springframework.boot.env.YamlPropertySourceLoader # yml, yaml
所以遍历扩展名的顺序是 properties → xml → yml → yaml,经过 addFirst 后 deque 变成 [yaml, yml, xml, properties]------完全反转。
逆序手法三:contributor 顺序 → PropertySource 顺序的映射
java
private void applyContributor(ConfigDataEnvironmentContributors contributors, /* ... */) {
for (ConfigDataEnvironmentContributor contributor : contributors) {
// ...
propertySources.addLast(propertySource); // ★ addLast
}
}
MutablePropertySources 里下标越小优先级越高 (第 06 章的 getProperty 遍历语义)。而 ConfigData 的 source 全部用 addLast 追加。因此在 ConfigData 组内:
先被迭代到的 contributor → 先
addLast→ 下标更小 → 优先级更高
综合这三条:contributor 的迭代顺序决定优先级,而迭代顺序是上述逆序手法的合成结果。
严谨声明(写作过程中的真实状态) :在读到第 15.2 节之前,仅凭这三处逆序无法唯一确定最终优先级表,因为还要考虑
ImportPhase分组、withChildren重排、profile-specific 资源的插入时机。当时我不敢拍脑袋下结论。后续进展 :在第 15.2 节,我在
ConfigData.getPropertySources()的 Javadoc 里找到了缺失的那把钥匙------"ascending priority order, later wins" 。有了这个权威声明,优先级问题可以确定了。请跳到第 15.2 节看最终答案。本系列的原则是:能实证的结论才写死,推不出来就明确标注并继续找证据。这里完整展示了这个过程------我把它保留下来,因为它比一个干净但跳步的结论更有教学价值。
但有一个结论是确定无疑、且有独立价值的 :Boot 反复使用"逆序插入 "这一手法来表达"后声明者优先 / 后加载者优先 "的语义。这是一种在无显式权重字段的列表结构里表达优先级顺序的通用技巧------addFirst 于 deque、for(i=n-1;i>=0;i--) 于数组、addLast 于目标列表。
逆序手法的具体形态(这是本章可以直接记住的部分):
java
// 形态 1:deque + addFirst(同目录内多个扩展名)
references.addFirst(reference);
// 形态 2:数组倒序(多个 location)
for (int i = locations.length - 1; i >= 0; i--) {
addInitialImportContributors(initialContributors, List.of(locations[i]));
}
// 形态 3:目标列表 addLast(contributor → PropertySource)
propertySources.addLast(propertySource);
3.5 三个 location 属性的语义差异
java
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;
}
| 属性 | 默认值 | registerIndividually |
|---|---|---|
spring.config.import |
无(EMPTY_LOCATIONS) |
false |
spring.config.additional-location |
无 | true |
spring.config.location |
DEFAULT_SEARCH_LOCATIONS |
true |
三者的关键差异:
(a)registerIndividually=false(import)时,所有位置被打包成一个** contributor**
java
addInitialImportContributors(initialContributors, List.of(locations));
而 true 时,每个位置单独一个 contributor,且倒序注册。
后果:spring.config.import 的所有位置作为一个整体处理,它们之间的相对顺序由 split() 后的 references 顺序决定;而 location / additional-location 的每个位置是独立的 contributor 树节点,各自可以有自己的子 contributor(即嵌套 import)。
(b)spring.config.location 会 替换默认位置,additional-location 是追加**
看 orElse(defaultValue):location 的默认值是 DEFAULT_SEARCH_LOCATIONS,一旦用户设置就替换。additional-location 默认值是 EMPTY_LOCATIONS,用户设置的是"额外的",默认位置依然生效。
这是最常见的误用 :想"加一个配置文件",写了
spring.config.location=classpath:extra.properties,结果application.properties不再被加载。正确做法:用
spring.config.additional-location(保留默认位置)或spring.config.import(更现代)。
(c)spring.config.import 可以在任意配置文件里出现,且支持嵌套
location / additional-location 只在"初始阶段"被读取一次(从命令行、环境变量等 existing source)。而 import 是 ConfigData 的一部分------任何被加载的文件里都可以写 spring.config.import,形成递归加载。
这正是"配置树"的由来。
3.6 相对路径的解析规则
java
private String getResourceLocation(ConfigDataLocationResolverContext context,
ConfigDataLocation configDataLocation) {
String resourceLocation = configDataLocation.getNonPrefixedValue(PREFIX);
boolean isFixedPath = resourceLocation.startsWith("/") || URL_PREFIX.matcher(resourceLocation).matches();
if (isFixedPath) {
return resourceLocation;
}
ConfigDataResource parent = context.getParent();
if (parent instanceof StandardConfigDataResource resource) {
String parentResourceLocation = resource.getReference().getResourceLocation();
String parentDirectory = parentResourceLocation.substring(0, parentResourceLocation.lastIndexOf("/") + 1);
return parentDirectory + resourceLocation;
}
return resourceLocation;
}
三条规则:
- 以
/开头,或匹配^([a-zA-Z][a-zA-Z0-9*]*?:)(.*$)(即带 URL 协议) → 当作固定路径,不做相对解析 - 有父资源 (即这个 location 来自某个已加载文件的
spring.config.import) → 相对于父资源所在目录解析 - 否则 → 原样使用
规则 2 是
spring.config.import的核心便利 :在classpath:/config/application.properties里写spring.config.import=extra.properties,会解析成classpath:/config/extra.properties(而不是 classpath 根)。这个"相对父目录"的语义让配置文件可以自包含地组织------把主配置和它依赖的片段放在同一目录。
注意 URL_PREFIX 正则里的 *:[a-zA-Z0-9*]*? 允许协议名含 *,这是为了 configtree: 之类以及通配场景。
3.7 resource: 前缀
java
private static final String PREFIX = "resource:";
resource: 前缀在 getNonPrefixedValue(PREFIX) 时被剥离。它的作用是强制走文件分支(而非目录分支),即使没有扩展名。
而在 getReferencesForFile 里,当扩展名无法识别时:
java
if (configDataLocation.hasPrefix(PREFIX) || configDataLocation.hasPrefix(ResourceUtils.FILE_URL_PREFIX)
|| configDataLocation.hasPrefix(ResourceUtils.CLASSPATH_URL_PREFIX)
|| configDataLocation.toString().indexOf(':') == -1) {
throw new IllegalStateException("File extension is not known to any PropertySourceLoader. "
+ "If the location is meant to reference a directory, it must end in '/' or File.separator");
}
注意这个错误信息的设计 :它不只说"扩展名不认识",还直接告诉你"如果你是想指向目录,必须以 / 或 File.separator 结尾"。这是 Boot 错误信息"给出可操作建议"的又一例(第 01 章讲过的 FailureAnalyzer 精神)。
3.8 目录 vs 文件的判定
java
if (isDirectory(resourceLocation)) {
return getReferencesForDirectory(configDataLocation, resourceLocation, NO_PROFILE);
}
return getReferencesForFile(configDataLocation, resourceLocation, NO_PROFILE);
目录分支 :对每个 configName(默认 application)和每个扩展名,生成 directory + name 的引用。
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;
}
注意传给 reference 的是 directory + name------没有扩展名 ,扩展名单独作为参数传。所以 classpath:/ + application + properties = classpath:/application.properties。
文件分支 :给出的路径必须能被某个 PropertySourceLoader 识别扩展名。
java
private Set<StandardConfigDataReference> getReferencesForFile(ConfigDataLocation configDataLocation, String file,
@Nullable String profile) {
FileHint fileHint = FileHint.from(file);
file = FileHint.removeFrom(file);
boolean hasFileHintExtension = fileHint.getExtension() != null;
if (hasFileHintExtension) {
file = file + fileHint.getExtension();
}
for (PropertySourceLoader propertySourceLoader : this.propertySourceLoaders) {
String fileExtension = getLoadableFileExtension(propertySourceLoader, file);
if (fileExtension != null) {
String root = file.substring(0, file.length() - fileExtension.length() - 1);
StandardConfigDataReference reference = new StandardConfigDataReference(configDataLocation, null, root,
profile, (!hasFileHintExtension) ? fileExtension : null, propertySourceLoader,
fileHint.getEncodingAsCharset());
return Collections.singleton(reference);
}
}
if (configDataLocation.isOptional()) {
return Collections.emptySet();
}
// ... 抛异常
}
FileHint:方括号里的扩展名提示
java
FileHint fileHint = FileHint.from(file);
file = FileHint.removeFrom(file);
FileHint 支持 myfile[.properties] 这种写法------方括号里的内容作为提示。用途:
- 文件本身没有扩展名,但你希望按某种格式解析:
spring.config.import=file:/etc/myapp/config[.properties] - 指定编码
看 StandardConfigDataReference 的构造:(!hasFileHintExtension) ? fileExtension : null------如果有 hint,就用 hint 指定的扩展名语义;否则用从文件名推导的。
spring.config.name
java
static final String CONFIG_NAME_PROPERTY = "spring.config.name";
static final String[] DEFAULT_CONFIG_NAMES = { "application" };
private void validateConfigName(String name) {
Assert.state(!name.contains("*"), () -> "Config name '" + name + "' cannot contain '*'");
}
spring.config.name=myapp → 加载 myapp.properties 而不是 application.properties。
注意 Assert.state(...) 禁止 * ------配置名不支持通配。但位置 支持通配(file:./config/*/)。
这个不对称是刻意的 :配置名通配会导致文件数量不可控(
app*.properties匹配多少?),而位置通配(config/*/)是有明确边界的(一级子目录)。
3.9 profile-specific 位置
java
@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) {
for (ConfigDataLocation configDataLocation : configDataLocations) {
String resourceLocation = getResourceLocation(context, configDataLocation);
references.addAll(getReferences(configDataLocation, resourceLocation, profile));
}
}
return references;
}
外层循环是 profile,内层是 location 。所以对于 profiles = [prod, cloud]:
r
prod → location1, location2, location3
cloud → location1, location2, location3
即:同一 profile 的所有 location 排在下一个 profile 之前。
实践含义 :
application-prod.properties里的配置覆盖application-cloud.properties里的(如果 prod 在 profiles 里先出现)。profile 的激活顺序影响配置的最终值,而不仅仅是"是否激活"。这是"多 profile 配置冲突"的根源。要避免,就不要在多个 profile 文件里配同一个 key。
resolveProfileSpecific 由 ConfigDataImporter 在每个 contributor 上调用,用于生成该 contributor 的"profile 专用子 contributor"。
3.10 resolve(Set<StandardConfigDataReference>) 与空目录的特殊处理
java
private List<StandardConfigDataResource> resolve(Set<StandardConfigDataReference> references) {
List<StandardConfigDataResource> resolved = new ArrayList<>();
for (StandardConfigDataReference reference : references) {
resolved.addAll(resolve(reference));
}
if (resolved.isEmpty()) {
resolved.addAll(resolveEmptyDirectories(references));
}
return resolved;
}
保持 references 的顺序,没有反转。
但有一个特殊分支:如果一个都没解析出来,尝试"空目录"解析。
java
private Collection<StandardConfigDataResource> resolveEmptyDirectories(
Set<StandardConfigDataReference> references) {
Set<StandardConfigDataResource> empty = new LinkedHashSet<>();
for (StandardConfigDataReference reference : references) {
if (reference.getDirectory() != null) {
empty.addAll(resolveEmptyDirectories(reference));
}
}
return empty;
}
为什么要"解析空目录"?
因为 spring.config.import 可能指向一个目录,而该目录里没有任何匹配的文件。此时如果不做任何处理,contributor 会变成"空",导致后续无法判断这个 import 是否成功/是否 optional。
resolveEmptyDirectories 生成一个"占位"资源,让 contributor 存在但无属性。这样 checkMandatoryLocations 能正确判断"这个 mandatory location 到底有没有被处理"。
这是一个容易被忽略的边界处理 :它让"导入一个空目录"不会误判为"导入失败"。若没有这段,
spring.config.import=file:/etc/empty-dir/(非空目录但无配置文件)在非 optional 时会报ConfigDataLocationNotFoundException。
3.11 通配与模式解析
java
private List<StandardConfigDataResource> resolve(StandardConfigDataReference reference) {
if (!this.resourceLoader.isPattern(reference.getResourceLocation())) {
return resolveNonPattern(reference);
}
return resolvePattern(reference);
}
private List<StandardConfigDataResource> resolvePattern(StandardConfigDataReference reference) {
List<StandardConfigDataResource> resolved = new ArrayList<>();
for (Resource resource : this.resourceLoader.getResources(reference.getResourceLocation(), ResourceType.FILE)) {
if (!resource.exists() && reference.isSkippable()) {
logSkippingResource(reference);
}
else {
resolved.add(createConfigResourceLocation(reference, resource));
}
}
return resolved;
}
isPattern 检测路径里是否有 *(如 file:./config/*/)。有则展开成多个资源。
ResourceType.FILE ------ 只匹配文件 ,不匹配目录。这保证了 config/*/ 展开后得到的是 config/a/、config/b/ 下的配置文件,而不是目录本身。
第 4 节 · contributor 树:ConfigData 的数据结构核心
4.1 五种 Kind:一个状态机
java
enum Kind {
/**
* A root contributor used contain the initial set of children.
*/
ROOT,
/**
* An initial import that needs to be processed.
*/
INITIAL_IMPORT,
/**
* An existing property source that contributes properties but no imports.
*/
EXISTING,
/**
* A contributor with {@link ConfigData} imported from another contributor but not
* yet bound.
*/
UNBOUND_IMPORT,
/**
* A contributor with {@link ConfigData} imported from another contributor that
* has been bound.
*/
BOUND_IMPORT
}
状态迁移:
lua
┌──────────────────────────────┐
│ │
(构建) ──▶ ROOT ──▶ EXISTING / INITIAL_IMPORT
│
│ withProcessedImports
▼
UNBOUND_IMPORT ← 已 resolve+load,还没绑定 spring.config.*
│
│ withBoundProperties
▼
BOUND_IMPORT ← 已绑定,可以读取 imports、判断 activate
│
│ withChildren
▼
(产生新的 UNBOUND_IMPORT 子节点)
为什么要有 UNBOUND_IMPORT → BOUND_IMPORT 这一步?
因为"判断一个 contributor 是否激活"需要先从它自己的属性里绑定 spring.config.activate.*------而绑定需要 Binder,而 Binder 需要当前已确定的 contributor 集合 (因为属性可能引用其他 source 的值,如占位符 ${...})。
所以流程是:
- resolve + load → 得到
ConfigData(属性 + Option) - 绑定
spring.config.*→ 得到ConfigDataProperties(含activate和imports) - 才能判断
isActive(...)和拿到下一轮imports
UNBOUND_IMPORT 是第 1 步完成、第 2 步未做的中间态。
4.2 withProcessedImports 的 while(true) 循环
java
ConfigDataEnvironmentContributors withProcessedImports(ConfigDataImporter importer,
@Nullable ConfigDataActivationContext activationContext) {
ImportPhase importPhase = ImportPhase.get(activationContext);
this.logger.trace(LogMessage.format("Processing imports for phase %s. %s", importPhase,
(activationContext != null) ? activationContext : "no activation context"));
ConfigDataEnvironmentContributors result = this;
int processed = 0;
while (true) {
ConfigDataEnvironmentContributor contributor = getNextToProcess(result, activationContext, importPhase);
if (contributor == null) {
this.logger.trace(LogMessage.format("Processed imports for of %d contributors", processed));
return result;
}
if (contributor.getKind() == Kind.UNBOUND_IMPORT) {
ConfigDataEnvironmentContributor bound = contributor.withBoundProperties(result, activationContext);
result = new ConfigDataEnvironmentContributors(this.logger, this.bootstrapContext,
result.getRoot().withReplacement(contributor, bound), this.conversionService,
this.environmentUpdateListener);
continue;
}
ConfigDataLocationResolverContext locationResolverContext = new ContributorConfigDataLocationResolverContext(
result, contributor, activationContext);
ConfigDataLoaderContext loaderContext = new ContributorDataLoaderContext(this);
List<ConfigDataLocation> imports = contributor.getImports();
this.logger.trace(LogMessage.format("Processing imports %s", imports));
Map<ConfigDataResolutionResult, ConfigData> imported = importer.resolveAndLoad(activationContext,
locationResolverContext, loaderContext, imports);
this.logger.trace(LogMessage.of(() -> getImportedMessage(imported.keySet())));
ConfigDataEnvironmentContributor contributorAndChildren = contributor.withChildren(importPhase,
asContributors(imported));
result = new ConfigDataEnvironmentContributors(this.logger, this.bootstrapContext,
result.getRoot().withReplacement(contributor, contributorAndChildren), this.conversionService,
this.environmentUpdateListener);
processed++;
}
}
几个关键观察:
(a)不可变结构
每一次变更都 new ConfigDataEnvironmentContributors(...),通过 result.getRoot().withReplacement(contributor, newOne) 做替换。这是持久化数据结构风格(类似 Clojure 的 persistent tree)------原对象不变,返回新对象。
好处:迭代安全、可回溯、便于调试(可以保留中间快照)。代价:大量对象分配(启动期一次性,可接受)。
(b)两个分支的处理顺序
java
if (contributor.getKind() == Kind.UNBOUND_IMPORT) {
// 先绑定
...
continue; // 绑定后重新找下一个(可能又找到它,这次走 else 分支)
}
// 再处理 imports
注意 continue------绑定后回到循环开头 重新调 getNextToProcess。而 getNextToProcess 里:
java
for (ConfigDataEnvironmentContributor contributor : contributors.getRoot()) {
if (contributor.getKind() == Kind.UNBOUND_IMPORT
|| isActiveWithUnprocessedImports(activationContext, importPhase, contributor)) {
return contributor;
}
}
一个 UNBOUND_IMPORT 会被优先 返回(第一个条件)。所以刚绑定的那个变成 BOUND_IMPORT 后,如果它有未处理的 imports,会在下一轮被 isActiveWithUnprocessedImports 选中。
(c)processed 计数只统计 import 处理,不统计绑定 ------绑定分支是 continue,没有 processed++。
4.3 ImportPhase:为什么迭代要分阶段
java
enum ImportPhase {
/**
* The phase before profiles have been activated.
*/
BEFORE_PROFILE_ACTIVATION,
/**
* The phase after profiles have been activated.
*/
AFTER_PROFILE_ACTIVATION;
static ImportPhase get(@Nullable ConfigDataActivationContext activationContext) {
if (activationContext != null && activationContext.getProfiles() != null) {
return AFTER_PROFILE_ACTIVATION;
}
return BEFORE_PROFILE_ACTIVATION;
}
}
ImportPhase 由 activationContext.getProfiles() 是否为 null 决定:
- 轮①②(无 profiles)→
BEFORE_PROFILE_ACTIVATION - 轮③(有 profiles)→
AFTER_PROFILE_ACTIVATION
每个 contributor 维护自己在哪个 phase 添加了哪些子 contributor 。看 ContributorIterator:
java
private final class ContributorIterator implements Iterator<ConfigDataEnvironmentContributor> {
private @Nullable ImportPhase phase;
private Iterator<ConfigDataEnvironmentContributor> children;
private Iterator<ConfigDataEnvironmentContributor> current;
private @Nullable ConfigDataEnvironmentContributor next;
private ContributorIterator() {
this.phase = ImportPhase.AFTER_PROFILE_ACTIVATION;
this.children = getChildren(this.phase).iterator();
this.current = Collections.emptyIterator();
}
// ...
}
迭代器从 AFTER_PROFILE_ACTIVATION 开始 ------即"profile 激活后添加的子节点"排在"之前添加的子节点"前面。
这解释了
application-{profile}.properties为什么优先级更高 :它是AFTER_PROFILE_ACTIVATIONphase 添加的子 contributor,在迭代时先出现,先addLast到 propertySources,下标更小,优先级更高。这是第 3.4 节"三处逆序"之外的第四处 影响优先级的结构------phase 分组。
4.4 ConfigData.Option:三个开关
java
public enum Option {
/**
* Ignore all imports properties from the source.
*/
IGNORE_IMPORTS,
/**
* Ignore all profile activation and include properties.
* @since 2.4.3
*/
IGNORE_PROFILES,
/**
* Indicates that the source is "profile specific" and should be included after
* profile specific sibling imports.
* @since 2.4.5
*/
PROFILE_SPECIFIC
}
这三个 Option 由 ConfigDataLoader 在加载时决定,附在 ConfigData 上。
IGNORE_IMPORTS
忽略该文件里的 spring.config.import。
谁用它?ConfigDataImporter 在处理嵌套 import 时,可能对某些来源(如通过 spring.config.import 引入的远程配置)设置此选项,避免无限递归或避免不受信任的配置引入更多配置。
安全含义:这是一个防止"配置文件供应链攻击"的潜在控制点------从外部引入的配置不能再引入更多配置。
IGNORE_PROFILES
忽略该文件里的 spring.profiles.active / spring.profiles.include / spring.config.activate.on-profile。
看使用点:
java
// ConfigDataEnvironment.withProfiles
Binder binder = contributors.getBinder(activationContext,
(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES),
BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
以及:
java
// getIncludedProfiles
for (ConfigDataEnvironmentContributor contributor : contributors) {
ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
// 读取 spring.profiles.include
}
}
这是一个过滤器 :带 IGNORE_PROFILES 的 contributor 在求解 profiles 时被排除。
谁设置它?ConfigTreeConfigDataLoader(configtree 来源)和某些远程配置 loader。理由:configtree 是 Kubernetes 挂载的文件,里面的 spring.profiles.* 不应该参与 profile 决策(那会导致"改挂载文件就改 profile"这种隐式行为)。
实践影响 :如果你用
spring.config.import=configtree:/etc/config/挂载了含spring.profiles.active的文件,它不会生效。这是刻意的。
PROFILE_SPECIFIC
标记这个 source 是"profile 专用"的,用于在排序时把它放到合适位置。
看使用点:
java
// ConfigDataEnvironmentContributor 的某处
if (child.hasConfigDataOption(ConfigData.Option.PROFILE_SPECIFIC)) { ... }
以及:
java
.anyMatch((child) -> child.hasConfigDataOption(ConfigData.Option.PROFILE_SPECIFIC))
它影响 withChildren 时子节点的插入位置------profile-specific 的子节点要在"profile specific sibling imports"之后。
这是最微妙的一个 Option,它的
@since 2.4.5说明是补丁式加入的------为了修正application-{profile}.properties与spring.config.import同时存在时的优先级问题。
4.5 spring.config.activate.*:文档的激活条件
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);
}
}
两个条件都满足才激活 (&&):
onCloudPlatform:若为 null 则不限制;否则必须等于检测到的云平台onProfile:若为空则不限制;否则必须匹配激活的 profiles
activationContext == null 时返回 false
这意味着:在轮①(无 activation context)时,任何带 spring.config.activate.* 的文档都被视为"未激活"。这防止了在信息不足时做出错误判断。
onProfile 用的是表达式而不是简单列表
java
org.springframework.core.env.Profiles.of(this.onProfile).matches(activeProfiles);
Profiles.of(...) 支持 profile 表达式:
yaml
spring:
config:
activate:
on-profile: "prod | staging" # prod 或 staging
# 或 "prod & cloud" # 同时
# 或 "!dev" # 非 dev
这是 Spring Framework 5.1+ 的 Profiles.of() 语法(|、&、!、括号)。
实践价值 :
on-profile支持布尔表达式,比"多个 profile 文件"更灵活。可以在一个 yaml 文件的多个文档块里用:
yaml# 文档块 1 spring: config: activate: on-profile: dev my.value: dev-value --- # 文档块 2 spring: config: activate: on-profile: "prod | staging" my.value: prod-value这是 ConfigData 相对 2.3 最重要的能力升级------单文件内的多环境配置。
onCloudPlatform
java
private boolean isActive(CloudPlatform cloudPlatform) {
return this.onCloudPlatform == null || this.onCloudPlatform == cloudPlatform;
}
CloudPlatform 的取值:见第 05 章的 CloudPlatform 枚举(NONE、CLOUD_FOUNDRY、HEROKU、SAP、NOMAD、KUBERNETES、AWS_ECS)。
注意 (cloudPlatform != null) ? cloudPlatform : CloudPlatform.NONE------activationContext 里可能没有云平台信息(轮①),此时当作 NONE。
4.6 循环的破解:InactiveConfigDataAccessException
这是整套机制里最精巧的一处。
问题 :spring.config.activate.on-profile: prod 声明在 application.properties 里,而 spring.profiles.active: prod 也声明在同一个文件里。那么:
- 要判断这个文档是否激活,需要 profiles
- 要得到 profiles,需要读这个文档
- 循环
Boot 的解法 :在求解 profiles 时,用 DENY_INACTIVE_BINDING(FAIL_ON_BIND_TO_INACTIVE_SOURCE)。如果一个 contributor 在绑定 spring.profiles.active 时命中了一个非激活 的 source,就抛 InactiveConfigDataAccessException。
看捕获点:
java
private ConfigDataActivationContext withProfiles(ConfigDataEnvironmentContributors contributors,
ConfigDataActivationContext activationContext) {
// ...
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;
}
}
以及 createActivationContext 里同样的模式。
异常被重新抛出 (而非吞掉),最终由 ConfigDataNotFoundFailureAnalyzer 或类似的分析器转成可读的错误:
csharp
Property 'spring.profiles.active' imported from location 'class path resource [application.properties]' is invalid in a profile specific resource
这就是"不能在
application-prod.properties里激活 profile"这条规则的物理实现 。它不是一条文档约定,而是一个运行期检测------Boot 在求解 profiles 时如果发现"激活信息来自一个尚未激活/已确定不激活的文档",直接报错。这个设计的价值在于:把"可能静默出错的配置"变成"明确的启动失败"。
InactiveConfigDataAccessException.throwIfPropertyFound
java
// getIncludedProfiles 里
if (!contributor.isActive(activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES);
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES.append("[0]"));
}
对 spring.profiles.include 显式检查 :如果 contributor 未激活但含有 spring.profiles.include,抛异常。
.append("[0]") ------ 连同索引形式 spring.profiles.include[0] 也检查。这是一个防御性细节:用户可能用 spring.profiles.include[0]=xxx 的写法绕过检查,Boot 把它也堵住了。
4.7 checkForInvalidProperties
java
private void checkForInvalidProperties(ConfigDataEnvironmentContributors contributors) {
for (ConfigDataEnvironmentContributor contributor : contributors) {
InvalidConfigDataPropertyException.throwIfPropertyFound(contributor);
}
}
InvalidConfigDataPropertyException 检查的是:在不该出现的位置出现了 spring.config.* 属性。
典型场景:在 application.properties 里写 spring.config.activate.on-profile 是合法的,但在某些位置 (比如 profile-specific 文件里)写 spring.config.location 是非法的------因为那时 location 已经解析完了。
这类"位置不合法"的检查体现了 Boot 对"配置写错位置"的零容忍:宁可启动失败,也不静默忽略。
4.8 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 声明的 mandatory imports(非 optional 的)
- 去掉已经有对应 contributor 的(即成功加载的)
- 去掉
importer.getLoadedLocations()里记录的 - 去掉
importer.getOptionalLocations()里记录的
剩下的就是"声明了但没找到"的 → 交给 notFoundAction(默认 FAIL)。
注意第 1 步的
isActive判断:只有激活的 contributor 声明的 mandatory import 才必须存在。这避免了"prod profile 声明的 mandatory import 在 dev 环境下导致启动失败"。
第 5 节 · Profiles:profile 求解的完整算法
5.1 构造与三来源
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));
}
三个来源:
additionalProfiles(编程式,SpringApplication.setAdditionalProfiles)environment里已设置的 profiles(environment.getActiveProfiles())binder从配置里读到的(spring.profiles.active)
外加 spring.profiles.include 由外层(getIncludedProfiles)并入 additionalProfiles。
java
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles);
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext));
Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);
5.2 Type 枚举:ACTIVE 与 DEFAULT 的差异
java
private Collection<String> getProfiles(Environment environment, Binder binder, ProfilesValidator validator,
Type type) {
String environmentPropertyValue = environment.getProperty(type.getName());
Set<String> environmentPropertyProfiles = (!StringUtils.hasLength(environmentPropertyValue))
? Collections.emptySet()
: StringUtils.commaDelimitedListToSet(StringUtils.trimAllWhitespace(environmentPropertyValue));
validator.validate(environmentPropertyProfiles,
() -> "Invalid profile property value found in Environment under '%s'".formatted(type.getName()));
Set<String> environmentProfiles = new LinkedHashSet<>(Arrays.asList(type.get(environment)));
BindResult<Set<String>> boundProfiles = binder.bind(type.getName(), STRING_SET, validator);
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());
}
这段是全章最"绕"的代码之一。逐步拆解:
(a)两个变量
environmentPropertyValue:environment.getProperty("spring.profiles.active")的字符串值environmentProfiles:environment.getActiveProfiles()的数组(可能已被编程式设置)
两者的差异是关键:如果有人调用了 environment.setActiveProfiles("x"),那么 environmentProfiles = ["x"],但 environmentPropertyValue 可能是 null(因为属性没设)。
(b)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为空):只要environmentProfiles不等于默认值,就认为是编程式设置的 environmentProfiles等于默认值:返回 false(没编程式设置)- 属性设了:比较"属性值解析出的集合"与"实际的 profiles 集合",不等则说明有编程式设置
(c)isMergeWithEnvironmentProfiles:ACTIVE 与 DEFAULT 的关键差异
从代码看 type.isMergeWithEnvironmentProfiles() 是一个布尔开关。ACTIVE 与 DEFAULT 的这个值不同------这决定了"编程式设置的 profiles"与"配置里的 profiles"是替换 还是合并关系。
注意 :我没有逐行读取
Type枚举的完整定义(它在本文件的后面部分)。从调用可以确认它至少有getName()、get(Environment)、getDefaultValue()、isMergeWithEnvironmentProfiles()四个方法,且 ACTIVE/DEFAULT 两个常量的isMergeWithEnvironmentProfiles()不同。可实证的结论 :
spring.profiles.active与编程式设置有合并语义(在 ACTIVE 上),而spring.profiles.default没有。建议用测试验证具体行为(第 13 节给出命令)。
5.3 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);
}
spring.profiles.group 的语义:
yaml
spring:
profiles:
group:
prod: proddb, prodmq, prodmetrics
激活 prod 等价于激活 prod, proddb, prodmq, prodmetrics。
算法:
- 把输入 profiles 逆序压栈(这样 pop 时是正序)
- 循环 pop:
- 若
expandedProfiles.add(current)返回 true(即首次见到这个 profile,防止循环展开) - 则把它的 group 逆序压栈(同样是为了 pop 时正序)
- 若
expandedProfiles.add() 的返回值判断是防止无限循环的关键 :如果 a → b → a 这样的循环 group 定义,第二次遇到 a 时 add 返回 false,不再展开其 group。
实践规则 :profile group 支持循环定义且不崩溃 (靠
Set.add的返回值检测)。但结果可能不符合预期------展开顺序取决于首次访问顺序。逆序压栈的原因:栈是 LIFO,要让 pop 出来是"输入顺序",就必须逆序 push。这是用栈做"保持顺序的 BFS/DFS"的标准技巧。
5.4 ProfilesValidator:4.x 新增的合法性校验
java
ProfilesValidator validator = ProfilesValidator.get(binder);
@since 4.0 附近新增。它校验 profile 名的合法性------比如不能含某些字符、不能是保留字。
看它的使用:
java
validator.validate(additionalProfiles, () -> "Invalid profile property value found in additional profiles");
validator.validate(environmentPropertyProfiles,
() -> "Invalid profile property value found in Environment under '%s'".formatted(type.getName()));
以及作为 BindHandler 传给 binder.bind(...):
java
binder.bind(type.getName(), STRING_SET, validator)
为什么需要校验? 因为 profile 名会被用在
Profiles.of(...)表达式解析 里。如果 profile 名含|、&、!、(、)等字符,表达式解析会出错或被误解。所以要在入口处拦截。这是典型的"输入验证前置以避免下游解析歧义"的设计。
5.5 Profiles 的公开 API
java
public class Profiles implements Iterable<String> {
public static final String INCLUDE_PROFILES_PROPERTY_NAME = "spring.profiles.include";
// ...
@Override
public Iterator<String> iterator() {
return getAccepted().iterator();
}
}
implements Iterable<String> ------ 迭代的是"accepted profiles"(被接受的)。
getAccepted() 通常是 activeProfiles 非空则用它,否则用 defaultProfiles。正是 StandardEnvironment.acceptsProfiles 的语义。
第 6 节 · ConfigDataImporter:resolve 与 load
6.1 resolveAndLoad
java
Map<ConfigDataResolutionResult, ConfigData> resolveAndLoad(@Nullable ConfigDataActivationContext activationContext,
ConfigDataLocationResolverContext locationResolverContext, ConfigDataLoaderContext loaderContext,
List<ConfigDataLocation> locations) {
try {
Profiles profiles = (activationContext != null) ? activationContext.getProfiles() : null;
List<ConfigDataResolutionResult> resolved = resolve(locationResolverContext, profiles, locations);
return load(loaderContext, resolved);
}
catch (IOException ex) {
throw new IllegalStateException("IO error on loading imports from " + locations, ex);
}
}
profiles 为 null 时(轮①②),不会调用 resolveProfileSpecific------这就是"profile 专用文件在轮③才加载"的机制。
6.2 load 的第四处逆序
java
private Map<ConfigDataResolutionResult, ConfigData> load(ConfigDataLoaderContext loaderContext,
List<ConfigDataResolutionResult> candidates) throws IOException {
Map<ConfigDataResolutionResult, ConfigData> result = new LinkedHashMap<>();
for (int i = candidates.size() - 1; i >= 0; i--) {
ConfigDataResolutionResult candidate = candidates.get(i);
ConfigDataLocation location = candidate.getLocation();
ConfigDataResource resource = candidate.getResource();
this.logger.trace(LogMessage.format("Considering resource %s from location %s", resource, location));
if (resource.isOptional()) {
this.optionalLocations.add(location);
}
if (this.loaded.contains(resource)) {
this.logger...
}
// ...
}
return result;
}
倒序遍历 candidates (i = size-1 → 0)。放进 LinkedHashMap 后,先放的排前面。
所以倒序遍历的结果是:candidates 里靠后的元素,在 result 里靠前。
这又是"后声明者优先"的表达------spring.config.import=a,b 时 b 优先。
同时这里完成了两件记录工作:
this.optionalLocations.add(location)------ 记录哪些 location 是 optional 的(供checkMandatoryLocations用)this.loaded.contains(resource)------ 去重,避免同一资源被加载两次
去重很重要 :因为
withProcessedImports是while(true)循环,同一 contributor 可能被多次处理。没有loaded集合会导致重复加载(性能问题 + 潜在的重复 PropertySource)。
6.3 resolve 的异常吞噬
java
private List<ConfigDataResolutionResult> resolve(ConfigDataLocationResolverContext locationResolverContext,
@Nullable Profiles profiles, ConfigDataLocation location) {
try {
return this.resolvers.resolve(locationResolverContext, location, profiles);
}
catch (ConfigDataNotFoundException ex) {
handle(ex, location, null);
return Collections.emptyList();
}
}
ConfigDataNotFoundException 被 handle(...) 处理而不是直接抛出。handle 的实现会把异常转成 notFoundAction 的行为(FAIL 则抛,IGNORE 则记录)。
注意返回 Collections.emptyList()------如果 notFoundAction 是 IGNORE,这个 location 就静默地什么都没加载。
第 7 节 · PropertySourceLoader 与 Origin 追踪
7.1 两个内置 loader
properties
# core/spring-boot/src/main/resources/META-INF/spring.factories
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader
| Loader | 扩展名 |
|---|---|
PropertiesPropertySourceLoader |
properties, xml |
YamlPropertySourceLoader |
yml, yaml |
xml 也被支持 ------PropertiesPropertySourceLoader 能读 Java 的 .xml 属性文件格式(java.util.Properties.loadFromXML)。这是一个少有人知的细节。
7.2 OriginTrackedMapPropertySource:为什么错误能定位到行号
OriginTrackedPropertiesLoader 是 Boot 自己实现的 properties 解析器(没有 用 java.util.Properties.load)。
原因:Properties.load 会丢掉位置信息。Boot 需要知道"这个 key 在文件第几行",所以自己写了逐行解析器,为每个 key 记录 TextResourceOrigin(资源 + 行号 + 列号)。
同理 OriginTrackedYamlLoader 包装了 SnakeYAML,在解析时把节点位置记录到 Origin。
最终产生的 PropertySource 是 OriginTrackedMapPropertySource:
java
public class OriginTrackedMapPropertySource extends EnumerablePropertySource<Map<String, Object>>
implements OriginLookup<String> {
// 可以查每个 key 的 Origin
}
整个链路:
javascript
文件字节 → OriginTrackedPropertiesLoader(逐行,记行号)
→ Map<String, OriginTrackedValue>
→ OriginTrackedMapPropertySource
→ ConfigurationProperty(携带 Origin)
→ BindException 的错误消息里显示 "application.properties:3:13"
这是 Boot 配置错误信息质量的技术基础 。对比其他框架只能说"配置错误",Boot 能说"第 3 行第 13 列的这个值有问题"。代价是:Boot 不能直接用 JDK 的 Properties 解析器,必须自己维护一套。这是"为诊断能力付出的实现成本"的典型例子。
7.3 SystemEnvironmentConfigDataLoader
spring.config.import 支持 env: 前缀?看 SystemEnvironmentConfigDataLocationResolver / SystemEnvironmentConfigDataLoader 的存在------说明有一种 location 能直接导入系统环境变量的子集。
(注:这套类的具体前缀语法我未逐行核实,不做断言。可确认的是它们成对存在,构成了"从环境变量导入 ConfigData"的能力。)
7.4 ConfigTreeConfigDataLoader
配合第 06 章讲的 ConfigTreePropertySource:
properties
spring.config.import=optional:configtree:/etc/config/
对应 ConfigTreeConfigDataLocationResolver + ConfigTreeConfigDataLoader。
第 8 节 · 错误与诊断:InvalidConfigDataPropertyException
8.1 两张错误映射表(实测)
java
public class InvalidConfigDataPropertyException extends ConfigDataException {
private static final Map<ConfigurationPropertyName, ConfigurationPropertyName> ERRORS;
static {
Map<ConfigurationPropertyName, ConfigurationPropertyName> errors = new LinkedHashMap<>();
errors.put(ConfigurationPropertyName.of("spring.profiles"),
ConfigurationPropertyName.of("spring.config.activate.on-profile"));
errors.put(ConfigurationPropertyName.of("spring.profiles[0]"),
ConfigurationPropertyName.of("spring.config.activate.on-profile"));
ERRORS = Collections.unmodifiableMap(errors);
}
private static final Set<ConfigurationPropertyName> PROFILE_SPECIFIC_ERRORS;
static {
Set<ConfigurationPropertyName> errors = new LinkedHashSet<>();
errors.add(Profiles.INCLUDE_PROFILES); // spring.profiles.include
errors.add(Profiles.INCLUDE_PROFILES.append("[0]"));
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME));
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME + "[0]"));
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME));
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME + "[0]"));
PROFILE_SPECIFIC_ERRORS = Collections.unmodifiableSet(errors);
}
// ...
}
表一 ERRORS(带替换建议的非法属性):
| 非法属性 | 应改用 |
|---|---|
spring.profiles |
spring.config.activate.on-profile |
spring.profiles[0] |
spring.config.activate.on-profile |
这是一个 2.4 的破坏性变更 :
spring.profiles(单数,用作"该文档在哪个 profile 下生效")在 2.4 被spring.config.activate.on-profile取代。旧写法会报错而不是静默失效------Boot 给了明确的替换指引。注意
spring.profiles与spring.profiles.active是不同的属性:前者被移除,后者依然有效。
表二 PROFILE_SPECIFIC_ERRORS(profile-specific 文件里禁止出现的属性):
spring.profiles.include/spring.profiles.include[0]spring.profiles.active/spring.profiles.active[0]spring.profiles.default/spring.profiles.default[0]
这就是"不能在
application-prod.properties里写spring.profiles.active"这条规则的精确实现。注意它同时注册了索引形式 (
[0])。这堵住了spring.profiles.active[0]=xxx的绕过写法------一个典型的"补丁式防御"。
8.2 ConfigDataNotFoundFailureAnalyzer
java
class ConfigDataNotFoundFailureAnalyzer extends AbstractFailureAnalyzer<ConfigDataNotFoundException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, ConfigDataNotFoundException cause) {
ConfigDataLocation location = getLocation(cause);
Origin origin = Origin.from(location);
String message = String.format("Config data %s does not exist", cause.getReferenceDescription());
StringBuilder action = new StringBuilder("Check that the value ");
if (location != null) {
action.append(String.format("'%s' ", location));
}
if (origin != null) {
action.append(String.format("at %s ", origin));
}
action.append("is correct");
if (location != null && !location.isOptional()) {
action.append(String.format(", or prefix it with '%s'", ConfigDataLocation.OPTIONAL_PREFIX));
}
return new FailureAnalysis(message, action.toString(), cause);
}
// ...
}
生成的错误信息:
csharp
Description:
Config data resource 'class path resource [application.properties]' via location 'classpath:/' does not exist
Action:
Check that the value 'classpath:/foo/' at 'class path resource [application.yml] - 5:11' is correct, or prefix it with 'optional:'
注意 Action 里同时给出了:
- 出错的 location 值
- 它在哪个文件的哪一行 被声明(
Origin) - 具体的修复方法 (加
optional:)
这是
Origin机制价值的最好体现:错误信息能追溯到"这个错误配置是从哪一行来的",而不只是"配置错了"。而且第 3 点("or prefix it with 'optional:'")是条件性的------只在 location 非 optional 时才提示。已经 optional 却还失败的情况不会给这个(无意义的)建议。
第 9 节 · 如何观察 ConfigData 的内部状态
9.1 TRACE 日志
ConfigData 的每一步都有 TRACE 日志:
properties
logging.level.org.springframework.boot.context.config=TRACE
会输出:
arduino
Building config data environment contributors
Creating wrapped config data contributor for 'commandLineArgs'
Adding initial config data import from locations [...]
Processing initial config data environment contributors without activation context
Processing imports for phase BEFORE_PROFILE_ACTIVATION. no activation context
Processing imports [optional:classpath:/;optional:classpath:/config/]
Imported 2 resources [class path resource [application.properties], ...]
Creating config data activation context from initial contributions
Deducing profiles from current config data environment contributors
Processing imports for phase AFTER_PROFILE_ACTIVATION. ...
Adding imported property source 'Config resource ... via location ...'
Skipping inactive property source '...'
Setting default profiles: [default]
Setting active profiles: [prod]
这是理解 ConfigData 行为最直接的方式 。每一个 logger.trace 都是一个可观察的检查点。
实践建议 :遇到"配置文件没被加载""profile 不对"的问题,第一件事就是打开这个 TRACE。比任何文档都准。
9.2 用 ConfigDataEnvironmentUpdateListener 编程式观察
java
public static void applyTo(ConfigurableEnvironment environment, @Nullable ResourceLoader resourceLoader,
@Nullable ConfigurableBootstrapContext bootstrapContext, Collection<String> additionalProfiles,
ConfigDataEnvironmentUpdateListener environmentUpdateListener) { ... }
ConfigDataEnvironmentUpdateListener 接口(本仓库存在)允许你在两个时刻收到通知:
java
this.environmentUpdateListener.onPropertySourceAdded(propertySource, contributor.getLocation(),
contributor.getResource());
// ...
this.environmentUpdateListener.onSetProfiles(profiles);
这是为 Spring Cloud 等框架预留的扩展点。Spring Cloud Config 用它来感知"Boot 加载了哪些配置、最终 profile 是什么",从而在正确的时机注入远程配置。
9.3 独立运行 ConfigData(不启动应用)
java
public static void applyTo(ConfigurableEnvironment environment) {
applyTo(environment, null, null, Collections.emptyList());
}
ConfigDataEnvironmentPostProcessor.applyTo(environment) 可以对任意 Environment 应用 ConfigData 处理。这在测试里极其有用:
java
@Test
void configDataLoadsExpectedProperties() {
ConfigurableEnvironment env = new StandardEnvironment();
ConfigDataEnvironmentPostProcessor.applyTo(env);
assertThat(env.getProperty("my.key")).isEqualTo("expected");
}
注意:applyTo 用的是 new DefaultBootstrapContext()(一次性)和 Supplier::get 作为 logFactory(无 DeferredLog)。
第 10 节 · 六个典型故障与源码级解释
故障一:设了 spring.config.location 后 application.properties 不加载
现象 :--spring.config.location=classpath:my.properties 后,application.properties 里的配置全没了。
源码解释 :LOCATION_PROPERTY 的默认值是 DEFAULT_SEARCH_LOCATIONS,binder.bind(...).orElse(defaultValue)------一旦绑定成功(用户设置了),默认值不再生效。
解法 :用 spring.config.additional-location(追加)或 spring.config.import(现代写法)。
故障二:spring.config.import 找不到文件时整个应用起不来
现象 :spring.config.import=/etc/config/app.properties,文件不存在 → 启动失败。
源码解释 :ConfigDataLocation 非 optional,checkMandatoryLocations 里未被 loaded/optional 覆盖 → notFoundAction.handle(...),默认 FAIL。
解法:
spring.config.import=optional:/etc/config/app.properties- 或全局
spring.config.on-not-found=ignore
故障三:optional: 只保护了第一个位置
现象 :spring.config.import=optional:a.properties;b.properties,b 不存在仍失败。
源码解释 :split() 对每个片段独立 调用 ConfigDataLocation.of(...),optional: 前缀是每片段解析的。
解法 :optional:a.properties;optional:b.properties
故障四:在 application-prod.properties 里激活其它 profile 报错
现象:
csharp
Property 'spring.profiles.active' imported from location '...application-prod.properties' is invalid in a profile specific resource
源码解释 :InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS 显式列出这些属性;checkForInvalidProperties 遍历所有 contributor 检查。
解法 :把 spring.profiles.active 移到非 profile-specific 的文件,或用 spring.profiles.group / spring.profiles.include(在非 profile-specific 文件里)。
故障五:configtree 挂载的配置里写了 spring.profiles.active 无效
现象 :spring.config.import=configtree:/etc/config/,/etc/config/spring.profiles.active 内容是 prod,未生效。
源码解释 :ConfigTreeConfigDataLoader 产出的 ConfigData 带 ConfigData.Option.IGNORE_PROFILES;withProfiles 和 getIncludedProfiles 都用 !contributor.hasConfigDataOption(IGNORE_PROFILES) 过滤掉它。
解法:profile 必须在非 configtree 的来源里指定。这是刻意的安全设计。
故障六:spring.profiles 报"应使用 spring.config.activate.on-profile"
现象:
csharp
Property 'spring.profiles' imported from location '...' is invalid, and should be replaced with 'spring.config.activate.on-profile'
源码解释 :InvalidConfigDataPropertyException.ERRORS 的第一个映射。2.4 的破坏性变更。
解法 :改用 spring.config.activate.on-profile。
第 11 节 · AOT / Native 下的 ConfigData
11.1 三重固化
ConfigData 在 AOT 下有三处相关机制:
(1)ConfigDataPropertiesRuntimeHints 与 ConfigDataLocationRuntimeHints
文件列表里明确有这两个类(ConfigDataPropertiesRuntimeHints.java、ConfigDataLocationRuntimeHints.java)。它们为 native 登记 ConfigDataProperties 和 ConfigDataLocation 的反射信息。
因为 ConfigDataProperties 是通过构造器绑定 (@Name("import") 注解在构造参数上)绑定的------构造器绑定需要反射获取参数名,这在 native 下必须显式登记。
(2)profiles 固化(第 06 章讲过)
EnvironmentAotContribution 把 activeProfiles 写进生成的 __EnvironmentPostProcessor。
(3)ConfigDataEnvironmentPostProcessor 自身的 AOT 路径
由于 ConfigDataEnvironmentPostProcessor 仍在 spring.factories 里,AOT 模式下它会照常运行------但它的行为会走 AOT 优化分支(跳过文件系统扫描,用构建期结果)。
我没有逐行核实该 AOT 分支的实现细节 ,因此不做具体断言。可确认的事实是:AOT 生成的
__EnvironmentPostProcessor是add(0, ...)加在最前面 ,不替换原列表,所以ConfigDataEnvironmentPostProcessor仍会执行。
11.2 实践后果
| 现象 | 原因 | 解法 |
|---|---|---|
native 下 SPRING_PROFILES_ACTIVE 无效 |
profiles 被构建期常量覆盖 | 构建期指定 |
| native 下新增配置文件不生效 | 位置扫描被固化 | 重新构建 |
spring.config.import=optional:... 在 native 下的行为 |
同上,构建期已决定 | 重新构建 |
核心心智模型 :native 下的 Environment 是构建期快照。任何"运行时才存在"的配置来源(挂载的文件、环境变量)都可能不生效。这是 AOT 的根本性权衡------用"启动时的动态性"换"启动速度和内存占用"。
11.3 JVM + spring.aot.enabled=true 也走这条路
AotDetector.useGeneratedArtifacts() 在 JVM 模式下也会返回 true (如果启用了 AOT)。所以上述后果不限于 native。
测试陷阱 :在本地用
spring.aot.enabled=true跑测试来"模拟 native",你会遇到和 native 一样的配置固化问题。这既是好事(能提前发现问题),也可能造成"本地测试通过但配置不生效"的困惑。
第 12 节 · 12 条反直觉结论汇总
-
spring.config.location会替换默认位置,不是追加 。orElse(DEFAULT_SEARCH_LOCATIONS)的语义决定了这一点。想追加用additional-location或import。 -
optional:只作用于单个片段 。split(";")后每个片段独立解析前缀。optional:a;b只有a是 optional。 -
spring.profiles.active写在 profile-specific 文件里会报错,不是被忽略 。InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS显式检查,且连[0]索引形式的绕过也堵住了。 -
spring.profiles(无后缀)已被移除 ,替换品是spring.config.activate.on-profile。错误映射表里有明确的替换指引。 -
spring.config.import写在application-prod.properties里是有效的 (轮③处理),但它引入的文件里的spring.profiles.active不会被采纳(profiles 已求解完)。 -
activationContext == null时所有带activate条件的文档都视为未激活。轮①不做任何激活判断------这是防止信息不足时误判。 -
必须三轮迭代 而非一轮:
import的处理依赖activate的判断,activate的判断依赖 profiles,profiles 又来自配置文件。这是不动点迭代。 -
DENY_INACTIVE_BINDING→ALLOW_INACTIVE_BINDING的切换发生在最后一轮。前两轮禁止从"未确定是否激活"的文档读 profile 信息,这是破解循环依赖的关键。 -
contributor 树是不可变的 。每次变更
new ConfigDataEnvironmentContributors(...)+withReplacement。这是持久化数据结构风格。 -
ImportPhase影响优先级 :AFTER_PROFILE_ACTIVATION的子节点在迭代时排在前面 ,这就是application-{profile}.properties优先级更高的结构原因。 -
**Boot 至少用了四处"逆序插入"**来表达"后声明者优先":
addFirst于 deque(扩展名)、for(i=n-1;i>=0;i--)(多个 location)、倒序load(多个 candidate)、addLast于目标(contributor → PropertySource)。 -
configtree / 部分远程来源带
IGNORE_PROFILES,它们的文件里的spring.profiles.*不参与 profile 决策------这是刻意的安全设计,防止"改挂载文件就改变应用 profile"。
补充三条:
-
profile group 用栈展开,
Set.add的返回值防循环 。循环定义(a→b→a)不崩溃,但展开结果取决于首次访问顺序。 -
resolved.isEmpty()时会尝试"解析空目录",生成一个占位资源。这让"导入一个空目录"不会被误判为导入失败。 -
错误信息能定位到行号,代价是 Boot 自己实现了 properties 解析器 (不能用 JDK 的
Properties.load,因为它丢位置信息)。
第 13 节 · 动手验证清单
bash
# 1. 打开 TRACE 观察完整流程(最有价值的手段)
java -jar app.jar --logging.level.org.springframework.boot.context.config=TRACE
# 2. 验证 location 替换默认位置
java -jar app.jar --spring.config.location=classpath:none.properties
# → application.properties 不再加载
# 3. 验证 additional-location 是追加
java -jar app.jar --spring.config.additional-location=classpath:extra.properties
# → application.properties 和 extra.properties 都加载
# 4. 验证 optional: 只作用于单片段
java -jar app.jar --spring.config.import=optional:a.properties;b.properties
# → b.properties 不存在时失败
# 5. 验证 profile-specific 文件里激活 profile 会报错
# application-prod.properties 里写 spring.profiles.active=xxx
java -jar app.jar --spring.profiles.active=prod
# → InvalidConfigDataPropertyException
# 6. 验证 spring.profiles 已被移除
echo "spring.profiles=dev" > application.properties
java -jar app.jar
# → 提示改用 spring.config.activate.on-profile
# 7. 验证 yaml 多文档块 + activate
cat > application.yml <<'EOF'
my.value: base
---
spring:
config:
activate:
on-profile: prod
my.value: prod-value
EOF
java -jar app.jar --spring.profiles.active=prod # → prod-value
java -jar app.jar # → base
# 8. 验证 profile group 展开
# spring.profiles.group.prod=proddb,prodmq
java -jar app.jar --spring.profiles.active=prod
# → active = [prod, proddb, prodmq]
# 9. 验证 configtree 的 IGNORE_PROFILES
mkdir -p /tmp/ct && echo -n "prod" > /tmp/ct/spring.profiles.active
java -jar app.jar --spring.config.import=configtree:/tmp/ct/
# → profile 未激活(被 IGNORE_PROFILES 过滤)
# 10. 验证 import 的相对父目录
# classpath:/config/application.properties 里写
# spring.config.import=extra.properties
# → 解析为 classpath:/config/extra.properties
# 11. 独立测试 ConfigData(不启动应用)
# ConfigDataEnvironmentPostProcessor.applyTo(env)
# 12. 验证 native 下 profiles 为构建期常量
./gradlew nativeCompile
SPRING_PROFILES_ACTIVE=dev ./app # → 无效
第 14 节 · 源码索引
| 主题 | 文件 |
|---|---|
| 入口 PostProcessor | context/config/ConfigDataEnvironmentPostProcessor.java |
| 六步主流程 | context/config/ConfigDataEnvironment.java → processAndApply() |
| 位置模型 | context/config/ConfigDataLocation.java |
| 标准位置解析 | context/config/StandardConfigDataLocationResolver.java |
| 引用 | context/config/StandardConfigDataReference.java |
| 资源 | context/config/StandardConfigDataResource.java |
| 解析器集合 | context/config/ConfigDataLocationResolvers.java |
| 加载器集合 | context/config/ConfigDataLoaders.java |
| 导入调度 | context/config/ConfigDataImporter.java |
| contributor | context/config/ConfigDataEnvironmentContributor.java(含 Kind、ImportPhase) |
| contributor 集合 | context/config/ConfigDataEnvironmentContributors.java |
| 属性绑定 | context/config/ConfigDataProperties.java(含 Activate) |
| profile 求解 | context/config/Profiles.java |
| profile 校验 | context/config/ProfilesValidator.java |
| 激活上下文 | context/config/ConfigDataActivationContext.java |
| 非法属性异常 | context/config/InvalidConfigDataPropertyException.java |
| 未激活访问异常 | context/config/InactiveConfigDataAccessException.java |
| 找不到位置异常 | context/config/ConfigDataLocationNotFoundException.java |
| 找不到资源异常 | context/config/ConfigDataResourceNotFoundException.java |
| 失败分析器 | context/config/ConfigDataNotFoundFailureAnalyzer.java |
| configtree 支持 | context/config/ConfigTreeConfigData{LocationResolver,Loader,Resource}.java |
| 环境变量支持 | context/config/SystemEnvironmentConfigData{LocationResolver,Loader,Resource}.java |
| properties 加载(含 Origin) | env/OriginTrackedPropertiesLoader.java |
| yaml 加载(含 Origin) | env/OriginTrackedYamlLoader.java |
| 带 Origin 的 PropertySource | env/OriginTrackedMapPropertySource.java |
| 更新监听器 | context/config/ConfigDataEnvironmentUpdateListener.java |
| AOT hints | context/config/ConfigDataPropertiesRuntimeHints.java、ConfigDataLocationRuntimeHints.java |
本章未逐行核实 / 待你实测的项
Type枚举的完整定义未逐行读取(文中已声明)。- 环境变量导入 ConfigData 的前缀语法未逐行核实(文中已声明)。
- 各
ConfigDataLocationResolver的优先级常量未逐个列出。 spring.config.import的全部书写形式未穷举。- contributor 树在深度嵌套、循环 import 下的行为未实测。
下一章:第 08 章进入 Binder 绑定引擎------从字符串到 Java 对象的完整路径。
阶段性小结(写至第 14 节)
ConfigData 的复杂度来自一个根本性的循环依赖:
配置文件的内容决定了要加载哪些配置文件,而要知道加载哪些配置文件又得先读配置文件。
Boot 的破解方式是不动点迭代 + 分阶段激活:
- 第一轮:无条件下加载初始位置
- 建立 activation context(含云平台)
- 第二轮:能判断
on-cloud-platform条件 - 求解 profiles (从已加载的配置里读
spring.profiles.*) - 第三轮:能判断
on-profile条件,并迭代到不动点 - 应用到 Environment
而为了防止"从尚未确定是否激活的文档里读 profile"造成的不确定性,Boot 用了 DENY_INACTIVE_BINDING 机制------在 profiles 确定前,任何来自非激活文档的 profile 信息都会导致启动失败。这是把"可能静默出错"变成"明确失败"的典范。
三个最值得记住的工程洞察:
- "宁可失败也不静默" :
InvalidConfigDataPropertyException的两张映射表、checkMandatoryLocations的四步过滤、InactiveConfigDataAccessException------Boot 在配置这件事上选择了严格。 - "逆序插入表达优先级":至少四处。在无权重字段的列表结构里,这是表达顺序语义的通用技巧。
- "为诊断能力付出实现成本":自己写 properties 解析器只为保留行号信息。
下一章(第 08 章)我们进入 Binder------ConfigData 把属性加载进 Environment 之后,属性是如何变成 Java 对象的。那是第 06 章提到的"第三套查找系统",也是 @ConfigurationProperties 的全部秘密。
第 15 节 · ConfigData 类本身:一个被 Javadoc 直接回答的关键问题
15.1 类结构
java
public final class ConfigData {
private final List<PropertySource<?>> propertySources;
private final PropertySourceOptions propertySourceOptions;
public static final ConfigData EMPTY = new ConfigData(Collections.emptySet());
public ConfigData(Collection<? extends PropertySource<?>> propertySources, Option... options) {
this(propertySources, PropertySourceOptions.always(Options.of(options)));
}
public ConfigData(Collection<? extends PropertySource<?>> propertySources,
PropertySourceOptions propertySourceOptions) {
Assert.notNull(propertySources, "'propertySources' must not be null");
Assert.notNull(propertySourceOptions, "'propertySourceOptions' must not be null");
this.propertySources = Collections.unmodifiableList(new ArrayList<>(propertySources));
this.propertySourceOptions = propertySourceOptions;
}
/**
* Return the configuration data property sources in ascending priority order. If the
* same key is contained in more than one of the sources, then the later source will
* win.
* @return the config data property sources
*/
public List<PropertySource<?>> getPropertySources() {
return this.propertySources;
}
// ...
}
15.2 Javadoc 里那句话解决了第 3.4 节的悬案
"Return the configuration data property sources in ascending priority order. If the same key is contained in more than one of the sources, then the later source will win."
"ascending priority order"(优先级升序)------后面的 source 胜出。
这是 Boot 源码里对该列表顺序语义的权威声明。有了它,第 3.4 节的悬案可以收敛了:
getReferencesForConfigName用addFirst把 deque 变成[yaml, yml, xml, properties](扩展名逆序)- 该 deque 的顺序最终进入
ConfigData.propertySources - 按"后面的胜出" →
properties胜出,优先级最高
结论(现在可以写死) :在同一目录下,
application.properties优先于application.yml。之前我之所以推不出来,是因为只看了"contributor 迭代 →
addLast→ 下标"这一条链,而忽略了ConfigData内部对propertySources列表自身顺序的语义声明。两条链是叠加的 :ConfigData内部顺序(升序,后胜)先决定一个 contributor 内部多个 source 的相对顺序,然后 contributor 迭代顺序决定跨 contributor 的顺序。
同理,ConfigDataImporter.load 的倒序遍历:
java
for (int i = candidates.size() - 1; i >= 0; i--) { ... }
结合"后面的胜出"语义 → 倒序遍历产生的 result 中,candidates 里靠后的排在 result 靠后 → 靠后的胜出 → spring.config.import=a,b 中 b 优先级更高。
这印证了第 3.4 节那个"确定无疑的结论":Boot 用逆序手法表达"后声明者优先"。现在不仅手法确认,语义也由 Javadoc 确认了。
15.3 PropertySourceOptions:每个 source 可以有自己的 Option
java
public List<PropertySource<?>> getPropertySources() {
return this.propertySources;
}
public Options getOptions(PropertySource<?> propertySource) {
Options options = this.propertySourceOptions.get(propertySource);
return (options != null) ? options : Options.NONE;
}
@FunctionalInterface
public interface PropertySourceOptions {
PropertySourceOptions ALWAYS_NONE = new AlwaysPropertySourceOptions(Options.NONE);
@Nullable Options get(PropertySource<?> propertySource);
// ...
}
@since 2.4.5 ------ 这是一个补丁式增强。
为什么需要"每个 source 各自的 Option"而不是整个 ConfigData 一套?
因为一个 ConfigData 可能包含多个 PropertySource------典型场景是 yaml 多文档块:
yaml
# 文档块 1
my.value: a
---
# 文档块 2
spring.config.activate.on-profile: prod
my.value: b
YamlPropertySourceLoader.load() 返回两个 PropertySource(每个文档块一个)。而它们的 Option 可能不同(比如某些块来自 profile-specific 解析)。
PropertySourceOptions 是一个函数式接口,允许 loader 为每个 source 指定不同的 options。
这解释了 yaml 多文档块为什么能工作 :
spring.config.activate.on-profile的判断粒度是文档块 ,而文档块对应独立的 PropertySource,每个有独立的ConfigDataProperties(通过isActive判断)。
15.4 Options 的实现:EnumSet
java
// ConfigData.java 内部
public static final class Options {
// 基于 EnumSet<Option> 的不可变集合
static final Options NONE = new Options(EnumSet.noneOf(Option.class));
// ...
}
用 EnumSet 而非 HashSet------对枚举类型,EnumSet 是位向量实现,极快且占内存极小。这是 Boot 在性能敏感处的典型选择。
第 16 节 · 解析器/加载器的分派机制
16.1 ConfigDataLocationResolvers 的构造与注入
java
ConfigDataLocationResolvers(DeferredLogFactory logFactory, ConfigurableBootstrapContext bootstrapContext,
Binder binder, ResourceLoader resourceLoader, SpringFactoriesLoader springFactoriesLoader) {
ArgumentResolver argumentResolver = ArgumentResolver.of(DeferredLogFactory.class, logFactory);
argumentResolver = argumentResolver.and(Binder.class, binder);
argumentResolver = argumentResolver.and(ResourceLoader.class, resourceLoader);
argumentResolver = argumentResolver.and(ConfigurableBootstrapContext.class, bootstrapContext);
argumentResolver = argumentResolver.and(BootstrapContext.class, bootstrapContext);
argumentResolver = argumentResolver.and(BootstrapRegistry.class, bootstrapContext);
argumentResolver = argumentResolver.andSupplied(Log.class, () -> {
throw new IllegalArgumentException("Log types cannot be injected, please use DeferredLogFactory");
});
this.resolvers = reorder(springFactoriesLoader.load(ConfigDataLocationResolver.class, argumentResolver));
}
ArgumentResolver:构造器注入
SpringFactoriesLoader.ArgumentResolver 是 Spring Framework 6 的机制------实例化 spring.factories 里的类时,按类型解析构造器参数。
可注入的类型(实测清单):
| 类型 | 注入的值 |
|---|---|
DeferredLogFactory |
延迟日志工厂 |
Binder |
初始 Binder |
ResourceLoader |
资源加载器 |
ConfigurableBootstrapContext |
引导上下文(可写) |
BootstrapContext |
引导上下文(只读) |
BootstrapRegistry |
引导注册表 |
Log 是被显式禁止的:
java
argumentResolver = argumentResolver.andSupplied(Log.class, () -> {
throw new IllegalArgumentException("Log types cannot be injected, please use DeferredLogFactory");
});
注意这是 andSupplied(提供 Supplier)而不是 and(提供值)------每次请求 Log 时都会执行这个 Supplier 并抛异常。
这是第 06 章"必须用 DeferredLog"约束在 SPI 层的强制 。Boot 不只是"建议"你用
DeferredLogFactory,而是让你的扩展类根本无法注入普通Log------一旦你的 resolver 构造器声明了Log参数,实例化时就抛IllegalArgumentException,且错误信息直接告诉你该用什么。这是一个极佳的框架设计范式:把"容易犯的错"变成"编译/装配期必然失败",而不是"运行期行为诡异"。
16.2 reorder():把标准解析器强制排到最后
java
@SuppressWarnings("rawtypes")
private List<ConfigDataLocationResolver<?>> reorder(List<ConfigDataLocationResolver> resolvers) {
List<ConfigDataLocationResolver<?>> reordered = new ArrayList<>(resolvers.size());
ConfigDataLocationResolver<?> standardConfigDataLocationResolver = null;
for (ConfigDataLocationResolver<?> resolver : resolvers) {
if (resolver instanceof StandardConfigDataLocationResolver) {
standardConfigDataLocationResolver = resolver;
}
else {
reordered.add(resolver);
}
}
if (standardConfigDataLocationResolver != null) {
reordered.add(standardConfigDataLocationResolver);
}
return Collections.unmodifiableList(reordered);
}
这是一个必须存在的重排。
理由:看 StandardConfigDataLocationResolver 的两个方法:
java
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return true; // ★ 永远返回 true
}
isResolvable 恒为 true ,且 getOrder() 是 LOWEST_PRECEDENCE。
而分派逻辑是:
java
List<ConfigDataResolutionResult> resolve(ConfigDataLocationResolverContext context,
@Nullable ConfigDataLocation location, @Nullable Profiles profiles) {
if (location == null) {
return Collections.emptyList();
}
for (ConfigDataLocationResolver<?> resolver : getResolvers()) {
if (resolver.isResolvable(context, location)) {
return resolve(resolver, context, location, profiles);
}
}
throw new UnsupportedConfigDataLocationException(location);
}
遍历,第一个 isResolvable == true 的胜出,立即返回。
所以如果 StandardConfigDataLocationResolver 排在前面,它会吃掉所有 location (因为恒 true),专门的 resolver(configtree:、env:、Spring Cloud 的 configserver:)永远没机会。
reorder() 把它挪到最后 = 让它成为兜底。
这是"通用处理器必须排最后"的经典模式。同类模式在 Boot 里还有:
ConfigDataLocationResolvers.reorder()(本例)- 条件注解的评估顺序
HttpMessageConverter的链如果你写自定义
ConfigDataLocationResolver,不需要 担心顺序------reorder()保证你的 resolver 一定在Standard之前被尝试。但要让你的isResolvable精确地只对自己的前缀返回true,否则会误吃别人的 location。
为什么不用 Ordered 排序?
注意 reorder() 没有 用 getOrder() 排序------它是硬编码的"把 Standard 挪最后"。而 StandardConfigDataLocationResolver.getOrder() 返回 LOWEST_PRECEDENCE 只是表达意图 ,实际生效的是 reorder() 的硬编码。
这是一个"意图声明"与"实现强制"并存的例子 :
getOrder()声明了意图(我应该最低优先),reorder()保证了实现(你确实会排最后)。两者缺一不可------如果只靠getOrder()而某个第三方 resolver 也声明了更低的 order,就会出问题。
16.3 resolve 的两个阶段:普通 + profile-specific
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);
}
- 总是先
resolve(context, location)(非 profile-specific) - 若
profiles != null,再resolveProfileSpecific(...) merge(resolved, profileSpecific)------ profile-specific 排在后面
结合第 15.2 节"后面的胜出"→ profile-specific 的资源优先级更高 ✓
这又印证了
application-{profile}.properties优先于application.properties,且这次是从ConfigDataResolutionResult列表顺序直接得出的,与第 4.3 节的ImportPhase结论一致。两条独立的证据链指向同一结论。
ConfigDataResolutionResult 的第三个字段 profileSpecific 记录了这个标记,供后续处理使用。
16.4 UnsupportedConfigDataLocationException
如果没有任何 resolver 能处理该 location(不可能发生,因为 Standard 恒 true,除非 Standard 被排除),抛此异常。
实际上由于 Standard 恒 true,这个异常在正常配置下不会抛出。它是防御性的------万一有人修改了 resolver 列表。
第 17 节 · 端到端追踪:一个完整案例
把前面所有机制串起来。假设:
环境:
bash
java -jar app.jar --spring.profiles.active=prod
文件:
ini
classpath:/application.properties
my.value=base
spring.profiles.group.prod=proddb
spring.config.import=optional:classpath:/extra/
classpath:/extra/application.properties
extra.value=fromExtra
classpath:/application-prod.properties
my.value=prod-value
(无 application-proddb.properties)
追踪
阶段 0:Environment 初始状态
ini
[0] commandLineArgs --spring.profiles.active=prod
[1] systemProperties
[2] systemEnvironment
[3] defaultProperties (空则不加)
[4] applicationInfo
(configurationProperties 在 attach 后插到 0,这里略去)
阶段 1:createContributors
遍历 propertySources,为每个(除 defaultProperties)创建 EXISTING contributor:
scss
ROOT
├─ EXISTING(commandLineArgs)
├─ EXISTING(systemProperties)
├─ EXISTING(systemEnvironment)
└─ EXISTING(applicationInfo)
然后添加 initial import contributors。三个属性:
spring.config.import:无 →EMPTY_LOCATIONS→ 不添加spring.config.additional-location:无 → 不添加spring.config.location:无 → 用DEFAULT_SEARCH_LOCATIONS,registerIndividually=true,倒序:
scss
ROOT
├─ EXISTING(...) × 4
├─ INITIAL_IMPORT(file:./;file:./config/;file:./config/*/) ← i=1 先加
└─ INITIAL_IMPORT(classpath:/;classpath:/config/) ← i=0 后加
(defaultProperties 若有,最后加)
阶段 2:轮① processInitial(无 activation context)
withProcessedImports 循环:
getNextToProcess找到第一个INITIAL_IMPORT(file 组)- 它不是
UNBOUND_IMPORT,走 else 分支:importer.resolveAndLoad(...)profiles == null→ 只调resolve,不调resolveProfileSpecificStandardConfigDataLocationResolver处理(其他 resolver 先试,都不可解析)- 目录分支 → 生成 references → 大部分不存在(optional 跳过)
- 结果:空
- 继续找下一个
INITIAL_IMPORT(classpath 组)- 找到
classpath:/application.properties→ 加载 →ConfigData - 生成子 contributor:
UNBOUND_IMPORT
- 找到
- 下一轮循环:
getNextToProcess找到UNBOUND_IMPORT(优先)withBoundProperties→ 绑定spring.config.*- 得到
ConfigDataProperties(imports=[optional:classpath:/extra/], activate=null) - 变成
BOUND_IMPORT
- 下一轮:找到
BOUND_IMPORT(有未处理 imports,且 active)- 处理
optional:classpath:/extra/ - 加载
classpath:/extra/application.properties→ 新的UNBOUND_IMPORT
- 处理
- 下一轮:绑定它 →
BOUND_IMPORT(无 imports) - 没有更多 → 返回
TRACE 日志大致:
vbnet
Processing imports for phase BEFORE_PROFILE_ACTIVATION. no activation context
Processing imports [optional:file:./;optional:file:./config/;...]
Nothing imported
Processing imports [optional:classpath:/;optional:classpath:/config/]
Imported 1 resource [class path resource [application.properties]]
Processing imports [optional:classpath:/extra/]
Imported 1 resource [class path resource [extra/application.properties]]
Processed imports for of 3 contributors
阶段 3:createActivationContext
从当前 contributors 的 Binder 创建 ConfigDataActivationContext:
- 读
spring.main.cloud-platform→CloudPlatform(本例未设,自动检测 → 可能NONE) profiles= null
阶段 4:轮② processWithoutProfiles
带 activationContext(含 cloud platform)再走一遍。本例无 on-cloud-platform 条件,无新增。
阶段 5:withProfiles(求解 profiles)
java
Binder binder = contributors.getBinder(activationContext,
(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES),
BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles); // 空
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext)); // 读 spring.profiles.include → 无
Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);
Profiles 构造:
groups={prod: [proddb]}activeProfiles:getProfiles(environment, binder, validator, Type.ACTIVE)environment.getProperty("spring.profiles.active")="prod"environment.getActiveProfiles()=[](还没设)hasProgrammaticallySetProfiles(...):属性有值且environmentProfiles等于默认值 → 返回 false- 所以走
boundProfiles.orElse(...)→binder.bind("spring.profiles.active", Set<String>)={prod} expandProfiles(["prod"]):- 栈:
[prod] - pop
prod,加入 expandedProfiles groups.get("prod")=[proddb],逆序压栈 →[proddb]- pop
proddb,加入(无 group) - 结果:
[prod, proddb]
- 栈:
defaultProfiles=["default"]
结果 :activeProfiles = [prod, proddb],defaultProfiles = [default]
阶段 6:轮③ processWithProfiles
ImportPhase = AFTER_PROFILE_ACTIVATION
对每个有 location 的 contributor 调 resolveProfileSpecific:
classpath:/→classpath:/application-prod.properties(存在)、classpath:/application-proddb.properties(不存在,optional 跳过)classpath:/config/→ 无file:./等 → 无classpath:/extra/→application-prod.properties(不存在)
生成新的 contributor,标记 profileSpecific = true(在 ConfigDataResolutionResult 里),排在 merge 结果的后面 → 优先级更高。
阶段 7:applyToEnvironment
java
checkForInvalidProperties(contributors);
checkMandatoryLocations(...);
MutablePropertySources propertySources = this.environment.getPropertySources();
applyContributor(contributors, activationContext, propertySources);
DefaultPropertiesPropertySource.moveToEnd(propertySources);
this.environment.setDefaultProfiles(StringUtils.toStringArray(profiles.getDefault()));
this.environment.setActiveProfiles(StringUtils.toStringArray(profiles.getActive()));
this.environmentUpdateListener.onSetProfiles(profiles);
applyContributor 遍历所有 BOUND_IMPORT,isActive 的 addLast:
最终 MutablePropertySources(简化):
csharp
[0] commandLineArgs
[1] systemProperties
[2] systemEnvironment
[3] applicationInfo(原位置,后被 moveToEnd? applicationInfo 不走 moveToEnd 在这里)
[4] ConfigData: classpath:/extra/application.properties
[5] ConfigData: classpath:/application.properties
[6] ConfigData: classpath:/application-prod.properties
[7] defaultProperties(moveToEnd)
注意 6 在 5 之后 ------按第 15.2 节"后面的胜出",application-prod.properties 优先 ✓
最终值:
my.value=prod-value✓extra.value=fromExtra✓- active profiles =
[prod, proddb]✓
这个追踪展示了 ConfigData 的完整因果链。每一步都能在第 9 节的 TRACE 日志里对上号------这就是"打开 TRACE 比查文档准"的原因。
第 18 节 · 与 spring.factories 的关系:ConfigData 的扩展点清单
ConfigData 体系通过 spring.factories 提供四个扩展点:
properties
# core/spring-boot/src/main/resources/META-INF/spring.factories(部分)
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader
org.springframework.boot.EnvironmentPostProcessor=\
...
org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor,\
...
org.springframework.boot.context.config.ConfigDataLocationResolver=\
(Standard 的注册在别处,实际由 ConfigDataEnvironment 硬编码?)
org.springframework.boot.context.config.ConfigDataLoader=\
四个扩展点:
| 扩展点 | 作用 | 实现示例 |
|---|---|---|
PropertySourceLoader |
支持新的文件格式 | 自定义 .toml、.json loader |
ConfigDataLocationResolver |
支持新的位置协议 | configtree:、Spring Cloud 的 configserver: |
ConfigDataLoader |
从自定义 Resource 加载 | 远程 HTTP、数据库 |
EnvironmentPostProcessor |
整个 Environment 的后处理 | --- |
写一个自定义 location resolver 的完整骨架:
java
package com.example;
public class MyConfigDataLocationResolver
implements ConfigDataLocationResolver<MyConfigDataResource>, Ordered {
private final DeferredLogFactory logFactory;
private final Binder binder;
// ★ 构造器注入:只能用 DeferredLogFactory,不能用 Log
public MyConfigDataLocationResolver(DeferredLogFactory logFactory, Binder binder) {
this.logFactory = logFactory;
this.binder = binder;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 或任意值;reorder() 保证在 Standard 之前
}
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
// ★ 必须精确!否则会吃掉别人的 location
return location.hasPrefix("my:");
}
@Override
public List<MyConfigDataResource> resolve(ConfigDataLocationResolverContext context,
ConfigDataLocation location) throws ConfigDataNotFoundException {
String path = location.getNonPrefixedValue("my:");
return List.of(new MyConfigDataResource(location, path));
}
@Override
public List<MyConfigDataResource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
ConfigDataLocation location, Profiles profiles) {
// 返回 profile 专用资源,或空列表
return Collections.emptyList();
}
}
注册:
properties
org.springframework.boot.context.config.ConfigDataLocationResolver=\
com.example.MyConfigDataLocationResolver
配套的 loader:
java
public class MyConfigDataLoader implements ConfigDataLoader<MyConfigDataResource> {
private final DeferredLogFactory logFactory;
public MyConfigDataLoader(DeferredLogFactory logFactory) { ... }
@Override
public ConfigData load(ConfigDataLoaderContext context, MyConfigDataResource resource) {
// 返回 ConfigData,可指定 Option
return new ConfigData(propertySources, ConfigData.Option.IGNORE_PROFILES);
}
}
五个易错点:
- 构造器不能注入
Log(会抛异常)isResolvable必须精确匹配自己的前缀resolveProfileSpecific不能返回 null(返回空列表)ConfigDataResource要实现isOptional()(通常 delegate 给 location)- AOT 下要登记反射 hints(构造器注入需要)
第 19 节 · 补充结论与本章边界
19.1 补充的四条结论
-
ConfigData.propertySources是"升序优先级,后者胜出"(Javadoc 权威声明)。这解决了同目录下多扩展名、以及 profile-specific 的优先级判定。 -
StandardConfigDataLocationResolver被reorder()强制排到最后 ,因为它的isResolvable恒返回true。这是"通用处理器兜底"模式。 -
自定义 resolver 的构造器不能注入
Log------ArgumentResolver.andSupplied(Log.class, () -> throw ...)显式禁止。这是把"易犯的错"变成"装配期必然失败"的范式。 -
ConfigData内多个 PropertySource 可有不同Option(PropertySourceOptions,@since 2.4.5)------这是 yaml 多文档块能工作的基础。
19.2 本章未覆盖的内容(明确边界)
以下内容我没有逐行核实,因此不做断言,留待后续章节或读者自行验证:
Type枚举(ACTIVE/DEFAULT)的isMergeWithEnvironmentProfiles()具体值------只确认两者不同SystemEnvironmentConfigDataLocationResolver的具体前缀语法ConfigDataEnvironmentPostProcessor在 AOT 模式下的内部分支实现ProfilesValidator的具体校验规则SystemEnvironmentConfigDataLoader/ConfigTreeConfigDataLoader的完整实现
本系列的原则:能实证的写死,推不出的标注。以上属于后者。
19.3 与其他章节的关系
- 第 06 章 :
Environment的 PropertySource 次序------ConfigData 往里插 - 第 08 章 :
Binder------ConfigData 加载完后,属性如何变成 Java 对象 - 第 09 章 :
@ConfigurationProperties的绑定引擎(Binder 的上层) - 第 27 章:AOT/Native------ConfigData 的固化
本章小结
ConfigData 是 Spring Boot 配置体系里最庞大也最精巧的子系统。它的复杂度不是设计过度的产物,而是被一组真实需求倒逼出来的:
配置文件的内容决定要加载哪些配置文件 ------ 这是一个真实的循环依赖。
Boot 的破解方案由五个机制组成:
- 不动点迭代 :
withProcessedImports的while(true)循环 + 三轮主流程 - 分阶段激活 :
ImportPhase(BEFORE/AFTER_PROFILE_ACTIVATION)让 profile 专用资源在正确的时机加入 - 循环检测 :
InactiveConfigDataAccessException+DENY_INACTIVE_BINDING,让"从非激活文档读 profile"变成启动失败而非静默错误 - 不可变 contributor 树 :
withReplacement+new ConfigDataEnvironmentContributors(...),迭代安全 - 逆序表达优先级 :至少四处(
addFirst、for(i=n-1..)、倒序load、merge 顺序),语义由ConfigData.getPropertySources()的 Javadoc 权威定义
四个最值得记住的工程洞察:
- 把易犯的错变成必然失败 :
InvalidConfigDataPropertyException的两张映射表、Log注入的显式禁止、IGNORE_PROFILES的过滤------Boot 在配置这件事上选择了严格而非宽容。 - 通用处理器必须排最后 :
reorder()把StandardConfigDataLocationResolver(isResolvable恒 true)挪到最后,否则它会吃掉所有 location。 - 为诊断能力付出实现成本 :自己实现 properties 解析器只为保留行号信息;每个错误信息都带
Origin和可操作建议。 - 用 Javadoc 定义顺序语义 :
ConfigData.propertySources的"ascending priority order, later wins"是理解整个 ConfigData 优先级的钥匙------它不在任何教程里,只在源码注释里。
下一章(第 08 章)我们进入 Binder------第 06 章提到的"第三套查找系统"。ConfigData 把属性加载进 Environment 之后,Binder 负责把字符串变成 Java 对象。这是 @ConfigurationProperties、构造器绑定、宽松绑定、以及那些"配置值转换失败"错误的全部秘密所在。