Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:`spring.config.activate`

Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:spring.config.activate、ConfigTree、多文档 YAML、环境变量映射

源码根:spring-boot-4.1.1 主要包:org.springframework.boot.context.config、org.springframework.boot.env、org.springframework.boot.context.properties.source 版本:Spring Boot 4.1.1 / Spring Framework 7.0.9


0. 本章在整套体系中的位置

第 07 章我们拆开了 ConfigData 的骨架:位置 → 解析 → 加载 → contributor 树 → 应用。第 08 章拆开了 Binder,第 09 章拆开了 @ConfigurationProperties。

但第 07 章留了四个"只说了机制、没说透语义"的口子:

  1. profile 到底是什么时候、按什么规则求出来的? 为什么说"profile 求解是一次不动点迭代"?
  2. spring.config.activate.on-profile 到底绑定在谁的身上? 多文档 YAML 里第二个文档写了 on-profile: prod,它是怎么"只对自己生效"的?
  3. 一个文件怎么变成多个 PropertySource? YAML 的 --- 好理解,但 .properties 文件也能多文档?
  4. SPRING_PROFILES_ACTIVE 这种大写下划线变量,是怎么变成 spring.profiles.active 的? 为什么 MY_THE_BAR 和 MY_THEBAR 都能命中?

本章打通前三个口子(第四个------环境变量映射------连同 ConfigTree 一起归入第 11 章)。它们是同一件事的四个切面:Boot 如何把"一堆来源各异的字符串"收敛成"一组有序、可判定、可追溯的属性"。

这一章的核心方法论仍然是第 07 章总结的第四定律:顺序语义常藏在 Javadoc 里。但本章会再加一条:

第六定律:语义的"作用域"常藏在构造参数里。 想知道一个属性是"全局判定"还是"只对当前文档生效",去看绑定它时传进去的 ConfigurationPropertySource 集合------ 是 Collections.singleton(...) 还是 Iterable<...>。这一字之差,就是 spring.config.activate.on-profile 与 spring.profiles.active 的根本区别。


1. Profile 的三重身份

"Profile" 这个词在 Boot 里被用在三个完全不同的层面,混用会导致大量误判。先做术语切分:

层面 名称 载体 谁消费 判定时机
A. 运行时激活集合 active profiles Environment.getActiveProfiles()、spring.profiles.active @Profile、条件注解、用户代码 上下文刷新期(Environment 已定型)
B. 文件变体选择 profile-specific file application-{profile}.yaml StandardConfigDataLocationResolver ConfigData 解析期(已有 profiles)
C. 文档级开关 spring.config.activate.on-profile 单个 PropertySource ConfigDataProperties.Activate 属性源应用期(逐源判定)

三者不是同一件事:

  • A 是"状态" :一个字符串集合,最终写进 Environment。
  • B 是"命名约定" :文件名里带 -{profile},由 resolver 按 profile 字符串拼出额外的候选路径。
  • C 是"布尔判定":每个属性源独立回答"我是否应当参与"。

最容易犯的错误是把 B 和 C 当成等价物。它们看起来都能做到"只在 prod 生效",但:

  • B 只能按文件名切分,粒度是"整个文件"。
  • C 可以按文档 切分,粒度是"文件里的一个 --- 片段",而且支持 &、|、!、括号表达式。

另一个高频误解是以为 A 决定 C。实际上 C 判定 A (读 Profiles 对象),而 A 又反过来由 C 过滤后的属性源贡献------这是一个环。第 07 章说过的"不动点迭代"就是为破这个环而存在的。本章第 4 节把它彻底讲清楚。


2. Profiles 类:一次求解,三处输出

2.1 类的定位

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/context/config/Profiles.java
public class Profiles implements Iterable<String> {

    public static final String INCLUDE_PROFILES_PROPERTY_NAME = "spring.profiles.include";

    static final ConfigurationPropertyName INCLUDE_PROFILES =
        ConfigurationPropertyName.of(Profiles.INCLUDE_PROFILES_PROPERTY_NAME);

    private static final Bindable<MultiValueMap<String, String>> STRING_STRINGS_MAP = Bindable
        .of(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class));

    private static final Bindable<Set<String>> STRING_SET = Bindable.setOf(String.class);

    private final MultiValueMap<String, String> groups;
    private final List<String> activeProfiles;
    private final List<String> defaultProfiles;
    ...
}

三个字段,全部 final,构造完成后不再变化。Profiles 是不可变快照 ------这一点非常重要:ConfigData 处理过程中会多次调用 Profiles 的构造,每次都是基于当时的 Binder 重新算一遍,不存在"增量更新"。

implements Iterable<String> 迭代的是 getAccepted()(第 2.7 节),不是 active。这是第一个容易踩的坑:

java 复制代码
@Override
public Iterator<String> iterator() {
    return getAccepted().iterator();
}

所以 for (String p : profiles) 拿到的可能是 default profiles。

2.2 构造过程的三条线

java 复制代码
Profiles(Environment environment, Binder binder, @Nullable Collection<String> additionalProfiles) {
    ProfilesValidator validator = ProfilesValidator.get(binder);
    if (additionalProfiles != null) {
        validator.validate(additionalProfiles,
            () -> "Invalid profile property value found in additional profiles");
    }
    this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP, validator)
        .orElseGet(LinkedMultiValueMap::new);
    this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));
    this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder, validator));
}

执行顺序是固定的、不可交换的:

  1. 先取 ProfilesValidator(它自己也要读 spring.profiles.validate,见 2.6)
  2. 校验 additionalProfiles(来自 SpringApplication.setAdditionalProfiles(...))
  3. 绑定 spring.profiles.group → MultiValueMap<String, String>,同时用 validator 校验
  4. 求 active profiles,然后 expandProfiles
  5. 求 default profiles,然后 expandProfiles

注意 groups 必须先于 active/default 绑定 ,因为 expandProfiles 要用 this.groups。如果你在 application.properties 里同时写 spring.profiles.group.prod=prod-db,prod-mq 和 spring.profiles.active=prod,那么 active 会被展开成 [prod, prod-db, prod-mq]------这个顺序第 2.5 节细讲。

2.3 Type 枚举:ACTIVE 与 DEFAULT 的不对称

这是 Profiles 里最容易被忽略、但影响最大的一处设计:

java 复制代码
private enum Type {

    ACTIVE(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME,   // "spring.profiles.active"
           Environment::getActiveProfiles,
           true,                        // mergeWithEnvironmentProfiles
           Collections.emptySet()),     // defaultValue

    DEFAULT(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME, // "spring.profiles.default"
            Environment::getDefaultProfiles,
            false,                      // mergeWithEnvironmentProfiles  ← 注意
            Collections.singleton("default"));

    private final Function<Environment, String[]> getter;
    private final boolean mergeWithEnvironmentProfiles;
    private final String name;
    private final Set<String> defaultValue;
    ...
}

四个字段逐一看:

字段 ACTIVE DEFAULT 含义
name spring.profiles.active spring.profiles.default 绑定的属性名
getter Environment::getActiveProfiles Environment::getDefaultProfiles 读"编程式设置"的值
mergeWithEnvironmentProfiles true false 编程式设置与配置属性是否合并
defaultValue {}(空) {"default"} 什么都没有时的兜底

mergeWithEnvironmentProfiles 的不对称是刻意的行为差异:

  • 若你在代码里写了 springApplication.setAdditionalProfiles("a"),同时在 application.properties 里写了 spring.profiles.active=b,结果是 [b, a]------两者合并(注意顺序,additional 在前,见 2.4)。
  • 但对 default 不适用:一旦 default 被"编程式设置"过,配置属性就不再参与合并。

这个不对称在源码里只有一处体现,就在 getProfiles() 里:

java 复制代码
if (hasProgrammaticallySetProfiles(type, environmentPropertyValue, environmentPropertyProfiles,
        environmentProfiles)) {
    if (!type.isMergeWithEnvironmentProfiles() || !boundProfiles.isBound()) {
        return environmentProfiles;          // 直接返回编程式设置,忽略配置属性
    }
    return boundProfiles.map((bound) -> merge(environmentProfiles, bound)).get();
}
return boundProfiles.orElse(type.getDefaultValue());

翻译成人话:

arduino 复制代码
若 profile 被"编程式设置"过:
    若 该 Type 不合并(即 DEFAULT) 或 配置属性根本没绑定上:
        → 只用编程式设置的值
    否则(ACTIVE 且配置属性绑定成功):
        → 编程式 ∪ 配置属性
否则(没被编程式设置):
    → 用配置属性的值,没绑上就用 defaultValue

2.4 hasProgrammaticallySetProfiles:怎么判断"被编程式设置过"

这段是纯逻辑推理,没有魔法,但非常绕:

java 复制代码
private boolean hasProgrammaticallySetProfiles(Type type, @Nullable String environmentPropertyValue,
        Set<String> environmentPropertyProfiles, Set<String> environmentProfiles) {
    if (!StringUtils.hasLength(environmentPropertyValue)) {
        return !type.getDefaultValue().equals(environmentProfiles);
    }
    if (type.getDefaultValue().equals(environmentProfiles)) {
        return false;
    }
    return !environmentPropertyProfiles.equals(environmentProfiles);
}

先明确四个入参:

  • environmentPropertyValue:environment.getProperty("spring.profiles.active") 的原始字符串(未切分)
  • environmentPropertyProfiles:把上面那个字符串按逗号切分、去空白后的集合
  • environmentProfiles:new LinkedHashSet<>(Arrays.asList(type.get(environment))),即 environment.getActiveProfiles() 的数组转集合

判断逻辑分三种情形:

情形 1:属性原始值为空 (environment.getProperty(...) 返回 null 或空串)

此时只有一种可能说明"编程式设置过":environment.getActiveProfiles() 不等于 defaultValue。

  • 对 ACTIVE,defaultValue 是 {}。所以只要 getActiveProfiles() 非空 → 编程式设置过。典型场景:setAdditionalProfiles("x") 或 setActiveProfiles("x")。
  • 对 DEFAULT,defaultValue 是 {"default"}。所以只要 getDefaultProfiles() 不是 ["default"] → 编程式设置过。典型场景:setDefaultProfiles("base")。

情形 2:属性值非空,且 environmentProfiles 恰好等于 defaultValue → 返回 false,即"不算编程式设置"。

这是一个防御性分支 :防止属性值被 environment.getProperty() 读到、但 getActiveProfiles() 仍返回默认数组时,被误判成"编程式设置"从而跳过配置属性绑定。

  • ACTIVE:属性有值但 getActiveProfiles() 是空数组 → 不算编程式 → 正常绑定(最常见的正常路径)。
  • DEFAULT:属性有值但 getDefaultProfiles() 是 ["default"] → 不算编程式 → 正常绑定,这保证了 spring.profiles.default=base 能被读到。

情形 3:属性值非空,且 environmentProfiles != defaultValue → 比较"切分后的属性集合"与"Environment 数组集合":

  • 相等 → 二者同源(Environment 的值就是从属性来的)→ 不算编程式设置。
  • 不等 → Environment 上有额外的值(编程式塞进去的)→ 算。

Environment::getActiveProfiles 在 Spring Framework 里会读 spring.profiles.active 属性,但 Boot 的 ApplicationEnvironment(见第 06 章)重写 doGetActiveProfilesProperty() 返回 null,剥夺了 AbstractEnvironment 自己读属性的能力 ------所以 Boot 里 getActiveProfiles() 只反映编程式设置,情形 3 的"不等"判断才成立。

方法论提示 :这段代码之所以绕,是因为它要同时兼容 Boot 的 ApplicationEnvironment 和任意第三方 Environment 实现。

2.5 expandProfiles:group 展开的真实顺序

java 复制代码
private List<String> expandProfiles(@Nullable List<String> profiles) {
    Deque<String> stack = new ArrayDeque<>();
    asReversedList(profiles).forEach(stack::push);
    Set<String> expandedProfiles = new LinkedHashSet<>();
    while (!stack.isEmpty()) {
        String current = stack.pop();
        if (expandedProfiles.add(current)) {
            asReversedList(this.groups.get(current)).forEach(stack::push);
        }
    }
    return asUniqueItemList(expandedProfiles);
}

这是一个显式栈的深度优先展开 (而不是递归,避免栈溢出也便于控制顺序)。要理解它,必须同时看 asReversedList:

java 复制代码
private List<String> asReversedList(@Nullable List<String> list) {
    if (CollectionUtils.isEmpty(list)) {
        return Collections.emptyList();
    }
    List<String> reversed = new ArrayList<>(list);
    Collections.reverse(reversed);
    return reversed;
}

为什么反转两次? 这是第 05 章说的"逆序插入 = 后声明者优先"惯用法的又一例:

  • 输入 [a, b, c],反转 → [c, b, a],依次 push 到栈顶 → 栈从顶到底是 a, b, c。

  • 弹出顺序:a, b, c ✓(保持原始顺序)

  • 若 a 的 group 是 [a1, a2],反转 → [a2, a1],push → 栈顶是 a1。

  • 此时栈从上到下是 a1, a2, b, c,弹出 a1,然后把 a1 的 group 压栈......

结论 :展开是深度优先 ,a 的子 profile 完全展开完才会轮到 b。

举个具体例子。配置:

yaml 复制代码
spring:
  profiles:
    group:
      prod: prod-db,prod-mq
      prod-db: prod-pool
    active: prod

输入 active = [prod]:

ini 复制代码
栈(顶→底): prod
pop prod       → expanded = [prod]
  groups[prod] = [prod-db, prod-mq],反转 → [prod-mq, prod-db],push
栈(顶→底): prod-db, prod-mq
pop prod-db    → expanded = [prod, prod-db]
  groups[prod-db] = [prod-pool],反转 → [prod-pool],push
栈(顶→底): prod-pool, prod-mq
pop prod-pool  → expanded = [prod, prod-db, prod-pool]
  groups[prod-pool] = null
栈(顶→底): prod-mq
pop prod-mq    → expanded = [prod, prod-db, prod-pool, prod-mq]

最终 [prod, prod-db, prod-pool, prod-mq]。注意 prod-mq 排在 prod-pool 后面------深度优先的产物。

expandedProfiles.add(current) 的返回值被用作去重与环保护:如果某个 profile 已经在集合里(本次或更早),就不再展开它的 group。这天然处理了:

  • 重复声明(active: prod,prod)
  • group 环(a: b 且 b: a)

注意集合是 LinkedHashSet,所以第一次出现的顺序被保留。

2.6 ProfilesValidator:字符集规则的边界

java 复制代码
final class ProfilesValidator implements BindHandler {

    private static final String ALLOWED_CHARS = "-_.+@";

    private final boolean validate;

