深入 Spring 02:BeanDefinition 如何描述对象创建
注册一条定义,是否已经创建一个对象
一个订单服务依赖仓储和计价规则。手动装配时,构造器调用同时决定创建时点与依赖对象;改成 Spring 后,配置可以先记录“服务由哪个类、哪些依赖创建”,等配置处理完成再创建实例。BeanDefinition 承担的正是这段尚未执行的创建描述。
本篇检验一个问题:同一订单业务改用编程注册、XML 和配置类后,注册、实例化与名称解析是否仍是三件不同的事?实验固定 Spring Framework 6.2.11、JDK 21;实现依据固定到 4c134254642d88e058aa004bdaf44168e1be7bb2。同系列第 01 篇讨论谁拥有生命周期,本篇进一步区分对象的描述与对象本身。
图据固定版本的 DefaultListableBeanFactory、BeanDefinitionReaderUtils 和 ConfigurationClassBeanDefinitionReader 绘制。图中的单例缓存只表示 singleton 路径,prototype 不保留同样的共享实例。
定义保存创建条件,而非对象字段的快照
BeanDefinition 描述候选类、作用域、依赖关系、构造参数、属性值、初始化与销毁方法等信息。AbstractBeanDefinition 是常用实现的基础。它的构造参数集合可以装入 RuntimeBeanReference:此时保存的是待解析的 Bean 名称,还不是对应的 Java 引用。直到实际创建服务,容器才解析这个依赖并选取构造器。
这个间隔允许注册表后处理器补充定义,也允许工厂后处理器修改属性值。假设配置里先写了一个占位符,而注册动作已经立即创建对象,后处理器便无法在构造器执行前替换它。元数据与实例分离,使“先处理完整配置,再使用配置创建对象”成为可执行的顺序约束。BeanDefinition 契约
| 信息 | 描述的内容 | 不能据此推断的内容 |
|---|---|---|
beanClassName |
初始类信息,可能是构造目标或静态工厂所在类 | getBean 返回值一定具有这个具体类 |
| 构造参数 | 常量、引用或其他待解析值 | 所有依赖都已经实例化 |
factoryBeanName、factoryMethodName |
实例工厂与创建方法 | 必须有产品类的 beanClassName |
scope、lazyInit |
重用规则与主动预实例化策略 | 单例天然线程安全,或懒加载对象绝不被提前依赖 |
resourceDescription、source |
定义的定位线索 | 缺少来源描述就代表注册失败 |
DefaultListableBeanFactory.registerBeanDefinition 把定义保存到以 Bean 名为键的注册表,并维护名称集合与相关缓存。本例的启动前首次注册不会调用订单服务构造器。运行中覆盖既有定义还涉及重置与既有单例处理,不属于这个“首次注册”的实验结论,也不宜用它推导热更新方案。
模式提炼:描述与执行分阶段
参数化表达为 输入 → 描述({创建方式, 参数, 策略}) → 校验/改写 → 执行。
| 场景 | 描述 | 执行结果 |
|---|---|---|
| Bean 装配 | 类、依赖、scope | 对象及其依赖引用 |
| SQL 处理 | 解析后的查询与计划 | 结果行与数据库副作用 |
| 构建任务 | 目标、依赖、命令 | 生成文件 |
排查“配置存在但效果没有发生”时,先分别确认描述是否注册、描述是否经过处理、执行是否发生。三个阶段的证据不能互换;注册表里的记录只能回答第一类问题。
三种输入收敛到创建机制,但元数据形态不同
实验的编程注册用 RootBeanDefinition 指定 TrackedOrderService,给两个构造参数填入命名引用,再把 orders 注册为 orderService 的别名。XML 用 class 和 <constructor-arg ref="…"/> 表达相同关系;@Configuration 中的 @Bean 则提供返回订单服务的工厂方法。
XML 读取器解析资源后注册定义。@Bean 的处理多了一层配置类解析:ConfigurationClassBeanDefinitionReader 把实例方法记录为“哪个配置 Bean 的哪个方法”,并标记工厂方法参数需要自动装配。三种方式可以创建行为相同的业务对象,却不应要求它们的 beanClassName、构造参数集合与来源字段逐项相等。工厂方法里的 new 和构造参数不会被 Spring 反编译后还原成另一条构造器定义。工厂方法定义的注册实现
这个区别也解释了来源信息。XML 定义通常能报告资源描述;配置类定义能携带方法与注解元数据;纯编程定义若没有显式设置来源,相关字段可以为空。定位工具应同时记录 Bean 名、创建方式、来源与工厂方法,不能只打印 getBeanClassName() 再把空值判成异常。
可复制实验
从本系列实验工程目录执行,Maven Wrapper 和依赖版本由第 00 篇冻结:
1 | |
完整入口为 src/main/java/blog/spring/Chapter02.java,XML 为 src/main/resources/chapter02.xml;共用的 OrderService、OrderRepository、PricePolicy 和 Checks 位于同一工程。入口包含正常断言与反例,断言失败会抛出 AssertionError,不能只靠日志里有“创建成功”判断通过。
1 | |
本地重跑使用 Amazon Corretto 21.0.11,框架实际版本为 6.2.11,命令退出码为 0,末行是 CHAPTER 02 PASS。原始输出保存在工程的 evidence/02/local-20261002/run.txt。关键断言的观察结果如下。
| 场景 | 二值断言结果 | 解释 |
|---|---|---|
| 编程首次注册 | 服务创建计数等于 0 | 定义注册未调用业务构造器 |
| XML 加载 | 服务累计计数仍等于 1 | 读取资源未额外创建服务 |
| 三种配置方式 | 订单结果均等于 25.00,别名身份均相同 | 在各自容器中得到相同业务行为 |
| prototype 主名与别名 | 两次获取的引用不相同 | 别名不覆盖创建策略 |
| 来源与工厂元数据 | 编程来源为空、XML来源含资源名、配置工厂方法名匹配 | 三种输入保留不同的定位线索 |
这些断言把“结果相同”拆成金额、引用身份、创建时点和元数据四种观测。金额相同单独不能证明复用了同一个仓储或服务,类名相同也不能证明单例。
实例计数在本次运行开始时归零。加载 XML 前已有编程容器创建的一个服务,因此加载 XML 后计数仍为一,证明的是“本次读取没有额外实例化服务”,不是计数的绝对值必须为零。三个容器各自创建服务后,计数才达到三。跨容器的 singleton 不合并,它们各有自己的对象缓存。
scope 决定重用,alias 只改变查找名称
AbstractBeanDefinition 的默认 scope 字段是空字符串;在尚未继承或合并出其他 scope 时,空值按 singleton 解释。直接打印原始字段而只接受字符串 singleton,会误判默认定义。需要判断语义时使用 isSingleton()、isPrototype(),同时保留原始值用于定位。默认 scope 实现
singleton 的共享边界是相应容器中的这条定义,不是整个 JVM 里的某个 Class。把同一个类注册为两个独立 Bean 名通常会得到两个对象。与此相对,给一条定义增加 alias,只建立另一个名称到原名称的映射。BeanDefinitionReaderUtils 先注册主名称对应的定义,再分别注册 alias,并没有为每个别名复制一份定义。
因此,“主名和别名返回同一对象”还需要 singleton 这个条件。prototype 定义每次请求都走创建流程,通过主名请求一次、别名请求一次,仍可得到不同实例。别名没有覆盖作用域;它只把两个查找入口指向相同的创建规则。名称与别名注册
上游 DefaultListableBeanFactoryTests 的 prototype 测试对照默认单例与 prototype 的引用身份,aliasChaining 测试验证多级别名仍指向同一个单例。本篇阅读了这些断言;本地实验独立运行,不把上游测试文件的存在写成整套上游测试已通过。对应上游测试
模式提炼:名称解析与对象策略分离
参数化表达为 输入名称 → 规范名称 → {scope策略}(定义)。
| 场景 | 先解析什么 | 再决定什么 |
|---|---|---|
| Bean alias | 别名对应的 Bean 名 | 取缓存还是创建实例 |
| 文件路径别名 | 实际目标路径 | 打开方式与访问权限 |
| 服务逻辑名 | 路由后的目标 | 连接复用与请求执行 |
多个名字不必对应多个对象;相同目标也不保证两次请求得到相同对象。身份验证应在名称解析之后进行,并明确决定重用的那一层策略。
类型查询为何不能只读 class 字段
直接构造普通对象时,定义中的类与运行类经常相同,容易形成错误预期。实例工厂方法的产品类型来自方法返回信息;FactoryBean 的工厂类型与产品类型又不同;后续代理还可能改变容器暴露的实际类型。定义描述创建入口,最终引用经过创建与后处理,二者没有逐字段的一一对应关系。
诊断时优先向 BeanFactory 查询类型。如果需要尽量避免为了判断类型而初始化 FactoryBean,使用 getType(name, false) 并接受可能返回 null;这个参数不是“整个容器绝无任何副作用”的承诺。若业务已经需要该实例,可以对已取得的引用检查 getClass() 与接口,但这回答的是运行对象类型,不再是创建前的元数据问题。BeanFactory 类型查询契约
设计上的代价是:配置阶段保留更多弹性,类型判断就需要处理未知、工厂方法和代理等分支。注册元数据不能证明所有依赖都存在,也不能证明构造器一定能执行成功;这些错误可能直到 refresh 或第一次请求才暴露。第 03 篇将按启动阶段定位这些失败。
练习与解答
练习一。 同一个 RootBeanDefinition 描述的类分别以 ordersA、ordersB 两个主名注册,与注册 ordersA 后设置 ordersB 为别名,有什么不同?
解答:前者有两个名称对应的定义入口,默认会各自维护单例;后者只有一个目标定义,名称解析后共享该定义的 scope 规则。比较数量时同时查看定义名称集合与别名映射,比较实例时使用 ==,不要用类名相同替代身份相同。
练习二。 XML 和配置类实验返回相同订单金额,但配置类定义的构造参数数量不是二,能否判定注册遗漏了依赖?
解答:不能。配置类产品通过工厂方法创建,其参数由工厂方法解析,而不是复制 new TrackedOrderService(r, p) 中的参数到定义的构造参数集合。应检查工厂 Bean 名、工厂方法与创建后的依赖身份,再判断是否遗漏。
模式速查
| 观察 | 对应模式 | 下一项证据 |
|---|---|---|
| 定义存在,构造器尚未执行 | 描述与执行分阶段 | 请求与 refresh 是否触发创建 |
| 换别名后实例身份变化 | 名称与对象策略分离 | 目标定义的 scope |
| class 字段为空或与运行类不同 | 创建描述与最终引用分层 | 工厂方法、类型查询、后处理结果 |
| 配置文件不同但业务相同 | 输入格式收敛到执行协议 | 实际依赖与业务断言,不比较字段快照 |
参考资料
实验工程与环境准备见00:容器实验入口。继续阅读03:refresh 如何组织容器启动。
- 官方 Bean 定义说明:6.2 系列文档会滚动更新;本篇内部字段与方法以固定源码为准。
- AbstractBeanFactory:名称转换、单例与 prototype 的获取分支。
- DefaultListableBeanFactory:定义注册与预实例化。
