Cabloy/Vona 为什么不需要 NestJS 的 Dynamic Module

NestJS 在装配前"造出"模块定义;Vona 根本没有一份需要被造出来的模块定义。

用过 NestJS 的人对 forRoot() 不会陌生。接入数据库、队列、配置模块时,几乎每个第三方库都会提供一个静态方法,让你把 options 传进去:

ts 复制代码
@Module({
  imports: [TypeOrmModule.forRoot({ type: 'mysql', host: 'localhost' })],
})
export class AppModule {}

第一次接触时,这像是"模块的高级写法";写得多了才发现,它其实是一个补丁------补在"模块定义必须提前确定"这个前提上。

上手 Cabloy/Vona 时,很多人会找这个补丁的对应物:组件、配置、脚手架都齐全,唯独没有"动态模块"。原因不是还没做,而是它所要解决的问题,在 Vona 里并不存在。


一、Dynamic Module 到底在做什么

先把它还原成最小形式。一个动态模块通常会写成这样:

ts 复制代码
@Module({})
export class PayModule {
  static forRoot(options: PayModuleOptions): DynamicModule {
    return {
      module: PayModule,
      providers: [
        { provide: PAY_OPTIONS, useValue: options },
        PayService,
      ],
      exports: [PayService],
    };
  }

  static forRootAsync(options: PayModuleAsyncOptions): DynamicModule {
    return {
      module: PayModule,
      imports: options.imports ?? [],
      providers: [
        {
          provide: PAY_OPTIONS,
          useFactory: options.useFactory,
          inject: options.inject ?? [],
        },
        PayService,
      ],
      exports: [PayService],
    };
  }
}

三个特征很关键:

  1. 模块类本身是空壳,@Module({}) 里没有任何 provider;
  2. 真正的 providers / exports / imports 由静态方法返回;
  3. 返回值仍然是一份模块定义,只是推迟到调用时才计算。

换句话说,Dynamic Module 是一个"模块定义的延迟构造函数"。它不改变模块的语义,只改变模块定义被计算出来的时机。


二、NestJS 为什么必须有这一层

因为在 NestJS 里,模块不是"一组代码的目录",而是容器的编译输入 。imports / providers / exports 必须在 NestFactory.create() 之前全部确定,容器才能建立依赖图、完成实例化与可见性检查。

但下面这些信息,在 @Module 装饰器求值时拿不到:

诉求 典型场景 为什么静态装饰器表达不了
调用方传参 数据库地址、支付密钥、租户配置 options 由使用方决定,库模块自己不知道
异步拿配置 从远程配置中心、密钥管理服务读取 装饰器求值是同步的,异步结果无法写回
同模块多实例 多个数据库连接、多个消息队列 一个类只能有一份模块元数据
条件生成 provider 按环境决定实现集合 provider 数组要在拿到条件后才能确定

这四类诉求看起来不同,根因是同一个:模块定义必须在编译前确定,而决定它的信息在编译前不完整。

于是只能把"模块定义"从类上摘下来,变成一个函数,让 forRoot / forRootAsync / register 在 bootstrap 阶段把这份定义补齐。forRootAsync 更进一步,把"将来会 ready 的定义"包装成异步 provider。

理解了这一点,就能理解后面的判断标准:只要模块不再是编译输入,Dynamic Module 就失去了存在的地基。


三、Vona 的模块是什么

Vona 的模块由三部分组成:目录与包结构、package.json 里的 vonaModule 元数据、以及配置/locale/资源所处的命名空间。

json 复制代码
{
  "vonaModule": {
    "fileVersion": 1,
    "dependencies": {
      "a-core": "5.0.0",
      "a-orm": "5.0.0",
      "a-user": "5.0.0"
    },
    "onions": {
      "payProvider": { "scopeResource": true }
    }
  }
}

它没有 providers,没有 exports,也没有 imports。模块不定义"容器里有什么",更不定义"谁可以看见谁"。

Bean 的注册发生在装饰器执行的那一刻。createBeanDecorator 把类直接写进全局 AppResource,键是 {module}.{scene}.{name}:

ts 复制代码
// 节选
export function createBeanDecorator<T>(scene, options?, optionsPrimitive?, fn?) {
  return function (target) {
    const beanClass = target as unknown as Constructable;
    const module = parseModuleName(beanClass);
    const name = scene === 'scope' ? 'module' : undefined;
    const beanOptions = appResource.addBean({ module, scene, name, beanClass, options });
    fn?.(beanClass);
  };
}

容器是扁平的 app / ctx 两级,取 Bean 就是按名字查表,取不到才实例化:

ts 复制代码
// 节选
_getBeanSelectorInner<T>(beanFullName: Constructable<T> | string, ...) {
  const beanOptions = appResource.getBean(beanFullName as any);
  if (!beanOptions) return null!;
  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;
}

模块在这里只提供两样东西:归属 (moduleBelong,决定 this.scope、配置路径、日志标签)和装载顺序 (vonaModule.dependencies)。它不提供可见性边界,因此也不存在"某个模块实例化时要暴露哪些 provider"这个问题。