    @Override
    public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
        validate(result);
        return result;
    }

    void validate(Object value, Supplier<String> wrappedExceptionMessage) {
        try {
            validate(value);
        }
        catch (IllegalStateException ex) {
            throw new IllegalStateException(wrappedExceptionMessage.get(), ex);
        }
    }

    private void validate(@Nullable Object value) {
        if (!this.validate) {
            return;
        }
        if (value instanceof Collection<?> list) {
            list.forEach(this::validate);
            return;
        }
        if (value instanceof Map<?, ?> map) {
            map.forEach((k, v) -> validate(v));
            return;
        }
        String profile = (value != null) ? value.toString() : null;
        Assert.state(StringUtils.hasText(profile), "Invalid empty profile");
        for (int i = 0; i < profile.length(); i++) {
            int codePoint = profile.codePointAt(i);
            boolean isAllowedChar = ALLOWED_CHARS.indexOf(codePoint) != -1;
            Assert.state(isAllowedChar || Character.isLetterOrDigit(codePoint),
                () -> "Profile '%s' must contain a letter, digit or allowed char (%s)".formatted(profile, ...));
            Assert.state((i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint),
                () -> "Profile '%s' must start and end with a letter or digit".formatted(profile));
        }
    }

    static ProfilesValidator get(Binder binder) {
        return new ProfilesValidator(binder.bind("spring.profiles.validate", Boolean.class).orElse(true));
    }
}

六个必须记住的事实:

  1. 它是一个 BindHandler,不是独立的校验器 。它通过 onSuccess 钩子在绑定成功后校验------这意味着只有在绑定成功时才会校验。如果一个 profile 属性值存在但因类型转换失败而没绑定上,校验不会跑。

  2. 允许的额外字符只有 4 个 :-、_、.、+、@------等等,ALLOWED_CHARS = "-_.+@" 是 5 个字符。逐个拆:-、_、.、+、@。空格、逗号、斜杠、冒号都不允许 (逗号被当作分隔符,在 StringUtils.commaDelimitedListToSet 层就切掉了)。

  3. 首尾必须是字母或数字 。条件 (i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint):中间位置(i 不是首尾)允许特殊字符;首尾位置必须是字母数字。

    • prod-1 ✓
    • -prod ✗(首字符 -)
    • prod_ ✗(尾字符 _)
  4. Map 分支校验 value 不校验 key :map.forEach((k, v) -> validate(v))------key 是 group 名,value 是成员。group 名本身不被校验 。所以 spring.profiles.group.prod-x=a,b 里 prod-x 不会被这个校验器检查(它会在 expandProfiles 后作为 active profile 被校验------如果它被激活的话)。

    这是一个真实的可钻空子点:未被激活的 group 名可能不合法 。我在此标注为由源码推演,未在 CI 测试中确认 ,因为 Profiles 的 groups 绑定走的是 STRING_STRINGS_MAP,validator 的 onSuccess 在绑定 MultiValueMap 时会命中 Map 分支,只校验 value。

  5. 可以整体关闭 :spring.profiles.validate=false。这个属性在官方元数据里没有列出(我在 additional-spring-configuration-metadata.json 中未检索到 spring.profiles.validate 条目),属于"存在但未被元数据收录"的属性。

  6. get(Binder) 每次都新建一个实例 ,且它自己用 binder 读属性。在 Profiles 构造里它被创建一次,然后传给三处绑定(groups / active / default)和一处手动校验(additionalProfiles)。

2.7 getAccepted():active 优先,回退 default

java 复制代码
public List<String> getAccepted() {
    return (!this.activeProfiles.isEmpty()) ? this.activeProfiles : this.defaultProfiles;
}

public boolean isAccepted(String profile) {
    return getAccepted().contains(profile);
}

一句话:有 active 就用 active,否则用 default。

这解释了那个经典的"反直觉"现象:

yaml 复制代码
# application.yaml
spring:
  profiles:
    default: base
    active: dev

isAccepted("base") 返回 false------因为 active 非空,default 被完全屏蔽。

而:

yaml 复制代码
spring:
  profiles:
    default: base

isAccepted("base") 返回 true。

isAccepted 是 spring.config.activate.on-profile 的最终判定入口 (见第 5 节 Activate.matchesActiveProfiles(profiles::isAccepted))。所以:

spring.config.activate.on-profile: default 只在没有任何 active profile 时生效 。 一旦有人激活了 dev,on-profile: default 的文档就不再生效------即使你同时写了 spring.profiles.default: default。

这条在 ConfigDataEnvironmentPostProcessorIntegrationTests 里有直接用例(源码行 198:spring.config.activate.on-profile: default)。

2.8 Profiles 的 toString 与调试

java 复制代码
@Override
public String toString() {
    ToStringCreator creator = new ToStringCreator(this);
    creator.append("active", getActive().toString());
    creator.append("default", getDefault().toString());
    creator.append("accepted", getAccepted().toString());
    return creator.toString();
}

调试 ConfigData 时,把 logging.level.org.springframework.boot.context.config=TRACE 打开,ConfigDataActivationContext 的 toString() 会打印 profiles(因为它也用 ToStringCreator 追加了 profiles)。这是定位 profile 求解问题最直接的手段。


3. spring.profiles.* 全套属性的语义矩阵

把 Profiles 用到的属性连同相关属性摆在一张表里:

属性 类型 读取方 语义 是否参与 group 展开
spring.profiles.active Set<String> Profiles.Type.ACTIVE 激活的 profile ✅ 是
spring.profiles.default Set<String> Profiles.Type.DEFAULT 无 active 时的回退 ✅ 是
spring.profiles.include List<String> ConfigDataEnvironment.getIncludedProfiles() 追加激活 ❌ 否(第 4 节解释)
spring.profiles.group.* MultiValueMap Profiles 构造 profile 的展开规则 ---
spring.profiles.validate Boolean ProfilesValidator.get() 是否校验名字合法性 ---
spring.config.activate.on-profile String[] ConfigDataProperties.Activate 文档级开关 ❌ 否
spring.config.activate.on-cloud-platform CloudPlatform ConfigDataProperties.Activate 云平台开关 ---
spring.main.cloud-platform String CloudPlatform.isEnforced 强制指定云平台 ---

3.1 spring.profiles.include 为什么"不参与 group 展开"

看 Profiles 构造:additionalProfiles 是构造参数 ,直接并进 activeProfiles:

java 复制代码
private List<String> getActivatedProfiles(Environment environment, Binder binder,
        ProfilesValidator validator, @Nullable Collection<String> additionalProfiles) {
    return asUniqueItemList(getProfiles(environment, binder, validator, Type.ACTIVE), additionalProfiles);
}

private List<String> asUniqueItemList(Collection<String> profiles, @Nullable Collection<String> additional) {
    LinkedHashSet<String> uniqueItems = new LinkedHashSet<>();
    if (!CollectionUtils.isEmpty(additional)) {
        uniqueItems.addAll(additional);      // additional 先加
    }
    uniqueItems.addAll(profiles);            // 后加 active
    return Collections.unmodifiableList(new ArrayList<>(uniqueItems));
}

additional 排在前面 。而 ConfigDataEnvironment.withProfiles() 里:

java 复制代码
Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles);   // SpringApplication.setAdditionalProfiles
additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext));  // spring.profiles.include
Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);

所以顺序是:SpringApplication.setAdditionalProfiles(...) → spring.profiles.include 收集值 → spring.profiles.active。

include 的值被当作 additionalProfiles 传入,然后整体参与 expandProfiles------等等,这里要澄清:

java 复制代码
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));

getActivatedProfiles 返回的合并结果 (include + active)会一起传给 expandProfiles。所以**include 的值其实是参与 group 展开的**。

那我为什么说"不参与"?准确的表述应该是:

spring.profiles.include 与 spring.profiles.active 合并后再统一展开 group ,二者在展开层面没有区别。 区别在于收集时机 :include 是在 ConfigDataEnvironment 里、逐个 contributor 单独绑定收集的(第 4 节),而 active 是在 Profiles 里、用聚合 Binder 绑定的。

这个区别带来一个真实的行为差异,我在第 4.2 节讲。这里先给结论:spring.profiles.include 可以被写在任何 contributor 里(每个属性源单独绑定),而 spring.profiles.active 只能用聚合 Binder 从"所有活跃属性源"里读。

3.2 asUniqueItemList 的去重语义

LinkedHashSet + addAll 意味着先出现者胜 (add 对已存在元素返回 false,不覆盖)。所以:

  • setAdditionalProfiles("dev") + spring.profiles.active=dev,prod → [dev, prod](dev 只出现一次,位置在前)
  • spring.profiles.include=dev + spring.profiles.active=dev → [dev]

这个"先出现者胜"与属性优先级(后者覆盖前者)是相反的。这里是集合合并,不是属性覆盖,不要混淆。

3.3 一个完整的求解示例

环境:

csharp 复制代码
SpringApplication.setAdditionalProfiles("ops")
环境变量 SPRING_PROFILES_ACTIVE=dev
application.yaml:
  spring.profiles.group.dev: dev-db
  spring.profiles.include: metrics
  spring.profiles.default: base

推演(按源码算法推演,未在本地实际运行验证):

  1. ProfilesValidator.get(binder) → validate=true

  2. withProfiles() 里 additionalProfiles = {ops} ∪ {metrics} = {ops, metrics}(ops 来自 SpringApplication,metrics 来自 spring.profiles.include)

  3. groups = {dev: [dev-db]}

  4. getActivatedProfiles:

    • environmentPropertyValue = "dev"(SPRING_PROFILES_ACTIVE 在 systemEnvironment 里)
    • environmentProfiles = {}(Boot 的 ApplicationEnvironment 不读属性,且无编程式设置)
    • hasProgrammaticallySetProfiles → getDefaultValue()({}).equals({}) → 情形 2 → false
    • 走 boundProfiles.orElse(...) → 绑定得 {dev}
    • asUniqueItemList({dev}, {ops, metrics}) → [ops, metrics, dev](additional 在前)
  5. expandProfiles([ops, metrics, dev]):

    ini 复制代码
    栈(顶→底): ops, metrics, dev
    pop ops     → [ops]
    pop metrics → [ops, metrics]
    pop dev     → [ops, metrics, dev];push groups[dev]=[dev-db]
    pop dev-db  → [ops, metrics, dev, dev-db]
  6. getDefaultProfiles:绑定 spring.profiles.default → {base}(同理走情形 2)

  7. getAccepted() = [ops, metrics, dev, dev-db]

  8. isAccepted("base") = false

关键洞察 :spring.profiles.default: base 写了,但因为 active 非空,base 完全没有生效。这是生产事故高发区。


4. profile 求解的时机:不动点迭代的第四、五步

第 07 章讲过 processAndApply() 的六步。这里只聚焦与 profile 直接相关的第 4、5 步。

4.1 withProfiles:profile 的唯一求解点

java 复制代码
private ConfigDataActivationContext withProfiles(ConfigDataEnvironmentContributors contributors,
        ConfigDataActivationContext activationContext) {
    this.logger.trace("Deducing profiles from current config data environment contributors");
    Binder binder = contributors.getBinder(activationContext,
            (contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES),
            BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
    try {
        Set<String> additionalProfiles = new LinkedHashSet<>(this.additionalProfiles);
        additionalProfiles.addAll(getIncludedProfiles(contributors, activationContext));
        Profiles profiles = new Profiles(this.environment, binder, additionalProfiles);
        return activationContext.withProfiles(profiles);
    }
    catch (BindException ex) {
        if (ex.getCause() instanceof InactiveConfigDataAccessException inactiveException) {
            throw inactiveException;
        }
        throw ex;
    }
}

四处细节:

(1) contributor 过滤器 :(contributor) -> !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)

带 IGNORE_PROFILES 选项的属性源完全不参与 profile 求解 。哪些源带这个选项?ConfigData.Option.IGNORE_PROFILES 是 public enum,任何自定义 ConfigDataLoader 都可以设置。Boot 内置用法我在源码中检索到的设置点集中在 ConfigDataEnvironmentContributors 之外------具体哪些内置 loader 设置了它,我未逐行核实 ,标注为待查。但语义明确:这是"我这个来源不应该影响 profile 决策"的声明位。

(2) BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE:见 4.3。

(3) additionalProfiles 的顺序 :SpringApplication 设置的值在前,spring.profiles.include 收集的在后。

(4) 异常包装 :BindException 的 cause 若是 InactiveConfigDataAccessException,则直接抛出 cause (剥掉 BindException 外壳)。这让错误信息更干净------用户看到的是 "Inactive property source 'xxx' cannot contain property 'spring.profiles.active'",而不是一层 BindException 包裹。

4.2 getIncludedProfiles:逐 contributor 单独绑定

java 复制代码
private Collection<? extends String> getIncludedProfiles(ConfigDataEnvironmentContributors contributors,
        ConfigDataActivationContext activationContext) {
    PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
            contributors, activationContext, null, true, this.environment.getConversionService());
    Set<String> result = new LinkedHashSet<>();
    for (ConfigDataEnvironmentContributor contributor : contributors) {
        ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
        if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
            Binder binder = new Binder(Collections.singleton(source), placeholdersResolver);
            binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST, ProfilesValidator.get(binder))
                .ifBound((includes) -> {
                    if (!contributor.isActive(activationContext)) {
                        InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
                                Profiles.INCLUDE_PROFILES);
                        InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
                                Profiles.INCLUDE_PROFILES.append("[0]"));
                    }
                    result.addAll(includes);
                });
        }
    }
    return result;
}

这段是本章"第六定律"(语义作用域藏在构造参数里)的最佳注脚:

new Binder(Collections.singleton(source), placeholdersResolver) ------每个 contributor 用自己的单元素 Binder 绑定 spring.profiles.include。

含义:

  • spring.profiles.include 不能从别的属性源"继承" 。A 文件里没写 spring.profiles.include,B 文件里写了,A 不会被算上 B 的值(但 B 自己会被算上,因为遍历到了 B)。
  • 这与 spring.profiles.active 形成鲜明对比------后者用聚合 Binder(所有活跃源的集合)绑定,所以 active 可以来自任意一个活跃源。

ifBound + isActive 的组合:

java 复制代码
binder.bind(...).ifBound((includes) -> {
    if (!contributor.isActive(activationContext)) {
        InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES);
        InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES.append("[0]"));
    }
    result.addAll(includes);
});

逻辑有点绕:

  • ifBound 只在值存在时执行(绑定成功且有值)。
  • 进入后再检查这个 contributor 是否 active 。若不 active(比如它的 on-profile 没匹配上),就抛异常。

为什么要检查两次名字(spring.profiles.include 和 spring.profiles.include[0])?因为 STRING_LIST 的绑定在 Binder 里会分别尝试 spring.profiles.include(整体,逗号切分)和 spring.profiles.include[0]、[1]...(索引形式)。throwIfPropertyFound 用的是 source.getConfigurationProperty(name),只精确查一个名字,所以要查两次才能覆盖"写成标量"和"写成数组第 0 项"两种写法。

