深入 Spring 11:属性解析与条件装配的时间边界
属性已经变了,为什么 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 | |
上游 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 | |
输出保存在 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 的判断时点 | 定义注册 |
| 属性变化但对象旧 | 读取是否被保存为字段 | 实例创建与业务更新 |
| 多键需要一致更新 | 快照构建与原子切换 | 业务配置协议 |