一句话对比:

  • NestJS 的模块 = 编译单元 + DI 边界 + 可见性规则;
  • Vona 的模块 = 代码组织 + 配置/资源命名空间。

前者的模块定义必须被提前构造,后者的"模块定义"从来就不是一个对象。


四、诉求一:传配置------配置是数据,不是构造入参

forRoot(options) 存在的直接原因,是 options 要参与 provider 的构造。Vona 把配置从模块构造里彻底剥离:模块只提供默认配置,应用配置在装载阶段覆盖它。

模块的默认配置放在 src/config/config.ts,导出一个函数:

ts 复制代码
// a-file/src/config/config.ts
export function config(_app: VonaApplication) {
  return {
    file: {
      defaultProvider: 'file-native:native',
      public: false,
      upload: { maxSize: 20 * 1024 * 1024 },
    },
  };
}

装载器在模块装载阶段(moduleLoading 钩子之后)把它合并进 app.config.modules[relativeName],并且让应用配置排在模块默认值之后:

ts 复制代码
app.config.modules[module.info.relativeName] = deepExtend(
  {},
  configModule,
  app.config.modules[module.info.relativeName],
);

应用层的定制写在项目配置里,覆盖的是数据:

ts 复制代码
// src/backend/config/config/config.ts
config.modules['a-file'] = {
  file: {
    defaultProvider: 'file-cloudflare:cloudflare',
  },
};

需要区分环境的模块,会把这件事做得更完整。比如 ORM 模块在自己的 Main.moduleLoading() 里叠加 dev / prod / test 三份默认值,再把应用配置盖在最上层:

ts 复制代码
export class Main extends BeanSimple implements IModuleMain {
  async moduleLoading() {
    const _configDefault = await combineConfigDefault<ConfigDatabase>(
      this.app,
      configDefault,
      configDev,
      configProd,
      configTest,
    );
    this.app.config.database = deepExtend({}, _configDefault, this.app.config.database);
  }

  async moduleLoaded() {
    // 扩展 Knex、挂载 ctx.db 等
  }

  async configLoaded(_config) {}
}

注意 Main 是在模块装载阶段被实例化的,而应用配置在此之前就已经加载完毕。所以"给库模块传配置"在 Vona 里就是一次配置合并:时机固定、方向明确(应用 > 模块默认),不需要重新生成模块定义。


五、诉求二:异步------装载阶段与生命周期已经吸收了它

forRootAsync 的根因是:模块定义必须同步返回,而依赖是异步的。Vona 没有"同步返回模块定义"这一约束,整条启动链本身就是异步的。

启动顺序可以概括为:

text 复制代码
appConfig → appLoad(moduleLoading → 配置/locale/error/constant → moduleLoaded)→ appStart → appReady → appStarted

对应到源码,Start.start() 依次执行 _start_appConfig、_start_appLoad、_start_appStart、_start_appReady、_start_appStarted。其中:

  • 配置项本身可以返回 Promise,多个配置项会被 Promise.all 并行求值;
  • 模块的 Main.moduleLoading()、Main.moduleLoaded() 都是 async,可以在这个阶段读远程配置、扩展第三方库、挂载 ctx 属性;
  • 应用级异步初始化放在 Monkey.appStart()、Monkey.appReady() 里,由框架按模块顺序调用。
ts 复制代码
export class Monkey extends BeanSimple implements IMonkeyAppStart, IMonkeyAppReady {
  async appStart() {
    await this.app.scope(__ThisModule__).service.startup.appStart();
  }

  async appReady() {
    await this.app.scope(__ThisModule__).service.startup.appReady();
  }
}

数据库连接、队列消费者、密钥拉取这类"启动时必须完成"的事情,落在 moduleLoaded 或 appStart 里;它们不参与任何"模块定义"的构造,因此不需要一个"将来会 ready 的模块定义"来包装。

NestJS 的 forRootAsync 是把异步性塞进模块定义;Vona 是把异步性放回启动流程。后者本来就有明确的阶段和顺序,前者则是为了绕开"定义必须同步"。


六、诉求三:多实例------没有模块实例,只有 Bean 名

Dynamic Module 的另一个大用途,是同一模块注册多份:多数据库、多队列、多租户。

这在 NestJS 里必须靠动态模块,因为模块是编译单元,一个类只有一份元数据,要"再来一份"就得生成第二份模块定义。

Vona 里没有"模块实例"这一层。多份实现就是多个 Bean 平级注册,各自有唯一全名:

领域 场景 实现(各自独立模块)
支付 payProvider pay-stripe、pay-paypal、pay-mock
文件 fileProvider file-native、file-cloudflare
认证 authProvider auth-simple、auth-oauth
验证码 captchaProvider captcha-simple

消费方拿到的不是模块实例,而是一个名字,再由名字换算成 Bean 全名:

ts 复制代码
@Bean()
export class BeanPayProvider extends BeanBase {
  get<N extends keyof IPayProviderRecord>(providerName: N) {
    const beanFullName = beanFullNameFromOnionName(providerName, 'payProvider');
    return this.app.bean._getBean(beanFullName as never);
  }
}