这条规则的实质 :不允许在"永不生效的文档"里写 spring.profiles.include------因为它看起来会生效,实际不会,是典型的静默失效。

对应的错误消息(来自 InactiveConfigDataAccessException.getMessage):

python 复制代码
Inactive property source 'Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/' (document #1)' imported from location '...' cannot contain property 'spring.profiles.include' [origin: ...]

ProfilesValidator.get(binder) 在这里又新建了一个 validator ------用的是单元素 Binder。这意味着 spring.profiles.validate 也会从该单源读取。如果 spring.profiles.validate 只写在某个源里,那么校验 include 时用的是"该源自己的视角"。这是一个极细微的行为差异,我标注为源码推演。

4.3 FAIL_ON_BIND_TO_INACTIVE_SOURCE 与 InactiveSourceChecker

java 复制代码
private Binder getBinder(@Nullable ConfigDataActivationContext activationContext,
        Predicate<ConfigDataEnvironmentContributor> filter, Set<BinderOption> options) {
    boolean failOnInactiveSource = options.contains(BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE);
    Iterable<ConfigurationPropertySource> sources = () -> getBinderSources(
            filter.and((contributor) -> failOnInactiveSource || contributor.isActive(activationContext)));
    PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
            this.root, activationContext, null, failOnInactiveSource, this.conversionService);
    BindHandler bindHandler = !failOnInactiveSource ? null : new InactiveSourceChecker(activationContext);
    return new Binder(sources, placeholdersResolver, null, null, bindHandler);
}

关键 :当 failOnInactiveSource = true 时,sources 的过滤器是 filter.and(contributor -> true || ...)------即不过滤活跃性!所有 contributor(包括不活跃的)都进了 Binder 的源集合。

然后靠 InactiveSourceChecker 在 onSuccess 时回头检查:

java 复制代码
private class InactiveSourceChecker implements BindHandler {
    private final @Nullable ConfigDataActivationContext activationContext;

    @Override
    public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
        for (ConfigDataEnvironmentContributor contributor : ConfigDataEnvironmentContributors.this) {
            if (!contributor.isActive(this.activationContext)) {
                InactiveConfigDataAccessException.throwIfPropertyFound(contributor, name);
            }
        }
        return result;
    }
}

为什么这样设计? 因为要产生准确的错误消息。若直接过滤掉不活跃源,绑定会"静默"用活跃源的值,用户永远不知道自己在不生效的文档里写了 spring.profiles.active。现在的设计是:先让它绑定成功,再检查"这个值是不是从不活跃的源来的",是就报错。

Profiles 求解用的是 DENY_INACTIVE_BINDING:

java 复制代码
private static final BinderOption[] ALLOW_INACTIVE_BINDING = {};
private static final BinderOption[] DENY_INACTIVE_BINDING = { BinderOption.FAIL_ON_BIND_TO_INACTIVE_SOURCE };

processAndApply() 里的分布:

步骤 Binder 选项 含义
registerBootstrapBinder(contributors, null, DENY_INACTIVE_BINDING)(初始) DENY 还没有 activationContext,但已经拒绝
processInitial → registerBootstrapBinder(..., null, DENY) DENY 同上
processWithoutProfiles → registerBootstrapBinder(..., activationContext, DENY) DENY 有 cloudPlatform 无 profiles
withProfiles 内部 DENY profile 求解本身
processWithProfiles → registerBootstrapBinder(..., activationContext, ALLOW_INACTIVE_BINDING) ALLOW profile 定完之后,允许从不活跃源绑定

最后一步切换成 ALLOW 是关键 :profile 定完之后,后续的绑定(比如 spring.config.import 的处理)不再因为"源不活跃"而报错------因为此时"不活跃"已经是正常的、预期内的状态(文档被 on-profile 关掉了),不应该再对用户报错。

4.4 registerBootstrapBinder 与 BootstrapRegistry

java 复制代码
private void registerBootstrapBinder(ConfigDataEnvironmentContributors contributors,
        @Nullable ConfigDataActivationContext activationContext, BinderOption... binderOptions) {
    this.bootstrapContext.register(Binder.class,
            InstanceSupplier.from(() -> contributors.getBinder(activationContext, binderOptions))
                .withScope(Scope.PROTOTYPE));
}

每次 processXxx 之后都重新注册一次 Binder.class 到 bootstrap context。Scope.PROTOTYPE 意味着每次 get() 都会调 from(...) 的 supplier,拿到最新快照。

这是给"需要在 ConfigData 阶段读配置的基础设施"用的通道(典型的例子是 Spring Cloud Config Server 的早期引导,或者自定义 ConfigDataLocationResolver 要读属性)。第 11 章讲 BootstrapRegistry 时会展开。

4.5 不动点:为什么是三轮而不是两轮

回顾第 07 章的六步:

  1. processInitial ------ activationContext = null(无 cloudPlatform、无 profiles)
  2. createActivationContext ------ 算出 cloudPlatform(还没 profiles)
  3. processWithoutProfiles ------ 用 cloudPlatform 处理 import
  4. withProfiles ------ 算出 profiles
  5. processWithProfiles ------ 用 cloudPlatform + profiles 处理 import(含 profile-specific)
  6. applyToEnvironment ------ 应用并写回 Environment

为什么第 3 步不能省? 因为 spring.config.activate.on-cloud-platform 需要 cloudPlatform 才能判定某些文档是否活跃,而 cloudPlatform 的强制值 spring.main.cloud-platform 可能写在被 import 进来的文件里。所以必须先(无 profile 地)处理一轮 import,拿到完整的"非 profile 相关"属性,才能算 cloudPlatform。

为什么第 5 步不能省? 因为 profile-specific 文件(application-{profile}.yaml)需要知道 profile 才能解析,而 profile 只有在第 4 步才算出来。

为什么不能在第 5 步之后再算一次 profile? 因为 Boot 明确规定:profile-specific 文件里不能 写 spring.profiles.active / spring.profiles.include / spring.profiles.default。这是硬约束,由 InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS 强制(见第 7.3 节)。既然禁止,就不需要再迭代------迭代必然收敛。

这就是"不动点"的完整含义 :不是"反复迭代直到不变",而是"结构上保证三轮必收敛,并通过禁写规则把收敛性钉死"。

这是一个非常漂亮的设计:用静态禁止 替代动态收敛判定。


5. spring.config.activate.*:文档级开关的完整语义

5.1 ConfigDataProperties:绑定入口

java 复制代码
class ConfigDataProperties {

    private static final ConfigurationPropertyName NAME = ConfigurationPropertyName.of("spring.config");

    private static final Bindable<ConfigDataProperties> BINDABLE_PROPERTIES =
        Bindable.of(ConfigDataProperties.class);

    private final List<ConfigDataLocation> imports;
    private final @Nullable Activate activate;

    ConfigDataProperties(@Nullable @Name("import") List<ConfigDataLocation> imports, @Nullable Activate activate) {
        this.imports = (imports != null) ? imports.stream().filter(ConfigDataLocation::isNotEmpty).toList()
                : Collections.emptyList();
        this.activate = activate;
    }

    boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
        return this.activate == null || this.activate.isActive(activationContext);
    }

    ConfigDataProperties withoutImports() {
        return new ConfigDataProperties(null, this.activate);
    }

    static @Nullable ConfigDataProperties get(Binder binder) {
        return binder.bind(NAME, BINDABLE_PROPERTIES, new ConfigDataLocationBindHandler()).orElse(null);
    }
    ...
}

三件事:

  1. NAME = "spring.config" :绑定的是整个 spring.config 前缀。import 字段用 @Name("import") 指定(而不是靠字段名反射),activate 靠字段名匹配。

    注意:@Name 注解来自 org.springframework.boot.context.properties.bind.Name,是 Binder 的构造器参数命名机制(第 08 章讲 ValueObjectBinder 时提到过)。

  2. imports 会过滤空 location :filter(ConfigDataLocation::isNotEmpty)。ConfigDataLocation.of("") 返回 EMPTY,会被丢掉。所以 spring.config.import=(空值)等价于没写。

  3. isActive 的短路 :activate == null → true。没写 spring.config.activate.* 的文档永远活跃。

5.2 绑定的作用域:Collections.singleton

这是本章最核心的一段:

java 复制代码
// ConfigDataEnvironmentContributor.withBoundProperties
ConfigDataEnvironmentContributor withBoundProperties(Iterable<ConfigDataEnvironmentContributor> contributors,
        @Nullable ConfigDataActivationContext activationContext) {
    ConfigurationPropertySource configurationPropertySource = getConfigurationPropertySource();
    Assert.state(configurationPropertySource != null, "'configurationPropertySource' must not be null");
    Iterable<ConfigurationPropertySource> sources = Collections.singleton(configurationPropertySource);   // ← 只有自己
    PlaceholdersResolver placeholdersResolver = new ConfigDataEnvironmentContributorPlaceholdersResolver(
            contributors, activationContext, this, true, this.conversionService);
    Binder binder = new Binder(sources, placeholdersResolver, null, null, null);
    ConfigDataProperties properties = ConfigDataProperties.get(binder);
    if (properties != null && this.configDataOptions.contains(ConfigData.Option.IGNORE_IMPORTS)) {
        properties = properties.withoutImports();
    }
    return new ConfigDataEnvironmentContributor(Kind.BOUND_IMPORT, this.location, this.resource,
            this.fromProfileSpecificImport, this.propertySource, this.configurationPropertySource, properties,
            this.configDataOptions, null, this.conversionService);
}

sources = Collections.singleton(configurationPropertySource)------当前 contributor 自己的那一个源。

而 placeholdersResolver 用的是全部 contributors。

这两个参数的差异,决定了两个完全不同的语义:

机制 查找范围 含义
属性值绑定 (spring.config.import、spring.config.activate.*) 仅当前文档 必须写在这个文档里
占位符解析 (${...}) 全部 contributors 可以引用别的文档/属性源的值

实战推论:

yaml 复制代码
# application.yaml ------ 文档 #0
my:
  host: db.internal
---
# 文档 #1
spring:
  config:
    import: optional:file:/etc/conf/${my.host}.yaml

这是可以的 :spring.config.import 写在文档 #1 里(自己的源),${my.host} 从文档 #0 解析(全局占位符)。

反过来:

yaml 复制代码
# 文档 #0
spring:
  config:
    activate:
      on-profile: prod
---
# 文档 #1 ------ 没有写 activate

文档 #1 不会 继承文档 #0 的 on-profile。它永远活跃。

这就是为什么多文档 YAML 里每个需要条件化的文档都必须自己 写 spring.config.activate.on-profile。

5.3 Activate.isActive:双条件 AND

java 复制代码
static class Activate {

    private final @Nullable CloudPlatform onCloudPlatform;
    private final String @Nullable [] onProfile;

    boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
        if (activationContext == null) {
            return false;
        }
        CloudPlatform cloudPlatform = activationContext.getCloudPlatform();
        boolean activate = isActive((cloudPlatform != null) ? cloudPlatform : CloudPlatform.NONE);
        activate = activate && isActive(activationContext.getProfiles());
        return activate;
    }

    private boolean isActive(CloudPlatform cloudPlatform) {
        return this.onCloudPlatform == null || this.onCloudPlatform == cloudPlatform;
    }

    private boolean isActive(@Nullable Profiles profiles) {
        return ObjectUtils.isEmpty(this.onProfile)
                || (profiles != null && matchesActiveProfiles(profiles::isAccepted));
    }

    private boolean matchesActiveProfiles(Predicate<String> activeProfiles) {
        Assert.state(this.onProfile != null, "'this.onProfile' must not be null");
        return org.springframework.core.env.Profiles.of(this.onProfile).matches(activeProfiles);
    }
}

四条规则:

(1) activationContext == null → false

这意味着:在 processInitial 阶段(还没有 activationContext),所有写了 spring.config.activate.* 的文档都不活跃。

为什么?因为 processInitial 时 ConfigDataEnvironmentContributor.isActive(null) 返回 this.properties == null || this.properties.isActive(null)。对于 UNBOUND_IMPORT 类型的 contributor,第 07 章讲过 isActive 有个特殊分支:

java 复制代码
boolean isActive(@Nullable ConfigDataActivationContext activationContext) {
    if (this.kind == Kind.UNBOUND_IMPORT) {
        return false;    // ← 未绑定的 import 一律不活跃
    }
    return this.properties == null || this.properties.isActive(activationContext);
}

而 ConfigDataEnvironmentContributorPlaceholdersResolver.isActive 里对 UNBOUND_IMPORT 做了特殊处理:

java 复制代码
private boolean isActive(ConfigDataEnvironmentContributor contributor) {
    if (contributor == this.activeContributor) {
        return true;
    }
    if (contributor.getKind() != Kind.UNBOUND_IMPORT) {
        return contributor.isActive(this.activationContext);
    }
    return contributor.withBoundProperties(this.contributors, this.activationContext)
        .isActive(this.activationContext);
}

即:临时绑定一下再判定 。这样即使还没进入 withProcessedImports 的正式绑定流程,占位符解析时也能正确判断活跃性。

(2) onCloudPlatform 的 NONE 兜底 :activationContext.getCloudPlatform() 返回 null 时用 CloudPlatform.NONE。所以写了 on-cloud-platform: none 只在"没探测到任何云平台"时生效。

(3) 两个条件是 AND :同时写 on-profile 和 on-cloud-platform,两个都要满足。

(4) ObjectUtils.isEmpty(this.onProfile) → profile 条件恒真 。只写 on-cloud-platform 的文档,profile 条件不设限。

5.4 profile 表达式:!、&、|、括号

matchesActiveProfiles 委托给 Spring Framework 的 org.springframework.core.env.Profiles.of(String...):

java 复制代码
return org.springframework.core.env.Profiles.of(this.onProfile).matches(activeProfiles);

Boot 侧没有自己的解析器,全部交给 Framework。Boot 的 CI 测试锁定了具体语法(ConfigDataEnvironmentPostProcessorIntegrationTests):

java 复制代码
@Test
@WithResource(name = "application.yaml", content = """
        ---
        my:
          property: fromyamlfile
        ---
        spring.config.activate.on-profile: dev & other
        my:
          property: devandother
        ---
        spring.config.activate.on-profile: (dev | other) & another
        my:
          property: devorotherandanother
        """)
void runWhenHasAndProfileExpressionLoadsExpectedProperties() {
    this.application.setAdditionalProfiles("dev", "other");
    ConfigurableApplicationContext context = this.application.run();
    assertThat(context.getEnvironment().getProperty("my.property")).isEqualTo("devandother");
}

从测试可确认的语法:

表达式 含义 测试证据
prod 单一 profile 处处可见
!other 非(注意 YAML 需加引号:"!other") runWhenHasNegatedProfilesLoadsExpectedProperties
dev & other 且 runWhenHasAndProfileExpressionLoadsExpectedProperties
`(dev other) & another` 括号 + 或 + 且
default 匹配"无 active profile"的回退态 spring.config.activate.on-profile: default(行 198)

