Skip to content

依赖注入容器里,「同一个组件」由什么决定 ​

一个报表服务需要两个数据源:原始数据和按天汇总的数据,两者实现同一个接口,用限定符区分。容器启动时报「循环依赖」,可代码里明明没有环。另一个项目把检查改成「访问过就报错」之后,凡是两个组件共用同一个底层依赖,都被报成了环。

依赖注入容器做三件事:登记绑定、在配置阶段检查依赖关系、在运行时创建对象。三件事都要回答同一个问题:两个依赖指的是不是同一个组件。这个「身份」如果在三处用的不是同一种定义,就会出现上面这类问题:登记时互相覆盖,检查时误报或漏报,运行时取错对象。

本文用一个约 150 行的最小容器,把身份从「只看 Class」逐步改成「类型加限定符」,观察注册、图检查和解析各自的变化。实验见文末。这也是一个关于建模的例子:一个概念的身份一旦确定,就要贯穿使用它的所有地方。

同一个身份 Key(type, qualifier)绑定表:登记与查找依赖元数据:注入点要什么检查路径:是否回到自己重入保护:构造是否绕回错误信息:哪个绑定出错身份用错时只按 Class 登记 Source@raw、Source@daily只剩 1 个绑定,检查报「Source → Source」绑定按 Key,检查路径只记 Class仍报「Source → Source」:两处用了两种身份菱形 Top→Left/Right→Base,用已访问集合判断在 Base 处误报环;应维护当前路径栈Provider 延迟边:配置检查通过构造器里立刻 get():运行期重入演进顺序:先引入 Key 并让五处都用它(限定符为空,行为不变),再读取限定符,最后支持泛型引用;每一步的测试都在问同一件事:所有使用身份的地方,用的是不是同一个定义匿名子类捕获的类型引用按 getClass() 比较永远不相等:值对象按它表达的含义比较
图 1 · 实测(JDK 25.0.4.1):身份从 Class 扩展为「类型 + 限定符」时,绑定表、依赖元数据、检查路径、重入保护和错误信息要一起改。只按 Class 时两个绑定变成一个并报假环;绑定改了而检查路径没改,仍报假环;用已访问集合判断环会把菱形依赖误报为环;延迟边能通过配置检查,构造器里立刻 get() 仍会在运行期重入。

一、身份只看 Class:两个绑定变成一个 ​

最直观的实现是用 Map<Class<?>, Provider> 登记绑定。报表的两个数据源都是 Source:

java
interface Source { String read(); }
class RawSource implements Source { ... }
class DailySource implements Source {
    @Inject DailySource(@Named("raw") Source raw) { ... }   // 汇总依赖原始数据
}
text
注册 Source@raw 与 Source@daily 后,容器里有 1 个绑定:[DailySource]
检查 Source@daily:循环依赖:Source → Source
解析 Source@raw:构造过程中重入:Source

第二次登记覆盖了第一次。之后的检查和解析都在一个错误的模型上进行:DailySource 依赖 Source,而 Source 就是它自己,于是检查报环,解析时无限重入。错误信息说的是「循环依赖」,真正的问题却在登记阶段就已经发生了。

二、身份是「类型 + 限定符」 ​

把绑定键改成一个值对象:

java
record Key(Class<?> type, Annotation qualifier) {}

jakarta.inject 的限定符是注解,注解实例按成员值比较相等,两处独立写的 @Named("raw") 是相等的键:

text
两个独立创建的 @Named("raw") 注解实例 equals=true
注册 Source@raw 与 Source@daily 后,容器里有 2 个绑定:[RawSource, DailySource]
检查 Source@daily:通过
解析 Source@raw:得到 raw

真正的环仍然能被发现,而且路径里带着限定符,一眼能看出是哪两个绑定:

text
Source@a 依赖 Source@b、Source@b 又依赖 Source@a:循环依赖:Source@a → Source@b → Source@a

三、只改一半:绑定用新身份,检查路径还用旧身份 ​

容器演进时,新身份往往不是一次改完的。绑定表改成了 Key,但图检查里的访问路径还记着 Class:

text
绑定按完整键、检查路径只记 Class,检查 Source@daily:循环依赖:Source → Source

绑定是对的,可检查算法把 Source@daily → Source@raw 当成了「Source 回到了 Source」。这类错误很难从单个方法里看出来:每段代码单独看都没错,只是用了两种身份定义。

一个身份概念在容器里出现的地方通常有:

