Spring Boot 4.1.1 深度源码解析-第 11 章 · ConfigTree、env: 位置与环境变量映射:非文件型配置源的完整机制
源码根:
spring-boot-4.1.1主要包:org.springframework.boot.context.config、org.springframework.boot.env、org.springframework.boot.context.properties.source、org.springframework.boot.origin版本:Spring Boot 4.1.1 / Spring Framework 7.0.9
0. 本章的起点:第 10 章留下的第四个口子
第 10 章解决了 profile 求解、spring.config.activate.*、多文档解析三个问题,留下一个:
SPRING_PROFILES_ACTIVE这种大写下划线环境变量,是怎么变成spring.profiles.active的?
本章先回答它,再往前推两步——把整个"非 .properties / .yaml 文件"的配置源家族讲透:
| 源类型 | 位置语法 | 典型场景 |
|---|---|---|
| 目录树 | configtree:/etc/config/ | Kubernetes ConfigMap / Secret 卷挂载 |
| 环境变量内容 | env:APP_CONFIG[.yaml] | 把一整份 YAML 塞进一个环境变量 |
| 系统环境变量本身 | (无需位置,自动存在) | SPRING_PROFILES_ACTIVE=dev |
这三者的共同点是:它们都不是"一个文件"。而 ConfigData 的抽象(ConfigDataLocation → ConfigDataResource → ConfigData)恰好能把它们统一处理——这正是第 07 章说的"ConfigData 是一次彻底重写"的价值所在。
本章同时补齐第 10 章承诺的两项:
PropertySourceLoader在spring.factories中的注册顺序(影响.propertiesvs.yaml的优先级)ConfigDataEnvironmentUpdateListener
1. ConfigData 三张扩展点表:先看全貌
Boot 的 ConfigData 体系对外开放三个 SPI,全部通过 META-INF/spring.factories 注册(注意:不是 *.imports——ConfigData 的 SPI 仍然走传统的 SpringFactoriesLoader,这是 4.x 里少数没有迁移到模块化 imports 的地方)。
core/spring-boot/src/main/resources/META-INF/spring.factories 的相关片段:
# PropertySource Loaders
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader
# ConfigData Location Resolvers
org.springframework.boot.context.config.ConfigDataLocationResolver=\
org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver,\
org.springframework.boot.context.config.StandardConfigDataLocationResolver,\
org.springframework.boot.context.config.SystemEnvironmentConfigDataLocationResolver
# ConfigData Loaders
org.springframework.boot.context.config.ConfigDataLoader=\
org.springframework.boot.context.config.ConfigTreeConfigDataLoader,\
org.springframework.boot.context.config.StandardConfigDataLoader,\
org.springframework.boot.context.config.SystemEnvironmentConfigDataLoader
1.1 第一张表:PropertySourceLoader —— 解析格式
| 实现 | 扩展名 | 说明 |
|---|---|---|
PropertiesPropertySourceLoader | properties、xml | 第 10 章 §6.4 详述 |
YamlPropertySourceLoader | yml、yaml | 第 10 章 §6.1 详述 |
顺序确认:Properties 在前,Yaml 在后。这解答了第 10 章 §8.4 的悬念。
回看 getReferencesForConfigName 的 addFirst 堆积:
Deque<StandardConfigDataReference> references = new ArrayDeque<>();
for (PropertySourceLoader propertySourceLoader : this.propertySourceLoaders) {
for (String extension : propertySourceLoader.getFileExtensions()) {
StandardConfigDataReference reference = new StandardConfigDataReference(...);
if (!references.contains(reference)) {
references.addFirst(reference);
}
}
}
return references;
遍历顺序 properties, xml, yml, yaml,addFirst 后 deque 的迭代顺序(顶→底)是 yaml, yml, xml, properties。
ConfigData.getPropertySources() 是升序、后者胜,所以 properties 排在最后 → 优先级最高。
✅ 结论确认:
application.properties覆盖application.yaml。第 10 章 §8.4 的推演在此得到spring.factories的实证支持。
1.2 第二张表:ConfigDataLocationResolver —— 解析位置
三个内置 resolver。但实际生效顺序不等于注册顺序,因为有一个 reorder():
// ConfigDataLocationResolvers
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));
}
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 被强制移到最后。为什么?看它的 isResolvable:
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return true; // ← 永远 true
}
它是兜底 resolver,任何位置它都"认领"。如果它排在第一位,configtree:、env: 就永远轮不到自己的 resolver。
而分发算法是"第一个 isResolvable 返回 true 的胜出":
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);
}
第一个匹配即返回,不是全部尝试。所以顺序至关重要,reorder() 把兜底的 Standard 放最后。
另外 StandardConfigDataLocationResolver 自己也实现了 Ordered:
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
这是双保险——即使某个第三方库的 SpringFactoriesLoader 不遵循声明顺序,或者用户自定义了 ordering,getOrder() 也能表达意图。但注意:reorder() 是硬编码 instanceof 检查,不读 getOrder()。getOrder() 只是给其它排序机制(如果有)看的。
方法论提示(第七定律):Boot 里"兜底实现"通常同时用三种方式表达自己: ①
isResolvable返回true;②getOrder()返回LOWEST_PRECEDENCE;③ 在容器里被硬编码移到末尾。 看到这三个信号同时出现,就知道这是"最后的退路"。
1.3 第三张表:ConfigDataLoader —— 加载资源
| 实现 | 对应 Resource |
|---|---|
ConfigTreeConfigDataLoader | ConfigTreeConfigDataResource |
StandardConfigDataLoader | StandardConfigDataResource |
SystemEnvironmentConfigDataLoader | SystemEnvironmentConfigDataResource |
loader 的选择不是通过 isResolvable 轮询,而是通过泛型类型匹配:
public interface ConfigDataLoader<R extends ConfigDataResource> {
ConfigData load(ConfigDataLoaderContext context, R resource) throws IOException, ConfigDataNotFoundException;
}
ConfigDataLoaders 用反射取出实现类的泛型参数 R,建立 Class<R> → ConfigDataLoader<R> 的映射,然后按 resource.getClass() 查找。
这里有一个 native image 的隐患:泛型类型擦除 + 反射取泛型参数,在 GraalVM 下需要 hint。这是 Boot 的 AOT 支持要处理的问题之一(第 27 章详述)。
1.4 ArgumentResolver:SPI 构造注入的 4.x 写法
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");
});
这是 Spring Framework 7 / Spring Boot 4 的新 API(SpringFactoriesLoader.ArgumentResolver)。旧写法是"按构造器参数类型猜",新写法是显式声明每个类型对应哪个实例。
可注入的六类:
| 类型 | 值 | 用途 |
|---|---|---|
DeferredLogFactory | 延迟日志工厂 | 日志系统还没初始化完,不能直接用 Log |
Binder | 初始 Environment 的 Binder | 读 spring.config.name 等早期属性 |
ResourceLoader | 应用资源加载器 | 解析 classpath: / file: |
ConfigurableBootstrapContext | 引导上下文 | 注册早期共享实例 |
BootstrapContext | 同上(只读视图) | |
BootstrapRegistry | 同上(注册视图) |
Log 被显式禁止:andSupplied(Log.class, () -> { throw ... })。
原因:ConfigDataEnvironmentPostProcessor 运行在日志系统初始化之前(第 06 章讲过 DeferredLogFactory)。此时直接注入 Log 会导致日志输出到错误的 appender,甚至触发日志系统过早初始化。所以 Boot 强制你用 DeferredLogFactory.getLog(Class),拿到的 Log 会把消息缓存起来,等日志系统就绪后回放。
这是一个典型的"把易错用法变成运行时异常"的设计。
1.5 SpringFactoriesLoader 在 4.x 的处境
值得注意的是:自动配置注册已经迁移到 META-INF/spring/*.imports(第 03 章),但 spring.factories 仍然活跃且承载了大量基础设施 SPI:
LoggingSystemFactory
PropertySourceLoader
ConfigDataLocationResolver
ConfigDataLoader
SpringApplicationRunListener
SpringBootExceptionReporter
ApplicationContextInitializer
ApplicationListener
EnvironmentPostProcessor
FailureAnalyzer
FailureAnalysisReporter
ProtocolResolver
ApplicationResourceLoader$FilePathResolver
记忆点:*.imports 负责"自动配置类",spring.factories 负责"基础设施 SPI"。两者分工不同,不要混淆。
2. ConfigTree:把目录树当配置文件
2.1 一句话定位
ConfigTreePropertySource 的类注释说得很清楚:
This property source is typically used to read Kubernetes
configMapvolume mounts.
设计目标:Kubernetes 的 ConfigMap/Secret 挂载到 Pod 里是"一个 key 一个文件"的目录结构:
/etc/config/
├── database.url → 内容 "jdbc:mysql://..."
├── database.username → 内容 "app"
└── database.password → 内容 "s3cret"
ConfigTree 把它变成三个属性:
database.url=jdbc:mysql://...
database.username=app
database.password=s3cret
这比"把整份 YAML 塞进一个 ConfigMap 的 application.yaml key"更符合 K8s 的运维习惯(可以单独更新一个 key,可以单独设置 Secret 权限)。
2.2 Resolver:语法与两条硬约束
public class ConfigTreeConfigDataLocationResolver implements ConfigDataLocationResolver<ConfigTreeConfigDataResource> {
private static final String PREFIX = "configtree:";
private final LocationResourceLoader resourceLoader;
public ConfigTreeConfigDataLocationResolver(ResourceLoader resourceLoader) {
this.resourceLoader = new LocationResourceLoader(resourceLoader);
}
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return location.hasPrefix(PREFIX);
}
@Override
public List<ConfigTreeConfigDataResource> resolve(ConfigDataLocationResolverContext context,
ConfigDataLocation location) {
try {
return resolve(location.getNonPrefixedValue(PREFIX));
}
catch (IOException ex) {
throw new ConfigDataLocationNotFoundException(location, ex);
}
}
private List<ConfigTreeConfigDataResource> resolve(String location) throws IOException {
Assert.state(location.endsWith("/"),
() -> String.format("Config tree location '%s' must end with '/'", location));
if (!this.resourceLoader.isPattern(location)) {
return Collections.singletonList(new ConfigTreeConfigDataResource(location));
}
Resource[] resources = this.resourceLoader.getResources(location, ResourceType.DIRECTORY);
List<ConfigTreeConfigDataResource> resolved = new ArrayList<>(resources.length);
for (Resource resource : resources) {
resolved.add(new ConfigTreeConfigDataResource(resource.getFile().toPath()));
}
return resolved;
}
}
约束一:必须以 / 结尾
Assert.state(location.endsWith("/"), () -> "Config tree location '%s' must end with '/'");
Assert.state 抛 IllegalStateException,不是 ConfigDataNotFoundException——所以 optional: 前缀救不了它。写 configtree:/etc/config(少个斜杠)会直接崩。
这个约束的必要性:resolver 需要区分"这是一个目录"还是"这是一个文件",而 ConfigTree 只支持目录。
约束二:直接构造 Path,不做 ResourceLoader 转换
非模式分支:new ConfigTreeConfigDataResource(location)——直接把字符串传给 Resource,而资源类内部会 Path.of(...)(具体转换见 ConfigTreeConfigDataResource,该类源码我未在本章展开阅读)。
通配符分支:ResourceType.DIRECTORY——返回的是子目录列表,每个子目录一个 ConfigTreeConfigDataResource。用于 configtree:/etc/config/*/ 这种"每个子目录一组配置"的场景。
2.3 Loader:一行创建
public class ConfigTreeConfigDataLoader implements ConfigDataLoader<ConfigTreeConfigDataResource> {
@Override
public ConfigData load(ConfigDataLoaderContext context, ConfigTreeConfigDataResource resource)
throws IOException, ConfigDataResourceNotFoundException {
Path path = resource.getPath();
ConfigDataResourceNotFoundException.throwIfDoesNotExist(resource, path);
String name = "Config tree '" + path + "'";
ConfigTreePropertySource source = new ConfigTreePropertySource(name, path,
Option.AUTO_TRIM_TRAILING_NEW_LINE);
return new ConfigData(Collections.singletonList(source));
}
}
四点:
-
throwIfDoesNotExist:目录不存在 →ConfigDataResourceNotFoundException。所以configtree:默认是非 optional 的,必须写optional:configtree:/etc/config/才允许目录不存在。 -
PropertySource 名字:
"Config tree '" + path + "'"。在/actuator/env里看到的就是这个。 -
强制带
AUTO_TRIM_TRAILING_NEW_LINEoption——无论用户怎么配置。这是 K8s 场景的刚需:echo -nvsecho的差异会导致值多一个换行。 -
new ConfigData(Collections.singletonList(source))——没有传PropertySourceOptions,所以走PropertySourceOptions.always(Options.of())→Options.NONE。含义:ConfigTree 的属性源不带
PROFILE_SPECIFIC、不带IGNORE_IMPORTS、不带IGNORE_PROFILES。第 10 章讲过这些选项的作用,这里对照一下:选项 ConfigTree 是否带 后果 PROFILE_SPECIFIC❌ 不参与 moveProfileSpecific,保持在BEFORE_PROFILE_ACTIVATION组IGNORE_IMPORTS❌ 允许在 ConfigTree 里写 spring.config.importIGNORE_PROFILES❌ 允许在 ConfigTree 里写 spring.profiles.active这是一个真实的、可利用的能力:ConfigTree 里写
spring.profiles.active是合法的(而 profile-specific 文件里不合法)。因为在 K8s 里,用 ConfigMap 控制 profile 是非常自然的需求。但这是否真的能生效取决于时机——ConfigTree 是通过
spring.config.import=optional:configtree:/etc/config/导入的,而 import 的处理分两轮(BEFORE_PROFILE_ACTIVATION和AFTER_PROFILE_ACTIVATION)。只有BEFORE_PROFILE_ACTIVATION轮导入的 ConfigTree 才能影响 profile 求解。这一点我标注为按源码结构的推演:
spring.config.import在processInitial/processWithoutProfiles阶段(即 BEFORE 轮)就会被处理,所以写在application.yaml里的spring.config.import=configtree:...会在 profile 求解前完成 → 其spring.profiles.active能被Profiles读到。但我未在 Boot 测试中找到直接用例,实践前请自行验证。
2.4 目录扫描:PropertyFile.findAll
private static Map<String, PropertyFile> findAll(Path sourceDirectory, Set<Option> options) {
try {
Map<String, PropertyFile> propertyFiles = new TreeMap<>();
try (Stream<Path> pathStream = Files.find(sourceDirectory, MAX_DEPTH, PropertyFile::isPropertyFile,
FileVisitOption.FOLLOW_LINKS)) {
pathStream.forEach((path) -> {
String name = getName(sourceDirectory.relativize(path));
if (StringUtils.hasText(name)) {
if (options.contains(Option.USE_LOWERCASE_NAMES)) {
name = name.toLowerCase(Locale.getDefault());
}
propertyFiles.put(name, new PropertyFile(path, options));
}
});
}
return Collections.unmodifiableMap(propertyFiles);
}
catch (IOException ex) {
throw new IllegalStateException("Unable to find files in '" + sourceDirectory + "'", ex);
}
}
六个实现细节:
-
Files.find(..., MAX_DEPTH=100, ...)——递归深度上限 100。超过就截断(静默截断,不报错)。 -
FileVisitOption.FOLLOW_LINKS——跟随符号链接。这对 K8s Secret 至关重要:K8s 的 Secret 挂载用符号链接实现"原子更新":/etc/config/database.password -> ..data/database.password /etc/config/..data -> ..2024_01_01_12_00_00.123456789跟随链接才能读到真实内容。但这也意味着可能形成符号链接环——
Files.find遇到环会抛FileSystemException,被包装成IllegalStateException("Unable to find files in ...")。 -
TreeMap——属性名有序(字典序)。影响getPropertyNames()的顺序,进而影响/actuator/env展示,不影响优先级。 -
USE_LOWERCASE_NAMES用Locale.getDefault()——注意不是Locale.ENGLISH。在 Turkish locale 下I→ı(无点 i),会产生意外行为。这是一个真实但罕见的坑。 -
StringUtils.hasText(name)——空名被跳过。 -
isPropertyFile的过滤:
private static boolean isPropertyFile(Path path, BasicFileAttributes attributes) {
return !hasHiddenPathElement(path) && (attributes.isRegularFile() || attributes.isSymbolicLink());
}
private static boolean hasHiddenPathElement(Path path) {
for (Path element : path) {
if (element.toString().startsWith("..")) {
return true;
}
}
return false;
}
只接受普通文件或符号链接(目录、设备文件、FIFO 都被排除)。
隐藏路径的判定是"以 .. 开头"——这是针对 K8s 的 ..data 目录设计的:
/etc/config/
├── ..data -> ..2024_01_01_... ← 被过滤(以 .. 开头)
├── ..2024_01_01_.../ ← 被过滤
│ └── database.password
└── database.password -> ..data/database.password ← 保留
如果没有这个过滤,..2024_01_01_12_00_00.123456789/database.password 会被扫描成一个名为 ..2024_01_01_....database.password 的属性——污染配置命名空间。
注意:这里的"隐藏"定义与 Unix 的"以 . 开头"不同。Unix 的 .hidden 文件不会被过滤(除非以 .. 开头)。所以 .gitkeep、.DS_Store 这类文件会出现在属性里。实践上应避免把它们放进 config tree 目录。
2.5 名字拼装:getName 用 . 而不是 /
private static String getName(Path relativePath) {
int nameCount = relativePath.getNameCount();
if (nameCount == 1) {
return relativePath.toString();
}
StringBuilder name = new StringBuilder();
for (int i = 0; i < nameCount; i++) {
name.append((i != 0) ? "." : "");
name.append(relativePath.getName(i));
}
return name.toString();
}
嵌套目录用 . 连接(类注释明确写了"with a '.' rather than '/' used as the path separator")。
/etc/config/
└── database/
├── url → database.url
└── pool/
└── size → database.pool.size
这与 @ConfigurationProperties(prefix = "database") 的层级天然契合。
单元素路径走 relativePath.toString()——直接用文件名。
2.6 Value:既是 CharSequence 又是 InputStreamSource
public interface Value extends CharSequence, InputStreamSource {
}
这是一个双重身份接口。为什么要这样?
场景:
- 普通配置值(
database.url=jdbc:...)→ 需要CharSequence(转成 String) - SSL 证书、密钥(
ssl.keystore内容)→ 需要InputStreamSource(读成字节流,避免 String 转换破坏二进制)
PropertyFileContent 同时实现两者:
private static final class PropertyFileContent implements Value, OriginProvider {
...
@Override
public int length() { return toString().length(); }
@Override
public char charAt(int index) { return toString().charAt(index); }
@Override
public CharSequence subSequence(int start, int end) { return toString().subSequence(start, end); }
@Override
public String toString() { ... }
@Override
public InputStream getInputStream() throws IOException { ... }
}
注意 length()/charAt()/subSequence() 都委托给 toString()——即每次都会触发文件读取(如果没缓存)。这是 CharSequence 语义下的必然:无法只解码部分字节。
配合 ApplicationConversionService(类注释明说):
In addition, if used with an
Environmentconfigured with anApplicationConversionService, property values can be converted to aStringorbyte[].
Boot 的 ApplicationConversionService 注册了 CharSequence → String 和 CharSequence → byte[] 的转换器(具体实现类我在本章未检索,第 06/08 章讲过 ApplicationConversionService 的结构)。所以:
@ConfigurationProperties(prefix = "ssl")
public class SslProperties {
private byte[] keystore; // ← 从 configtree 的文件内容直接绑成 byte[]
}
这是 ConfigTree 相对"把证书 Base64 塞进 YAML"的巨大优势——无需编码,且 K8s 可以独立轮换。
2.7 三个 Option 详解
public enum Option {
ALWAYS_READ,
USE_LOWERCASE_NAMES,
AUTO_TRIM_TRAILING_NEW_LINE
}
(1) ALWAYS_READ
private PropertyFile(Path path, Set<Option> options) {
...
this.cachedContent = options.contains(Option.ALWAYS_READ) ? null
: new PropertyFileContent(path, this.resource, this.origin, true, this.autoTrimTrailingNewLine);
}
PropertyFileContent getContent() {
if (this.cachedContent != null) {
return this.cachedContent;
}
return new PropertyFileContent(this.path, this.resource, this.origin, false, this.autoTrimTrailingNewLine);
}
cachedContent | 含义 | |
|---|---|---|
不带 ALWAYS_READ | 构造时立即读取并缓存 | 只读一次 |
带 ALWAYS_READ | null | 每次 getContent() 新建对象,读取发生在 getBytes() 时 |
注意:即使不带 ALWAYS_READ,PropertyFileContent 内部的 getBytes() 也是懒加载的(this.content 初始为 null,加锁后读)。所以"不带 ALWAYS_READ" = "每个文件最多读一次",而不是"构造时读一次"。
private byte[] getBytes() {
try {
if (!this.cacheContent) {
assertStillExists();
return FileCopyUtils.copyToByteArray(this.resource.getInputStream());
}
byte[] content = this.content;
if (content == null) {
assertStillExists();
this.resourceLock.lock();
try {
content = this.content;
if (content == null) {
content = FileCopyUtils.copyToByteArray(this.resource.getInputStream());
this.content = content;
}
}
finally {
this.resourceLock.unlock();
}
}
return content;
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
双检锁 + ReentrantLock:每个 PropertyFileContent 实例有自己的锁。这是标准的懒加载缓存模式。
assertStillExists():
private void assertStillExists() {
Assert.state(Files.exists(this.path), () -> "The property file '" + this.path + "' no longer exists");
}
带 ALWAYS_READ 时,每次读取都检查文件是否还在。这是为 K8s 的"Secret 被删除"场景准备的——给出明确错误而不是 NPE。
什么时候用 ALWAYS_READ? 当 ConfigMap 会被热更新时。K8s 更新 ConfigMap 后(默认约 60 秒内),挂载的文件内容会变。此时 ALWAYS_READ 能读到新值。
但注意类注释的警告:
The directory is not monitored for updates, so files should not be added or removed.
目录结构(有哪些文件)不变,只有文件内容可以变。新增/删除文件不会被感知。
而 ConfigTreeConfigDataLoader 没有传 ALWAYS_READ——所以默认情况下 ConfigTree 的值是"读一次即固定"。想热更新必须自己注册 ConfigTreePropertySource(不走 ConfigData 机制)。
(2) USE_LOWERCASE_NAMES
见 2.4 的 Locale.getDefault() 陷阱。
用途:K8s ConfigMap 的 key 规范允许大写和下划线,而 Java 配置属性习惯小写短横线。但这个 option 只做 lowercase,不做 _ → . 的映射。所以 DATABASE_URL → database_url,仍然不是 database.url。作用有限。
(3) AUTO_TRIM_TRAILING_NEW_LINE
这是默认开启的(由 ConfigTreeConfigDataLoader 强制),算法值得细看:
private String autoTrimTrailingNewLine(String string) {
if (!string.endsWith("\n")) {
return string;
}
int numberOfLines = 0;
for (int i = 0; i < string.length(); i++) {
char ch = string.charAt(i);
if (ch == '\n') {
numberOfLines++;
}
}
if (numberOfLines > 1) {
return string; // ← 多行不 trim!
}
return (string.endsWith("\r\n")) ? string.substring(0, string.length() - 2)
: string.substring(0, string.length() - 1);
}
三条规则:
- 不以
\n结尾 → 原样返回 - 有超过一个
\n→ 原样返回(不 trim) - 恰好一个
\n→ 去掉它(若前面是\r则一起去掉)
为什么"多行不 trim"? 因为多行值(比如 PEM 证书)的换行是有意义的内容。如果 trim 掉最后一个换行,PEM 格式可能被破坏。而单行值(绝大多数配置)的尾部换行几乎总是 echo 命令的产物,应该去掉。
计数逻辑的微妙之处:循环统计的是所有 \n 的个数,不是"尾部的"。所以:
"a\nb\n" → 2 个 \n → 不 trim → "a\nb\n"
"abc\n" → 1 个 \n → trim → "abc"
"abc\r\n" → 1 个 \n → trim → "abc"
"abc\n\n" → 2 个 \n → 不 trim → "abc\n\n"
注意 "abc\n\n"(两个换行)不被 trim——这可能是"故意留空行"的语义,也可能是 echo; echo 的产物。Boot 选择保守不 trim。
性能注解:这个循环是 O(n),且每次 toString() 都执行。对于大文件(比如 1MB 的 keystore)会有开销。但因为 content 被缓存(不带 ALWAYS_READ 时),实际只执行一次。
2.8 isImmutable() 与 AOT / 缓存
@Override
public boolean isImmutable() {
return !this.options.contains(Option.ALWAYS_READ);
}
isImmutable 来自 PropertySourceInfo 接口(4.x 新增,见第 5 节)。语义:"这个源的查找结果永不变化"。
ALWAYS_READ 意味着值可能变 → 不 immutable。
为什么需要这个标志? 主要是给 AOT / native image 用的:immutable 的属性源可以在编译期求值并内联到生成的字节码里,运行时不需要再读文件。mutable 的必须在运行时读。
2.9 ConfigTree 的 getOrigin
@Override
public @Nullable Origin getOrigin(String name) {
PropertyFile propertyFile = this.propertyFiles.get(name);
return (propertyFile != null) ? propertyFile.getOrigin() : null;
}
Origin 在构造时创建:new TextResourceOrigin(this.resource, START_OF_FILE),其中 START_OF_FILE = new Location(0, 0)。
行号列号是 (0, 0),不是真实位置——因为 ConfigTree 不做内容解析,没有"第几行"的概念。它只记录"来自哪个文件"。
这带来一个诊断上的好处:错误消息里能显示
[origin: class path resource [...] - 0:0]
虽然行号没用,但文件名能告诉你"这个属性来自 config tree 的哪个文件"。
2.10 ConfigTree 与 PropertySourceInfo.getPrefix()
public class ConfigTreePropertySource extends EnumerablePropertySource<Path>
implements PropertySourceInfo, OriginLookup<String> {
...
@Override
public boolean isImmutable() { return !this.options.contains(Option.ALWAYS_READ); }
// getPrefix() 未重写 → 返回 null
}
getPrefix() 是 PropertySourceInfo 的 default 方法(返回 null)。ConfigTreePropertySource 没有重写它。
而第 10 章讲过,ConfigDataEnvironmentContributor.asConfigurationPropertySource 会用它:
private static @Nullable ConfigurationPropertySource asConfigurationPropertySource(
PropertySource<?> propertySource) {
ConfigurationPropertySource configurationPropertySource = ConfigurationPropertySource.from(propertySource);
if (configurationPropertySource != null && propertySource instanceof PropertySourceInfo propertySourceInfo) {
configurationPropertySource = configurationPropertySource.withPrefix(propertySourceInfo.getPrefix());
}
return configurationPropertySource;
}
withPrefix(null) 应该等价于不设前缀(withPrefix 对 null 的处理我未核实)。若 Boot 的 ConfigTreePropertySource 想支持前缀隔离,需要自己重写 getPrefix()——当前版本没有。
2.11 完整用法示例
K8s 部署片段:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
database.url: "jdbc:mysql://mysql:3306/app"
database.pool.size: "20"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secret
data:
database.password: <base64>
Pod 挂载:
volumes:
- name: config
configMap:
name: app-config
- name: secret
secret:
secretName: app-secret
volumeMounts:
- name: config
mountPath: /etc/config
- name: secret
mountPath: /etc/secret
Boot 侧(application.yaml):
spring:
config:
import:
- optional:configtree:/etc/config/
- optional:configtree:/etc/secret/
或者命令行:
java -jar app.jar \
--spring.config.import=optional:configtree:/etc/config/,optional:configtree:/etc/secret/
必须写 optional:——因为 ConfigTreeConfigDataLoader 对不存在的目录抛 ConfigDataResourceNotFoundException。本地开发时没有这些目录,不加 optional: 会起不来。
顺序语义:spring.config.import 是一个 List,按第 10 章 §8.1 的分析,spring.config.import 是 registerIndividually = false,所有位置打包成一个 contributor。而 ConfigDataImporter.load 逆序遍历:
for (int i = candidates.size() - 1; i >= 0; i--) { ... }
结合 asContributors 的逆序插入……这里我要谨慎:第 10 章 §7.3 已经踩过一次方向坑。
ConfigDataImporter.load 的逆序遍历影响的是 loadedLocations / loaded 集合的填充顺序,而 result 是 LinkedHashMap,其 key 顺序 = 插入顺序 = 逆序。
然后 asContributors 对 imported.forEach(...) 按 map 顺序处理,每个 ConfigData 内部再逆序 for (int i = data.getPropertySources().size() - 1; i >= 0; i--) 然后 add。
结论(标注为推演):spring.config.import 列表中靠前的位置优先级更高。这与官方文档的 "imports are processed in the order they are declared, with earlier imports taking precedence"...
我在此停止推导——我在第 07 章和第 10 章各踩过一次方向坑,本章没有找到像 ConfigData.getPropertySources() 那样明确的 Javadoc 锚点来锁定 spring.config.import 列表的优先级方向。建议读者用 10.x 的验证清单自己测一次(见 §11)。这是一个诚实的不确定性声明。
3. env: 位置:把环境变量的内容当配置文件
3.1 定位与语法
class SystemEnvironmentConfigDataLocationResolver
implements ConfigDataLocationResolver<SystemEnvironmentConfigDataResource> {
private static final String PREFIX = "env:";
private static final String DEFAULT_EXTENSION = ".properties";
private final List<PropertySourceLoader> loaders;
private final Function<String, @Nullable String> environment;
SystemEnvironmentConfigDataLocationResolver() {
this.loaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, getClass().getClassLoader());
this.environment = System::getenv;
}
...
}
env: 是包级私有的 class(不是 public)——它是 Boot 内部实现,不能像 ConfigTreeConfigDataLocationResolver(public)那样被外部直接引用。但通过 spring.config.import=env:XXX 使用是完全支持的。
@since:类注释没写 @since,作者是 Moritz Halbritter 与 Phillip Webb——这是相对较新的能力。
3.2 解析流程
@Override
public List<SystemEnvironmentConfigDataResource> resolve(ConfigDataLocationResolverContext context,
ConfigDataLocation location)
throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException {
String value = location.getNonPrefixedValue(PREFIX);
FileHint fileHint = FileHint.from(value);
String variableName = FileHint.removeFrom(value);
PropertySourceLoader loader = getLoader(fileHint.getExtensionOrElse(DEFAULT_EXTENSION));
if (hasEnvVariable(variableName)) {
return List.of(new SystemEnvironmentConfigDataResource(variableName, loader, this.environment,
fileHint.getEncodingAsCharset()));
}
if (location.isOptional()) {
return Collections.emptyList();
}
throw new ConfigDataLocationNotFoundException(location,
"Environment variable '%s' is not set".formatted(variableName), null);
}
FileHint 复用:env:APP_CONFIG[.yaml] → variableName = "APP_CONFIG",extension = ".yaml"。
默认扩展名 .properties:env:APP_CONFIG 等价于 env:APP_CONFIG[.properties]。
变量不存在时:
- 带
optional:→ 返回空列表(静默) - 不带 →
ConfigDataLocationNotFoundException("Environment variable 'X' is not set")
3.3 getLoader:扩展名 → loader
private PropertySourceLoader getLoader(String extension) {
extension = (!extension.startsWith(".")) ? extension : extension.substring(1);
for (PropertySourceLoader loader : this.loaders) {
for (String supportedExtension : loader.getFileExtensions()) {
if (supportedExtension.equalsIgnoreCase(extension)) {
return loader;
}
}
}
throw new IllegalStateException(
"File extension '%s' is not known to any PropertySourceLoader".formatted(extension));
}
找不到对应 loader → IllegalStateException,不是 ConfigDataNotFoundException。所以 optional: 也救不了 env:APP[.json](没有 JSON 的 PropertySourceLoader)。
扩展点:想支持 JSON 配置文件,就实现一个
PropertySourceLoader(getFileExtensions()返回{"json"})并注册到spring.factories。见 §9。
3.4 Resource:把字符串变成 ByteArrayResource
@Nullable List<PropertySource<?>> load() throws IOException {
String content = this.environment.apply(this.variableName);
return (content != null)
? this.loader.load(StringUtils.capitalize(toString()), asResource(content), this.encoding) : null;
}
private ByteArrayResource asResource(String content) {
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8));
}
@Override
public String toString() {
return "system environment variable [" + this.variableName + "] content loaded using "
+ ClassUtils.getShortName(this.loader.getClass());
}
三点:
-
编码写死 UTF-8(
asResource里StandardCharsets.UTF_8)。环境变量本身是字符串,编成字节流时用 UTF-8。注意:如果
FileHint指定了[ISO-8859-1],这个 encoding 传给loader.load(name, resource, encoding),但 resource 的字节已经是 UTF-8 编码的了。这会产生双重编码问题——env:APP[ISO-8859-1]会先用 UTF-8 编成字节,再按 ISO-8859-1 解读。我标注这是一个潜在的设计缺陷或有意取舍:UTF-8 → ISO-8859-1 的往返对非 ASCII 字符是有损的。实践中
env:位置不应指定非 UTF-8 编码。 -
PropertySource 名字:
StringUtils.capitalize(toString())→System environment variable [APP_CONFIG] content loaded using YamlPropertySourceLoader首字母被大写。在
/actuator/env里这是很好辨识的名字。 -
load()返回 null 表示"变量不存在"(而不是抛异常)。由SystemEnvironmentConfigDataLoader转成异常:
class SystemEnvironmentConfigDataLoader implements ConfigDataLoader<SystemEnvironmentConfigDataResource> {
@Override
public ConfigData load(ConfigDataLoaderContext context, SystemEnvironmentConfigDataResource resource)
throws IOException, ConfigDataResourceNotFoundException {
List<PropertySource<?>> loaded = resource.load();
if (loaded == null) {
throw new ConfigDataResourceNotFoundException(resource);
}
return new ConfigData(loaded);
}
}
为什么要走 load() 返回 null 再转异常,而不是在 resolver 里就抛? 因为 resolver 阶段已经用 hasEnvVariable 检查过了,理论上不会为 null。这是防御性重检——环境变量可能在 resolve 与 load 之间被修改(极罕见,但在测试里用 Function<String,String> 注入 mock 时可能发生)。
测试注入点:
SystemEnvironmentConfigDataLocationResolver(List<PropertySourceLoader> loaders,
Function<String, @Nullable String> environment) {
this.loaders = loaders;
this.environment = environment;
}
这个包级私有构造器允许测试注入假的 environment。是"为测试留的缝"的典型例子。
3.5 equals / hashCode:用 loader 的 Class
@Override
public boolean equals(Object obj) {
...
SystemEnvironmentConfigDataResource other = (SystemEnvironmentConfigDataResource) obj;
return Objects.equals(this.loader.getClass(), other.loader.getClass())
&& Objects.equals(this.variableName, other.variableName);
}
@Override
public int hashCode() {
return Objects.hash(this.variableName, this.loader.getClass());
}
用 loader.getClass() 而不是 loader 实例。因为 PropertySourceLoader 没有实现 equals,用实例比较会导致"内容相同但不是同一个对象"的两个 resource 被认为不同,从而重复加载。
ConfigDataImporter 的去重依赖这个:
private final Set<ConfigDataResource> loaded = new HashSet<>();
...
if (this.loaded.contains(resource)) {
this.logger.trace(... "Already loaded resource %s ignoring location %s" ...);
this.loadedLocations.add(location);
}
3.6 典型使用场景
场景 1:K8s / Docker 里注入整份配置
export APP_CONFIG="
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://mysql:3306/app
"
java -jar app.jar --spring.config.import=env:APP_CONFIG[.yaml]
场景 2:12-factor 应用的"一份大配置"
export SPRING_APPLICATION_JSON='{"server":{"port":8080}}'
这个是 SpringApplicationJsonEnvironmentPostProcessor 的能力(第 06 章),不是 env: 位置。二者区别:
| 机制 | 变量 | 解析方式 | 属性源名 |
|---|---|---|---|
env:XXX[.json] | 任意 | 需要 JSON 的 PropertySourceLoader(Boot 内置没有) | System environment variable [XXX]... |
spring.application.json / SPRING_APPLICATION_JSON | 固定 | 专用 JSON 解析器 | spring.application.json |
所以 Boot 内置不支持 env:XXX[.json]——这一点必须澄清,很多文章会误传。
场景 3:与 spring.config.import 组合的多层配置
spring:
config:
import:
- optional:env:APP_OVERRIDE[.properties]
- optional:configtree:/etc/config/
4. 环境变量映射:relaxed binding 的另一半
4.1 问题的提出
Environment 里有一个 SystemEnvironmentPropertySource,它的 key 是 SPRING_PROFILES_ACTIVE、MY_PROPERTY_NAME 这种形式。而 Boot 的 Binder 用的是 ConfigurationPropertyName(spring.profiles.active)。
二者需要一个双向映射器。这就是 PropertyMapper 接口,其环境变量实现是 SystemEnvironmentPropertyMapper。
(第 08 章讲 Binder 时提到过 SpringConfigurationPropertySource 的 mapper 选择,本章展开。)
4.2 前置知识:ConfigurationPropertyName.Form
要理解映射,先要理解 ConfigurationPropertyName 的三种"形态":
public enum Form {
/**
* The original form as specified when the name was created or adapted.
* "foo-bar" = "foo-bar"
* "fooBar" = "fooBar"
* "foo_bar" = "foo_bar"
* "[Foo.bar]" = "Foo.bar"
*/
ORIGINAL,
/**
* The dashed configuration form (used for toString; lower-case with only
* alphanumeric characters and dashes).
* "foo-bar" = "foo-bar"
* "fooBar" = "foobar"
* "foo_bar" = "foobar"
*/
DASHED,
/**
* The uniform configuration form (used for equals/hashCode; lower-case with only
* alphanumeric characters).
* "foo-bar" = "foobar"
* "fooBar" = "foobar"
* "foo_bar" = "foobar"
*/
UNIFORM
}
记忆口诀:
| Form | 保留什么 | 用途 |
|---|---|---|
ORIGINAL | 原样(保留 _ 与 -,但不改大小写) | 追溯用户原始写法 |
DASHED | 只留字母数字与 - | toString()(规范形式) |
UNIFORM | 只留字母数字 | equals / hashCode(比较) |
关键:UNIFORM 把 foo-bar、fooBar、foo_bar 全部归一成 foobar。这就是 relaxed binding 的技术基础——三种写法在比较时是同一个名字。
4.3 ToStringFormat:三种字符串化方式
enum ToStringFormat {
DEFAULT, SYSTEM_ENVIRONMENT, LEGACY_SYSTEM_ENVIRONMENT
}
String toString(ToStringFormat format, boolean upperCase) {
String string = this.string[format.ordinal()];
if (string == null) {
string = buildToString(format);
this.string[format.ordinal()] = string; // ← 缓存
}
return (!upperCase) ? string : string.toUpperCase(Locale.ENGLISH);
}
private String buildToString(ToStringFormat format) {
return switch (format) {
case DEFAULT -> buildDefaultToString();
case SYSTEM_ENVIRONMENT -> buildSimpleToString('_', (i) -> getElement(i, Form.UNIFORM));
case LEGACY_SYSTEM_ENVIRONMENT ->
buildSimpleToString('_', (i) -> getElement(i, Form.ORIGINAL).replace('-', '_'));
};
}
三个格式:
| 格式 | 元素形态 | 连接符 | 示例(foo.the-bar) |
|---|---|---|---|
DEFAULT | DASHED + [index] | . | foo.the-bar |
SYSTEM_ENVIRONMENT | UNIFORM | _ | foo_thebar |
LEGACY_SYSTEM_ENVIRONMENT | ORIGINAL,且 -→_ | _ | foo_the_bar |
为什么有 LEGACY? 因为 Boot 2.0 之前的映射规则是"把 - 换成 _"(my.property-name → MY_PROPERTY_NAME)。Boot 2.0 引入了 relaxed binding 之后,规范变成"去掉 -"(MY_PROPERTYNAME)。为了兼容老部署,两种都支持。
缓存:this.string[format.ordinal()] 是 ConfigurationPropertyName 的实例字段(长度 3 的数组)。每个 name 实例缓存三种格式的字符串(小写形式),大写形式每次 toUpperCase。
4.4 SystemEnvironmentPropertyMapper.map(ConfigurationPropertyName):四候选
@Override
public List<String> map(ConfigurationPropertyName configurationPropertyName) {
List<String> mapped = new ArrayList<>(4);
addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.SYSTEM_ENVIRONMENT, true));
addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.LEGACY_SYSTEM_ENVIRONMENT, true));
addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.SYSTEM_ENVIRONMENT, false));
addIfMissing(mapped, configurationPropertyName.toString(ToStringFormat.LEGACY_SYSTEM_ENVIRONMENT, false));
return mapped;
}
private void addIfMissing(List<String> list, String value) {
if (!list.contains(value)) {
list.add(value);
}
}
四个候选,按优先级:
SYSTEM_ENVIRONMENT+ 大写 →FOO_THEBARLEGACY_SYSTEM_ENVIRONMENT+ 大写 →FOO_THE_BARSYSTEM_ENVIRONMENT+ 小写 →foo_thebarLEGACY_SYSTEM_ENVIRONMENT+ 小写 →foo_the_bar
为什么有小写候选? 因为 Windows 的环境变量名是大小写不敏感的(SystemEnvironmentPropertySource 在 Spring Framework 里对 Windows 做了特殊处理,会同时尝试大写和小写)。Linux 下小写候选基本用不上,但无害。
addIfMissing 去重——当 SYSTEM_ENVIRONMENT 与 LEGACY_SYSTEM_ENVIRONMENT 结果相同时(比如名字里没有 -),只保留一个。
4.5 测试锁定的映射表(权威)
@Test
void mapFromConfigurationShouldReturnBestGuess() {
assertThat(mapConfigurationPropertyName("server")).containsExactly("SERVER", "server");
assertThat(mapConfigurationPropertyName("server.port")).containsExactly("SERVER_PORT", "server_port");
assertThat(mapConfigurationPropertyName("host[0]")).containsExactly("HOST_0", "host_0");
assertThat(mapConfigurationPropertyName("host[0][1]")).containsExactly("HOST_0_1", "host_0_1");
assertThat(mapConfigurationPropertyName("host[0].name")).containsExactly("HOST_0_NAME", "host_0_name");
assertThat(mapConfigurationPropertyName("host.f00.name")).containsExactly("HOST_F00_NAME", "host_f00_name");
assertThat(mapConfigurationPropertyName("foo.the-bar")).containsExactly("FOO_THEBAR", "FOO_THE_BAR",
"foo_thebar", "foo_the_bar");
}
七条实证结论:
ConfigurationPropertyName | 环境变量候选(按顺序) |
|---|---|
server | SERVER、server |
server.port | SERVER_PORT、server_port |
host[0] | HOST_0、host_0 |
host[0][1] | HOST_0_1、host_0_1 |
host[0].name | HOST_0_NAME、host_0_name |
host.f00.name | HOST_F00_NAME、host_f00_name |
foo.the-bar | FOO_THEBAR、FOO_THE_BAR、foo_thebar、foo_the_bar |
注意 host.f00.name → HOST_F00_NAME(两个大写形式相同,所以只列一次)。
foo.the-bar 的四个候选是唯一触发全部四候选的例子。
host[0] → HOST_0:索引的下括号被去掉,只留数字。看 buildSimpleToString:
private String buildSimpleToString(char joinChar, IntFunction<String> elementConverter) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < getNumberOfElements(); i++) {
if (!result.isEmpty()) {
result.append(joinChar);
}
result.append(elementConverter.apply(i));
}
return result.toString();
}
(注:我读到的是截断版本,完整实现未逐行确认,但从 "host[0].name" → "HOST_0_NAME" 可确认索引不做括号包裹。)
4.6 反向映射:map(String) → ConfigurationPropertyName
@Override
public ConfigurationPropertyName map(String propertySourceName) {
ConfigurationPropertyName configurationPropertyName = this.propertySourceNameCache.get(propertySourceName);
if (configurationPropertyName == null) {
configurationPropertyName = convertName(propertySourceName);
this.propertySourceNameCache.put(propertySourceName, configurationPropertyName);
}
return configurationPropertyName;
}
private ConfigurationPropertyName convertName(String propertySourceName) {
try {
return ConfigurationPropertyName.adapt(propertySourceName, '_', this::processElementValue);
}
catch (Exception ex) {
return ConfigurationPropertyName.EMPTY;
}
}
private CharSequence processElementValue(CharSequence value) {
String result = value.toString().toLowerCase(Locale.ENGLISH);
return isNumber(result) ? "[" + result + "]" : result;
}
private static boolean isNumber(String string) {
return string.chars().allMatch(Character::isDigit);
}
三步:
adapt(name, '_', processor):以_为分隔符拆分元素,每个元素交给processElementValue处理。processElementValue:转小写(Locale.ENGLISH——注意这里用的是 ENGLISH,与USE_LOWERCASE_NAMES的Locale.getDefault()不同);如果是纯数字,包成[n](变成索引)。- 异常兜底:任何异常 →
ConfigurationPropertyName.EMPTY(不是抛异常)。
测试锁定:
@Test
void mapFromStringShouldReturnBestGuess() {
assertThat(mapPropertySourceName("SERVER")).isEqualTo("server");
assertThat(mapPropertySourceName("SERVER_PORT")).isEqualTo("server.port");
assertThat(mapPropertySourceName("HOST_0")).isEqualTo("host[0]");
assertThat(mapPropertySourceName("HOST_0_1")).isEqualTo("host[0][1]");
assertThat(mapPropertySourceName("HOST_0_NAME")).isEqualTo("host[0].name");
assertThat(mapPropertySourceName("HOST_F00_NAME")).isEqualTo("host.f00.name");
assertThat(mapPropertySourceName("S-ERVER")).isEqualTo("s-erver");
}
七条实证:
| 环境变量名 | 映射结果 | 说明 |
|---|---|---|
SERVER | server | 单元素 |
SERVER_PORT | server.port | _ → . |
HOST_0 | host[0] | 数字元素 → 索引 |
HOST_0_1 | host[0][1] | 连续索引 |
HOST_0_NAME | host[0].name | 索引后接普通元素 |
HOST_F00_NAME | host.f00.name | F00 不是纯数字 → 不当索引 |
S-ERVER | s-erver | - 被保留 |
HOST_F00_NAME → host.f00.name(不是 host[f00].name)——因为 isNumber("f00") 为 false(有字母 f)。这是纯数字判断的严格性。
S-ERVER → s-erver:adapt 只按 _ 切分,- 不是分隔符,被保留。所以 S-ERVER 是一个元素 s-erver。
两个边界测试:
@Test
void underscoreShouldMapToEmptyString() {
ConfigurationPropertyName mapped = getMapper().map("_");
assertThat(mapped.isEmpty()).isTrue();
}
@Test
void underscoreWithWhitespaceShouldMapToEmptyString() {
ConfigurationPropertyName mapped = getMapper().map(" _");
assertThat(mapped.isEmpty()).isTrue();
}
_ 和 _ 都映射成 EMPTY(不是抛异常,也不是空元素的名字)。
4.7 缓存:ConcurrentReferenceHashMap
private final Map<String, ConfigurationPropertyName> propertySourceNameCache = new ConcurrentReferenceHashMap<>();
ConcurrentReferenceHashMap 是 Spring 的软/弱引用并发 Map。用作缓存可以在内存压力时被 GC 回收,避免配置名缓存导致内存泄漏。
缓存的是"环境变量名 → ConfigurationPropertyName",键的数量级 = 环境变量个数(通常几十到几百)。
4.8 getAncestorOfCheck:宽松的祖先判定
@Override
public BiPredicate<ConfigurationPropertyName, ConfigurationPropertyName> getAncestorOfCheck() {
return this::isAncestorOf;
}
private boolean isAncestorOf(ConfigurationPropertyName name, ConfigurationPropertyName candidate) {
return name.isAncestorOf(candidate) || isLegacyAncestorOf(name, candidate);
}
private boolean isLegacyAncestorOf(ConfigurationPropertyName name, ConfigurationPropertyName candidate) {
if (!name.hasDashedElement()) {
return false;
}
ConfigurationPropertyName legacyCompatibleName = name.asSystemEnvironmentLegacyName();
return legacyCompatibleName != null && legacyCompatibleName.isAncestorOf(candidate);
}
@Nullable ConfigurationPropertyName asSystemEnvironmentLegacyName() {
ConfigurationPropertyName name = this.systemEnvironmentLegacyName;
if (name == null) {
name = ConfigurationPropertyName
.ofIfValid(buildSimpleToString('.', (i) -> getElement(i, Form.DASHED).replace('-', '.')));
this.systemEnvironmentLegacyName = (name != null) ? name : EMPTY;
}
return (name != EMPTY) ? name : null;
}
legacy name 的构造:把 DASHED 形态的元素用 . 连接,且把 - 换成 .。
my.spring-boot → DASHED 元素:my、spring-boot → 换 - 为 . → my.spring.boot。
为什么需要这个? 因为 MY_SPRING_BOOT_PROPERTY 映射成 my.spring.boot.property(_ → .),而原始名 my.spring-boot 的 UNIFORM 形态是 my.springboot。二者在 UNIFORM 空间里不是前缀关系:
my.spring-boot→ uniform 元素[my, springboot]my.spring.boot.property→ uniform 元素[my, spring, boot, property]
isAncestorOf 按元素比较:[my, springboot] 不是 [my, spring, boot, property] 的前缀。
所以需要 legacy 判定:把 my.spring-boot 转成 my.spring.boot,再比较 → 是前缀 ✓
测试确认:
@Test
void isAncestorOfConsidersLegacyNames() {
ConfigurationPropertyName name = ConfigurationPropertyName.of("my.spring-boot");
BiPredicate<ConfigurationPropertyName, ConfigurationPropertyName> check = getMapper().getAncestorOfCheck();
assertThat(check.test(name, ConfigurationPropertyName.adapt("MY_SPRING_BOOT_PROPERTY", '_'))).isTrue();
assertThat(check.test(name, ConfigurationPropertyName.adapt("MY_SPRINGBOOT_PROPERTY", '_'))).isTrue();
assertThat(check.test(name, ConfigurationPropertyName.adapt("MY_BOOT_PROPERTY", '_'))).isFalse();
}
MY_SPRING_BOOT_PROPERTY 和 MY_SPRINGBOOT_PROPERTY 都能匹配 my.spring-boot 的前缀 ✓
以及非规范源的情况:
@Test
void isAncestorOfWhenNonCanonicalSource() {
ConfigurationPropertyName name = ConfigurationPropertyName.adapt("my.springBoot", '.');
...
assertThat(check.test(name, ConfigurationPropertyName.of("my.spring-boot.property"))).isTrue();
assertThat(check.test(name, ConfigurationPropertyName.of("my.springboot.property"))).isTrue();
assertThat(check.test(name, ConfigurationPropertyName.of("my.boot.property"))).isFalse();
}
getAncestorOfCheck 用在哪? 在 SpringIterableConfigurationPropertySource.containsDescendantOf(...)——判断"这个属性源里是否可能有某个前缀的后代属性"。这是 Binder 的优化:避免枚举全部属性。
性能意义:SystemEnvironmentPropertySource 的属性可能有上百个,每次绑定都全量枚举代价高。containsDescendantOf 先做快速判定。
4.9 SPRING_PROFILES_ACTIVE 的完整链路
把第 10 章与本章合起来,走一遍:
① 操作系统环境变量 SPRING_PROFILES_ACTIVE=dev
↓
② SpringApplication 创建 ApplicationEnvironment(第 06 章)
→ StandardEnvironment 添加 SystemEnvironmentPropertySource("systemEnvironment")
↓
③ ConfigDataEnvironment 构造,Binder.get(environment)
→ ConfigurationPropertySources.from(environment) 把每个 PropertySource 包成
SpringConfigurationPropertySource;对名为 "systemEnvironment" 或
以 "-systemEnvironment" 结尾的源,使用 SYSTEM_ENVIRONMENT_MAPPERS
(见 SpringConfigurationPropertySource 第 174/184-185 行)
↓
④ Binder 绑定 spring.profiles.active
→ SpringConfigurationPropertySource.getConfigurationProperty(name)
→ SystemEnvironmentPropertyMapper.map(name) 得到 4 个候选:
SPRING_PROFILES_ACTIVE / SPRING_PROFILES_ACTIVE(legacy 同) /
spring_profiles_active / spring_profiles_active(legacy 同)
↓
⑤ 逐个候选查 SystemEnvironmentPropertySource → 命中 SPRING_PROFILES_ACTIVE=dev
↓
⑥ ConfigurationPropertyName 反向映射:map("SPRING_PROFILES_ACTIVE")
→ adapt('_', ...) → 元素 [spring, profiles, active] → spring.profiles.active
↓
⑦ ConfigurationProperty(spring.profiles.active, "dev", SystemEnvironmentOrigin)
↓
⑧ Binder 把 "dev" 绑成 Set<String> {dev}
↓
⑨ Profiles.activeProfiles = [dev](经 expandProfiles 展开 group)
关键节点 ③:SYSTEM_ENVIRONMENT_MAPPERS 的选择条件:
PropertyMapper[] mappers = (!systemEnvironmentSource) ? DEFAULT_MAPPERS : SYSTEM_ENVIRONMENT_MAPPERS;
...
&& (StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME.equals(name)
|| name.endsWith("-" + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME));
(注:这段是我在第 08 章阅读时的记忆,本章未重新读取 SpringConfigurationPropertySource,标注为未重新核实。)
条件:属性源名是 systemEnvironment,或以 -systemEnvironment 结尾。
为什么有第二个条件? 因为 Spring Boot 的 Actuator 或某些集成会创建名为 xxx-systemEnvironment 的属性源(比如 test-systemEnvironment)。Boot 的测试里就有:
sources.addLast(new SystemEnvironmentPropertySource(
"test-" + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, source));
4.10 SystemEnvironmentOrigin
public class SystemEnvironmentOrigin implements Origin {
private final String property;
public SystemEnvironmentOrigin(String property) {
Assert.hasText(property, "'property' must not be empty");
this.property = property;
}
@Override
public String toString() {
return "System Environment Property \"" + this.property + "\"";
}
}
当属性值来自环境变量时,Origin 是这个。错误消息里会出现:
[origin: System Environment Property "SPRING_PROFILES_ACTIVE"]
这对诊断极有价值——一眼看出"这个值来自环境变量,不是配置文件"。
对比其它 Origin 类型:
| Origin 实现 | 场景 | toString 形态 |
|---|---|---|
SystemEnvironmentOrigin | 系统环境变量 | System Environment Property "X" |
TextResourceOrigin | 配置文件(含行号列号) | class path resource [x.yaml] - 12:5 |
PropertySourceOrigin | 其它属性源 | (未在本章展开) |
JarUri / OriginTrackedResource | jar 内的资源 | (未在本章展开) |
4.11 与 systemProperties 的对比
SystemEnvironmentPropertySource(环境变量)与 PropertiesPropertySource(-D 系统属性)的映射规则不同:
| 维度 | 环境变量(systemEnvironment) | 系统属性(systemProperties) |
|---|---|---|
| 使用的 Mapper | SystemEnvironmentPropertyMapper | DefaultPropertyMapper |
| 分隔符 | _ → . | 原生 . |
| 大小写 | 大写候选 + 小写候选 | 小写(DASHED / UNIFORM) |
| 数字索引 | HOST_0 → host[0] | 无特殊处理 |
推论:-Dmy.property-name=value 直接就能绑定到 my.property-name,而 MY_PROPERTYNAME=value 需要走环境变量映射。
优先级(第 06 章讲过):systemProperties 在 systemEnvironment 之前(更高优先级)。所以 -D 覆盖环境变量。
4.12 一个常见误解的澄清
误解:"SPRING_APPLICATION_JSON 走的是 env: 位置机制"
真相:SPRING_APPLICATION_JSON 由 SpringApplicationJsonEnvironmentPostProcessor 处理,它直接读 environment.getProperty("spring.application.json") 或 SPRING_APPLICATION_JSON,用 Jackson 解析后添加一个名为 spring.application.json 的 PropertySource。与 ConfigData 的 env: 位置无关。
从 spring.factories 可以验证:
org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.cloud.CloudFoundryVcapEnvironmentPostProcessor,\
org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor,\
org.springframework.boot.support.RandomValuePropertySourceEnvironmentPostProcessor,\
org.springframework.boot.support.SpringApplicationJsonEnvironmentPostProcessor,\
org.springframework.boot.support.SystemEnvironmentPropertySourceEnvironmentPostProcessor
注意 4.x 的包名:org.springframework.boot.support.*(第 01 章讲过的 3.x → 4.x 包名迁移)。
另一个误解:"env: 位置支持 JSON"
真相:Boot 内置的 PropertySourceLoader 只有 properties/xml 和 yml/yaml。env:X[.json] 会抛 IllegalStateException("File extension 'json' is not known to any PropertySourceLoader")。
5. PropertySourceInfo:4.x 新增的属性源元信息接口
5.1 接口定义
// core/spring-boot/src/main/java/org/springframework/boot/env/PropertySourceInfo.java
public interface PropertySourceInfo {
default boolean isImmutable() {
return false;
}
default @Nullable String getPrefix() {
return null;
}
}
@since 4.0.0——这是 Spring Boot 4 新增的 SPI。
类注释对 getPrefix() 的用途说得很明确:
Prefixes can be used to disambiguate keys that would otherwise clash. For example, if multiple applications are running on the same machine a different prefix can be set on each application to ensure that different environment variables are used.
解决的核心问题:同一台机器(或同一个 K8s Pod)上跑多个 Boot 应用时,环境变量命名空间冲突。
5.2 getPrefix 的生效链路
有两处消费点:
消费点 1:SpringConfigurationPropertySources.adapt
private ConfigurationPropertySource adapt(PropertySource<?> source) {
ConfigurationPropertySource result = this.cache.get(source);
// Most PropertySources test equality only using the source name, so we need to
// check the actual source hasn't also changed.
if (result != null && result.getUnderlyingSource() == source) {
return result;
}
result = SpringConfigurationPropertySource.from(source);
if (source instanceof PropertySourceInfo propertySourceInfo) {
result = result.withPrefix(propertySourceInfo.getPrefix());
}
this.cache.put(source, result);
return result;
}
消费点 2:ConfigDataEnvironmentContributor.asConfigurationPropertySource(第 10 章引用过)
private static @Nullable ConfigurationPropertySource asConfigurationPropertySource(
PropertySource<?> propertySource) {
ConfigurationPropertySource configurationPropertySource = ConfigurationPropertySource.from(propertySource);
if (configurationPropertySource != null && propertySource instanceof PropertySourceInfo propertySourceInfo) {
configurationPropertySource = configurationPropertySource.withPrefix(propertySourceInfo.getPrefix());
}
return configurationPropertySource;
}
两处逻辑相同,但作用于不同阶段:消费点 1 是"Environment 已定型后,Binder 读属性时";消费点 2 是"ConfigData 处理期间,contributor 自己绑定 spring.config.* 时"。
5.3 withPrefix 的 null 语义(第 10 章悬念的答案)
default ConfigurationPropertySource withPrefix(@Nullable String prefix) {
return (StringUtils.hasText(prefix)) ? new PrefixedConfigurationPropertySource(this, prefix) : this;
}
withPrefix(null) 返回 this——不包装。这解答了第 10 章 §2.10 的疑问:ConfigTreePropertySource 不重写 getPrefix()(返回 null),所以不会包装。
PrefixedConfigurationPropertySource 的行为:
protected final ConfigurationPropertyName getPrefix() { ... }
@Override
public ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) {
ConfigurationProperty configurationProperty = this.source.getConfigurationProperty(getPrefixedName(name));
...
}
即:Binder 请求 server.port → 实际在底层源里查 {prefix}.server.port。
对用户是透明的:@ConfigurationProperties(prefix = "server") 照常写,但底层查的是带前缀的环境变量。
5.4 三个内置实现者
| 实现者 | isImmutable() | getPrefix() | 位置 |
|---|---|---|---|
ConfigTreePropertySource | !options.contains(ALWAYS_READ) | null(未重写) | env/ |
ApplicationInfoPropertySource | true | null(未重写) | 根包(org.springframework.boot) |
OriginAwareSystemEnvironmentPropertySource | (Object) getSource() == System.getenv() | 来自 SpringApplication.getEnvironmentPrefix() | support/ |
(1) ApplicationInfoPropertySource
class ApplicationInfoPropertySource extends MapPropertySource implements PropertySourceInfo {
static final String NAME = "applicationInfo";
ApplicationInfoPropertySource(@Nullable Class<?> mainClass) {
super(NAME, getProperties(readVersion(mainClass)));
}
@Override
public boolean isImmutable() {
return true;
}
private static Map<String, Object> getProperties(@Nullable String applicationVersion) {
Map<String, Object> result = new HashMap<>();
if (StringUtils.hasText(applicationVersion)) {
result.put("spring.application.version", applicationVersion);
}
ApplicationPid applicationPid = new ApplicationPid();
Long pid = applicationPid.toLong();
if (pid != null) {
result.put("spring.application.pid", pid);
}
return result;
}
...
static void moveToEnd(ConfigurableEnvironment environment) {
MutablePropertySources propertySources = environment.getPropertySources();
PropertySource<?> propertySource = propertySources.remove(NAME);
if (propertySource != null) {
propertySources.addLast(propertySource);
}
}
}
提供 spring.application.pid(进程 ID)与 spring.application.version(从 Package.getImplementationVersion() 读,即 jar MANIFEST 的 Implementation-Version)。
moveToEnd —— 与 DefaultPropertiesPropertySource.moveToEnd 同款:它在 Environment 里被移到最后(最低优先级),保证用户可以覆盖它。
(2) OriginAwareSystemEnvironmentPropertySource:本章重点
protected static class OriginAwareSystemEnvironmentPropertySource extends SystemEnvironmentPropertySource
implements PropertySourceInfo, OriginLookup<String> {
private final @Nullable String prefix;
OriginAwareSystemEnvironmentPropertySource(String name, Map<String, Object> source,
@Nullable String environmentPrefix) {
super(name, source);
this.prefix = determinePrefix(environmentPrefix);
}
private @Nullable String determinePrefix(@Nullable String environmentPrefix) {
if (!StringUtils.hasText(environmentPrefix)) {
return null;
}
if (environmentPrefix.endsWith(".") || environmentPrefix.endsWith("_") || environmentPrefix.endsWith("-")) {
return environmentPrefix.substring(0, environmentPrefix.length() - 1);
}
return environmentPrefix;
}
@Override
public @Nullable Origin getOrigin(String key) {
String property = resolvePropertyName(key);
if (super.containsProperty(property)) {
return new SystemEnvironmentOrigin(property);
}
return null;
}
@Override
public @Nullable String getPrefix() {
return this.prefix;
}
@Override
public boolean isImmutable() {
return (Object) getSource() == System.getenv();
}
}
四件事:
(a) determinePrefix 的尾巴处理:前缀末尾的 .、_、- 会被去掉。
setEnvironmentPrefix("app1.") → prefix = "app1"
setEnvironmentPrefix("APP1_") → prefix = "APP1"
这样用户可以按习惯写 APP1_、APP1.、APP1,都能得到 APP1。
注意大小写不敏感:SystemEnvironmentPropertyMapper 在 map(ConfigurationPropertyName) 时会产生大写和小写候选,而前缀是拼在环境变量名上的。若 prefix 是 APP1,则查询的 key 是 APP1_SERVER_PORT(大写拼接)。具体的拼接逻辑在 PrefixedConfigurationPropertySource.getPrefixedName 里,我没有读到其实现——标注为未核实。
(b) isImmutable() 的身份比较:
return (Object) getSource() == System.getenv();
这是一个引用相等判断,不是内容比较。语义:"如果这个属性源的底层 map 就是 System.getenv() 返回的那个(不可修改的)map,那么它是 immutable 的"。
如果属性源被替换成一个 HashMap 副本(某些测试或集成会这么做),isImmutable() 返回 false。
(c) getOrigin:这是这个类的主要存在理由(类名就叫 "OriginAware")。
普通的 SystemEnvironmentPropertySource 不实现 OriginLookup,所以从环境变量读到的属性没有 Origin,错误消息里无法显示来源。OriginAwareSystemEnvironmentPropertySource 补上了这一点,返回 SystemEnvironmentOrigin。
这就是 §4.10 里 [origin: System Environment Property "SPRING_PROFILES_ACTIVE"] 的来源。
(d) 谁安装它?
public class SystemEnvironmentPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
public static final int DEFAULT_ORDER = SpringApplicationJsonEnvironmentPostProcessor.DEFAULT_ORDER - 1;
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
postProcessEnvironment(environment, application.getEnvironmentPrefix());
}
private void postProcessEnvironment(ConfigurableEnvironment environment, @Nullable String environmentPrefix) {
String sourceName = StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME;
PropertySource<?> propertySource = environment.getPropertySources().get(sourceName);
if (propertySource != null) {
replacePropertySource(environment, sourceName, propertySource, environmentPrefix);
}
}
private void replacePropertySource(ConfigurableEnvironment environment, String sourceName,
PropertySource<?> propertySource, @Nullable String environmentPrefix) {
Map<String, Object> originalSource = (Map<String, Object>) propertySource.getSource();
SystemEnvironmentPropertySource source = new OriginAwareSystemEnvironmentPropertySource(sourceName,
originalSource, environmentPrefix);
environment.getPropertySources().replace(sourceName, source);
}
}
replace 而不是 addFirst——保持 systemEnvironment 在 Environment 中的原始位置(第 06 章讲过它在 systemProperties 之后)。这是一个细节但很重要:如果换成 addFirst,环境变量会意外获得最高优先级。
@since 4.0.0 —— 这个 post-processor 是 4.x 新增的。包名 org.springframework.boot.support(3.x 是 org.springframework.boot.env),符合第 01 章讲的 4.x 包名迁移规律。
(e) 父上下文传播:
public static void postProcessEnvironment(ConfigurableEnvironment environment,
ConfigurableEnvironment parentEnvironment) {
PropertySource<?> parentSystemEnvironmentPropertySource = parentEnvironment.getPropertySources()
.get(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME);
if (parentSystemEnvironmentPropertySource instanceof OriginAwareSystemEnvironmentPropertySource parentOriginAwareSystemEnvironmentPropertySource) {
new SystemEnvironmentPropertySourceEnvironmentPostProcessor().postProcessEnvironment(environment,
parentOriginAwareSystemEnvironmentPropertySource.getPrefix());
}
}
@since 3.4.12。当存在父上下文(比如 Spring Cloud 的 bootstrap context,或 SpringApplicationBuilder.parent(...))时,把父上下文的 prefix 传给子上下文。
这保证了"同一应用族"共享同一个环境变量前缀。
5.5 setEnvironmentPrefix 实战
SpringApplication app = new SpringApplication(MyApp.class);
app.setEnvironmentPrefix("APP1");
app.run(args);
效果:所有环境变量查找变成 APP1_{原环境变量名}:
| 未设前缀 | 设了 APP1 |
|---|---|
SERVER_PORT | APP1_SERVER_PORT |
SPRING_PROFILES_ACTIVE | APP1_SPRING_PROFILES_ACTIVE |
MY_DATASOURCE_URL | APP1_MY_DATASOURCE_URL |
典型用途:
- 单机多实例:同一台机器跑 app1 / app2,各自配
APP1_SERVER_PORT/APP2_SERVER_PORT,互不干扰。 - 测试隔离:集成测试里给每个测试设不同前缀,避免环境变量泄漏。
- 多租户 Pod:一个 Pod 里跑多个 Boot 应用(不推荐但存在)。
注意:只影响环境变量,不影响 -D 系统属性,也不影响配置文件。这是 PropertySourceInfo 只被 systemEnvironment 实现的结果。
6. 通配符与位置模式:LocationResourceLoader
StandardConfigDataLocationResolver 与 ConfigTreeConfigDataLocationResolver 都用它来解析带 * 的位置。
6.1 isPattern:只认 *
boolean isPattern(String location) {
return StringUtils.hasLength(location) && location.contains("*");
}
唯一的条件是包含 *。没有 ?、没有 []、没有 **。
6.2 validatePattern:四条断言
private void validatePattern(String location, ResourceType type) {
Assert.state(isPattern(location), () -> String.format("Location '%s' must be a pattern", location));
Assert.state(!location.startsWith(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX),
() -> String.format("Location '%s' cannot use classpath wildcards", location));
Assert.state(StringUtils.countOccurrencesOf(location, "*") == 1,
() -> String.format("Location '%s' cannot contain multiple wildcards", location));
String directoryPath = (type != ResourceType.DIRECTORY) ? location.substring(0, location.lastIndexOf("/") + 1)
: location;
Assert.state(directoryPath.endsWith("*/"), () -> String.format("Location '%s' must end with '*/'", location));
}
| 断言 | 条件 | 错误消息 |
|---|---|---|
| 必须是 pattern | 含 * | Location 'x' must be a pattern |
| 禁止 classpath 通配符 | 不能以 classpath*: 开头 | Location 'x' cannot use classpath wildcards |
只能一个 * | countOccurrencesOf("*") == 1 | Location 'x' cannot contain multiple wildcards |
必须以 */ 结尾 | 目录路径以 */ 结束 | Location 'x' must end with '*/' |
为什么禁止 classpath 通配符? 因为 classpath*: 的通配符语义依赖 ClassLoader.getResources(),在 native image 里行为不可预测,且性能差(要枚举所有 jar)。Boot 明确不支持。
为什么只能一个 *? 多级通配会让优先级语义变得难以定义(哪个维度优先?)。Boot 选择只支持"单层目录通配"。
ResourceType.DIRECTORY 时对整串检查 endsWith("*/")(因为整个 location 就是目录模式);ResourceType.FILE 时取最后一个 / 之前的目录部分再检查。
6.3 getResources:展开算法
Resource[] getResources(String location, ResourceType type) {
validatePattern(location, type);
String directoryPath = location.substring(0, location.indexOf("*/"));
String fileName = location.substring(location.lastIndexOf("/") + 1);
Resource resource = getResource(directoryPath);
if (!resource.exists()) {
return EMPTY_RESOURCES;
}
File file = getFile(location, resource);
if (!file.isDirectory()) {
return EMPTY_RESOURCES;
}
File[] subDirectories = file.listFiles(this::isVisibleDirectory);
if (subDirectories == null) {
return EMPTY_RESOURCES;
}
Arrays.sort(subDirectories, FILE_PATH_COMPARATOR);
if (type == ResourceType.DIRECTORY) {
return Arrays.stream(subDirectories).map(FileSystemResource::new).toArray(Resource[]::new);
}
List<Resource> resources = new ArrayList<>();
FilenameFilter filter = (dir, name) -> name.equals(fileName);
for (File subDirectory : subDirectories) {
File[] files = subDirectory.listFiles(filter);
if (files != null) {
Arrays.sort(files, FILE_NAME_COMPARATOR);
Arrays.stream(files).map(FileSystemResource::new).forEach(resources::add);
}
}
return resources.toArray(EMPTY_RESOURCES);
}
算法:
-
directoryPath = location.substring(0, indexOf("*/"))—— 取*/之前的部分(含*之前的所有字符,不含*)例:
file:./config/*/→directoryPath = "file:./config/" -
fileName = location.substring(lastIndexOf("/") + 1)—— 最后一段例:
optional:file:./config/*/application.yaml→fileName = "application.yaml" -
用
getResource(directoryPath)拿到基目录(非 pattern,走getResource) -
列子目录,过滤"可见目录"
-
按绝对路径排序
-
DIRECTORY模式:直接返回子目录;FILE模式:在每个子目录里找fileName,按文件名排序,收集
两个 Comparator:
private static final Comparator<File> FILE_PATH_COMPARATOR = Comparator.comparing(File::getAbsolutePath);
private static final Comparator<File> FILE_NAME_COMPARATOR = Comparator.comparing(File::getName);
- 子目录按绝对路径排序 → 字典序
- 文件按文件名排序
isVisibleDirectory:
private boolean isVisibleDirectory(File file) {
return file.isDirectory() && !file.getName().startsWith("..");
}
排除以 .. 开头的目录(与 ConfigTreePropertySource.hasHiddenPathElement 同源的防守)。
失败静默:基目录不存在 / 不是目录 / listFiles 返回 null → 返回 EMPTY_RESOURCES,不抛异常。
6.4 getResource:非 pattern 的转换
Resource getResource(String location) {
validateNonPattern(location);
location = StringUtils.cleanPath(location);
if (!ResourceUtils.isUrl(location)) {
location = ResourceUtils.FILE_URL_PREFIX + location;
}
return this.resourceLoader.getResource(location);
}
ResourceUtils.isUrl(location) 判断是否是 xxx: 形式。不是则加 file: 前缀。
所以 /etc/config/ 被转成 file:/etc/config/,classpath:/ 保持原样。
StringUtils.cleanPath 规范化路径(./、../、连续 /)。
6.5 典型用法
./config/*/(默认搜索位置的一部分):
./
├── config/
│ ├── db/
│ │ └── application.yaml
│ ├── mq/
│ │ └── application.yaml
│ └── web/
│ └── application.yaml
optional:file:./config/*/ 会展开成三个目录,每个目录里找 application.{properties,xml,yml,yaml}(configNames 默认 application)。
优先级:子目录按绝对路径字典序排列。结合第 10 章的推导顺序,web 排在 mq、db 之后……
我在这里再次标注方向不确定性:getResources 返回的数组顺序 = 字典序,但"先返回的是高优先级还是低优先级"取决于 ConfigDataImporter.load 的逆序遍历与 asContributors 的组合。第 11.1 节会给出自测方法。官方未明确文档化 config/*/ 子目录间的优先级,实践中不应依赖它。
configtree:/etc/config/*/:ResourceType.DIRECTORY,每个子目录一个 ConfigTree 源。
7. 错误处理与诊断
7.1 异常家族
ConfigDataException (RuntimeException)
├── ConfigDataNotFoundException
│ ├── ConfigDataLocationNotFoundException ← 位置层面找不到
│ └── ConfigDataResourceNotFoundException ← 资源层面找不到
├── InactiveConfigDataAccessException ← 从不活跃源读属性(第 10 章)
├── InvalidConfigDataPropertyException ← 属性写在错误的地方(第 10 章)
└── UnsupportedConfigDataLocationException ← 没有任何 resolver 认领
ConfigDataNotFoundException 的两个抽象:
| 类 | 携带 | getReferenceDescription() |
|---|---|---|
ConfigDataLocationNotFoundException | ConfigDataLocation | location 'xxx' |
ConfigDataResourceNotFoundException | ConfigDataResource + 可选 ConfigDataLocation | resource 'xxx' 或 resource 'xxx' via location 'yyy' |
ConfigDataResourceNotFoundException.withLocation:把 location 补进去,让消息更完整:
ConfigDataResourceNotFoundException withLocation(ConfigDataLocation location) {
return new ConfigDataResourceNotFoundException(this.resource, location, getCause());
}
在 ConfigDataImporter.handle 里被调用:
private void handle(ConfigDataNotFoundException ex, ConfigDataLocation location,
@Nullable ConfigDataResource resource) {
if (ex instanceof ConfigDataResourceNotFoundException notFoundException) {
ex = notFoundException.withLocation(location);
}
getNotFoundAction(location, resource).handle(this.logger, ex);
}
即:resource 级异常在冒泡时会补上 location 信息。
7.2 ConfigDataResourceNotFoundException 的静态工具
public static void throwIfDoesNotExist(ConfigDataResource resource, Path pathToCheck) {
throwIfNot(resource, Files.exists(pathToCheck));
}
public static void throwIfDoesNotExist(ConfigDataResource resource, File fileToCheck) {
throwIfNot(resource, fileToCheck.exists());
}
public static void throwIfDoesNotExist(ConfigDataResource resource, Resource resourceToCheck) {
throwIfNot(resource, resourceToCheck.exists());
}
三个重载:Path、File、Resource。
throwIfNot 不检查 resource 的 optional 标志——optional 的判断在 ConfigDataImporter.getNotFoundAction 里,不在异常构造处。这是关注点分离:异常只负责"报告",策略由调用方决定。
7.3 ConfigDataNotFoundAction
public enum ConfigDataNotFoundAction {
FAIL {
@Override
void handle(Log logger, ConfigDataNotFoundException ex) {
throw ex;
}
},
IGNORE {
@Override
void handle(Log logger, ConfigDataNotFoundException ex) {
logger.trace(LogMessage.format("Ignoring missing config data %s", ex.getReferenceDescription()));
}
};
abstract void handle(Log logger, ConfigDataNotFoundException ex);
}
只有两个值。IGNORE 只打 TRACE 日志——不会有任何 WARN。所以排查"配置没生效"时,开 TRACE 是唯一手段。
7.4 ConfigDataNotFoundFailureAnalyzer:错误消息的艺术
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);
}
...
}
输出样例:
***************************
APPLICATION FAILED TO START
***************************
Description:
Config data location 'file:/etc/nope.yaml' does not exist
Action:
Check that the value 'file:/etc/nope.yaml' at class path resource [application.yaml] - 12:5 is correct, or prefix it with 'optional:'
三段信息:
Description:发生了什么Action:怎么做——包含 Origin(哪个文件第几行)和解决方案(加optional:)- 原始异常(cause)
Origin 从哪来? Origin.from(location),而 ConfigDataLocation.getOrigin() 由 ConfigDataLocationBindHandler 回填(第 10 章 §5.6)。
这就是 §5.6 那个"给 location 贴 Origin"的 handler 的价值兑现点。
7.5 诊断 checklist
遇到"配置没生效",按此顺序排查:
1. 开 TRACE:--logging.level.org.springframework.boot.context.config=TRACE
找这几行:
- "Adding initial config data import from locations [...]"
- "Considering resource X from location Y"
- "Loaded resource X from location Y"
- "Already loaded resource X ignoring location Y"
- "Skipping missing resource X" ← 静默跳过
- "Ignoring missing config data ..." ← IGNORE 策略生效
- "Skipping inactive property source 'X'" ← activate 判定为不活跃
- "Adding imported property source 'X'" ← 成功应用
2. 查 /actuator/env(需要 actuator)看 PropertySource 列表与顺序
3. 查 Origin:每个属性都能告诉你来自哪个文件哪一行
4. 确认 profile:environment.getActiveProfiles() 与 getDefaultProfiles()
"Skipping inactive property source" 这一行是排查 spring.config.activate.* 问题的关键——它说明文档被判定为不活跃,但不报错(因为不活跃是合法的)。
8. AOT 与 GraalVM Native 下的配置源
8.1 ConfigDataLocationRuntimeHints
class ConfigDataLocationRuntimeHints implements RuntimeHintsRegistrar {
private static final Log logger = LogFactory.getLog(ConfigDataLocationRuntimeHints.class);
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
List<String> fileNames = getFileNames(classLoader);
List<String> locations = getLocations(classLoader);
List<String> extensions = getExtensions(classLoader);
if (logger.isDebugEnabled()) {
logger.debug("Registering application configuration hints for " + fileNames + "(" + extensions + ") at "
+ locations);
}
FilePatternResourceHintsRegistrar.forClassPathLocations(locations)
.withFilePrefixes(fileNames)
.withFileExtensions(extensions)
.registerHints(hints.resources(), classLoader);
}
protected List<String> getFileNames(@Nullable ClassLoader classLoader) {
return Arrays.asList(StandardConfigDataLocationResolver.DEFAULT_CONFIG_NAMES);
}
protected List<String> getLocations(@Nullable ClassLoader classLoader) {
List<String> classpathLocations = new ArrayList<>();
for (ConfigDataLocation candidate : ConfigDataEnvironment.DEFAULT_SEARCH_LOCATIONS) {
for (ConfigDataLocation configDataLocation : candidate.split()) {
String location = configDataLocation.getValue();
if (location.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
classpathLocations.add(location);
}
}
}
return classpathLocations;
}
protected List<String> getExtensions(@Nullable ClassLoader classLoader) {
List<String> extensions = new ArrayList<>();
List<PropertySourceLoader> propertySourceLoaders = getSpringFactoriesLoader(classLoader)
.load(PropertySourceLoader.class);
for (PropertySourceLoader propertySourceLoader : propertySourceLoaders) {
for (String fileExtension : propertySourceLoader.getFileExtensions()) {
String candidate = "." + fileExtension;
if (!extensions.contains(candidate)) {
extensions.add(candidate);
}
}
}
return extensions;
}
}
三个维度:
| 维度 | 值 | 来源 |
|---|---|---|
fileNames | ["application"] | DEFAULT_CONFIG_NAMES |
locations | classpath:/、classpath:/config/ | DEFAULT_SEARCH_LOCATIONS 中以 classpath: 开头的 |
extensions | .properties、.xml、.yml、.yaml | 所有 PropertySourceLoader 的 getFileExtensions() |
关键限制:getLocations() 只收集 classpath 位置。
if (location.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
classpathLocations.add(location);
}
optional:file:./ 那些位置被跳过——因为 native image 里"当前工作目录"是不可预测的,无法在编译期 hint。
含义:
Native image 里,
classpath:/application.yaml与classpath:/config/application.yaml能被 AOT 处理;file:./config/application.yaml、configtree:、env:必须在运行时动态读取,且需要相应的反射/资源 hint。
这也解释了为什么 native 应用的"外部配置文件"通常通过 spring.config.location 在运行时指定(而不是编译期内联)。
但注意:DEFAULT_SEARCH_LOCATIONS 里的 location 值是 optional:classpath:/,不是 classpath:/。那么 startsWith("classpath:") 会返回 false!
让我再仔细看:
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]);
}
ConfigDataLocation.of("optional:classpath:/;...") → optional=true,value = "classpath:/;optional:classpath:/config/"。
然后 candidate.split() → 按 ; 拆成两个 ConfigDataLocation:
ConfigDataLocation.of("classpath:/")→ value ="classpath:/"✓ 以classpath:开头ConfigDataLocation.of("optional:classpath:/config/")→ optional=true,value ="classpath:/config/"✓
split() 用的是 toString()(看 ConfigDataLocation.split:StringUtils.delimitedListToStringArray(toString(), delimiter)),而 toString() 会把 optional: 加回去:
@Override
public String toString() {
return (!this.optional) ? this.value : OPTIONAL_PREFIX + this.value;
}
而外层 ConfigDataLocation.of("optional:classpath:/;optional:classpath:/config/") 的 optional 是 true,value 是 "classpath:/;optional:classpath:/config/"。
toString() → "optional:classpath:/;optional:classpath:/config/"
split() → ["optional:classpath:/", "optional:classpath:/config/"]
ConfigDataLocation.of("optional:classpath:/") → optional=true, value = "classpath:/" ✓
getValue() 返回 "classpath:/" → startsWith("classpath:") ✓
✓ 所以逻辑是对的。但这里有一个微妙的正确性依赖:ConfigDataLocation.of() 只剥离最前面的 optional:。如果写成 optional:optional:classpath:/,第二层 optional: 不会被剥离,getValue() 返回 "optional:classpath:/",hint 就会漏掉。
这是一个真实的边界情况,但只有用户写错时才会触发。
8.2 ConfigDataPropertiesRuntimeHints
class ConfigDataPropertiesRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
BindableRuntimeHintsRegistrar.forTypes(ConfigDataProperties.class).registerHints(hints);
Method method = ReflectionUtils.findMethod(ConfigDataLocation.class, "of", String.class);
Assert.state(method != null, "'method' must not be null");
hints.reflection().registerMethod(method, ExecutableMode.INVOKE);
}
}
两件事:
-
BindableRuntimeHintsRegistrar.forTypes(ConfigDataProperties.class)—— 为ConfigDataProperties(含内部类Activate)注册绑定所需的反射 hint。因为 AOT 下 Binder 要靠反射调构造器/ setter。 -
ConfigDataLocation.of(String)方法注册为可反射调用 —— 因为 Binder 绑定spring.config.import时需要调用这个静态工厂方法(第 08 章讲过 Binder 如何用of/valueOf做类型转换)。
ExecutableMode.INVOKE —— 只需要能调用,不需要 INTROSPECT(后者更重,包含注解元数据)。
8.3 为什么 ConfigData 是 AOT 的难点
ConfigData 天生与 AOT 冲突:
| 特性 | AOT 友好度 | 原因 |
|---|---|---|
| classpath 配置文件 | ✅ 可 hint | 编译期可枚举 |
file: 位置 | ❌ | 运行时才有 |
configtree: | ❌ | 目录内容运行时才确定 |
env: | ❌ | 环境变量值运行时才有 |
通配符 */ | ❌ | 编译期无法枚举 |
自定义 ConfigDataLocationResolver | ⚠️ | 需要反射 hint |
自定义 PropertySourceLoader | ⚠️ | 泛型擦除 + 反射 |
Boot 的策略是"编译期能做的做掉,做不了的留给运行时":AOT 处理器(第 27 章详述)会尝试在编译期跑一遍 ConfigData 并内联结果,但保留运行时的动态路径。
9. 自定义配置源:完整扩展指南
9.1 扩展点选择
| 需求 | 扩展点 | 难度 |
|---|---|---|
| 支持新文件格式(JSON、TOML) | PropertySourceLoader | 低 |
支持新位置协议(vault:、s3:) | ConfigDataLocationResolver + ConfigDataLoader | 中 |
| 在属性源加入时做点什么 | ConfigDataEnvironmentUpdateListener | 低 |
| 早期读取配置(在 ConfigData 之前) | EnvironmentPostProcessor | 中 |
9.2 自定义 PropertySourceLoader
public class JsonPropertySourceLoader implements PropertySourceLoader {
@Override
public String[] getFileExtensions() {
return new String[] { "json" };
}
@Override
public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
Map<String, Object> map = new ObjectMapper().readValue(resource.getInputStream(),
new TypeReference<>() { });
return List.of(new MapPropertySource(name, flatten(map)));
}
}
注册:META-INF/spring.factories
org.springframework.boot.env.PropertySourceLoader=\
com.example.JsonPropertySourceLoader
注册后立刻获得的能力:
application.json会被自动搜索(因为ConfigDataLocationRuntimeHints.getExtensions()与getReferencesForConfigName都读getFileExtensions())env:APP[.json]可用spring.config.import=file:/x[.json]可用(FileHint)
注意 load(name, resource, encoding) 的 default 实现(@since 4.1.0):
default List<PropertySource<?>> load(String name, Resource resource, @Nullable Charset encoding)
throws IOException {
return load(name, resource);
}
默认忽略 encoding。如果你的格式支持编码(如 properties),应该重写它。这是 4.1 新增的能力。
9.3 自定义 ConfigDataLocationResolver + ConfigDataLoader
Step 1:定义 Resource
public class VaultConfigDataResource extends ConfigDataResource {
private final String path;
public VaultConfigDataResource(String path) {
this.path = path;
}
public String getPath() { return this.path; }
// equals / hashCode 必须实现(ConfigDataImporter 依赖去重)
@Override
public boolean equals(Object obj) {
return (obj instanceof VaultConfigDataResource other) && this.path.equals(other.path);
}
@Override
public int hashCode() { return this.path.hashCode(); }
@Override
public String toString() { return "vault [" + this.path + "]"; }
}
Step 2:Resolver
public class VaultConfigDataLocationResolver implements ConfigDataLocationResolver<VaultConfigDataResource> {
private static final String PREFIX = "vault:";
@Override
public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) {
return location.hasPrefix(PREFIX);
}
@Override
public List<VaultConfigDataResource> resolve(ConfigDataLocationResolverContext context,
ConfigDataLocation location) {
// context.getBinder() 可以读早期配置(如 vault.address)
// context.getBootstrapContext() 可以注册/获取早期共享实例
String path = location.getNonPrefixedValue(PREFIX);
return List.of(new VaultConfigDataResource(path));
}
}
可注入的构造器参数(见 §1.4):DeferredLogFactory、Binder、ResourceLoader、ConfigurableBootstrapContext / BootstrapContext / BootstrapRegistry。
Step 3:Loader
public class VaultConfigDataLoader implements ConfigDataLoader<VaultConfigDataResource> {
@Override
public ConfigData load(ConfigDataLoaderContext context, VaultConfigDataResource resource) {
Map<String, Object> secrets = fetchFromVault(resource.getPath());
PropertySource<?> source = new MapPropertySource("vault:" + resource.getPath(), secrets);
return new ConfigData(List.of(source));
}
}
Step 4:注册
org.springframework.boot.context.config.ConfigDataLocationResolver=\
com.example.VaultConfigDataLocationResolver
org.springframework.boot.context.config.ConfigDataLoader=\
com.example.VaultConfigDataLoader
Step 5:使用
spring:
config:
import: optional:vault:secret/data/myapp
关键设计点:
isResolvable必须精确:返回 true 就"独占"这个 location,别的 resolver 不会再看。- resolver 与 loader 分离:resolver 只做"位置 → 资源列表"的映射(可能涉及网络?不,resolver 不应有副作用),loader 才真正取数据。这样
ConfigDataImporter可以在 load 之前去重。 - Resource 的
equals/hashCode:ConfigDataImporter.loaded是HashSet,去重靠它。 optional语义:ConfigDataResource的构造器支持optional参数;也可以在 loader 里抛ConfigDataResourceNotFoundException,由ConfigDataImporter.getNotFoundAction决定是否忽略。
9.4 ConfigDataEnvironmentUpdateListener
public interface ConfigDataEnvironmentUpdateListener extends EventListener {
ConfigDataEnvironmentUpdateListener NONE = new ConfigDataEnvironmentUpdateListener() {};
default void onPropertySourceAdded(PropertySource<?> propertySource, @Nullable ConfigDataLocation location,
@Nullable ConfigDataResource resource) {
}
default void onSetProfiles(Profiles profiles) {
}
default ConfigData.Options onConfigDataOptions(ConfigData configData, PropertySource<?> propertySource,
Options options) {
return options;
}
}
三个钩子:
| 钩子 | 时机 | 用途 |
|---|---|---|
onPropertySourceAdded | 每个属性源被 addLast 到 Environment 时 | 审计、统计 |
onSetProfiles | environment.setActiveProfiles/DefaultProfiles 后 | 记录最终 profile |
onConfigDataOptions | ConfigData.getOptions(source) 得到选项后 | 修改选项(IGNORE_IMPORTS / IGNORE_PROFILES / PROFILE_SPECIFIC) |
onConfigDataOptions 是 @since 3.5.1 —— 这是一个后加的"逃生舱":允许外部干预某个属性源的选项。
怎么注入? 通过 ConfigDataEnvironmentPostProcessor 的构造或 ConfigDataEnvironment 的构造参数:
ConfigDataEnvironment(DeferredLogFactory logFactory, ConfigurableBootstrapContext bootstrapContext,
ConfigurableEnvironment environment, ResourceLoader resourceLoader, Collection<String> additionalProfiles,
@Nullable ConfigDataEnvironmentUpdateListener environmentUpdateListener) {
...
this.environmentUpdateListener = (environmentUpdateListener != null) ? environmentUpdateListener
: ConfigDataEnvironmentUpdateListener.NONE;
...
}
我没有找到公开的注入入口(比如 SpringApplication 上的 setter)——ConfigDataEnvironmentPostProcessor 的构造由 spring.factories 驱动,参数由 ArgumentResolver 解析。所以自定义 listener 需要自己写一个 EnvironmentPostProcessor 来构造 ConfigDataEnvironment。
这一点我标注为未核实:Boot 可能有我没找到的公开入口。定制需求建议先读 ConfigDataEnvironmentPostProcessor 源码确认。
10. 反直觉结论(本章十二条)
10.1 ConfigData 的 SPI 走 spring.factories,不是 *.imports
4.x 把自动配置迁到了 META-INF/spring/*.imports,但 PropertySourceLoader / ConfigDataLocationResolver / ConfigDataLoader 仍然在 spring.factories。写自定义 loader 时不要放错文件。
10.2 configtree: 路径必须以 / 结尾,否则抛 IllegalStateException
Assert.state 不是 ConfigDataNotFoundException——optional: 前缀救不了它。
10.3 configtree: 默认非 optional
目录不存在会抛 ConfigDataResourceNotFoundException。K8s 场景必须写 optional:configtree:/etc/config/。
10.4 ConfigTree 的属性源不带任何 ConfigData.Option
new ConfigData(Collections.singletonList(source)) 没传 PropertySourceOptions → Options.NONE。
后果:可以在 ConfigTree 里写 spring.profiles.active 和 spring.config.import(profile-specific 文件里则不行)。
10.5 AUTO_TRIM_TRAILING_NEW_LINE 只 trim"恰好一个换行"的值
多行值原样返回。这是为了不破坏 PEM 等格式。
10.6 ConfigTree 的隐藏目录判定是"以 .. 开头",不是"以 . 开头"
.gitkeep、.DS_Store 会被当成属性;..data、..2024_01_01_xxx 被过滤。这是为 K8s Secret 的符号链接布局定制的。
10.7 ConfigTree 的目录结构在构造时快照,之后不感知增删
只有开启 ALWAYS_READ 才能感知内容变化;文件增删永远不感知。而 ConfigTreeConfigDataLoader 没有开 ALWAYS_READ。
10.8 env: 位置不支持 JSON
Boot 内置只有 properties/xml/yml/yaml 四个扩展名。env:X[.json] 抛 IllegalStateException(不是 ConfigDataNotFoundException,optional: 无效)。
整份 JSON 配置请用 SPRING_APPLICATION_JSON(走 SpringApplicationJsonEnvironmentPostProcessor,机制完全不同)。
10.9 env: 的字节编码写死 UTF-8
asResource 里 StandardCharsets.UTF_8。再用 [ISO-8859-1] FileHint 会双重编码,非 ASCII 字符有损。
10.10 HOST_F00_NAME 映射成 host.f00.name 而不是 host[f00].name
索引判定是"纯数字"。F00 含字母 → 不当索引。
10.11 S-ERVER 映射成 s-erver(一个元素)
adapt 只按 _ 切分,- 被保留在元素内。
10.12 环境变量映射有四个候选,foo.the-bar 是唯一能全部触发的例子
FOO_THEBAR、FOO_THE_BAR、foo_thebar、foo_the_bar。
实践含义:my.property-name 既可以用 MY_PROPERTYNAME(新式)也可以用 MY_PROPERTY_NAME(旧式)设置。二者的优先级由候选顺序决定(新式在前)。
10.13(补)SystemEnvironmentPropertySourceEnvironmentPostProcessor 用 replace 而不是 addFirst
保持 systemEnvironment 的原始位置。这保证了环境变量不会意外获得最高优先级。
10.14(补)setEnvironmentPrefix 只影响环境变量
不影响 -D 系统属性、不影响配置文件。PropertySourceInfo.getPrefix() 只有 OriginAwareSystemEnvironmentPropertySource 实现了非空值。
11. 动手验证清单
11.1 确定 spring.config.import 列表的优先级方向(第 10/11 章的遗留问题)
mkdir -p /tmp/cfg && cd /tmp/cfg
printf 'a=1\nb=1\n' > a.properties
printf 'a=2\nb=2\n' > b.properties
application.properties 只写一行:
spring.config.import=file:/tmp/cfg/a.properties,file:/tmp/cfg/b.properties
启动后打印 environment.getProperty("a"):
- 若得到
1→ 靠前者优先级高 - 若得到
2→ 靠后者优先级高
请读者自行跑一次并把结果记录下来——我在第 10 章 §8.1 和第 11 章 §6.5 都标注了这个方向的不确定性,源码层面我找到了 ConfigDataImporter.load 的逆序遍历和 asContributors 的逆序插入,但没有找到像 ConfigData.getPropertySources() 那样明确的 Javadoc 锚点。
同理测 config/*/ 子目录顺序:
mkdir -p config/{aaa,zzz}
printf 'k=aaa\n' > config/aaa/application.properties
printf 'k=zzz\n' > config/zzz/application.properties
k 的值告诉你子目录的优先级方向。
11.2 验证 ConfigTree
mkdir -p /tmp/tree/database
printf 'jdbc:mysql://localhost:3306/app' > /tmp/tree/database.url
printf '20\n' > /tmp/tree/database/pool/size 2>/dev/null || { mkdir -p /tmp/tree/database/pool; printf '20\n' > /tmp/tree/database/pool/size; }
printf 'secret\n' > /tmp/tree/password
@SpringBootApplication
public class App {
public static void main(String[] args) {
new SpringApplicationBuilder(App.class)
.properties("spring.config.import=optional:configtree:/tmp/tree/")
.run(args);
}
}
验证:
database.url=jdbc:mysql://localhost:3306/app(注意没有尾随换行,除非你用了echo而非printf)database.pool.size=20(嵌套目录 →.连接)
再验证"必须以 / 结尾":改成 configtree:/tmp/tree → 应抛 IllegalStateException: Config tree location '/tmp/tree' must end with '/'。
再验证"目录不存在":改成 optional:configtree:/tmp/nope/ → 静默;去掉 optional: → ConfigDataResourceNotFoundException。
11.3 验证 AUTO_TRIM_TRAILING_NEW_LINE 的三条规则
printf 'abc' > /tmp/t/plain # 无换行 → "abc"
printf 'abc\n' > /tmp/t/one # 一个 \n → "abc"
printf 'abc\n\n' > /tmp/t/two # 两个 \n → "abc\n\n"(不 trim)
printf 'a\nb\n' > /tmp/t/multi # 两个 \n → "a\nb\n"(不 trim)
读取这四个属性并打印 length():期望 3, 3, 5, 4。
11.4 验证 env: 位置
export APP_CFG='
my:
key: from-env
'
java -jar app.jar --spring.config.import=env:APP_CFG[.yaml]
my.key = from-env。
验证不支持 JSON:
export APP_JSON='{"my":{"key":"x"}}'
java -jar app.jar --spring.config.import=env:APP_JSON[.json]
应抛 IllegalStateException: File extension 'json' is not known to any PropertySourceLoader。
验证变量不存在:
--spring.config.import=env:NOT_SET→ConfigDataLocationNotFoundException: Environment variable 'NOT_SET' is not set--spring.config.import=optional:env:NOT_SET→ 静默
11.5 验证环境变量映射的四个候选
# 新式(无 -)
MY_THEBAR=1 java -jar app.jar # 绑定 my.the-bar
# 旧式(有 _)
MY_THE_BAR=2 java -jar app.jar # 绑定 my.the-bar
# 同时设置
MY_THEBAR=1 MY_THE_BAR=2 java -jar app.jar # 期望 1(新式候选在前)
最后一条是 §4.4 候选顺序的可观测推论——如果实际不是 1,说明候选顺序或查找逻辑与我理解的不同。
11.6 验证数字索引判定
HOST_0=a HOST_F00=b java -jar app.jar
host[0]=ahost.f00=b(不是host[f00])
11.7 验证 setEnvironmentPrefix
SpringApplication app = new SpringApplication(App.class);
app.setEnvironmentPrefix("APP1");
app.run(args);
APP1_SERVER_PORT=9090 SERVER_PORT=8080 java -jar app.jar
期望端口 9090(SERVER_PORT 被忽略,因为前缀是 APP1)。
再验证尾部字符归一化:setEnvironmentPrefix("APP1_") 应与 setEnvironmentPrefix("APP1") 等价。
11.8 验证 ConfigTree 里可以写 spring.profiles.active
printf 'prod' > /tmp/tree/spring.profiles.active
# 注意文件名含点,ConfigTree 会把它作为属性名 "spring.profiles.active"
配合 --spring.config.import=optional:configtree:/tmp/tree/,观察 active profile 是否变成 prod。
这是我 §2.3 标注的推演项——如果无效,说明 ConfigTree 的 import 发生在 AFTER_PROFILE_ACTIVATION 轮,或 IGNORE_PROFILES 被隐式设置了。请把结论记录下来。
11.9 验证 AOT 只 hint classpath 位置
./gradlew :myapp:nativeCompile # 或 bootBuildImage
然后检查生成的 RuntimeHints 报告(target/spring-aot/main/resources/... 或 --spring.aot.enabled=true 的诊断输出),确认资源 hint 只含 classpath:/application* 与 classpath:/config/application*。
11.10 诊断演练
故意写错位置,观察错误消息的完整度:
# application.yaml 第 7 行
spring:
config:
import:
- file:/etc/definitely-not-here.yaml
期望:
Config data location 'file:/etc/definitely-not-here.yaml' does not exist
Action:
Check that the value 'file:/etc/definitely-not-here.yaml' at class path resource [application.yaml] - 7:7 is correct, or prefix it with 'optional:'
at class path resource [application.yaml] - 7:7 这一段就是 ConfigDataLocationBindHandler + Origin.from(location) 的功劳。
12. 本章源码索引
12.1 核心
| 文件 | 关注点 |
|---|---|
env/ConfigTreePropertySource.java | findAll、getName、PropertyFileContent、autoTrimTrailingNewLine、三个 Option |
context/config/ConfigTreeConfigDataLocationResolver.java | PREFIX、末尾 / 断言、通配符分支 |
context/config/ConfigTreeConfigDataLoader.java | 强制 AUTO_TRIM_TRAILING_NEW_LINE、不传 options |
context/config/SystemEnvironmentConfigDataLocationResolver.java | DEFAULT_EXTENSION、getLoader、optional 处理 |
context/config/SystemEnvironmentConfigDataResource.java | asResource 的 UTF-8、equals 用 loader.getClass() |
context/properties/source/SystemEnvironmentPropertyMapper.java | 四候选、processElementValue、getAncestorOfCheck |
context/properties/source/ConfigurationPropertyName.java | Form 枚举、ToStringFormat、buildToString、asSystemEnvironmentLegacyName、withPrefix |
env/PropertySourceInfo.java | 接口定义与 Javadoc |
support/SystemEnvironmentPropertySourceEnvironmentPostProcessor.java | OriginAwareSystemEnvironmentPropertySource、determinePrefix、replace |
context/config/LocationResourceLoader.java | isPattern、validatePattern、getResources、两个 Comparator |
context/config/ConfigDataLocationResolvers.java | reorder、ArgumentResolver |
12.2 支撑
| 文件 | 关注点 |
|---|---|
core/spring-boot/src/main/resources/META-INF/spring.factories | 三张 ConfigData 表 + EnvironmentPostProcessor 列表 |
env/PropertySourceLoader.java | 三参 load 的 default 实现(@since 4.1.0) |
context/config/ConfigDataNotFoundAction.java | FAIL / IGNORE |
context/config/ConfigDataLocationNotFoundException.java | getReferenceDescription |
context/config/ConfigDataResourceNotFoundException.java | 三个 throwIfDoesNotExist 重载、withLocation |
context/config/ConfigDataNotFoundFailureAnalyzer.java | 错误消息三段构造 |
context/config/ConfigDataEnvironmentUpdateListener.java | 三个钩子 |
context/config/ConfigDataLocationRuntimeHints.java | 只收 classpath 位置 |
context/config/ConfigDataPropertiesRuntimeHints.java | ConfigDataLocation.of 的反射注册 |
ApplicationInfoPropertySource.java | isImmutable=true、moveToEnd |
origin/SystemEnvironmentOrigin.java | toString 形态 |
12.3 测试
| 文件 | 关注点 |
|---|---|
context/properties/source/SystemEnvironmentPropertyMapperTests.java | 本章最重要:映射表的权威断言 |
context/properties/source/SpringIterableConfigurationPropertySourceTests.java | legacy 属性的 containsDescendantOf / getConfigurationProperty |
env/ConfigTreePropertySourceTests.java(未确认存在,待核实) | ConfigTree 行为 |
context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java | 位置解析集成测试 |
13. 下一章
第 12 章 · 事件总线与 Bootstrap 上下文:SpringApplicationRunListener、BootstrapRegistry、CRaC
本章与第 10 章讲的是"配置从哪来、怎么收敛"。第 12 章回到启动主线,讲贯穿整个 run() 生命周期的两套机制:
SpringApplicationRunListener:starting/environmentPrepared/contextPrepared/contextLoaded/started/ready/failed七个事件点,以及EventPublishingRunListener如何把它们桥接到ApplicationEvent体系。BootstrapRegistry:为什么需要"上下文之外的临时容器"、InstanceSupplier的Scope(SINGLETON/PROTOTYPE)、BootstrapContext关闭时的事件。- CRaC(Coordinated Restore at Checkpoint):
org.crac集成、checkpoint / restore 的生命周期、为什么它与配置体系冲突。
第 12 章之后进入卷四(IoC 与自动配置引擎):Bean 定义加载、AutoConfigurationImportSelector、条件注解体系。
附:本章遗留悬念
| 悬念 | 将在 |
|---|---|
spring.config.import 列表与 config/*/ 子目录的优先级方向(需实测) | 读者自测(§11.1) |
ConfigDataEnvironmentUpdateListener 的公开注入入口 | 第 12 章(事件总线)一并说明 |
PrefixedConfigurationPropertySource.getPrefixedName 的前缀拼接细节 | 第 15 章(配置元数据)或读者自查 |
ConfigDataLoaders 的泛型匹配与 native 反射 | 第 27 章(AOT 与 Native) |
SpringConfigurationPropertySource 的 SYSTEM_ENVIRONMENT_MAPPERS 选择条件 | 第 08 章已述,本章标注未重新核实 |
ConfigTreePropertySourceTests 是否存在 | 待核实 |
本章未逐行核实 / 待你实测的项
- ConfigTree 在 Kubernetes 挂载下的真实表现未实测(需要集群环境)。
SystemEnvironmentPropertyMapper的完整映射规则未逐条列出。- 各
ConfigDataLocationResolver的排序来源未逐个确认。 Properties/Yamlloader 的顺序在不同版本间是否稳定未考证。spring.config.import与 ConfigTree 的组合行为未实测。