表达式不匹配时的行为 (runWhenProfileExpressionsDoNotMatchLoadsExpectedProperties):

java 复制代码
this.application.setAdditionalProfiles("dev");   // 只有 dev
// 文档 #1: on-profile: dev & other   → 不匹配
// 文档 #2: on-profile: (dev | other) & another  → 不匹配
assertThat(context.getEnvironment().getProperty("my.property")).isEqualTo("fromyamlfile");  // 文档 #0 的值

即:不匹配的文档被完全跳过,回退到文档 #0 的值。不是"报错",不是"用默认值"。

取反的语法细节 :YAML 里 ! 是标签前缀(tag prefix),必须加引号:

yaml 复制代码
spring.config.activate.on-profile: "!other"

不加引号会被 snakeyaml 当成标签解析。Boot 的测试用例明确写了 "!other"。

另外注意:onProfile 是 String[]。Binder 对 String[] 支持逗号切分(第 08 章的 aggregate binding),所以 on-profile: "dev,other" 会变成 ["dev", "other"] 两个元素,交给 Profiles.of("dev", "other") 表示"dev 或 other"(多参数 = 或);而 on-profile: "dev & other" 无逗号,是单元素 ["dev & other"]。

这条差异是按 Binder 通用行为的推演 ------Boot 的测试只覆盖了 &、|、!、括号,没有直接覆盖逗号写法。实践建议:用 & / | 而非逗号,意图更明确。

5.5 on-cloud-platform 与 CloudPlatform

CloudPlatform 枚举(org.springframework.boot.cloud.CloudPlatform):

枚举 探测条件 备注
NONE isDetected 恒 false 特殊:用于"禁用误判"
CLOUD_FOUNDRY VCAP_APPLICATION 或 VCAP_SERVICES
HEROKU DYNO
SAP HC_LANDSCAPE
NOMAD NOMAD_ALLOC_ID @since 3.1.0
KUBERNETES KUBERNETES_SERVICE_HOST + KUBERNETES_SERVICE_PORT,或任意 *_SERVICE_HOST + 同名 *_SERVICE_PORT 最复杂的探测
AZURE_APP_SERVICE WEBSITE_SITE_NAME+WEBSITE_INSTANCE_ID+WEBSITE_RESOURCE_GROUP+WEBSITE_SKU 全部存在 allMatch
AWS_ECS AWS_EXECUTION_ENV 以 AWS_ECS 开头 @since 4.0.0(4.x 新增)

AWS_ECS 是 Spring Boot 4.0 新增的------这是 4.x 相对 3.x 的一个实质增强点。

探测入口有两个:

java 复制代码
public boolean isActive(Environment environment) {
    String platformProperty = environment.getProperty(PROPERTY_NAME);   // "spring.main.cloud-platform"
    return isEnforced(platformProperty) || (platformProperty == null && isDetected(environment));
}

public boolean isEnforced(Binder binder) {
    return isEnforced(binder.bind(PROPERTY_NAME, String.class).orElse(null));
}

private boolean isEnforced(@Nullable String platform) {
    return name().equalsIgnoreCase(platform);
}

public static @Nullable CloudPlatform getActive(@Nullable Environment environment) {
    if (environment != null) {
        for (CloudPlatform cloudPlatform : values()) {
            if (cloudPlatform.isActive(environment)) {
                return cloudPlatform;
            }
        }
    }
    return null;
}

而 ConfigDataActivationContext 的探测逻辑是两段式:

java 复制代码
private @Nullable CloudPlatform deduceCloudPlatform(Environment environment, Binder binder) {
    for (CloudPlatform candidate : CloudPlatform.values()) {
        if (candidate.isEnforced(binder)) {
            return candidate;
        }
    }
    return CloudPlatform.getActive(environment);
}

先查"强制",再查"探测":

  1. 遍历所有枚举,看谁的 isEnforced(binder) 为真------即 spring.main.cloud-platform 的值(大小写不敏感)等于枚举名。
  2. 若没有强制值,才走 getActive(environment) 做环境变量探测。

为什么第一步用 Binder 而不是 environment.getProperty? 因为 spring.main.cloud-platform 可能写在被 import 进来的 application.yaml 里,而那个文件在 createActivationContext 时已经通过 processInitial 加载进 contributors 了。用 Binder(contributors 的聚合 Binder)能读到它;用 environment.getProperty 读不到(还没 addLast 到 Environment)。

这也是"为什么 processInitial 必须在 createActivationContext 之前"的真正原因。

注意 values() 的遍历顺序就是枚举声明顺序,NONE 在最前面。isEnforced 对 NONE 要求 spring.main.cloud-platform=none。

NONE 的特殊用途 :Javadoc 写得很清楚------"No Cloud platform. Useful when false-positives are detected."。比如你在 K8s 里跑,但 KUBERNETES_SERVICE_HOST 的存在导致误判,而你不想要 K8s 相关行为,就设 spring.main.cloud-platform=none。

5.6 ConfigDataLocationBindHandler:给 import 位置补 Origin

java 复制代码
class ConfigDataLocationBindHandler extends AbstractBindHandler {

    @Override
    public @Nullable Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context,
            Object result) {
        OriginMapper originMapper = new OriginMapper(context.getConfigurationProperty());
        if (result instanceof ConfigDataLocation location) {
            return originMapper.map(location);
        }
        if (result instanceof List<?> locations) {
            return locations.stream().map(originMapper::mapIfPossible)
                .collect(Collectors.toCollection(ArrayList::new));
        }
        if (result instanceof ConfigDataLocation[] locations) {
            return Arrays.stream(locations).map(originMapper::mapIfPossible).toArray(ConfigDataLocation[]::new);
        }
        return result;
    }

    private record OriginMapper(@Nullable ConfigurationProperty property) {
        @Nullable Object mapIfPossible(@Nullable Object object) {
            return (object instanceof ConfigDataLocation location) ? map(location) : object;
        }

        @Nullable ConfigDataLocation map(@Nullable ConfigDataLocation location) {
            if (location == null) {
                return null;
            }
            Origin origin = Origin.from(location);
            return (origin != null) ? location : location.withOrigin(Origin.from(property()));
        }
    }
}

作用:绑定出来的 ConfigDataLocation 对象默认没有 Origin(它只是个字符串包装)。这个 handler 在 onSuccess 时把绑定来源属性的 Origin(即"这个 import 写在哪个文件第几行")贴上去。

ConfigDataLocation.of(...) 创建的实例 origin 为 null,所以 Origin.from(location) 返回 null,走 withOrigin(Origin.from(property()))。

这带来什么? 当 spring.config.import 指向的文件不存在时,错误消息能告诉你"是 application.yaml 第 12 行的那个 import 找不到",而不是干巴巴一句 "Config data location not found"。

ConfigDataLocationNotFoundException 的 message 会带上 origin。这是 Boot 错误诊断质量高的重要一环。

5.7 IGNORE_IMPORTS 选项与 withoutImports

java 复制代码
if (properties != null && this.configDataOptions.contains(ConfigData.Option.IGNORE_IMPORTS)) {
    properties = properties.withoutImports();
}

带 IGNORE_IMPORTS 的 contributor,即使写了 spring.config.import 也会被清空。withoutImports() 保留 activate,只清 imports------所以 spring.config.activate.* 仍然生效。

EMPTY_LOCATION 类型的 contributor 用这个选项构造:

java 复制代码
private static final ConfigData.Options EMPTY_LOCATION_OPTIONS =
    ConfigData.Options.of(ConfigData.Option.IGNORE_IMPORTS);

即:"这个位置合法但没内容"的占位 contributor,不参与 import。


6. 多文档文件:一个文件 → 多个 PropertySource

6.1 YAML 多文档:OriginTrackedYamlLoader

java 复制代码
public class YamlPropertySourceLoader implements PropertySourceLoader {

    @Override
    public String[] getFileExtensions() {
        return new String[] { "yml", "yaml" };
    }

    @Override
    public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
        if (!ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", getClass().getClassLoader())) {
            throw new IllegalStateException(
                    "Attempted to load " + name + " but snakeyaml was not found on the classpath");
        }
        List<Map<String, Object>> loaded = new OriginTrackedYamlLoader(resource).load();
        if (loaded.isEmpty()) {
            return Collections.emptyList();
        }
        List<PropertySource<?>> propertySources = new ArrayList<>(loaded.size());
        for (int i = 0; i < loaded.size(); i++) {
            String documentNumber = (loaded.size() != 1) ? " (document #" + i + ")" : "";
            propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
                    Collections.unmodifiableMap(loaded.get(i)), true));
        }
        return propertySources;
    }
}

六个可确认的事实:

  1. Spring Boot 4.1.1 仍然用 snakeyaml 解析 YAML 配置文件 (不是 Jackson YAML)。这一点值得强调------4.x 把 Jackson 升到 3(用于 JSON 编解码),但配置文件的 YAML 解析没有切换。

    另外 YamlPropertySourceLoader 只实现了两参的 load(name, resource),未实现三参的 load(name, resource, encoding),所以 YAML 不支持指定编码 (而 PropertiesPropertySourceLoader 支持)。三参方法在接口中应有默认转发实现,但我未核实 PropertySourceLoader 接口的具体声明,标注待查。

  2. snakeyaml 缺失时报错而不是静默跳过 :ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", ...)。这解释了为什么 "snakeyaml not found" 是个常见的启动错误。

  3. 文档号只在多于一个文档时才加 :(loaded.size() != 1) ? " (document #" + i + ")" : ""。所以单文档 YAML 的 PropertySource 名是干净的 Config resource '...' via location '...',多文档则带 (document #0)、 (document #1)。

    调试技巧 :/actuator/env 或 TRACE 日志里看到 (document #2) 就知道这是第三个 YAML 文档。

  4. 每个文档一个 OriginTrackedMapPropertySource ,且 true 表示"可展开 origin"(构造参数)。

  5. 返回的 List 顺序 = 文档顺序 (i 从 0 递增)。结合第 07 章的 asContributors 逆序插入:

    java 复制代码
    for (int i = data.getPropertySources().size() - 1; i >= 0; i--) {
        contributors.add(ConfigDataEnvironmentContributor.ofUnboundImport(location, resource,
                profileSpecific, data, i, this.conversionService, this.environmentUpdateListener));
    }

    逆序遍历 + addLast,最终 contributor 顺序 = 文档顺序。而 ConfigData.getPropertySources() 的 Javadoc 说"ascending priority order, later wins"------所以 文档 #2 覆盖文档 #1 覆盖文档 #0。

    这解释了 Boot 测试的 runWhenMultipleActiveProfilesWithMultiDocumentFilesLoadsInOrderOfDocument:

    java 复制代码
    // 文档 #1: on-profile: dev   → my.property: fromdevprofile
    // 文档 #2: on-profile: other → my.property: fromotherprofile
    this.application.setAdditionalProfiles("other", "dev");
    assertThat(...getProperty("my.property")).isEqualTo("fromotherprofile");   // 文档 #2 胜出

    注意 :是"文档顺序"决定胜负,不是 profile 的激活顺序(other 在 dev 之前传给 setAdditionalProfiles)。测试名字里 "InOrderOfDocument" 就是在强调这一点。

  6. loaded.isEmpty() 返回空 List ,此时 asContributors 走 ofEmptyLocation 分支。

6.2 OriginTrackedYamlLoader 的 snakeyaml 配置

java 复制代码
@Override
protected Yaml createYaml() {
    LoaderOptions loaderOptions = new LoaderOptions();
    loaderOptions.setAllowDuplicateKeys(false);
    loaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE);
    loaderOptions.setAllowRecursiveKeys(true);
    loaderOptions.setCodePointLimit(Integer.MAX_VALUE);
    return createYaml(loaderOptions);
}

private Yaml createYaml(LoaderOptions loaderOptions) {
    BaseConstructor constructor = new OriginTrackingConstructor(loaderOptions);
    DumperOptions dumperOptions = new DumperOptions();
    Representer representer = new Representer(dumperOptions);
    NoTimestampResolver resolver = new NoTimestampResolver();
    return new Yaml(constructor, representer, dumperOptions, loaderOptions, resolver);
}

四项 LoaderOptions 配置的含义:

配置 值 含义与影响
allowDuplicateKeys false YAML 里同一个 key 写两次会报错。这是 Boot 刻意加严的(snakeyaml 默认是 true,后者覆盖前者)
maxAliasesForCollections Integer.MAX_VALUE 解除别名数量限制(防止 YAML 炸弹的默认防护被关掉)
allowRecursiveKeys true 允许递归 key
codePointLimit Integer.MAX_VALUE 解除输入大小限制

安全提示 :maxAliasesForCollections 和 codePointLimit 都被设成了 Integer.MAX_VALUE,意味着Boot 关闭了 snakeyaml 自带的"十亿笑声"(billion laughs)防护 。这是因为配置文件是可信输入 (你自己写的),不是网络输入。但如果你做了"允许用户上传 YAML 配置文件并用 YamlPropertySourceLoader 加载"的功能,这就是一个真实的 DoS 面。

NoTimestampResolver:

java 复制代码
private static final class NoTimestampResolver extends Resolver {
    @Override
    public void addImplicitResolver(Tag tag, Pattern regexp, String first, int limit) {
        if (tag == Tag.TIMESTAMP) {
            return;
        }
        super.addImplicitResolver(tag, regexp, first, limit);
    }
}

禁用 TIMESTAMP 隐式解析 。含义:YAML 里的 2024-01-01 不会被 自动解析成 java.util.Date,而是保持字符串。这是为了避免"配置值类型意外变化"------如果 Boot 把它变成 Date,绑定给 String 字段时还要再转一次,且会丢失原始格式。

这对用户的直接影响 :my.date: 2024-01-01 绑定到 String 就是 "2024-01-01";绑定到 LocalDate 由 ConversionService 转,不是由 YAML 解析器转。

6.3 OriginTrackingConstructor:Origin 是怎么记下来的

java 复制代码
private class OriginTrackingConstructor extends SafeConstructor {

    @Override
    public @Nullable Object getData() throws NoSuchElementException {
        Object data = super.getData();
        if (data instanceof CharSequence charSequence && charSequence.isEmpty()) {
            return null;
        }
        return data;
    }

    @Override
    protected Object constructObject(Node node) {
        if (node instanceof SequenceNode sequenceNode && sequenceNode.getValue().isEmpty()) {
            return constructTrackedObject(node, "");
        }
        if (node instanceof ScalarNode) {
            if (!(node instanceof KeyScalarNode)) {
                return constructTrackedObject(node, super.constructObject(node));
            }
        }
        if (node instanceof MappingNode mappingNode) {
            replaceMappingNodeKeys(mappingNode);
        }
        return super.constructObject(node);
    }
    ...
    private Object constructTrackedObject(Node node, Object value) {
        Origin origin = getOrigin(node);
        return OriginTrackedValue.of(getValue(value), origin);
    }

    private Object getValue(@Nullable Object value) {
        return (value != null) ? value : "";
    }

    private Origin getOrigin(Node node) {
        Mark mark = node.getStartMark();
        Location location = new Location(mark.getLine(), mark.getColumn());
        return new TextResourceOrigin(OriginTrackedYamlLoader.this.resource, location);
    }
}

三个 constructObject 分支:

  1. 空 SequenceNode → constructTrackedObject(node, ""),即空列表变成空字符串 的 OriginTrackedValue。这样 my.list:(空)绑定成 List<String> 时是空列表,绑定成 String 时是 ""。

  2. ScalarNode 且不是 KeyScalarNode → 包装成 OriginTrackedValue。

  3. MappingNode → 把它的 key 节点替换成 KeyScalarNode,然后交给父类。

KeyScalarNode 的作用 :标记"这个 ScalarNode 是 key,不是 value"。这样在 constructObject 里遇到 key 时不会包装成 OriginTrackedValue(key 不需要 origin,它是名字不是值)。

java 复制代码
private void replaceMappingNodeKeys(MappingNode node) {
    List<NodeTuple> value = node.getValue();
    List<NodeTuple> newValue = new ArrayList<>(value.size());
    for (NodeTuple tuple : value) {
        newValue.add(KeyScalarNode.get(tuple));
    }
    node.setValue(newValue);
}

getValue(value) 把 null 转成 ""------YAML 里 key:(无值)会绑定成空字符串而不是 null。这是一个高频陷阱:

yaml 复制代码
my:
  password:

@Value("${my.password}") 得到 "",不是 null,@ConfigurationProperties 的 String password 也是 ""。

6.4 Properties 多文档:#--- 分隔符(很多人不知道)

这是本章最容易让人意外的发现:.properties 文件也支持多文档。

java 复制代码
// core/spring-boot/src/main/java/org/springframework/boot/env/PropertiesPropertySourceLoader.java
@Override
public List<PropertySource<?>> load(String name, Resource resource, @Nullable Charset encoding) throws IOException {
    List<Map<String, ?>> properties = loadProperties(resource, encoding);
    if (properties.isEmpty()) {
        return Collections.emptyList();
    }
    List<PropertySource<?>> propertySources = new ArrayList<>(properties.size());
    for (int i = 0; i < properties.size(); i++) {
        String documentNumber = (properties.size() != 1) ? " (document #" + i + ")" : "";
        propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
                Collections.unmodifiableMap(properties.get(i)), true));
    }
    return propertySources;
}

