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],
};
}
}
三个特征很关键:
- 模块类本身是空壳,
@Module({})里没有任何 provider; - 真正的
providers / exports / imports由静态方法返回; - 返回值仍然是一份模块定义,只是推迟到调用时才计算。
换句话说,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 把事情显式化。这条路仍然不经过"动态模块"这个概念。
十、小结
- Dynamic Module 的本质是"模块定义的延迟构造函数",服务于"模块定义必须在编译前确定"这一前提;
- Vona 的模块不承担 DI 边界与可见性职责,Bean 由装饰器注册进全局注册表,容器按名解析,因此不存在需要提前构造的模块定义;
- 传配置、异步配置、多实例、条件 provider 四类诉求,分别被配置合并、启动阶段、多个 Bean 名、onion 过滤吸收;
- 这不是"Vona 也实现了动态模块",而是取消了动态模块赖以存在的前提。
顺带一提,同一个原因也解释了为什么 Vona 的 Provider Scene 能取代 NestJS 的 Dynamic Providers:绑定时机一旦推到解析期,多实现对依赖结构就是零成本的。