依赖注入容器里,「同一个组件」由什么决定
一个报表服务需要两个数据源:原始数据和按天汇总的数据,两者实现同一个接口,用限定符区分。容器启动时报「循环依赖」,可代码里明明没有环。另一个项目把检查改成「访问过就报错」之后,凡是两个组件共用同一个底层依赖,都被报成了环。
依赖注入容器做三件事:登记绑定、在配置阶段检查依赖关系、在运行时创建对象。三件事都要回答同一个问题:两个依赖指的是不是同一个组件。这个「身份」如果在三处用的不是同一种定义,就会出现上面这类问题:登记时互相覆盖,检查时误报或漏报,运行时取错对象。
本文用一个约 150 行的最小容器,把身份从「只看 Class」逐步改成「类型加限定符」,观察注册、图检查和解析各自的变化。实验见文末。这也是一个关于建模的例子:一个概念的身份一旦确定,就要贯穿使用它的所有地方。
一、身份只看 Class:两个绑定变成一个
最直观的实现是用 Map<Class<?>, Provider> 登记绑定。报表的两个数据源都是 Source:
interface Source { String read(); }
class RawSource implements Source { ... }
class DailySource implements Source {
@Inject DailySource(@Named("raw") Source raw) { ... } // 汇总依赖原始数据
}注册 Source@raw 与 Source@daily 后,容器里有 1 个绑定:[DailySource]
检查 Source@daily:循环依赖:Source → Source
解析 Source@raw:构造过程中重入:Source第二次登记覆盖了第一次。之后的检查和解析都在一个错误的模型上进行:DailySource 依赖 Source,而 Source 就是它自己,于是检查报环,解析时无限重入。错误信息说的是「循环依赖」,真正的问题却在登记阶段就已经发生了。
二、身份是「类型 + 限定符」
把绑定键改成一个值对象:
record Key(Class<?> type, Annotation qualifier) {}jakarta.inject 的限定符是注解,注解实例按成员值比较相等,两处独立写的 @Named("raw") 是相等的键:
两个独立创建的 @Named("raw") 注解实例 equals=true
注册 Source@raw 与 Source@daily 后,容器里有 2 个绑定:[RawSource, DailySource]
检查 Source@daily:通过
解析 Source@raw:得到 raw真正的环仍然能被发现,而且路径里带着限定符,一眼能看出是哪两个绑定:
Source@a 依赖 Source@b、Source@b 又依赖 Source@a:循环依赖:Source@a → Source@b → Source@a三、只改一半:绑定用新身份,检查路径还用旧身份
容器演进时,新身份往往不是一次改完的。绑定表改成了 Key,但图检查里的访问路径还记着 Class:
绑定按完整键、检查路径只记 Class,检查 Source@daily:循环依赖:Source → Source绑定是对的,可检查算法把 Source@daily → Source@raw 当成了「Source 回到了 Source」。这类错误很难从单个方法里看出来:每段代码单独看都没错,只是用了两种身份定义。
一个身份概念在容器里出现的地方通常有:
| 位置 | 用身份做什么 |
|---|---|
| 绑定表 | 登记与查找 |
| 依赖元数据 | 描述每个注入点要什么 |
| 图检查的路径 | 判断是否回到了自己 |
| 运行时的重入保护 | 判断构造是否绕回来了 |
| 错误信息 | 告诉使用者是哪个绑定出了问题 |
身份从 Class 扩展为 Key 时,这五处要一起改,测试也要一起补:同类型不同限定符的无环链、同类型不同限定符组成的真环、限定绑定缺失时的错误路径。
四、检查环:路径栈,不是已访问集合
另一个常见错误是把「访问过」当成「有环」。菱形依赖里,Top 依赖 Left 和 Right,二者都依赖 Base:
菱形依赖,按当前路径判断:通过
菱形依赖,只看是否访问过:报告循环依赖(在 Base 处)环的定义是「沿当前这条依赖路径又回到了路径上的某个节点」,所以要维护的是当前递归路径(进入时压栈,返回时出栈)。已访问集合可以用来避免重复遍历共享的子图,但不能用来判断环。两者的职责不同,把它们合成一个集合是错误的根源。
缺失依赖的错误信息同样应该带上路径,而不只是缺了什么:
缺少依赖:NeedsMissing → Source@absent五、延迟依赖:图检查放过的,运行时不一定放过
注入 Provider<T> 而不是 T,表示「需要时再取」。配置期检查把这种边当作延迟边:只检查目标是否登记,不沿着它找环。这样 Clock 持有 Provider<Ticker>、Ticker 依赖 Clock 的结构可以通过检查,运行时也确实能工作:
Clock 只持有 Provider<Ticker>,Ticker 依赖 Clock,配置期检查:通过
解析 Clock:通过;之后调用 clock.ticker.get():通过但如果 Clock 在构造器里立刻调用 get(),延迟就变成了立即:
同样结构但构造器里立刻 get(),配置期检查:通过
解析 EagerClock:构造过程中重入:EagerClock配置期检查看的是依赖的声明,看不到构造器里的行为。所以延迟边能打破的是「声明上的环」,不能保证运行时没有环;运行时的重入保护仍然需要,错误信息也要说清楚是在构造过程中绕回来的。容器的文档应该写明这个边界,而不是说「用 Provider 就解决了循环依赖」。
运行时的重入保护在单线程里是对的;如果多个线程同时构造同一个组件,用一个共享的「正在构造」集合会把并发误判为重入,这个问题和单例缓存一起放在单例在谁的范围里唯一里讨论。
六、类型引用的相等性
要表达 Provider<List<String>> 这类带泛型的依赖,常用匿名子类捕获类型参数:
abstract class TypeRef<T> {
final Type type = ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0];
}
TypeRef<List<String>> r1 = new TypeRef<>() {}, r2 = new TypeRef<>() {};如果 equals 按 getClass() 比较:
两处写的 new TypeRef<List<String>>(){}:按 getClass() 比较相等=false,按捕获的 Type 比较相等=true每处 new TypeRef<>() {} 都是一个新的匿名类,按类比较永远不相等,拿它做 Map 的键就查不到绑定。IDE 生成的 equals 默认用 getClass(),对普通值对象通常合适,对这种「用子类携带信息」的对象却不对。值对象的相等性要按它表达的含义来定义:这里是捕获到的 Type,加上限定符。
七、演进的顺序
从「只按 Class」到「类型 + 限定符」,一个可行的顺序是:
- 先引入值对象
Key,绑定表、依赖元数据、检查路径、重入保护、错误信息全部改用它,限定符暂时都为空,行为不变。这一步是保持行为的重构,现有测试应该全部通过。 - 再让注入点读取限定符注解,填进
Key。新增测试覆盖同类型不同限定符的绑定、无环链与真环。 - 最后再支持泛型引用,补上类型引用的相等性测试,并明确拒绝不支持的形态(嵌套泛型、通配符、类型变量),而不是在强制类型转换时崩溃。
每一步的测试都在回答同一个问题:所有使用身份的地方,用的是不是同一个定义。
八、检查清单
- 绑定身份用一个值对象表达,注册、元数据、图检查、重入保护、错误信息都用它。
- 环检查维护当前路径栈;已访问集合只用于去重,不用于判断环。
- 延迟边只能打破声明上的环,构造器里立即取值的环要靠运行时保护发现。
- 用匿名子类携带类型信息的引用,按捕获的类型比较相等,不按
getClass()。 - 错误信息带上完整的依赖路径和限定符。
配套实验
- codesphere-labs/design/di-container-identity:JDK 25.0.4.1、jakarta.inject-api 2.0.1:只按 Class 与按类型加限定符的注册、检查与解析,只改一半的身份,菱形与真环,延迟边,类型引用相等性(验证记录)
参考资料