@SuppressWarnings({ "unchecked", "rawtypes" })
private List<Map<String, ?>> loadProperties(Resource resource, @Nullable Charset encoding) throws IOException {
    String filename = resource.getFilename();
    List<Map<String, ?>> result = new ArrayList<>();
    if (filename != null && filename.endsWith(XML_FILE_EXTENSION)) {   // ".xml"
        if (encoding == null) {
            result.add((Map) PropertiesLoaderUtils.loadProperties(resource));
        }
        else {
            result.add((Map) PropertiesLoaderUtils.loadProperties(new EncodedResource(resource, encoding)));
        }
    }
    else {
        List<Document> documents = new OriginTrackedPropertiesLoader(resource).load(encoding);
        documents.forEach((document) -> result.add(document.asMap()));
    }
    return result;
}

PropertiesPropertySourceLoader.getFileExtensions() 返回 {"properties", "xml"} ------.xml 也支持(Java 的 XML properties 格式),但 XML 分支只有一个文档 (result.add(...) 单次调用),且不追踪 Origin (用 PropertiesLoaderUtils.loadProperties 转成裸 Map)。

6.5 OriginTrackedPropertiesLoader:手写字符级解析器

OriginTrackedPropertiesLoader 没有用 java.util.Properties,而是自己写了字符级解析。原因是要记录 Origin(行号列号)和支持多文档与列表展开。

主循环:

java 复制代码
List<Document> load(@Nullable Charset encoding, boolean expandLists) throws IOException {
    List<Document> documents = new ArrayList<>();
    Document document = new Document();
    StringBuilder buffer = new StringBuilder();
    try (CharacterReader reader = new CharacterReader(this.resource, encoding)) {
        while (reader.read()) {
            if (reader.isCommentPrefixCharacter()) {
                char commentPrefixCharacter = reader.getCharacter();
                if (isNewDocument(reader)) {
                    if (!document.isEmpty()) {
                        documents.add(document);
                    }
                    document = new Document();
                }
                else {
                    if (document.isEmpty() && !documents.isEmpty()) {
                        document = documents.remove(documents.size() - 1);   // ← 回溯!
                    }
                    reader.setLastLineCommentPrefixCharacter(commentPrefixCharacter);
                    reader.skipComment();
                }
            }
            else {
                reader.setLastLineCommentPrefixCharacter(-1);
                loadKeyAndValue(expandLists, document, reader, buffer);
            }
        }
    }
    if (!document.isEmpty() && !documents.contains(document)) {
        documents.add(document);
    }
    return documents;
}

三个值得深挖的设计:

(1) isNewDocument 的严格判定
java 复制代码
private boolean isNewDocument(CharacterReader reader) throws IOException {
    if (reader.isSameLastLineCommentPrefix()) {
        return false;
    }
    boolean result = reader.getLocation().getColumn() == 0;
    result = result && readAndExpect(reader, reader::isHyphenCharacter);
    result = result && readAndExpect(reader, reader::isHyphenCharacter);
    result = result && readAndExpect(reader, reader::isHyphenCharacter);
    if (!reader.isEndOfLine()) {
        reader.read();
        reader.skipWhitespace();
    }
    return result && reader.isEndOfLine();
}

必须同时满足:

  • 注释符在列 0(行首,无缩进)
  • 后面紧跟三个 -
  • 三个 - 之后,跳过空白就是行尾(不能有其他字符)
  • 注释符不能与上一行的注释符相同 (isSameLastLineCommentPrefix)

所以:

写法 是否新文档 原因
#--- ✅ 列 0 + 三个 - + 行尾
!--- ✅ ! 也是注释符
# --- ❌ 第二个字符是空格,不是 -
#--- ❌ 列不是 0(有缩进)
#---- ✅(前三 - 匹配后仍有 -) ❌ ------ 三 - 后是 -,不是行尾
#--- comment ❌ 三 - 后有非空白内容
##--- ❌ 第二个字符是 # 不是 -

isSameLastLineCommentPrefix 这一条很微妙 :它要求"本行的注释前缀字符 != 上一行记录的注释前缀字符"。这是为了防止把连续两行 # 注释误判成文档分隔。举例:

properties 复制代码
#---
a=1
#---
b=2

第一行 #---:上一行没有注释前缀(初始 -1)→ 不同 → 判定 → 是新文档。 第三行 #---:上一行(#--- 那行)的前缀是 #,当前也是 # → 相同 → 返回 false!

等等,这样的话第三个 #--- 就判不出来了?让我再仔细看......

reader.setLastLineCommentPrefixCharacter(commentPrefixCharacter) 只在非新文档 的注释分支里调用。而在 else 分支(普通行)里调用 reader.setLastLineCommentPrefixCharacter(-1)。

所以:

  • 第 1 行 #--- → 是新文档 → 不设 lastLineCommentPrefix(走 document = new Document() 分支)
  • 第 2 行 a=1 → 普通行 → setLastLineCommentPrefixCharacter(-1)
  • 第 3 行 #--- → isSameLastLineCommentPrefix() 比较 lastLine(-1) 与 '#' → 不同 → 继续判定 → 是新文档 ✓

那么什么时候会命中 isSameLastLineCommentPrefix?当上一行也是注释行(非分隔行)时:

properties 复制代码
# comment
#---

第 1 行 # comment → 非新文档分支 → setLastLineCommentPrefixCharacter('#') 第 2 行 #--- → isSameLastLineCommentPrefix():'#' == '#' → true → 返回 false,不算新文档!

这是一个真实的反直觉行为 :注释行紧邻的 #--- 不会被识别为文档分隔符。

我明确标注:这是我对源码逻辑的推演,未在 Boot 测试中找到直接用例验证。 读者可用下例自测:

properties 复制代码
# comment
#---
a=1
#---
b=2

按推演,这会被解析成一个文档 (第一条 #--- 因紧邻注释而失效,第二条生效)。若实际行为不同,说明我对 isSameLastLineCommentPrefix 的调用时机判断有误------也欢迎据此修正本文。

(2) 注释归属的回溯:documents.remove(documents.size() - 1)
java 复制代码
if (document.isEmpty() && !documents.isEmpty()) {
    document = documents.remove(documents.size() - 1);
}

语义 :如果当前文档还是空的(说明刚开始一个新文档),并且前面已经有文档了,那么把上一个文档拿回来继续用。

这是为了解决:

properties 复制代码
a=1
#---
# 这是注释
b=2
  • a=1 → 文档 #0
  • #--- → 判定为新文档 → 文档 #0 非空 → documents.add(文档#0) → document = new Document()
  • # 这是注释 → 非新文档分支(不是 ---)→ document.isEmpty() 且 documents 非空 → 把文档 #0 拿回来 → 继续用文档 #0
  • b=2 → 写入文档 #0

结果:a=1 和 b=2 在同一个文档里。

意图 :让"紧跟在分隔符后面的注释"归属到分隔符之前的那个文档。这符合直觉------注释是解释上面内容的。

但这带来一个陷阱:

properties 复制代码
#---
# 注释
a=1
  • 第 1 行 #--- → 新文档 → document(初始的空文档)为空 → 不 add → document = new Document()
  • 第 2 行 # 注释 → document.isEmpty() 且 documents.isEmpty()(因为第一个空文档没 add)→ 不回溯 → 设 lastLine 前缀,skipComment
  • 第 3 行 a=1 → 写入当前 document

结果:一个文档,a=1。合理 ✓

(3) 列表展开:name[]=a,b,c
java 复制代码
private void loadKeyAndValue(boolean expandLists, Document document, CharacterReader reader, StringBuilder buffer)
        throws IOException {
    String key = loadKey(buffer, reader).trim();
    if (expandLists && key.endsWith("[]")) {
        key = key.substring(0, key.length() - 2);
        int index = 0;
        do {
            OriginTrackedValue value = loadValue(buffer, reader, true);
            document.put(key + "[" + (index++) + "]", value);
            if (!reader.isEndOfLine()) {
                reader.read();
            }
        }
        while (!reader.isEndOfLine());
    }
    else {
        OriginTrackedValue value = loadValue(buffer, reader, false);
        document.put(key, value);
    }
}

my.list[]=a,b,c 展开成:

css 复制代码
my.list[0]=a
my.list[1]=b
my.list[2]=c

这是 .properties 独有的语法糖,YAML 不需要(YAML 有原生列表)。

注意 expandLists 参数:load(encoding) 默认 true,load(encoding, boolean) 可关。PropertiesPropertySourceLoader 调用的是 load(encoding)(两参版本 → load(encoding, true)),所以默认开启。

loadValue 的 splitLists 参数 :为 true 时遇到 , 就停止读取(作为分隔符)。转义的 \, 不算(isListDelimiter 检查 !this.escaped)。

6.6 CharacterReader:转义与编码

java 复制代码
private static final String[] ESCAPES = { "trnf", "\t\r\n\f" };

CharacterReader(Resource resource, @Nullable Charset encoding) throws IOException {
    this.reader = new LineNumberReader(new InputStreamReader(resource.getInputStream(),
            (encoding != null) ? encoding : StandardCharsets.ISO_8859_1));
}

默认编码是 ISO-8859-1 (不是 UTF-8)。这是 Java .properties 规范的历史遗留。所以:

  • 中文必须写 \uXXXX 转义,或者用 [UTF-8] FileHint 指定编码(见 8.3)。
  • 注意 Spring Boot 2.x 之后很多人以为 properties 默认 UTF-8 了------Boot 的 OriginTrackedPropertiesLoader 仍然是 ISO-8859-1。

转义处理 :ESCAPES = { "trnf", "\t\r\n\f" }------两个字符串按索引对应:t→\t,r→\r,n→\n,f→\f。

java 复制代码
private void readEscaped() throws IOException {
    this.character = this.reader.read();
    int escapeIndex = ESCAPES[0].indexOf(this.character);
    if (escapeIndex != -1) {
        this.character = ESCAPES[1].charAt(escapeIndex);
    }
    else if (this.character == '\n') {
        this.columnNumber = -1;
        read();          // 续行符:反斜杠+换行 → 继续读下一行
    }
    else if (this.character == 'u') {
        readUnicode();
    }
}
  • \t/\r/\n/\f → 对应控制字符
  • \ + 换行 → 续行(多行值)
  • \uXXXX → Unicode 转义,4 位十六进制

\u 解析的严格性:

java 复制代码
private void readUnicode() throws IOException {
    this.character = 0;
    for (int i = 0; i < 4; i++) {
        int digit = this.reader.read();
        if (digit >= '0' && digit <= '9') { ... }
        else if (digit >= 'a' && digit <= 'f') { ... }
        else if (digit >= 'A' && digit <= 'F') { ... }
        else {
            throw new IllegalStateException("Malformed \\uxxxx encoding.");
        }
    }
}

不足 4 位 → IllegalStateException("Malformed \\uxxxx encoding.")。这个错误消息在排查"中文乱码/启动失败"时经常出现。

6.7 Document 的空 key 保护

java 复制代码
static class Document {
    private final Map<String, OriginTrackedValue> values = new LinkedHashMap<>();

    void put(String key, OriginTrackedValue value) {
        if (!key.isEmpty()) {
            this.values.put(key, value);
        }
    }

    boolean isEmpty() {
        return this.values.isEmpty();
    }

    Map<String, OriginTrackedValue> asMap() {
        return this.values;
    }
}

空 key 被丢弃 。=value(没有 key)这样的行不会产生任何属性。

LinkedHashMap 保证插入顺序------这影响 getPropertyNames() 的顺序,进而影响 /actuator/env 的展示顺序(不影响优先级)。

6.8 多文档与 profile 的组合矩阵

把 6.1--6.7 与第 5 节合起来,得到"同一个文件内做条件化"的完整能力矩阵:

需求 YAML 做法 Properties 做法
分成 N 段 --- #---
条件化第 k 段 第 k 段内写 spring.config.activate.on-profile 同
按云平台条件化 spring.config.activate.on-cloud-platform 同
引用其它段的值做占位符 ${...}(全局解析) 同
后续段 import 其它文件 第 k 段内写 spring.config.import 同

YAML 更常见是因为 --- 更醒目且不需要列 0 + 恰好三 - 的严格规则 。但 .properties 的多文档能力是真实存在且被 CI 覆盖的(Boot 有大量 .properties 多文档测试)。


7. profile-specific 文件:application-{profile}.yaml

7.1 解析入口

java 复制代码
// StandardConfigDataLocationResolver
@Override
public List<StandardConfigDataResource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
        ConfigDataLocation location, Profiles profiles) {
    return resolve(getProfileSpecificReferences(context, location.split(), profiles));
}

