属性已经变了,为什么 Bean 仍是旧对象

订单路由在启动时读取地区 east,并据此构造 Pricing。应用运行后,把属性源中的地区改成 west,Environment.getProperty 已返回 west,Pricing.region 却仍为 east。两次观察并不矛盾:一次查询读取当前属性,一次查询读取先前保存的对象状态。

属性解析、定义是否注册、对象是否创建是三个不同动作。Environment 提供 property 与 profile 视图,条件判断在配置处理过程中决定是否保留定义,Bean 工厂再按已经登记的定义创建对象。修改属性源可以影响后续查询,却不会自动重跑所有先前发生的动作。官方 Environment 抽象

本文以原生 AnnotationConfigApplicationContext 为入口,固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。实验文件为 examples/spring-framework-lab/src/main/java/blog/spring/Chapter11.java。它显式排列 MapPropertySource,不使用 Boot 配置加载规则,也不引入配置中心刷新组件。

观察位置 输入 输出保存在哪里
Environment.getProperty 当前有序属性源 本次调用返回值
Profile 与 Condition 配置处理时的环境 是否注册 BeanDefinition
Pricing 工厂方法 创建时解析的 region、limit Pricing record 的字段
再次 getBean 已登记定义与实例缓存 既有 singleton 引用

属性实时读取、配置判断与对象状态形成的三个时间点

多属性源按键查找,先命中不等于整份覆盖

实验先用 addFirst 加入 defaults,再用 addFirst 加入 command。因此 command 位于 defaults 前面。command 提供 region=east 和 enabled=true,defaults 提供 region=default、limit=“3” 与 enabled=false。region 和 enabled 从 command 得到值,limit 因 command 缺少该键而继续查找 defaults。

PropertySourcesPropertyResolver.getProperty() 顺序遍历 PropertySource。当前源返回非 null 时,按需要解析嵌套占位符,转换成请求的目标类型,然后返回;null 才继续。这个循环逐次解析一个键,并没有先把低优先级整份配置文件删除。固定版本属性解析器

“第一份配置优先”容易让读者误以为后面的配置完全无效。逐键回退意味着某个最终配置对象可以同时由多个属性源组成。排查生产配置时,需要记录每个关键键命中的源名称,不能只打印整个源列表便宣布配置已经对齐。

本例的 limit 原值是字符串,工厂方法要求 Integer,解析器转换后传入 Pricing 的 int 字段。转换失败与缺失是不同错误:缺失是所有源都没返回值,转换失败是找到了值却不符合要求。getRequiredProperty 对缺少的键抛 IllegalStateException,实验明确断言这个异常类型。

自定义 PropertySource 可以实现文件、远程服务或计算值等访问,但 getProperty 的副作用和成本仍由实现承担。统一接口不保证每次读取便宜,也不保证同一组键来自同一个一致性快照。本例使用不可变 Map,避免把并发修改问题混进属性优先级问题。

模式提炼:逐键追踪来源

解析可以写成 resolve(key) = first non-null source[key]。命令行覆盖、租户默认值和多层主题配置都可以采用这条规则。诊断应保留键、来源、类型和解析时点;含凭证的配置只记录来源与是否存在,不需要把秘密值打印进日志。

profile 表达式决定哪些定义进入容器

配置类分别声明 productionRoute 与 testRoute。productionRoute 使用 @Profile("prod & !test"),testRoute 使用 @Profile("test")。第一个上下文在注册和 refresh 之前激活 prod,因此只登记 productionRoute。这个结果依赖激活发生在处理配置前,不能把时点省略成“设置过 prod”。

Profile 使用条件机制表达匹配。ProfileCondition 从注解读取配置的 profile 字符串,通过 Environment 的 profile 判断决定条件是否满足。字符串内部的逻辑表达式与多个字符串参数的组合方式有区别;为减少阅读歧义,实验只使用一个明确的与、非表达式。ProfileCondition 固定源码

profile 名称在这里没有部署系统赋予的特殊权限。prod 只是一个被配置读取的标识,不能证明数据库连接的是生产库,也不能证明某个密钥已加载。对外部资源是否正确连接,还需要资源层证据。将 profile 激活成功视为整个环境验证成功,会漏掉相互独立的配置错误。

实验第二次创建一个全新的 Context,设置 test 后再 refresh,便得到 testRoute 而没有 productionRoute。两个 Context 拥有独立 BeanFactory;这是一次重新装配,不是对第一个 Context 的原地热更新。

Condition 如何影响配置处理分支

通知组件使用自定义 Enabled 条件。matches 从 Environment 按 Boolean 读取 spring.lab.enabled,缺省为 false。条件只读取元数据与环境,不调用 getBean 提前创建业务对象。false 导致定义不注册,后续按该 Bean 名查询自然也不可能取得实例。

ConditionEvaluator.shouldSkip() 先检查元数据是否存在 Conditional。没有条件直接保留;有条件时确定处理阶段,收集并排序条件。对实现 ConfigurationCondition 的条件还要检查要求阶段,仅在对应阶段参与。适用条件的 matches 返回 false 后,shouldSkip 返回 true。条件判断固定实现