位置用身份做什么
绑定表登记与查找
依赖元数据描述每个注入点要什么
图检查的路径判断是否回到了自己
运行时的重入保护判断构造是否绕回来了
错误信息告诉使用者是哪个绑定出了问题

身份从 Class 扩展为 Key 时,这五处要一起改,测试也要一起补:同类型不同限定符的无环链、同类型不同限定符组成的真环、限定绑定缺失时的错误路径。

四、检查环:路径栈,不是已访问集合 ​

另一个常见错误是把「访问过」当成「有环」。菱形依赖里,Top 依赖 Left 和 Right,二者都依赖 Base:

text
菱形依赖,按当前路径判断:通过
菱形依赖,只看是否访问过:报告循环依赖(在 Base 处)

环的定义是「沿当前这条依赖路径又回到了路径上的某个节点」,所以要维护的是当前递归路径(进入时压栈,返回时出栈)。已访问集合可以用来避免重复遍历共享的子图,但不能用来判断环。两者的职责不同,把它们合成一个集合是错误的根源。

缺失依赖的错误信息同样应该带上路径,而不只是缺了什么:

text
缺少依赖:NeedsMissing → Source@absent

五、延迟依赖:图检查放过的,运行时不一定放过 ​

注入 Provider<T> 而不是 T,表示「需要时再取」。配置期检查把这种边当作延迟边:只检查目标是否登记,不沿着它找环。这样 Clock 持有 Provider<Ticker>、Ticker 依赖 Clock 的结构可以通过检查,运行时也确实能工作:

text
Clock 只持有 Provider<Ticker>,Ticker 依赖 Clock,配置期检查:通过
解析 Clock:通过;之后调用 clock.ticker.get():通过

但如果 Clock 在构造器里立刻调用 get(),延迟就变成了立即:

text
同样结构但构造器里立刻 get(),配置期检查:通过
解析 EagerClock:构造过程中重入:EagerClock

配置期检查看的是依赖的声明,看不到构造器里的行为。所以延迟边能打破的是「声明上的环」,不能保证运行时没有环;运行时的重入保护仍然需要,错误信息也要说清楚是在构造过程中绕回来的。容器的文档应该写明这个边界,而不是说「用 Provider 就解决了循环依赖」。

运行时的重入保护在单线程里是对的;如果多个线程同时构造同一个组件,用一个共享的「正在构造」集合会把并发误判为重入,这个问题和单例缓存一起放在单例在谁的范围里唯一里讨论。

六、类型引用的相等性 ​

要表达 Provider<List<String>> 这类带泛型的依赖,常用匿名子类捕获类型参数:

java
abstract class TypeRef<T> {
    final Type type = ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0];
}
TypeRef<List<String>> r1 = new TypeRef<>() {}, r2 = new TypeRef<>() {};

如果 equals 按 getClass() 比较:

text
两处写的 new TypeRef<List<String>>(){}:按 getClass() 比较相等=false,按捕获的 Type 比较相等=true

每处 new TypeRef<>() {} 都是一个新的匿名类,按类比较永远不相等,拿它做 Map 的键就查不到绑定。IDE 生成的 equals 默认用 getClass(),对普通值对象通常合适,对这种「用子类携带信息」的对象却不对。值对象的相等性要按它表达的含义来定义:这里是捕获到的 Type,加上限定符。

七、演进的顺序 ​

从「只按 Class」到「类型 + 限定符」,一个可行的顺序是:

  1. 先引入值对象 Key,绑定表、依赖元数据、检查路径、重入保护、错误信息全部改用它,限定符暂时都为空,行为不变。这一步是保持行为的重构,现有测试应该全部通过。
  2. 再让注入点读取限定符注解,填进 Key。新增测试覆盖同类型不同限定符的绑定、无环链与真环。
  3. 最后再支持泛型引用,补上类型引用的相等性测试,并明确拒绝不支持的形态(嵌套泛型、通配符、类型变量),而不是在强制类型转换时崩溃。

每一步的测试都在回答同一个问题:所有使用身份的地方,用的是不是同一个定义。

八、检查清单 ​

  • 绑定身份用一个值对象表达,注册、元数据、图检查、重入保护、错误信息都用它。
  • 环检查维护当前路径栈;已访问集合只用于去重,不用于判断环。
  • 延迟边只能打破声明上的环,构造器里立即取值的环要靠运行时保护发现。
  • 用匿名子类携带类型信息的引用,按捕获的类型比较相等,不按 getClass()。
  • 错误信息带上完整的依赖路径和限定符。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权