private Set<StandardConfigDataReference> getProfileSpecificReferences(ConfigDataLocationResolverContext context,
        ConfigDataLocation[] configDataLocations, Profiles profiles) {
    Set<StandardConfigDataReference> references = new LinkedHashSet<>();
    for (String profile : profiles) {                          // ← 外层是 profile
        for (ConfigDataLocation configDataLocation : configDataLocations) {   // ← 内层是 location
            String resourceLocation = getResourceLocation(context, configDataLocation);
            references.addAll(getReferences(configDataLocation, resourceLocation, profile));
        }
    }
    return references;
}

循环嵌套顺序是"外层 profile,内层 location" ------这与非 profile-specific 的 getReferences(context, locations)(只有 location 一层循环)不同。

这带来一个优先级推论:

ini 复制代码
locations = [L1, L2]
profiles  = [p1, p2]

产生的 reference 顺序(LinkedHashSet 保序):

复制代码
L1-p1, L2-p1, L1-p2, L2-p2

即按 profile 分组,而不是按 location 分组。

而 ConfigDataLocationResolvers.resolve 的合并:

java 复制代码
private List<ConfigDataResolutionResult> resolve(ConfigDataLocationResolver<?> resolver,
        ConfigDataLocationResolverContext context, ConfigDataLocation location, @Nullable Profiles profiles) {
    List<ConfigDataResolutionResult> resolved = resolve(location, false, () -> resolver.resolve(context, location));
    if (profiles == null) {
        return resolved;
    }
    List<ConfigDataResolutionResult> profileSpecific = resolve(location, true,
            () -> resolver.resolveProfileSpecific(context, location, profiles));
    return merge(resolved, profileSpecific);        // 非 profile-specific 在前,profile-specific 在后
}

merge(list1, list2) 就是简单拼接。所以profile-specific 资源排在普通资源之后 ------即"优先级更高"(因为 ConfigData.getPropertySources() 是升序、后者胜)。

7.2 Profiles 迭代的是什么

getProfileSpecificReferences 里 for (String profile : profiles) 用的是 Profiles.iterator() → getAccepted()。

所以 profile-specific 文件用的是 accepted 集合(active 非空则用 active,否则用 default)。

推论:

  • 没有 active profile 时,application-default.yaml 会 被加载(因为 default 列表是 ["default"])。
  • 激活 dev 后,application-default.yaml 不会被加载。

7.3 PROFILE_SPECIFIC 选项与"后置"语义

java 复制代码
// StandardConfigDataLoader
private static final PropertySourceOptions PROFILE_SPECIFIC =
    PropertySourceOptions.always(Option.PROFILE_SPECIFIC);
private static final PropertySourceOptions NON_PROFILE_SPECIFIC = PropertySourceOptions.ALWAYS_NONE;

@Override
public ConfigData load(ConfigDataLoaderContext context, StandardConfigDataResource resource)
        throws IOException, ConfigDataNotFoundException {
    if (resource.isEmptyDirectory()) {
        return ConfigData.EMPTY;
    }
    ConfigDataResourceNotFoundException.throwIfDoesNotExist(resource, resource.getResource());
    StandardConfigDataReference reference = resource.getReference();
    Resource originTrackedResource = OriginTrackedResource.of(resource.getResource(),
            Origin.from(reference.getConfigDataLocation()));
    String name = String.format("Config resource '%s' via location '%s'", resource,
            reference.getConfigDataLocation());
    List<PropertySource<?>> propertySources = reference.getPropertySourceLoader()
        .load(name, originTrackedResource, reference.getEncoding());
    PropertySourceOptions options = (resource.getProfile() != null) ? PROFILE_SPECIFIC : NON_PROFILE_SPECIFIC;
    return new ConfigData(propertySources, options);
}

resource.getProfile() != null → 带 PROFILE_SPECIFIC 标记。

这个标记的作用 在 ConfigDataEnvironmentContributor.withChildren 里:

java 复制代码
ConfigDataEnvironmentContributor withChildren(ImportPhase importPhase,
        List<ConfigDataEnvironmentContributor> children) {
    Map<ImportPhase, List<ConfigDataEnvironmentContributor>> updatedChildren = new LinkedHashMap<>(this.children);
    updatedChildren.put(importPhase, children);
    if (importPhase == ImportPhase.AFTER_PROFILE_ACTIVATION) {
        moveProfileSpecific(updatedChildren);      // ← 只在 profile 激活后的阶段做
    }
    return new ConfigDataEnvironmentContributor(...);
}
java 复制代码
private void moveProfileSpecific(Map<ImportPhase, List<ConfigDataEnvironmentContributor>> children) {
    List<ConfigDataEnvironmentContributor> before = children.get(ImportPhase.BEFORE_PROFILE_ACTIVATION);
    if (!hasAnyProfileSpecificChildren(before)) {
        return;
    }
    List<ConfigDataEnvironmentContributor> updatedBefore = new ArrayList<>(before.size());
    List<ConfigDataEnvironmentContributor> updatedAfter = new ArrayList<>();
    for (ConfigDataEnvironmentContributor contributor : before) {
        updatedBefore.add(moveProfileSpecificChildren(contributor, updatedAfter));
    }
    updatedAfter.addAll(children.getOrDefault(ImportPhase.AFTER_PROFILE_ACTIVATION, Collections.emptyList()));
    children.put(ImportPhase.BEFORE_PROFILE_ACTIVATION, updatedBefore);
    children.put(ImportPhase.AFTER_PROFILE_ACTIVATION, updatedAfter);
}

语义 :在 AFTER_PROFILE_ACTIVATION 阶段,把 BEFORE_PROFILE_ACTIVATION 阶段的 children 中带 PROFILE_SPECIFIC 标记的,全部搬移到 AFTER_PROFILE_ACTIVATION 阶段的 children 列表的开头。

为什么要搬?看 ContributorIterator 的遍历顺序:

java 复制代码
private ContributorIterator() {
    this.phase = ImportPhase.AFTER_PROFILE_ACTIVATION;      // ← 先遍历 AFTER
    this.children = getChildren(this.phase).iterator();
    this.current = Collections.emptyIterator();
}

迭代器先遍历 AFTER_PROFILE_ACTIVATION 的子节点,再遍历 BEFORE_PROFILE_ACTIVATION 的,最后是自己。

而 applyContributor 用 addLast 顺序写入 Environment:

java 复制代码
for (ConfigDataEnvironmentContributor contributor : contributors) {
    PropertySource<?> propertySource = contributor.getPropertySource();
    if (contributor.getKind() == Kind.BOUND_IMPORT && propertySource != null) {
        if (!contributor.isActive(activationContext)) {
            this.logger.trace(...);
        }
        else {
            propertySources.addLast(propertySource);
            ...
        }
    }
}

addLast + 遍历顺序 → 先遍历到的排在 Environment 前面(低优先级),后遍历到的排在后面(高优先级)。

所以:

  • AFTER_PROFILE_ACTIVATION 的 children 先被遍历 → 先 addLast → 低优先级
  • BEFORE_PROFILE_ACTIVATION 的 children 后被遍历 → 高优先级

这正好是反的! profile-specific 文件本该优先级更高。

moveProfileSpecific 就是来修正这个的:把 PROFILE_SPECIFIC 的 contributor 从 BEFORE 列表搬到 AFTER 列表的开头 ------这样它们变成 AFTER 列表里最先被遍历的,也就是整体最低优先级?

不对,让我重新推。

moveProfileSpecific:

java 复制代码
updatedAfter.addAll(children.getOrDefault(AFTER, []));   // 把原 AFTER 的内容追加到 updatedAfter 尾部
children.put(AFTER, updatedAfter);                        // updatedAfter = [搬来的 PROFILE_SPECIFIC..., 原 AFTER...]

所以新的 AFTER 列表 = [被搬来的 profile-specific 们, 原来的 AFTER 内容]。

迭代器先遍历 AFTER → 顺序是 [profile-specific..., 原 AFTER 内容],然后遍历 BEFORE(已移除 profile-specific)→ [剩余 BEFORE],最后是 root 自己。

addLast 顺序 = 遍历顺序。Environment 里 addLast 越晚 = 优先级越高。

所以优先级从低到高:

sql 复制代码
profile-specific(最低) → 原 AFTER 内容 → 剩余 BEFORE → root(最高)

这与"profile-specific 文件应该优先级更高"完全相反!

这里我必须停下来做一个严谨声明。

我在第 07 章做过类似的推导并一度陷入死锁,最终靠 ConfigData.getPropertySources() 的 Javadoc 解开。这次的情况不同:我手上有完整的 moveProfileSpecific 代码和 ContributorIterator 代码,但推导出的结论与官方文档"profile-specific 文件覆盖非 profile-specific 文件"相矛盾。

可能的解释(均为推测,未核实):

  1. 我对 ContributorIterator 的方向理解反了 。fetchIfNecessary() 里:

    java 复制代码
    if (this.phase == ImportPhase.AFTER_PROFILE_ACTIVATION) {
        this.phase = ImportPhase.BEFORE_PROFILE_ACTIVATION;
        this.children = getChildren(this.phase).iterator();
        return fetchIfNecessary();
    }
    if (this.phase == ImportPhase.BEFORE_PROFILE_ACTIVATION) {
        this.phase = null;
        this.next = ConfigDataEnvironmentContributor.this;   // 最后是 self
        return this.next;
    }

    顺序确实是 AFTER → BEFORE → self。这一点我有把握。

  2. applyContributor 的 addLast 语义 。Environment 的 MutablePropertySources.addLast 是加到列表尾部,而 PropertySources 的查找是"从前往后找到第一个就返回"。所以越靠前优先级越高,不是越靠后!

    这才是关键。我在上面写反了。

    Spring 的 MutablePropertySources 内部是 List<PropertySource<?>>,getProperty(name) 遍历 list 返回第一个非 null。addLast = 加到尾部 = 最低优先级 。addFirst = 最高优先级。

    所以:先遍历到的先 addLast → 排在前面 → 优先级更高。

    重推:

    • AFTER children 先遍历、先 addLast → 排在 Environment 前面 → 高优先级
    • BEFORE children 后遍历 → 低优先级

    ✓ 这与"AFTER_PROFILE_ACTIVATION 阶段导入的东西优先级更高"一致(profile 激活后导入的,通常更具体)。

    而 moveProfileSpecific 把 profile-specific 从 BEFORE 搬到 AFTER 的开头 → 它们在 AFTER 列表里最先被遍历 → 优先级最高 ✓

    这就对了! application-{profile}.yaml 覆盖 application.yaml ✓

修正后的完整优先级链(从高到低):

sql 复制代码
AFTER_PROFILE_ACTIVATION children(其中 profile-specific 排在最前)
  ↓
BEFORE_PROFILE_ACTIVATION children(已移除 profile-specific)
  ↓
root self

教训:这是本章第二次踩"顺序方向"的坑。把结论记牢:

MutablePropertySources:addFirst = 高优先级,addLast = 低优先级。 ConfigData.getPropertySources():升序,后者胜(所以列表尾部 = 高优先级)。 这两个"尾"的含义相反 :Environment 的尾部是低优先级,ConfigData 列表的尾部是高优先级。 之所以不矛盾,是因为 applyContributor 遍历 contributor 树时,addLast 的顺序 = 优先级降序。

我在这里把推导过程完整保留,是因为这个坑太容易踩,而且第 07 章的第五定律(逆序插入)在这里有第二个应用实例。

7.4 fromProfileSpecificImport 与禁写规则

java 复制代码
static void throwIfPropertyFound(ConfigDataEnvironmentContributor contributor) {
    ConfigurationPropertySource propertySource = contributor.getConfigurationPropertySource();
    if (propertySource != null) {
        ERRORS.forEach((name, replacement) -> {
            ConfigurationProperty property = propertySource.getConfigurationProperty(name);
            if (property != null) {
                throw new InvalidConfigDataPropertyException(property, false, replacement, contributor.getResource());
            }
        });
        if (contributor.isFromProfileSpecificImport()
                && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
            PROFILE_SPECIFIC_ERRORS.forEach((name) -> {
                ConfigurationProperty property = propertySource.getConfigurationProperty(name);
                if (property != null) {
                    throw new InvalidConfigDataPropertyException(property, true, null, contributor.getResource());
                }
            });
        }
    }
}

两张错误表:

表 1(ERRORS)------ 永远禁止,并给出替代写法:

禁止的属性 替代为
spring.profiles spring.config.activate.on-profile
spring.profiles[0] spring.config.activate.on-profile

即:spring.profiles 这个"裸"属性名是被禁止的 (它是 2.4 之前的 spring.profiles 语义残留),必须写成 spring.config.activate.on-profile。

表 2(PROFILE_SPECIFIC_ERRORS)------ profile-specific 文件中禁止:

java 复制代码
errors.add(Profiles.INCLUDE_PROFILES);                                     // spring.profiles.include
errors.add(Profiles.INCLUDE_PROFILES.append("[0]"));                       // spring.profiles.include[0]
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME));      // spring.profiles.active
errors.add(ConfigurationPropertyName.of(... + "[0]"));                     // spring.profiles.active[0]
errors.add(ConfigurationPropertyName.of(AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME));     // spring.profiles.default
errors.add(ConfigurationPropertyName.of(... + "[0]"));                     // spring.profiles.default[0]

application-prod.yaml 里不能写 spring.profiles.active------这就是第 4.5 节说的"用静态禁止替代动态收敛判定"。

错误消息:

vbnet 复制代码
Property 'spring.profiles.active' imported from location '...' is invalid in a profile specific resource [origin: ...]

IGNORE_PROFILES 是豁免位:带这个选项的 contributor 即使来自 profile-specific import,也不做这套检查。

7.5 isSkippable:为什么找不到 application-prod.yaml 不报错

java 复制代码
// StandardConfigDataReference
boolean isSkippable() {
    return this.configDataLocation.isOptional() || this.directory != null || this.profile != null;
}

带 profile 的 reference 天然可跳过 。所以 application-prod.yaml 不存在时静默跳过,不报错------不需要写 optional:。

这是"profile-specific 文件是可选的"这一直觉的源码依据。

对比 getReferencesForFile 对于不带 profile 且不是目录的 location:

java 复制代码
if (configDataLocation.isOptional()) {
    return Collections.emptySet();
}
// 否则抛异常

所以 spring.config.import=file:/etc/nope.yaml(不带 optional)会报错。


8. spring.config.* 位置属性与 profile 的交互

8.1 四个位置属性的注册顺序

