Cabloy/Vona 如何避开 NestJS 的循环依赖问题:懒属性注入机制源码解析

0 阅读8分钟

循环依赖不是“能不能解析”的问题,而是“在什么时机解析”的问题。

NestJS 的循环依赖是一个高频讨论话题:两个 Service 在构造函数中互相注入对方,实例化瞬间陷入“先有鸡还是先有蛋”的困境,只能靠 forwardRef() 打破解析顺序。Cabloy/Vona 从架构层面就不存在这个问题——不是因为它做了更聪明的循环检测,而是因为它把依赖解析的时机推迟到了首次访问时。

本文以 Cabloy/Vona 的 BeanContainer 和 @Use 装饰器源码为线索,拆解懒属性注入机制的完整实现。


先看结论

维度NestJS 构造函数注入Vona 懒属性注入
注入时机实例化瞬间同步完成首次访问属性时延迟完成
循环依赖需要 forwardRef() + 延迟循环解析天然安全,无需特殊处理
构造函数参数依赖通过构造函数参数传入构造函数零依赖,只挂 getter
解析入口容器在 resolve() 时递归解析getter 触发时从容器缓存查找
缓存策略实例化后存入容器先入容器缓存,再执行 __init__

一句话:NestJS 是“构造时注入、急切解析”,循环会卡在实例化;Vona 是“声明时登记、访问时解析”,实例化阶段互不触碰,循环依赖被时机差异天然消解。


NestJS 循环依赖的根源

先看 NestJS 的问题出在哪里。

构造函数注入的时机困境

NestJS 的依赖注入发生在构造函数阶段。当容器解析 A 时:

@Injectable()
class ServiceA {
  constructor(private readonly b: ServiceB) {}  // 构造函数参数 = 依赖
}

容器需要先创建 B 才能创建 A。但如果 B 的构造函数又依赖 A:

@Injectable()
class ServiceB {
  constructor(private readonly a: ServiceA) {}
}

解析链条变成:new A() → 需要 B → new B() → 需要 A → A 还没建完 → 死循环。

forwardRef 只是变通,不是消除

NestJS 用 forwardRef() 和 InjectionToken 延迟解析顺序,让一边先完成实例化,再回头注入另一边。但这只是变通手段:

  • 需要开发者手动识别循环并标注 forwardRef;
  • 循环链条过长时仍可能遇到实例未就绪的问题;
  • 增加了代码的隐式复杂度。

Vona 的设计选择了不同的路径:从架构上就不让循环依赖有可乘之机。


Vona 的三层防线

Vona 从架构上不存在“实例化期”循环依赖问题,核心区别是把依赖解析从实例化时推迟到了首次访问时。由三层机制保证:

1. 没有构造函数注入

Vona 的 Service/Bean 一律继承 BeanBase,构造函数不接收任何依赖。实例创建完成后容器才补上 app / ctx 引用,依赖改用 @Use(...) 装饰器声明在属性上。

2. 懒属性注入(关键机制)

@Use 声明的依赖并不是在 Bean 创建时就注入实例,而是用 Object.defineProperty 挂一个 getter。首次访问属性时,getter 才从容器解析目标 Bean 并缓存结果。

3. 容器本身是运行时查找

BeanContainer.create 返回的是懒 Proxy,this.bean.xxxService、this.scope('module-x') 都是访问时才按 bean 全名从容器解析,不产生任何静态接线。


源码拆解:五个环节的完整实现

下面按"声明 → 存储 → 挂载 → 解析 → 缓存"五个环节拆解源码。

环节 1:声明期——@Use 装饰器只做两件事

// use.ts
export function Use(options?: IDecoratorUseOptions | string): PropertyDecorator & MethodDecorator {
  return function (target: object, prop: MetadataKey, descriptor?: PropertyDescriptor) {
    // ① 从 TS design:type 元数据读出属性类型
    const beanClass = appMetadata.getDesignType(target, prop) as Constructable;

    // ② 类 → bean 全名映射
    let beanFullName: string | undefined = options.beanFullName;
    if (!beanFullName) {
      beanFullName = appResource.getBeanFullName(beanClass);
    }

    // ③ 登记元数据,到此为止,没有任何实例化
    appResource.addUse(target, { ...options, prop, beanFullName, descriptor });
  };
}
  • 从 TypeScript emitDecoratorMetadata 生成的 design:type 元数据里读出属性类型,所以 @Use() 不传参也能知道目标 Bean;
  • 类 → bean 全名(module-name.scene.name)的映射必须存在,否则直接抛错;
  • 调用 appResource.addUse 登记元数据,没有任何实例化发生。

