IoC、DI 与容器对象:定义如何变成可解析的依赖图
应用启动报 NoUniqueBeanDefinitionException,开发者在注入点随手加了 @Qualifier,第二天另一个环境却变成 NoSuchBeanDefinitionException。问题不在注解是否拼对,而在容器如何把一组定义解析成对象依赖图:候选从哪里来,泛型与限定符怎样缩小集合,scope 是否允许长期对象持有短期对象,延迟获取又把失败推迟到哪一刻。
Spring Framework 7.0 是当前生产主线,最低运行基线仍是 Java 17,并提升到 Jakarta EE 11。仍使用 6.2 的系统可以沿同一 IoC 模型理解,但升级必须检查移除 API、Jakarta 依赖、字节码工具和第三方扩展,不能只改版本。官方 IoC 容器参考 是行为入口。
BeanFactory 保存配方,也负责按图取对象
BeanDefinition 是配方,DefaultListableBeanFactory 同时承担定义注册、按名称/类型查询、依赖解析、单例缓存和创建协调。ApplicationContext 在 BeanFactory 上增加 Environment、Resource、事件、国际化与生命周期。Context 不是“更高级的 Map”,它在 refresh() 中组织后处理器、监听器和非懒单例创建。
依赖注入的价值不是少写 new,而是把对象构造与协作关系集中成可验证图。构造器注入让必需依赖与不可变性显式,也会在启动时暴露循环;setter/字段注入允许对象先实例化再填充,却产生半初始化窗口。可选依赖更适合 Optional、ObjectProvider 或明确默认实现,不应捕获 NoSuchBeanDefinitionException 当控制流。
examples/backend-development/spring-framework/ioc-container/DependencyGraphDemo.java 把共享 repository 的拓扑顺序固定下来:
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-framework/ioc-container/DependencyGraphDemo.java examples/backend-development/spring-framework/ioc-container/CandidateResolutionDemo.java
java -cp examples/backend-development/spring-framework/ioc-container DependencyGraphDemocreationOrder=[repository, inventory, payment, orderService] singletonRepositoryInstances=1真实 Spring 会按按需创建、@DependsOn、lazy 与 scope 改变时序,稳定结论是依赖先达到可注入状态,singleton 依赖由同一 BeanFactory 缓存复用。若构造器里执行远程 I/O,整条依赖子图都会等待,启动慢的根因便被藏在“创建一个 Bean”之下。
候选解析不是只看 Java 类型
容器先按 ResolvableType 寻找候选,处理泛型、@Qualifier、bean name、@Primary、priority 和 autowire-candidate,再判断唯一性。集合注入返回所有匹配者并按顺序排序,单值注入必须收敛成一个。把所有实现都标 @Primary 不会得到随机一个,而会再次产生歧义。
CandidateResolutionDemo.java 演示两候选由 primary 收敛:
java -cp examples/backend-development/spring-framework/ioc-container CandidateResolutionDemocandidates=2 selected=stripe reason=primary生产诊断要打印注入点的声明类型、泛型、限定符和所有候选的来源 definition,而不是只打印最终对象类。FactoryBean 还要区分产品 name 与工厂自身 &name;代理对象的运行类型也可能与声明类型不同,按具体实现类注入会让 JDK 代理场景失效。
Scope 决定对象能否被安全持有
singleton 是每个 BeanFactory 一份,不是 JVM 全局单例;prototype 每次显式获取创建新对象,但容器通常不负责其完整销毁;Web request/session scope 依赖当前请求上下文。singleton 直接构造注入 request-scoped 对象时,需要 scoped proxy 或延迟 provider,否则在启动阶段没有请求上下文。
scoped proxy 保存的是访问入口,每次方法调用再定位当前 scope 实例。它解决生命周期错配,不改变线程安全。Session scope 对象仍可能被同一用户并发请求访问;prototype 注入 singleton 若在创建 singleton 时解析,只会得到一次 prototype,而不是每次业务调用一个。
自定义 scope 必须定义存储键、并发语义、销毁回调和上下文传播。只实现 get() 而不执行 destruction callback,会让连接、临时文件和监听器泄漏。异步任务离开请求线程后,request scope 也不会自动传播,复制整个 request 对象比显式复制必要值更危险。
层级容器的查找不是对称的
父 Context 的 Bean 对子 Context 可见,子 Bean 通常不能被父容器直接解析。同名定义可以在子层遮蔽父层,造成“Actuator 看见两个、注入却取到一个”的困惑。Web 应用、测试 Context 与插件系统若使用层级,必须记录 BeanFactory id 和 definition source。
getBean() 是运行时服务定位器,业务代码大量调用会把依赖从构造器移回隐藏查找,使测试与循环依赖更难。基础设施扩展点有时需要 BeanFactoryAware,但应把查找封装在窄边界,业务对象继续通过显式依赖表达。
把依赖图当成容量与启动资产
容器指标至少包括 definition 数、已实例化 singleton、创建耗时分位、最大依赖深度、lazy 首次创建失败、候选歧义和 scoped instance 数。一次升级后 Bean 数从 800 增到 1600,通常意味着扫描或自动注册范围改变,不能只接受“启动慢一点”。
测试要构造最小 ApplicationContext,分别断言单候选、多候选、缺失候选、scope 边界与关闭回调。生产启动应急切换不能通过全局允许 definition override 掩盖冲突;冲突名称、来源和覆盖规则必须显式。IoC 的完成条件不是对象能注入,而是依赖选择可解释、生命周期匹配、失败能在启动或明确的延迟边界暴露。
容器还在解析可解析依赖与工厂产品
并非所有依赖都来自普通 BeanDefinition。BeanFactory、ApplicationContext、ResourceLoader、事件发布器等基础设施可以作为 resolvable dependency 直接注入;它们不一定出现在普通候选枚举里。排查“按类型能注入、却在 Bean 列表找不到”时,要区分 definition 候选、手工注册 singleton、可解析依赖和父容器结果四个来源。
FactoryBean<T> 又引入两层身份:容器名称 x 通常取得产品 T,&x 才取得工厂自身。产品是否 singleton 由 FactoryBean 契约决定,不等同于工厂 Bean 的 scope;产品类型若在实例化前无法预测,按类型查找可能触发工厂或在早期阶段漏掉候选。框架型 FactoryBean 应尽量提供稳定的 object type,且不能在类型探测中执行不可逆远程动作。
容器还能注入数组、集合、Map、ObjectProvider<T> 与 Optional<T>。集合表达“使用全部实现”,单值表达“必须收敛成一个”,provider 表达“把解析时间推迟到调用点”,三者是不同架构契约。用 provider 隐藏必需依赖会把启动错误推迟到流量期;将集合直接暴露给业务又可能让新增实现无意改变执行链。应在组合根把候选集合收敛成明确策略对象。
自动注入候选还受 definition 的 autowireCandidate、default candidate、fallback、primary、priority 与限定符影响。团队若同时使用自定义 qualifier 和名称回退,需要固定优先级并为歧义写反向测试。候选解析结果应能输出“被排除原因”,否则只看到最终 winner,很难解释升级后为何换了实现。
依赖图的所有权比注入语法更重要
容器负责创建和连接对象,不负责替架构决定依赖方向。一个业务服务注入 ApplicationContext 再按字符串查找实现,表面消除了构造器参数,实质把类型依赖换成运行时名称依赖;测试必须启动容器,重构也失去编译器保护。服务定位只应停在插件适配、基础设施桥接等窄边界,并由一个明确组件把查找结果转换成业务接口。
可选依赖必须对应真实可选能力。如果没有审计组件时系统仍宣称审计成功,这是错误降级;若只是额外指标采集,则 no-op 实现比在每个调用点判断 null 更稳定。是否允许缺失、缺失时输出什么能力状态、恢复后是否动态生效,都要成为契约,而不是由 required=false 单独决定。
scope 同样体现所有权:singleton 持有连接池管理器合理,直接持有一次请求的安全主体不合理;prototype 若持有可关闭资源,创建者必须接管销毁;自定义租户 scope 必须防止租户标识缺失时落入共享默认实例。画图时应在每条依赖边标出“持有引用”还是“每次查找”,两者的并发与生命周期完全不同。
一次完整的容器故障复盘应给出最小依赖子图,而不是贴全部启动日志。子图包含失败 bean、构造/属性注入点、候选及排除原因、scope、definition 来源、创建线程和最深 cause;若存在父子 context,再加容器 id。用这些事实可以区分四类问题:图中缺节点、节点太多无法收敛、边的生命周期不合法、节点创建本身失败。分类之后,修复才不会退化为不断添加 @Primary 与 @Lazy。