java 复制代码
// ConfigDataEnvironment
static final String LOCATION_PROPERTY = "spring.config.location";
static final String ADDITIONAL_LOCATION_PROPERTY = "spring.config.additional-location";
static final String IMPORT_PROPERTY = "spring.config.import";
static final String ON_NOT_FOUND_PROPERTY = "spring.config.on-not-found";

static final ConfigDataLocation[] DEFAULT_SEARCH_LOCATIONS;
static {
    List<ConfigDataLocation> locations = new ArrayList<>();
    locations.add(ConfigDataLocation.of("optional:classpath:/;optional:classpath:/config/"));
    locations.add(ConfigDataLocation.of("optional:file:./;optional:file:./config/;optional:file:./config/*/"));
    DEFAULT_SEARCH_LOCATIONS = locations.toArray(new ConfigDataLocation[0]);
}

private List<ConfigDataEnvironmentContributor> getInitialImportContributors(Binder binder) {
    List<ConfigDataEnvironmentContributor> initialContributors = new ArrayList<>();
    addInitialImportContributors(initialContributors, binder, IMPORT_PROPERTY, EMPTY_LOCATIONS, false);
    addInitialImportContributors(initialContributors, binder, ADDITIONAL_LOCATION_PROPERTY, EMPTY_LOCATIONS, true);
    addInitialImportContributors(initialContributors, binder, LOCATION_PROPERTY, DEFAULT_SEARCH_LOCATIONS, true);
    return initialContributors;
}

private void addInitialImportContributors(List<ConfigDataEnvironmentContributor> initialContributors, Binder binder,
        String propertyName, ConfigDataLocation[] defaultValue, boolean registerIndividually) {
    ConfigDataLocation[] locations = binder.bind(propertyName, CONFIG_DATA_LOCATION_ARRAY).orElse(defaultValue);
    if (registerIndividually) {
        for (int i = locations.length - 1; i >= 0; i--) {      // ← 逆序!
            addInitialImportContributors(initialContributors, List.of(locations[i]));
        }
    }
    else {
        addInitialImportContributors(initialContributors, List.of(locations));
    }
}

关键差异 :registerIndividually 参数。

属性 registerIndividually 含义
spring.config.import false 所有 import 位置打包成一个 contributor
spring.config.additional-location true 每个位置单独一个 contributor
spring.config.location true 每个位置单独一个 contributor

为什么 import 不拆分? 因为 spring.config.import 的语义是"在当前位置导入",它们属于同一个"导入批次",应该作为一个整体参与 contributor 树。而 location / additional-location 是"独立的搜索位置",每个都要形成自己的子树。

for (int i = locations.length - 1; i >= 0; i--) ------第五定律的又一例:逆序注册,使得"数组中靠后的位置"在 contributor 列表中靠前,进而在 Environment 中"先 addLast"→ 高优先级。

所以:

javascript 复制代码
spring.config.location=classpath:/a/,classpath:/b/,classpath:/c/

逆序注册 → contributor 顺序 [c, b, a] → Environment 顺序 [c, b, a](c 在前)→ c 优先级最低?

等等,又要小心方向。根据 7.3 节的修正:先遍历到的先 addLast → 排在 Environment 前面 → 优先级更高。

  • 逆序循环:先加 c 的 contributor,再加 b,再加 a。
  • contributor 列表 = [c, b, a]
  • 遍历 → 先 c 先 addLast → Environment = [c, b, a]
  • Environment 从前向后查找 → c 最高优先级

✓ 符合直觉:spring.config.location 里后面的位置优先级更高。这也与官方文档一致("locations are searched in order, with later locations taking precedence")。

这正是"逆序注册"的目的------把"数组顺序"翻译成"优先级顺序"。

8.2 DEFAULT_SEARCH_LOCATIONS 的两组结构

java 复制代码
locations.add(ConfigDataLocation.of("optional:classpath:/;optional:classpath:/config/"));
locations.add(ConfigDataLocation.of("optional:file:./;optional:file:./config/;optional:file:./config/*/"));

两个元素,每个元素内部用 ; 分隔多个位置。

  • 元素 0(classpath 组):optional:classpath:/、optional:classpath:/config/
  • 元素 1(file 组):optional:file:./、optional:file:./config/、optional:file:./config/*/

每个元素会作为一个 ConfigDataLocation 参与 split()(在 StandardConfigDataLocationResolver.resolve 里 location.split())。

按 8.1 的推导,元素 1(file 组)优先级高于元素 0(classpath 组)→ classpath 外/当前目录的配置覆盖 jar 包内的配置 ✓

组内:逆序 → ./config/*/ 最高,然后 ./config/,然后 ./;classpath:/config/ 高于 classpath:/ ✓

完整默认优先级(从低到高):

bash 复制代码
classpath:/application.{yml,properties}
classpath:/config/application.{yml,properties}
file:./application.{yml,properties}
file:./config/application.{yml,properties}
file:./config/*/application.{yml,properties}

这与官方文档的"配置位置优先级"表一致 ✓

optional: 前缀 :DEFAULT_SEARCH_LOCATIONS 里每个都带 optional:。所以默认的搜索位置全部是"找不到就跳过"。

./config/*/ :这是一个通配符目录 。StandardConfigDataLocationResolver.resolvePattern 会被调用(this.resourceLoader.isPattern(...))。它的用途是"子模块/子目录各自放一份 config"的场景(Boot 2.4 引入)。

8.3 optional: 前缀与 FileHint

java 复制代码
// ConfigDataLocation
public static final String OPTIONAL_PREFIX = "optional:";

public static ConfigDataLocation of(@Nullable String location) {
    boolean optional = location != null && location.startsWith(OPTIONAL_PREFIX);
    String value = (location != null && optional) ? location.substring(OPTIONAL_PREFIX.length()) : location;
    return (StringUtils.hasText(value)) ? new ConfigDataLocation(optional, value, null) : EMPTY;
}

optional: 只在最前面 识别一次。所以 optional:optional:file:./x.yaml 的 value 是 optional:file:./x.yaml(第二个 optional: 不再被当作前缀)。

FileHint:方括号语法,给 location 附加属性:

java 复制代码
static FileHint from(String value) {
    if (!hasBrackets(value)) {
        return NONE;
    }
    List<String> betweenBrackets = findBetweenBrackets(value);
    Map<String, String> attributes = new HashMap<>();
    for (String entry : betweenBrackets) {
        String cleaned = entry.trim();
        if (cleaned.isEmpty()) {
            continue;
        }
        int equals = cleaned.indexOf('=');
        if (equals == -1) {
            attributes.put("extension", cleaned);       // 没有 = → 当作 extension
        }
        else {
            attributes.put(cleaned.substring(0, equals).trim(), cleaned.substring(equals + 1).trim());
        }
    }
    return new FileHint(Collections.unmodifiableMap(attributes));
}

两个属性:extension 和 encoding。

用法:

arduino 复制代码
spring.config.import=file:/etc/conf/extra[.yaml]
spring.config.import=file:/etc/conf/cn[UTF-8]
spring.config.import=file:/etc/conf/extra[.yaml][UTF-8]
  • [.yaml] → extension = ".yaml"
  • [UTF-8] → encoding = "UTF-8"
  • 无 = 的项 → 当 extension(必须以 . 开头 ,否则 getExtension() 返回 null)
java 复制代码
@Nullable String getExtension() {
    String extension = this.attributes.get("extension");
    if (extension == null) {
        return null;
    }
    if (!extension.startsWith(".")) {
        return null;
    }
    return extension;
}

[yaml](不带点)是无效的。

removeFrom 把所有方括号内容从路径里剥掉:

java 复制代码
static String removeFrom(String value) {
    if (!hasBrackets(value)) {
        return value;
    }
    StringBuilder result = new StringBuilder();
    int brackets = 0;
    for (int i = 0; i < value.length(); i++) {
        char c = value.charAt(i);
        if (c == '[') { brackets++; continue; }
        if (c == ']') { brackets--; continue; }
        if (brackets <= 0) {
            result.append(c);
        }
    }
    return result.toString();
}

嵌套括号支持 :brackets 计数,brackets > 0 时不输出。findBetweenBrackets 也支持嵌套(brackets > 0 时把 [/] 当普通字符)。

典型用途:非标准扩展名的配置文件

arduino 复制代码
spring.config.import=file:/etc/app/config[.properties]

这里 /etc/app/config 这个文件本身没有扩展名,用 [.properties] 告诉 Boot 用 properties loader 解析它。

典型用途 2:指定 properties 编码(中文)

arduino 复制代码
spring.config.import=file:./conf/cn[UTF-8].properties

8.4 spring.config.name 与多配置名

java 复制代码
private static final String CONFIG_NAME_PROPERTY = "spring.config.name";
static final String[] DEFAULT_CONFIG_NAMES = { "application" };

private String[] getConfigNames(Binder binder) {
    String[] configNames = binder.bind(CONFIG_NAME_PROPERTY, String[].class).orElse(DEFAULT_CONFIG_NAMES);
    for (String configName : configNames) {
        validateConfigName(configName);
    }
    return configNames;
}

private void validateConfigName(String name) {
    Assert.state(!name.contains("*"), () -> "Config name '" + name + "' cannot contain '*'");
}

spring.config.name 是 String[],可以指定多个名字。

getReferencesForDirectory 遍历所有 configNames:

java 复制代码
private Set<StandardConfigDataReference> getReferencesForDirectory(ConfigDataLocation configDataLocation,
        String directory, @Nullable String profile) {
    Set<StandardConfigDataReference> references = new LinkedHashSet<>();
    for (String name : this.configNames) {
        Deque<StandardConfigDataReference> referencesForName = getReferencesForConfigName(name, configDataLocation,
                directory, profile);
        references.addAll(referencesForName);
    }
    return references;
}

注意这里是正序 (不是逆序),且 configNames 的循环在外层,扩展名循环在内层。

getReferencesForConfigName 里对扩展名用了 addFirst:

java 复制代码
private Deque<StandardConfigDataReference> getReferencesForConfigName(String name,
        ConfigDataLocation configDataLocation, String directory, @Nullable String profile) {
    Deque<StandardConfigDataReference> references = new ArrayDeque<>();
    for (PropertySourceLoader propertySourceLoader : this.propertySourceLoaders) {
        for (String extension : propertySourceLoader.getFileExtensions()) {
            StandardConfigDataReference reference = new StandardConfigDataReference(configDataLocation, directory,
                    directory + name, profile, extension, propertySourceLoader, null);
            if (!references.contains(reference)) {
                references.addFirst(reference);
            }
        }
    }
    return references;
}

Deque + addFirst = 逆序堆积。

propertySourceLoaders 来自 SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, ...),顺序由 META-INF/spring.factories 决定。Boot 的 spring-boot/src/main/resources/META-INF/spring.factories 里注册了 PropertiesPropertySourceLoader 和 YamlPropertySourceLoader(具体顺序我未在本次阅读中核实)。

若顺序是 [Properties(properties, xml), Yaml(yml, yaml)],则遍历产生:

matlab 复制代码
properties, xml, yml, yaml

addFirst 后 deque 从顶到底是:yaml, yml, xml, properties。

references.addAll(referencesForName) 按 deque 迭代顺序(从顶到底)→ [yaml, yml, xml, properties]。

再结合 7.3 的"先遍历 = 高优先级"...... 这里 references 是"资源集合",它们被 resolve 成 resource 后进入 ConfigData 的 propertySources 列表,而 ConfigData.getPropertySources() 是升序、后者胜。

所以 properties 在最后 → .properties 优先级高于 .yaml ✓

这与官方文档一致 :同名文件,application.properties 覆盖 application.yaml(Boot 2.4+ 的行为;2.4 之前相反)。

我在此标注:propertySourceLoaders 的实际顺序我未核实 ,但".properties 覆盖 .yaml"这一结论与官方文档一致,可反推 loader 顺序使 deque 迭代顺序为 [yaml, yml, xml, properties]。

8.5 spring.config.on-not-found

java 复制代码
this.notFoundAction = binder.bind(ON_NOT_FOUND_PROPERTY, ConfigDataNotFoundAction.class)
    .orElse(ConfigDataNotFoundAction.FAIL);

ConfigDataNotFoundAction 枚举(源码未在本章读取,但语义可从使用处推知):FAIL / IGNORE。

作用点在 ConfigDataImporter.getNotFoundAction:

java 复制代码
private ConfigDataNotFoundAction getNotFoundAction(ConfigDataLocation location,
        @Nullable ConfigDataResource resource) {
    if (location.isOptional() || (resource != null && resource.isOptional())) {
        return ConfigDataNotFoundAction.IGNORE;
    }
    return this.notFoundAction;
}

optional: 前缀优先于 on-not-found 。所以 spring.config.on-not-found=ignore 不会让"非 optional 的 location"变成可选------它只是把默认行为从 FAIL 改成 IGNORE,而 optional 永远 IGNORE。

8.6 checkMandatoryLocations:最后一道防线

java 复制代码
private void checkMandatoryLocations(ConfigDataEnvironmentContributors contributors,
        ConfigDataActivationContext activationContext, Set<ConfigDataLocation> loadedLocations,
        Set<ConfigDataLocation> optionalLocations) {
    Set<ConfigDataLocation> mandatoryLocations = new LinkedHashSet<>();
    for (ConfigDataEnvironmentContributor contributor : contributors) {
        if (contributor.isActive(activationContext)) {
            mandatoryLocations.addAll(getMandatoryImports(contributor));
        }
    }
    for (ConfigDataEnvironmentContributor contributor : contributors) {
        if (contributor.getLocation() != null) {
            mandatoryLocations.remove(contributor.getLocation());
        }
    }
    mandatoryLocations.removeAll(loadedLocations);
    mandatoryLocations.removeAll(optionalLocations);
    if (!mandatoryLocations.isEmpty()) {
        for (ConfigDataLocation mandatoryLocation : mandatoryLocations) {
            this.notFoundAction.handle(this.logger, new ConfigDataLocationNotFoundException(mandatoryLocation));
        }
    }
}

四步过滤:

  1. 收集所有活跃 contributor 的非 optional import
  2. 移除"已经被某个 contributor 的 location 覆盖的"(即成功加载的)
  3. 移除 importer.getLoadedLocations()
  4. 移除 importer.getOptionalLocations()

剩下的就是"声明了 mandatory 但没加载上"的位置 → 按 notFoundAction 处理。

注意第 1 步的 contributor.isActive(activationContext) :不活跃的 contributor 声明的 mandatory import 不算数。所以:

yaml 复制代码
spring.config.activate.on-profile: never-match
spring.config.import: /etc/must-exist.yaml     # 非 optional

这个 import 不会导致启动失败------因为文档不活跃。

这是一个非常合理的"连带失效"设计。

9. 反直觉结论(本章十六条)

按"最容易踩坑"排序。

9.1 spring.profiles.default 在有任何 active profile 时完全失效