环节 2:存储期——以属性名为键的元数据 Map,沿原型链继承

// resource.ts
addUse(target: object, options: IDecoratorUseOptionsBase) {
  registerMappedClassMetadataKey(target, SymbolDecoratorUse);
  const uses = appMetadata.getOwnMetadataMap(true, SymbolDecoratorUse, target);
  uses[options.prop] = options;
}

getUses(target: object): Record<MetadataKey, IDecoratorUseOptionsBase> | undefined {
  return appMetadata.getMetadata(SymbolDecoratorUse, target);  // 注意是 getMetadata
}

写入用 getOwnMetadataMap(挂在类 prototype 上、以 prop 为键),读取用 getMetadata(沿原型链向上查)——这意味着子类自动继承父类声明的所有 @Use 依赖。

环节 3:挂载期——实例化后只装 getter,不解析

Bean 实例创建流程中的严格顺序是:

// beanContainer.ts
private _createBeanInstance(...) {
  const beanInstance = this._prepareBeanInstance(...);          // new + 挂 app/ctx
  this[SymbolBeanContainerInstances][key] = beanInstance;       // ① 先入容器缓存
  this._initBeanInstance(beanFullName, beanInstance, args);     // ② 注入 getter → __init__
}

这个顺序保证了:即使某个 Bean 在 __init__ 里就访问依赖属性,触发了反向解析,容器也能直接命中缓存返回已登记的实例。

注入本身的核心实现:

// beanContainer.ts
private _injectBeanInstancePropLazy(beanInstance, prop, targetBeanFullName, useOptions) {
  const self = this;
  Object.defineProperty(beanInstance, prop, {
    enumerable: true,        // 属性可枚举(序列化/展开时可见)
    configurable: true,      // HMR 时可重定义
    get() {
      if (!beanInstance[SymbolBeanInstancePropsLazy])
        beanInstance[SymbolBeanInstancePropsLazy] = {};
      if (!beanInstance[SymbolBeanInstancePropsLazy][prop]) {
        // 首次访问:真正解析,然后缓存到实例私有槽位
        beanInstance[SymbolBeanInstancePropsLazy][prop] =
          self._injectBeanInstanceProp(beanInstance, targetBeanFullName, useOptions);
        // app 级容器额外向 HMR 登记这条依赖边
        self.containerType === 'app' &&
          self.app.meta.hmr.addBeanInstanceProp(beanInstance, prop, targetBeanFullName);
      }
      return beanInstance[SymbolBeanInstancePropsLazy][prop];
    },
  });
}

三个实现要点:

  1. getter 闭包捕获的是安装它的容器(self)——app 容器装的属性永远走 app 解析,ctx(请求级)容器装的走 ctx 解析;
  2. 缓存槽是实例级普通对象 SymbolBeanInstancePropsLazy,第一次访问后属性退化成"伪静态",后续零查找成本;
  3. enumerable: true / configurable: true 的组合是刻意为之:可枚举保证实例导出行为一致,可配置让 HMR 拆掉重装。

环节 4:解析期——三种注入域

首次访问触发 _injectBeanInstanceProp:

// beanContainer.ts
private _injectBeanInstanceProp(
  beanInstance,
  targetBeanFullName: string,
  useOptions: IDecoratorUseOptionsBase,
) {
  const injectionScope = useOptions.injectionScope ?? 'app';

  if (injectionScope === 'app') {
    targetInstance = this.app.bean._getBeanSelectorInner(...);  // 应用级单例
  } else if (injectionScope === 'ctx') {
    targetInstance = this._getBeanSelectorInner(...);           // 请求级单例
  } else if (injectionScope === 'new') {
    targetInstance = this._newBeanInner(...);                   // 每次新实例
  }
  return targetInstance;
}
  • 'app' / 'ctx' 落到 _getBeanSelectorInner:容器实例缓存键为 beanFullName 或 beanFullName#selector,=== undefined 才创建;
  • 'new' 虽然每次解析都 new,但外层 getter 已缓存,所以单个属性生命周期内仍只解析一次;
  • selector 参数从三个来源按优先级取:装饰属性的 descriptor getter → useOptions.init 静态参数 → useOptions.selector 字符串表达式(求值上下文含 self/app/ctx)。

环节 5:缓存期——容器实例缓存

_getBeanSelectorInner 的缓存逻辑:

// beanContainer.ts
_getBeanSelectorInner<T>(
  beanFullName: Constructable<T> | string,
  withSelector?: boolean,
  ...args
): T {
  const beanOptions = appResource.getBean(beanFullName as any);
  const fullName = beanOptions.beanFullName;
  const key = __getSelectorKey(fullName, withSelector, args[0]);
  if (this[SymbolBeanContainerInstances][key] === undefined) {
    this._newBeanInner(true, fullName, withSelector, ...args);
  }
  return this[SymbolBeanContainerInstances][key] as T;
}

缓存键的生成规则:

function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) {
  if (!withSelector) return beanFullName;
  const isSelectorValid = !isNilOrEmptyString(selector);
  return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`;
}

A↔B 循环依赖的完整时序

以 A、B 两个 Service 互相 @Use 对方为例,走一遍完整时序:

时刻动作结果
装饰期@Use 登记 A→B、B→A 两条映射无实例交互
业务触发 _getBean('A')new A(),挂懒 getter,入容器缓存A 完整存在
首次访问 a.serviceBgetter 执行 → 容器创建 B(B 的构造和 getter 安装同样不碰 A),入缓存B 完整存在
B 内部访问 b.serviceAgetter 执行 → 容器缓存命中 → 返回现成的 A环闭合,无重入

两个不变量保证环安全:

  1. 实例化阶段 getter 从不执行(互不触碰);
  2. 解析阶段容器缓存里被依赖方必然已登记("先入缓存再 init"的顺序,甚至覆盖了 __init__ 期间就访问依赖的激进场景)。

实际代码示例

Vona 的 @Use 用法

import { BeanBase, Use } from 'zova';
import { Service } from 'zova-module-a-bean';

@Service()
export class BeanRouter extends BeanBase {
  @Use()
  $$sysRouter: SysRouter;

  @Use()
  $$modelPageData: ModelPageData;
}

注意:构造函数零参数,依赖全部通过 @Use 声明在属性上。访问 this.$$sysRouter 时才触发解析。

Vona 后端业务代码的等价路径

当前仓库后端业务代码更常见的写法其实不是属性级 @Use,而是 this.scope('module-x').service.yyy 和 this.bean.xxx——它们走的是同一容器的运行时查找路径:

// BeanContainer.create 返回的 Proxy
static create(app: VonaApplication, ctx: VonaContext | undefined) {
  const beanContainer = new BeanContainer(app, ctx);
  return new Proxy(beanContainer, {
    get(obj, prop) {
      if (typeof prop === 'symbol') return obj[prop];
      if (obj[prop]) return obj[prop];
      return obj._getBean(prop as any);  // 访问时才解析
    },
  });
}

本质上是同一套懒解析的"方法调用版"。两条路径的差异只在声明方式(装饰器元数据 vs 字符串查找),解析与缓存机制完全共用。


失效边界与 HMR

懒属性注入并非没有需要关注的边界:

_removeBean 只会销毁容器缓存里的实例并调用其 __dispose__,其他 Bean 实例上 SymbolBeanInstancePropsLazy 里已缓存的那份引用不会被自动清空——这正是 HMR 需要 addBeanInstanceProp 登记依赖边来重接线的原因。

所以在热更新场景下,框架能通过登记的依赖边找到所有引用了被替换 Bean 的属性,拆掉旧 getter、重装新 getter、清空缓存槽,完成依赖重接线。


模块层面的循环依赖

除了实例层面的循环依赖,Vona 在模块层面也做了设计。按文档约定:

使用 this.$scope.<fixedModule>、app.scope(...) 或 this.app.scope(...) 本身不要求将目标模块添加到调用者的 vonaModule.dependencies 中。查找不创建模块依赖边,因此单独的查找不能创建循环依赖。

这意味着模块层面的循环依赖也被设计性地避免了:跨模块 lookup 不产生依赖声明,不构成模块依赖边,所以模块层面也不会因"用了对方的服务"而形成循环依赖。


总结

NestJS 的循环依赖问题根源在于"构造函数注入 + 急切解析"的设计选择:依赖必须在实例化瞬间完成解析,循环引用必然导致死锁,只能靠 forwardRef 延迟解析顺序。

Vona 的懒属性注入机制通过三个设计决策从架构层面消解了这个问题:

  1. 构造函数零依赖——实例化阶段不触碰任何依赖;
  2. 懒属性 getter——依赖解析推迟到首次访问时;
  3. 先入缓存再 init——保证被依赖方在解析时必然已就绪。

循环依赖在 Vona 中不需要 forwardRef、不需要特殊标注、不需要开发者识别循环——它从时机差异上天然安全。这不是更聪明的循环检测,而是让循环依赖根本没有可乘之机的架构选择。


参考资料与源码出处

NestJS

CabloyJS / Vona