深入 Spring 05:依赖解析如何筛选候选对象
同一个接口出现两个实现以后
订单应用按渠道选择处理策略。Channel 有 Web、Store 两个实现,消费方声明一个 Channel 构造参数。只有 Web 时依赖可以确定;同时注册两个实现以后,容器需要额外的选择条件。把参数改成 List<Channel>,需求则变成取得所有匹配策略,不再要求从中选一个。
这一差别决定了 @Qualifier、@Primary 和集合注入的作用范围。Qualifier 缩小可用候选集,Primary 帮助单值依赖确定优先对象,集合表达多值需求。三者不能用一张“注解优先级”表解释。官方 Qualifier 文档也允许多个 Bean 具有同一限定值,并一起进入集合。
本文固定 Spring Framework 6.2.11、JDK 21,使用原生 AnnotationConfigApplicationContext。源码对应完整提交 4c134254642d88e058aa004bdaf44168e1be7bb2。完整实验位于仓库 examples/spring-framework-lab/src/main/java/blog/spring/Chapter05.java,包含导入、配置、断言与上下文关闭;正文的对象表用于解释实验,不替代可执行代码。
| 输入配置 | 请求形状 | 预期结果 |
|---|---|---|
| 不注册 Channel 实现 | 必需的 Channel | 缺少候选,消费者创建失败 |
| 仅注册 Web | 必需的 Channel | 注入 Web |
| 注册 Web、Store,无额外条件 | 必需的 Channel | 候选不唯一,消费者创建失败 |
| Web 标记 Primary,另有 Store | Channel 与 List<Channel> | 单值选择 Web;集合保留两者 |
| 注册 Box<String>、Box<Integer> | Box<String> | 选择字符串工厂方法对应的 Bean |
注入点保存的不只是一个 Class
构造参数 Box<String> 包含原始类型、类型参数、参数注解和参数名称。Spring 将注入点包装成 DependencyDescriptor,保留这些信息以及是否必需、是否允许 eager 类型检查等属性。只拿 Box.class 查一次容器,无法表达完整的依赖要求。
入口 DefaultListableBeanFactory.resolveDependency() 先识别依赖形状。Optional<T> 使用可缺省解析,ObjectProvider<T> 和 ObjectFactory<T> 生成基于描述符的取得入口;支持延迟解析的注入点还可能返回懒代理。普通依赖随后进入 doResolveDependency()。Provider 在这一步被注入,并不代表它包裹的目标已经创建。依赖解析实现
doResolveDependency() 也不总是扫描所有 Bean。它先尝试描述符已有的快捷结果、@Value 提供的值,以及依赖名称或 qualifier 建议名称命中的快捷路径。6.2.11 的名称快捷路径仍检查类型匹配、候选资格、fallback 标志、primary 冲突与自引用。参数碰巧与某个 Bean 同名,不会跳过这些条件。
因此,“先按类型,再按名称”适合作为语义模型,却不是每次执行的完整方法轨迹。断点追踪时若没有进入候选全量扫描,应先检查前面的快捷分支。参数名称参与选择还要求编译产物保留名称;依赖参数名完成业务装配会使重命名产生语义影响,显式的语义限定更容易审阅。
过滤候选以后才知道有几个可用对象
普通候选搜索由 findAutowireCandidates() 取得本容器及祖先容器中类型相符的名称,排除通常不应参加当前注入的自引用,再逐个调用候选解析器。注解配置上下文提供的解析器支持泛型与 qualifier;直接新建裸 DefaultListableBeanFactory 时,不能假定这些注解能力已经全部安装。
GenericTypeAwareAutowireCandidateResolver 根据 ResolvableType 比较依赖和目标元数据。目标信息可能来自 BeanDefinition 的 target type、工厂方法返回类型或 Bean 类型。@Bean Box<String> textBox() 与 @Bean Box<Integer> integerBox() 的返回签名给出了区分依据,所以 Box<String> 不必再靠名字挑选。
这不要求 Java 在任意对象上恢复被擦除的所有类型实参。若注册来源只暴露 raw Box 或过宽的 Object 返回类型,预测所能使用的信息就减少。实现还存在对无法解析泛型的 fallback 匹配,不能把“泛型参与筛选”扩大成“任何注册形式下泛型都严格排除全部 raw 候选”。保持工厂方法签名准确,是让解析发生在元数据阶段的直接办法。泛型解析器
Qualifier 解析建立在已有候选资格检查之上。@Qualifier("store") 可以与限定元数据匹配,也可以在规定条件下与 Bean 名或别名匹配。名称相同但类型不兼容的对象仍不可注入。若两个类型兼容的 Bean 都具有 offline 限定,@Qualifier("offline") Channel 仍可能歧义;@Qualifier("offline") List<Channel> 则可以表达取得两者。限定解析器
注册元数据还应与实际对象分开核对。例如,工厂方法只声明 Box,即使方法体最终返回保存字符串的实现,也不能假定未创建对象时能预测 Box<String>。反过来,上游测试 genericMatchingWithBeanNameDifferentiation() 注册两个未完整区分泛型的 NumberStore,按带泛型类型查询名称时没有得到对应结果,构造参数却可以通过各自的名字完成匹配。查询 API、依赖描述与 fallback 条件不同,结果就不一定相同。
故障定位可以保存一张注入点记录:消费方名称、字段或参数签名、限定注解、查询到的名称、每个候选的注册来源。若把泛型参数删掉后查询成功,问题可能是暴露类型信息不足;若去掉 qualifier 后成功,则应核对限定值和元注解;若单值失败而同类型集合成功,则首先检查基数与优先规则。每次只改一个条件,才能把成功关联到具体分支。这些是诊断改动,不应全部留成生产配置。
模式提炼:先约束集合,再选择结果
令 C 表示类型与限定均相容的候选集。单值请求执行 select(C),多值请求执行 collect(C)。过滤条件回答“哪些对象有资格”,选择规则回答“合格对象中返回谁”;在策略注册、服务路由与插件发现中,这两个问题也应分别建模。
误把 Primary 当成全局过滤条件,会把集合应有的实现删掉;误把 Qualifier 当成唯一键,又会漏掉同限定的多候选冲突。调试时先列出 C,再检查消费方要求的结果数量,往往比继续添加注解更快。
零、一、多候选分别在哪里结束
集合、数组、流和常见 Map 的多值解析在普通单值决策前尝试完成。集合元素仍接受类型与限定过滤,但不会因为某个元素是 Primary 就排除其余匹配元素。@Order 影响适用的多值结果顺序,也不能直接当成单值依赖的 Primary 替代品。
对于普通单值请求,零候选且必需时抛出缺少 Bean 的异常;一个候选时直接使用该候选;多个候选时进入 determineAutowireCandidate()。外部看到的异常可能是消费者创建过程包装后的 UnsatisfiedDependencyException,其根因才是 NoSuchBeanDefinitionException 或 NoUniqueBeanDefinitionException。定位时应同时记录消费方、注入参数和根因,不只搜索最外层异常名。
6.2.11 的单值选择先检查 primary 候选。该步骤还包含唯一非 fallback 候选的分支。若仍未决定,再考虑依赖名称、qualifier 建议名称、最高 priority、唯一 default candidate 和直接登记的可解析依赖。多个本地 primary 本身就会造成冲突,不能用“任意一个 Primary 优先”解释。
候选表里的值也未必都是已创建的对象。addCandidateEntry() 在部分单值路径先存类型,选定后才调用 resolveCandidate() 取得对象,避免为了挑选而先创建所有普通候选。多值收集则需要取得各个实际元素。类型检查还可能触发 FactoryBean 的工厂实例化,因此这项优化不能推广成“候选筛选绝无实例化副作用”;第 06 篇单独区分工厂与产品。
最终结果还要检查空产品与类型适配。过滤通过只说明元数据满足条件,目标创建失败、工厂返回空值或实际结果类型不符,仍可能让注入失败。异常阶段从“没有可用定义”转为“定义对应的对象无法取得”,排查方向随之改变。
Provider 改变取得时点,也保留歧义规则
ObjectProvider<T> 适合把目标取得推迟到显式方法调用,但其 API 对失败有不同契约。getObject() 要求能得到对象;getIfAvailable() 允许缺失,不会自动容忍无法解决的多候选;getIfUnique() 可以在没有唯一可选结果时返回空。这里的“唯一”包含 Primary 等选择规则确定的唯一结果,不等同于原始候选数只能为一。
上游 BeanFactoryGenericsTests.genericMatchingWithFullTypeDifferentiation() 对三种方法分别断言。未区分泛型时,provider 调用前两种方法会因歧义失败,调用 getIfUnique() 返回空。携带完整泛型时,provider 得到对应实例,流式遍历则取得所有适用对象。这是已阅读的上游回归测试,本地实验没有运行整个 Spring 上游测试套件。上游泛型测试
Provider 也没有自行定义新 scope。对 singleton 重复取得通常得到同一容器对象,对 prototype 重复取得可以触发新建。若目标本身是普通 eager singleton,容器预实例化阶段仍会创建它;仅把某一个消费者改成 provider,不会取消目标在其他路径上的创建需求。
模式提炼:把可选性与延迟性分开
取得时点 × 缺失策略 × 唯一性策略 × scope 是四个独立维度。需要延迟取得,不等于允许依赖缺失;允许缺失,也不等于容忍配置歧义。插件可选安装、按请求取得对象与按渠道选择实现,都可以用这组维度检查 API 是否准确表达需求。
运行四组隔离场景
实验工程下载和 JDK 21 环境准备见第 00 篇。执行前通过 java -version 确认当前终端使用 JDK 21;Maven 与应用运行环境应保持一致。
从仓库根目录执行:
1 | |
实验先后创建零候选、单候选、多候选三个上下文,再运行含 Primary、Qualifier、集合、泛型与 lazy provider 的配置。失败上下文使用断言确认异常,随后关闭;异常日志是预期反例,不表示整个实验失败。
本地运行使用 Spring 6.2.11、Java 21.0.11,保存于 examples/spring-framework-lab/evidence/05/local-20261002/run.txt。输出包含:
1 | |
这些断言检查对象身份、集合内容和构造计数。Expensive.created 在 provider 注入后仍为零;两次 getObject() 后为一,并且返回同一个对象。目标同时声明 lazy,所以计数能够区分取得 provider 与取得目标。它没有测量容器启动性能,也没有证明 provider 可以解决任意循环依赖。
练习与答案
反例题:把两个 Channel 都标记为同一个 @Qualifier("offline"),消费者声明该 qualifier 的单值参数,且没有其他选择规则,能否保证成功?答案是不能。限定只将 C 缩小到两个对象,单值选择仍可能歧义。若消费者改为同限定集合,两个对象都可成为元素。
改动题:在 Chapter05.Config.web() 上移除 @Primary,保留参数名 channel,且 Bean 仍名为 web、store,重新执行原命令。答案是单值 Channel 不能通过名称确定候选,配置创建会失败;集合本身有两个合法元素,但包含该单值参数的 selection 工厂方法无法完成。修复应由业务意图决定:需要固定渠道就添加明确 qualifier,需要全部渠道就改变消费方接口,不能随意标一个 Primary 只为消除报错。
模式速查表
继续阅读06:构造器、工厂与延迟创建。
| 现象 | 对应模式 | 优先核对 |
|---|---|---|
| 注册了 Bean 仍找不到依赖 | 先约束集合,再选择结果 | 类型、泛型、限定和候选资格 |
| 多个实现导致启动失败 | 先约束集合,再选择结果 | 结果基数、Primary 与名称是否能唯一确定 |
| 集合出现多个实现 | 先约束集合,再选择结果 | 是否本来就声明了多值需求 |
| 可选取得仍报歧义 | 把可选性与延迟性分开 | getIfAvailable 与 getIfUnique 的不同 |
| provider 注入后目标已创建 | 把可选性与延迟性分开 | eager 预实例化和其他直接依赖 |