Profiles.getAccepted() = active 非空 ? active : default。写了 spring.profiles.default: base 又激活了 dev,base 就彻底不生效------不是"优先级低",是"根本不在判定集合里"。

连带影响:spring.config.activate.on-profile: default 也只在"零 active profile"时匹配。

9.2 spring.profiles.include 与 spring.profiles.active 的读取作用域不同

  • include:每个 contributor 单独绑定 (new Binder(Collections.singleton(source), ...)),不能跨源继承。
  • active:聚合 Binder 绑定,可以来自任意一个活跃源。

副作用:写在文档 #1 的 spring.profiles.include 不会让文档 #0 变成"包含"状态;但写在文档 #1 的 spring.profiles.active 会被全局读到。

9.3 在不活跃的文档里写 spring.profiles.include 会直接报错

不是静默失效,是 InactiveConfigDataAccessException。这是 Boot 少数"主动拦截用户误用"的地方------因为 include 的失效太隐蔽。

9.4 spring.config.activate.on-profile 不会向下传播到后续文档

每个文档必须自己写。判定依据是 withBoundProperties 里的 Collections.singleton(configurationPropertySource)。

9.5 但 ${...} 占位符是全局解析的

同一个 Binder 里,属性值绑定用单源,占位符解析用全 contributors(ConfigDataEnvironmentContributorPlaceholdersResolver(contributors, ...))。

所以"文档 #1 用 ${my.host} 引用文档 #0 的值"是可行的,而"文档 #1 继承文档 #0 的 on-profile"不可行。二者机制不同,不要类比。

9.6 ACTIVE 与 DEFAULT 的合并行为不对称

Type.ACTIVE.mergeWithEnvironmentProfiles = true,Type.DEFAULT = false。编程式设置的 active 会与配置属性合并;编程式设置的 default 不会。

9.7 Profiles.iterator() 迭代的是 accepted 而不是 active

for (String p : profiles) 拿到的可能是 default profiles。StandardConfigDataLocationResolver.getProfileSpecificReferences 用的正是这个迭代器。

9.8 group 展开是深度优先,不是广度优先

expandProfiles 用显式栈 + 两次反转。prod → [prod-db, prod-mq] 且 prod-db → [prod-pool] 时,结果是 [prod, prod-db, prod-pool, prod-mq],prod-mq 排在 prod-pool 后面。

9.9 group 名(key)不被 ProfilesValidator 校验

validate(Map) 分支只校验 value。未激活的 group 名即使非法也不会报错------源码推演,未实测。

9.10 profile-specific 文件天然 optional

StandardConfigDataReference.isSkippable() 里 this.profile != null → 可跳过。application-prod.yaml 不存在不报错,不需要 optional: 前缀。

9.11 profile-specific 文件里禁止写 spring.profiles.*

由 InvalidConfigDataPropertyException.PROFILE_SPECIFIC_ERRORS 强制。这不是"风格建议",是硬异常。

这条规则同时是"不动点迭代只需三轮"的收敛性保证------因为禁止在 profile-specific 文件里改 profile,第 5 步之后不需要再算一遍 profile。

9.12 .properties 文件支持多文档(#---)

判定极其严格:注释符在列 0 、紧跟恰好三个 -、之后到行尾只有空白、且上一行不是同类型注释行。

最后一条(isSameLastLineCommentPrefix)是我对源码逻辑的推演,未找到直接测试用例------如果实际行为不符,请以此为准修正。

9.13 .properties 的默认编码是 ISO-8859-1

OriginTrackedPropertiesLoader.CharacterReader 里写死 StandardCharsets.ISO_8859_1。中文必须 \uXXXX 或用 [UTF-8] FileHint。

这不是 Boot 的 bug,是 Java .properties 规范。但很多人误以为 Boot 已经默认 UTF-8。

9.14 YAML 里 key:(空值)绑定成空字符串而不是 null

OriginTrackingConstructor.getValue(null) 返回 ""。所以 @Value("${my.password}") 得到 ""。

9.15 snakeyaml 的 DoS 防护被 Boot 主动关闭

maxAliasesForCollections = Integer.MAX_VALUE、codePointLimit = Integer.MAX_VALUE。配置文件是可信输入所以没问题,但不要用它加载用户上传的 YAML。

9.16 MutablePropertySources.addLast = 低优先级,ConfigData 列表尾部 = 高优先级

两个"尾"的含义相反。之所以不矛盾,是因为 applyContributor 遍历 contributor 树时用 addLast,而遍历顺序本身就是优先级降序。

这是我本章第二次踩的坑(第一次在第 07 章)。记牢判据:

Spring Environment 的 MutablePropertySources:前面优先(先找到先返回)。 ConfigData.getPropertySources():后面优先(Javadoc 明说 later wins)。

9.17(补)spring.config.on-not-found=ignore 与 optional: 不是一回事

ConfigDataImporter.getNotFoundAction 里 optional: 直接返回 IGNORE,优先级高于 on-not-found 的值。


10. 动手验证清单

10.1 观察 profile 求解全过程

bash 复制代码
# 1) 打开 ConfigData 全量 TRACE
java -jar app.jar \
  --logging.level.org.springframework.boot.context.config=TRACE \
  --spring.profiles.active=dev

第四行之后出现的 Adding imported property source '...(document #1)' 就是 profile 激活后才导入的。

10.2 验证"default 被 active 屏蔽"

application.yaml:

yaml 复制代码
spring:
  profiles:
    default: base
    active: dev
my:
  key: value

application-base.yaml:

yaml 复制代码
my:
  key: from-base

启动后 my.key 应为 value(来自 application.yaml),不是 from-base。 去掉 active: dev 后应为 from-base。

10.3 验证多文档优先级与 profile 表达式

激活 期望值 依据
无 base 两个表达式都不匹配(profiles 为 default 集合)
dev base dev & other 缺 other;`(dev
dev,other dev-and-other 第一个匹配
dev,another dev-or-other-and-another 第二个匹配
dev,other,another dev-or-other-and-another 两者都匹配,文档顺序后者胜

最后一行是重点:文档顺序决定胜负,与 profile 激活顺序无关。

10.4 验证 spring.profiles.include 的单源绑定

yaml 复制代码
# 文档 #0
spring:
  profiles:
    include: metrics
---
# 文档 #1(无 on-profile,永远活跃)
my:
  other: x

启动时 active = [metrics](外加任何 spring.profiles.active)。

反例(应报错):

yaml 复制代码
spring.config.activate.on-profile: never
spring.profiles.include: metrics

应抛 InactiveConfigDataAccessException。

10.5 验证 .properties 多文档

application.properties:

properties 复制代码
a=1
#---
b=2

/actuator/env 或 TRACE 日志应出现两个 PropertySource:

bash 复制代码
Config resource '...' via location '...' (document #0)
Config resource '...' via location '...' (document #1)

再验证边界:

properties 复制代码
a=1
# ---
b=2

# ---(第二字符是空格)不应产生第二个文档。

10.6 验证 group 展开顺序

yaml 复制代码
spring:
  profiles:
    group:
      prod: prod-db,prod-mq
      prod-db: prod-pool
    active: prod

打印 environment.getActiveProfiles() → 期望 [prod, prod-db, prod-pool, prod-mq]。

注意 prod-mq 在 prod-pool 之后(深度优先)。

10.7 验证 profile 名校验

10.8 验证云平台强制优先于探测

bash 复制代码
KUBERNETES_SERVICE_HOST=1.2.3.4 KUBERNETES_SERVICE_PORT=443 \
  java -jar app.jar --spring.main.cloud-platform=none

ConfigDataActivationContext.deduceCloudPlatform 先查 isEnforced(binder),none 命中 → 返回 NONE,探测结果被忽略。

配合:

yaml 复制代码
spring.config.activate.on-cloud-platform: kubernetes
my:
  k8s-only: yes

在强制 none 时 my.k8s-only 应不存在。

10.9 验证 mandatory import 的连带失效

yaml 复制代码
spring.config.activate.on-profile: never-match
spring.config.import: /etc/definitely-not-exist.yaml

不应报错 (contributor 不活跃 → 其 mandatory import 不参与 checkMandatoryLocations)。

对照:去掉 on-profile → 应报 ConfigDataLocationNotFoundException。

10.10 验证默认搜索位置的优先级

在 src/main/resources/application.yaml 写 my.k=classpath-root,在 src/main/resources/config/application.yaml 写 my.k=classpath-config,在 ./config/application.yaml 写 my.k=file-config。期望 my.k=file-config。


11. 本章源码索引

按阅读优先级排列,全部位于 core/spring-boot/src/main/java/org/springframework/boot/。

11.1 核心(必读)

文件 行数量级 本章关注点
context/config/Profiles.java ~270 全文,尤其是 Type 枚举与 expandProfiles
context/config/ConfigDataProperties.java ~150 Activate 内部类
context/config/ConfigDataEnvironmentContributor.java ~605 withBoundProperties、isActive、withChildren/moveProfileSpecific、ContributorIterator、Kind
context/config/ConfigDataEnvironment.java ~416 withProfiles、getIncludedProfiles、applyToEnvironment、checkMandatoryLocations
env/OriginTrackedPropertiesLoader.java ~353 load、isNewDocument、CharacterReader
env/OriginTrackedYamlLoader.java ~188 createYaml、OriginTrackingConstructor、NoTimestampResolver

11.2 支撑

文件 关注点
context/config/ProfilesValidator.java ALLOWED_CHARS、首尾规则、get(Binder)
context/config/InvalidConfigDataPropertyException.java ERRORS 与 PROFILE_SPECIFIC_ERRORS 两张表
context/config/InactiveConfigDataAccessException.java 消息构造
context/config/ConfigDataEnvironmentContributorPlaceholdersResolver.java isActive 对 UNBOUND_IMPORT 的特殊处理
context/config/ConfigDataEnvironmentContributors.java getBinder、InactiveSourceChecker、asContributors 逆序
context/config/StandardConfigDataLocationResolver.java getProfileSpecificReferences、getReferencesForConfigName、resolveEmptyDirectories
context/config/StandardConfigDataLoader.java PROFILE_SPECIFIC 选项
context/config/StandardConfigDataReference.java isSkippable
context/config/ConfigDataLocation.java OPTIONAL_PREFIX、split()、of()
context/config/FileHint.java from、removeFrom、getExtension
context/config/ConfigDataLocationBindHandler.java Origin 回填
context/config/ConfigDataImporter.java load 逆序、getNotFoundAction
context/config/ConfigDataLocationResolvers.java resolve 的 merge、reorder
cloud/CloudPlatform.java 各平台探测条件、isEnforced(Binder)
env/YamlPropertySourceLoader.java document #N 命名
env/PropertiesPropertySourceLoader.java .xml 分支、document 循环
env/ConfigData.java(即 context/config/ConfigData.java) Option 枚举、getPropertySources() Javadoc

11.3 测试(行为规格的权威来源)

| 文件 | 关注点 |
|--------------------------------------------------------------------------|-------------------------------------------------------|----------------------|
| context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java | profile 表达式(&、` | 、!`、括号)、多文档顺序、云平台 |
| context/config/ConfigDataEnvironmentContributorTests.java | on-cloud-platform、ContributorIterator 顺序断言 |
| context/config/ConfigDataEnvironmentContributorsTests.java | spring.config.activate.on-profile 的多 contributor 行为 |
| context/config/ProfilesTests.java | group 展开、include、校验器的单元断言 |
| context/properties/source/SystemEnvironmentPropertyMapperTests.java | 环境变量映射的四候选(第 11 章详述) |

测试文件清单中 ProfilesTests.java 一项我未打开确认其存在与内容------标注为待核实。


12. 本章与全系列的接口

本章解决了第 07 章留下的四个口子中的三个:

口子 解决位置 状态
① profile 何时、按何规则求解 §2--§4 ✅ 已解决
② spring.config.activate.* 绑定在谁身上 §5 ✅ 已解决
③ 一个文件 → 多个 PropertySource §6 ✅ 已解决
④ SPRING_PROFILES_ACTIVE 如何变成 spring.profiles.active --- ⏳ 第 11 章

同时本章产出了两条新的方法论:

  • 第六定律:语义的"作用域"常藏在构造参数里 (Collections.singleton(...) vs Iterable<...>)
  • 方向判据 :MutablePropertySources 前面优先,ConfigData 列表后面优先

13. 下一章

第 11 章 · ConfigTree、env: 位置与环境变量映射:非文件型配置源的完整机制

本章讲的是"文件内容怎么被切分、怎么被条件化"。第 11 章讲"文件之外的东西怎么变成配置":

  1. ConfigTree :configtree:/etc/config/ ------ Kubernetes ConfigMap/Secret 的标准接入方式。目录树 → 属性名(. 分隔)、文件内容 → 属性值(Value 既是 CharSequence 又是 InputStreamSource)、AUTO_TRIM_TRAILING_NEW_LINE 的"只 trim 单行"逻辑。
  2. env: 位置 :SystemEnvironmentConfigDataLocationResolver ------ 把一个环境变量的值 当作一份完整配置文件来解析(env:APP_CONFIG[.yaml])。
  3. 环境变量映射 :SystemEnvironmentPropertyMapper ------ SPRING_PROFILES_ACTIVE → spring.profiles.active 的四个候选名、HOST_0 → host[0] 的数字索引规则、LEGACY_SYSTEM_ENVIRONMENT 的历史包袱。
  4. PropertySourceInfo 与前缀 :4.x 新增的 isImmutable() / getPrefix(),以及它如何支撑"多应用共用一台机器"的属性隔离。

第 12 章再回到主线:事件总线与 Bootstrap 上下文 ------SpringApplicationRunListener、BootstrapRegistry、CRaC。


附:本章遗留悬念

spring.profiles.validate 未在配置元数据中收录(第 15 章);BootstrapRegistry 的 Scope.PROTOTYPE(第 12 章);ConfigDataEnvironmentUpdateListener 的 onPropertySourceAdded / onSetProfiles(第 12 章);AOT 阶段的 ConfigData(第 27 章)。

本章未逐行核实 / 待你实测的项

  1. spring.profiles.active 与 ApplicationEnvironment 的交互细节未逐行读。
  2. 多文档解析在 YAML 与 Properties 下的差异只做了推演(文中已标注)。
  3. spring.config.activate.* 的全部键未穷举。
  4. profile 求解不动点迭代的收敛边界未实测。
  5. AOT 下 profile 固化(__EnvironmentPostProcessor)的行为在第 28 章。
相关推荐
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 1 篇):回测引擎从零搭建
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT
java·后端·开源
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件
java·百度·交友
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源
希洛兔1 小时前
开闭原则详解:对扩展开放,对修改关闭(含图形编辑器反例 + 抽象正例 + JDK/Spring 实战)
java·开闭原则
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 03 章 · 模块化的自动配置:`*.imports`机制、classic 兼容层与 Starter 生成
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理
java·后端·开源
geats人山人海1 小时前
java 8 字符串
java·开发语言