连"同一实现的多个客户端"也不需要多份模块,而是配置里的 clients 加一个 clientName:

ts 复制代码
const clientOptions = options.clients?.[clientName];
const resolvedOptions = deepExtend(
  {},
  options.base,
  clientOptions,
  this.ctx?.state.payProviderClientOptions?.[`${providerName}/${clientName}`],
);

于是 register() 这类"再注册一份模块"的 API 无从落脚------没有可被再次实例化的模块对象,只有新的 Bean 名或新的配置键。


七、诉求四:条件 provider 与模块定制

最后一类诉求是"按条件决定容器里有什么"。Vona 用两层机制承接:

  • 注册期:场景装饰器静态注册,所有实现都在注册表里;
  • 解析期 :onion 的 enable / match / ignore 决定某个实现当前是否生效,VonaConfig.onions 与 ctx.onionsDynamic 分别提供应用级和请求级开关。

模块的差异化定制则走 config 与 monkey 钩子:moduleLoading、moduleLoaded、configLoaded、appStart、appReady、appStarted、appClose、appClosed。这些钩子按模块顺序执行,改的是元数据、配置与资源,而不是"返回一份新的模块定义"。

需要强调的一点是:vonaModule.dependencies 只用于装载顺序与最低版本约束,不产生依赖注入的边。这也是为什么模块可以后加、可以先引用后装配,而不必调整装配图。


八、逐项对照

需求 NestJS Vona
调用方给库模块传参 forRoot(options) 覆盖 config.modules[name]
异步获取配置 forRootAsync 配置管线本身就是 async
同模块多实例 多次注册动态模块 多个 Bean 名 + resolver / selector
按条件生成 provider register() 返回 providers 装饰器静态注册 + onion enable/match
模块可见性隔离 imports / exports 无(单容器,按名可查)
定制既有模块 动态模块参数 config 覆盖 + monkey 钩子
依赖顺序 模块 import 图 vonaModule.dependencies(仅顺序/版本)
绑定时机 容器 compile 前 每次 _getBean 时

表格最后一行是总纲。只要绑定发生在解析期,前面那些"必须提前算出来"的东西就都消失了。


九、代价

这种设计不是没有成本,只是成本被挪到了另一侧。

NestJS 换来的是显式的依赖图和可见性边界:provider 集合是静态的,模块导出什么一目了然,编译期与静态分析能给出更强的保证。代价是模块定义必须可提前构造,于是需要 Dynamic Module 这一层间接。

Vona 换来的是零装配顺序约束与模块即插即用:新增实现只是新增 Bean,切换实现只是改配置,模块之间不建立静态依赖边。代价是依赖关系变成了"名字契约"------可见性与装配正确性不靠编译器保证,而要靠命名规范、scope 约束与测试。

如果某天真的需要"相互隔离的多套 DI 图"(例如强隔离的多租户),Vona 的做法也不是"把模块多注册几次",而是用命名空间、独立容器实例与 selector 把事情显式化。这条路仍然不经过"动态模块"这个概念。


十、小结

  1. Dynamic Module 的本质是"模块定义的延迟构造函数",服务于"模块定义必须在编译前确定"这一前提;
  2. Vona 的模块不承担 DI 边界与可见性职责,Bean 由装饰器注册进全局注册表,容器按名解析,因此不存在需要提前构造的模块定义;
  3. 传配置、异步配置、多实例、条件 provider 四类诉求,分别被配置合并、启动阶段、多个 Bean 名、onion 过滤吸收;
  4. 这不是"Vona 也实现了动态模块",而是取消了动态模块赖以存在的前提。

顺带一提,同一个原因也解释了为什么 Vona 的 Provider Scene 能取代 NestJS 的 Dynamic Providers:绑定时机一旦推到解析期,多实现对依赖结构就是零成本的。


参考与源码

NestJS

CabloyJS / Vona

相关推荐
匠云创新3 小时前
读完一万段要两分钟:我们把标书读取从 WPS 对象模型搬到了 docx 解析
性能优化·node.js
某哈压力大10 小时前
SSH 突然 Connection reset?不是密钥坏了,是 OpenSSL 把 sshd 干趴了
前端·node.js·命令行
回眸&啤酒鸭11 小时前
【回眸】Node.js 新手从零到一实战指南
node.js
浪遏19 小时前
recruitment-cli:让 AI 接手求职招聘,从架构到一次发送超时
架构·typescript·node.js
律宏阔1 天前
微信小程序 + Taro 从 ESLint 迁移到 Oxlint 让项目代码检查速度提升近 12 倍
前端·typescript·eslint
瓦瓦gogo1 天前
从一个 TypeScript 函数开始理解 MCP 协议
linux·ubuntu·typescript
ZhengEnCi2 天前
Hono 4 多库多模型服务的企业级 Monorepo 目录结构:npm workspaces 与 Turborepo 该怎么分层
typescript
沉默小管2 天前
我用 NestJS + Nuxt 3 做了个导航站,聊几个踩过的坑
node.js·seo
浪遏3 天前
从命令到工作流:juejin-cli 如何让写作自动化更可靠
typescript