深入 Spring 39:导入、选择、注册与容器扩展边界
增加一个策略,是否需要一个框架
订单应用需要一个限额为 7 的策略。直接写 @Bean OrderPolicy registeredPolicy() 就能完成注册、构造器调用、依赖注入和销毁管理。增加一个 @EnableOrderPolicy 注解后,最终对象仍然是同一个 record 类型,使用方的业务能力没有增加。
扩展有价值的条件,是声明来源和注册规则出现重复:多个应用需要按同一种注解协议启用一组定义,或者注册数量与导入类元数据有关。只有一个应用、一个固定对象时,普通配置已经表达了全部事实。多加 Selector 和 Registrar 会增加故障发生的阶段与定位成本。
本篇构造一个刻意很小的扩展,并保留普通 Bean 对照。它只注册一个名字固定的策略,遇到独立导入者争用名称时立即失败,不提供扫描全包、动态脚本、自动发现所有策略或通用插件平台。这个限制让重复导入与名称冲突可以在十条断言中解释清楚。
实验使用 JDK 21、Framework 6.2.11,Framework 固定源码提交为 4c134254642d88e058aa004bdaf44168e1be7bb2。自动配置发现对照使用独立 Boot 3.5.6 模块。运行入口为 blog.spring.boot.Chapter39,所有嵌套配置都由入口显式选择,不扫描实验包。
Selector 选择类,Registrar 注册定义
组合注解使用运行时保留策略,并在类上声明 @Import(PolicySelector.class)。Selector 接收导入类的 AnnotationMetadata,返回 PolicyRegistrar 的类名;Registrar 再注册 RootBeanDefinition。这个例子里的 Selector 没有分支,因此在实际应用中可以直接导入 Registrar。保留这一层是为了观察两种扩展点的输入与输出,不能据此认定生产扩展必须拥有两层。
1 | |
Selector 的输出是待导入的类型名称,不是对象;Registrar 的输出是容器定义,也不是立即创建好的单例。后续构造器选择、属性填充、初始化与后处理仍沿普通 Bean 路径执行。若在 Registrar 中提前 getBean 来决定业务配置,就可能把尚未完整注册后处理器的阶段与对象创建阶段混在一起。
配置读取器的实际调用点是 ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsFromRegistrars。它遍历解析阶段收集的 Registrar 与导入元数据,再调用注册方法。保存导入者元数据能够解释“某个定义由哪个配置启用”,比仅在错误消息里写“策略已存在”更有用。固定调用点
实验把 metadata.getClassName() 放入 definition 的 source,并断言该值等于 Chapter39.First。这份来源信息由扩展主动保存。若组件作者没有保存来源,排障时就不能从对象的类名反推出具体启用者。
固定名称必须有明确的冲突语义
Registrar 的核心检查如下,完整实现位于下载工程中的 Chapter39.java:
1 | |
名称检查发生在定义注册之前。它不依赖原生容器是否允许覆盖,因此不会因为换成 Boot 启动或改变全局覆盖策略而静默替换一个业务对象。名称已存在时,无论原定义也是 OrderPolicy,还是毫不相关的 String,都得到明确冲突。
这里选择失败而非跳过,是因为扩展没有足够的信息证明两个定义语义相同。如果静默跳过,两个应用模块可能分别以为自己的限额已经启用,实际值却由配置顺序决定。要支持幂等重复启用,至少需要比较稳定的定义来源和参数,或者把实例命名明确交给调用者;这些都超出了“固定单策略”的契约。
containsBeanDefinition 也有范围:它回答当前 registry 的定义,不等价于检查整个父子上下文中的可见 Bean。本篇没有建立父上下文,所以不能把这条检查称为全局唯一性约束。若业务真的要求跨上下文唯一,需要另行定义搜索范围和冲突归属。
重复导入与两个导入者不是同一个场景
Duplicate 使用 @Import({First.class, First.class})。First 上有启用注解。运行结果是 Registrar 调用一次,容器里一个策略。这说明相同配置类的重复导入在当前配置解析路径中被合并,没有第二次进入本扩展的名称冲突分支。
另一个实验同时注册 First 与 Second。两者是不同配置类,各自使用启用注解。此时第二个注册动作遇到既有名称,抛出包含导入者类名的异常,refresh 失败,context 的 active 状态为 false。
这两个结果看似矛盾,实际输入不同。第一组重复的是同一个配置类型,第二组重复的是两个独立导入者产生的同名输出。只有明确输入身份,才能讨论“重复导入是否安全”。把第一组结果概括为“Spring 会自动对所有 Registrar 结果去重”会掩盖第二组的冲突。
第三组在 refresh 前预注册一个名叫 registeredPolicy 的 String,再注册 First。扩展照样拒绝。它验证名称属于共享容器命名空间,而不是策略类型私有空间。依赖注入按类型查找成功,并不能免除定义名称冲突。
固定版本的官方 ImportBeanDefinitionRegistrarTests 另外验证 Registrar 获取 classloader、Environment、ResourceLoader 等 Aware 回调。本实验阅读该测试以确认元数据扩展的基础设施边界,没有执行整个上游测试套件。官方测试覆盖的 Aware 注入不能冒充本实验验证了任意 Registrar 实现。官方测试
缺失类型失败在哪一层
缺失类型组注册一个 beanClassName 为 blog.spring.optional.MissingPolicy 的定义,然后 refresh。失败是 CannotLoadBeanClassException,异常指出 definition 名称 missingPolicy。注册字符串时并未创建该类的实例,所以问题可以晚于注册动作暴露。
它与第 38 篇的缺失可选驱动对照不同:那里类级条件先排除了配置,启动成功且策略缺席;这里明确要求创建一个不可加载的定义,启动失败。需要的业务组件缺失时,清楚地失败通常比吞掉异常后继续运行更容易验证。
若组件是可选功能,应在选入配置前评估条件并保留负匹配原因。若组件是业务启动前提,应让失败包含组件名、所需类型与声明来源。两者不能只靠捕获 ClassNotFoundException 后返回空值来混合处理,否则“被配置关闭”和“发布物缺依赖”会变成同一种沉默结果。
BeanPostProcessor 工作得更晚。它处理已经进入实例化路径的对象,适合初始化后的包装、校验或适配;缺失 Bean 类无法创建实例,后处理器也就没有这个对象可供修复。需要新增定义时选注册扩展,需要替换调用行为时再评估后处理或代理,避免为了一个固定业务分支插入容器生命周期钩子。
Boot imports 与自定义启用注解各自解决什么
本篇最后启动 Chapter38.App,不扫描、不显式导入 OrderAutoConfiguration,仍然得到来源为 auto 的策略。证据来自发布资源 AutoConfiguration.imports 的实际发现,并伴随条件报告。它验证可复用组件可以通过 Boot 约定加入候选装配集合。
自定义启用注解则由应用显式声明。它把选择权放在应用配置类型上,Registrar 能拿到这个具体导入者的元数据。两条路径最终都落到 Framework definition,但入口契约不同。为了在单个应用里放置一个固定策略,既不需要自定义启用注解,也不需要发布自动配置。
@Order、自动配置排序与业务策略的调用优先级也不能混用。某个配置先注册,只影响相关定义可见的时点;最终对象创建还受依赖图约束,业务策略选择则应有自己的稳定规则。若希望“优先策略”生效,应让业务代码明确使用该规则,并运行同输入下的选择断言,而不是把配置文件顺序作为业务协议。
从最小扩展获得可诊断性
本实验保留了四个可定位信息:导入配置身份、输出 definition 名称、对象类型和 refresh 终态。正常路径检查对象值与定义来源;失败路径检查异常类别或稳定消息,并确认失败发生在预期阶段。没有把日志出现一个自定义注解名当成扩展已生效。
普通 Bean 对照尤其重要。它提供同样限额为 7 的策略,且无需扩展协议。若去掉 Selector、Registrar 和组合注解后应用需求仍然完整满足,就应优先保留普通配置。扩展代码的成本不只在行数,还包括使用者必须理解的声明阶段、冲突约定与错误表面。
复现与修改练习
从实验根目录运行:
1 | |
原始输出保存在 evidence/39/local-20261002/run.txt,共十条 PASS 和一个 CHAPTER 39 PASS,退出码为 0。日志包含两个预期名称冲突和一个预期缺失类型;测试通过的含义是这些负例确实失败,而不是把所有上下文都启动成功。
先预测再修改:把 @Import({First.class, First.class}) 改为 @Import({First.class, Second.class})。预期从一个策略变成明确冲突。再将 Registrar 的名称改成由导入类完整名派生,预期两个 definition 可以共存,但原先按类型唯一查找 OrderPolicy 的消费方将需要新的候选选择规则。修复名称冲突并不自动修复业务歧义,这个变化正好检验扩展协议是否完整。
前置阅读:深入 Spring 38:Boot 自动配置的输入、退让与属性绑定。运行期排障继续阅读 深入 Spring 40:观测事件、资源等待与业务终态。