解析配置类与注册 Bean 不是完全相同的阶段。一个条件如果依赖尚未完成注册的其他定义,结果可能受到处理顺序影响。ConditionContext 能访问注册表,并不代表全部业务 Bean 都已存在;提前 getBean 更会改变原本应观察的状态。适合在条件中使用的判据,需要与该阶段已稳定的输入相匹配。

条件对象不等于长期监听器。ConfigurationClassBeanDefinitionReader 在加载 Bean 方法定义时检查是否跳过,决定注册与否;它不会给每个属性源安装“改变后撤销注册”的观察者。因此热更新需要显式重建、刷新范围或业务配置快照方案,不能从 @Conditional 的名称推导出来。定义读取与跳过分支

实时查询与既有实例的对照实验

第一个 Context refresh 完成后,实验保存 Pricing 引用和创建计数。随后把名为 command 的 PropertySource 替换为 region=west、enabled=false,并把活动 profile 改为 test。此时发生了三组可分辨的结果。

Environment 的直接查询立即读到 west,显式 resolveRequiredPlaceholders("orders-${spring.lab.region}") 返回 orders-west。这证明修改确实生效,避免把旧对象解释成“配置源没有更新成功”。

Pricing 引用与之前使用 == 比较相同,region 仍是 east,创建计数仍是一。工厂方法已经把字符串传入不可变 record;后续环境变化没有任何代码去替换这个字段,也没有触发新的 singleton 创建。

productionRoute 和 notification 仍存在,testRoute 仍不存在。这说明已处理的 profile 与 condition 没有再次筛选定义。再创建一个采用 test、enabled=false 的全新 Context,结果则是 testRoute 存在而 notification 缺失,Pricing 总创建计数变为二。

1
2
3
Context A refresh:east / prod / enabled=true → Pricing A + productionRoute + notification
修改 A 的环境:west / test / enabled=false → 下一次查询变,原定义与 Pricing A 保留
Context B refresh:east / test / enabled=false → Pricing B + testRoute

上游 PropertySourcesPropertyResolverTests 包含按加入顺序搜索、构建后替换属性值和替换 PropertySource 的测试;ConfigurationClassWithConditionTests.methodConditional() 检查方法条件导致 Bean 缺失。两者分别约束读取行为和定义装配。本文阅读了这些测试,运行证据来自独立 main,没有宣称执行上游全部测试。

模式提炼:查找接口与配置快照分开

可以把服务初始化写成 snapshot = resolve(environment, t0),业务读取写成 use(snapshot)。若业务希望动态改变,必须决定在哪个边界建立新 snapshot、如何校验整组配置,以及哪些正在进行的调用继续使用旧快照。直接把属性 Map 换掉只完成第一层输入变化,尚未回答对象迁移与并发一致性问题。

这个模型也适用于编译参数、连接池配置和路由表:读取源支持变化,并不意味着已经创建的对象支持原地修改。对昂贵资源重新创建时,还要安排旧对象退役与失败恢复;容器基础篇的对照仅证明当前行为,不提供完整配置中心实现。

从现象定位时间边界

若 Environment 与 Bean 字段不同,先分别记录读取时点和创建时点。再检查字段是否来自构造参数、Value 注入、业务 setter 或每次动态查询。这几条路径更新能力不同,不应凭“使用了 Spring 配置”归为一种。

若条件 Bean 未出现,检查条件是在配置类还是方法上,条件所需输入是否在判断前已经就绪。还要区分定义根本没有注册与定义已注册但实例创建失败。前者需要回到条件结果,后者需要沿创建异常调查,简单改变 lazy 标记不会修复一个被条件排除的定义。

Boot 如何将文件、环境变量、命令行等输入加入 Environment,需要在对应 Boot 基线上另外核验。这里显式调用 addFirst 的相对顺序是实验事实,不能拿来作为 Boot 完整配置优先级表。

运行、反例题与练习

在实验工程中使用 JDK 21:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter11

输出保存在 evidence/11/local-20261002/run.txt,11 条 CHECK PASS,退出码 0。两个 Context 均在 try-with-resources 中关闭。未启动文件监听、配置中心、线程池或网络服务,因此不存在这些系统的动态刷新验证结论。

反例题:只把运行中 Context 的 profile 改成 test,为什么 getBean(“testRoute”) 仍失败?因为 profile 视图已变化,但对应 Bean 方法不会自动重新处理,testRoute 定义仍未注册。建立新 Context 后,注册判断才读取这组新输入。

可执行练习:在副本中只删除 command 中的 spring.lab.region 键,保留 enabled,重跑 main。预测首条断言的 region 应变为 default,notification 仍注册;已有单例在属性替换后仍保存 default,所以对应身份断言也要将旧 region 的期望改为 default。Environment 的 west 查询保持不变。完成后恢复基线,再独立删除 defaults 中的 limit,观察 refresh 中 getRequiredProperty 的失败。两个变体分别检验单键回退与必需创建参数缺失,不能合并修改。

需求或症状 应验证什么 合适的边界
某个键取错值 源顺序、非 null 命中、类型转换 一次属性解析
某个 Bean 缺失 条件与 profile 的判断时点 定义注册
属性变化但对象旧 读取是否被保存为字段 实例创建与业务更新
多键需要一致更新 快照构建与原子切换 业务配置协议

参考资料