项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇 :上一篇《自定义 Provider 四形态》结尾留了钩子:ConfigModule 和 VectorDbModule 都不是静态@Module------providers 是 forRoot 按参数现算返回的。07 期讲模块时也只给了落地概览,完整机制留到"18/19 期"。 这篇就拆它:动态模块是"可传参的模块工厂"------forRoot返回的对象凭什么算一个模块?options 怎么变成可注入的 provider?"组合根"这个角色为什么重要?
定位 :本篇讲 DynamicModule 的判定机制(源码级)、forRoot/register/forFeature 命名习惯、options 两种接线(同步 useValue / 异步 forRootAsync)、global参数与 07 期退役的@Global()的关系。实证 = rag-server 两个真实 forRoot(ConfigModule + VectorDbModule)逐行对读。不讲 @Global 完整取舍(19 期)、不讲 useFactory 细节(17 期)。读完你能手写一个带 options 的动态模块,并解释 imports 数组里那个对象是怎么被 Nest 认成模块的。
一、先立判断:静态模块是"写死的一份定义",动态模块是"调用时现拼的工厂"
| 静态模块 | 动态模块 | |
|---|---|---|
| 用法 | imports: [RagModule]------只能是类引用 |
imports: [ConfigModule.forRoot({...})]------可以是对象 |
| 内容 | @Module() 里写死 |
由静态方法按传入 options 现拼 providers/exports |
| 想传参/复用 | ❌ 做不到 | ✅ 这正是它存在的意义 |
痛点一句话:你想封装一个模块让每个应用传自己的配置,但 imports 里只能放模块类,参数塞不进去 ------真正要用配置的 provider 又在模块内部。于是你需要的不是一个"模块类",而是一个方法:给它 options,它返回一份"带这些配置的模块定义"。
前端直觉:静态模块 =
import <组件>直接用;动态模块 = 先调createComponent({ options })拿到一个配置好的实例再挂上去。
二、最小动态模块与源码判定:!!m.module
ts
export const DB_OPTIONS = Symbol("DB_OPTIONS"); // options 的注入 token
@Module({}) // 类上可以先空着
export class DatabaseModule {
static forRoot(options: DatabaseOptions): DynamicModule {
return {
module: DatabaseModule, // ① 必须自引用:这是"身份"
providers: [
{ provide: DB_OPTIONS, useValue: options }, // ② options 注册成 provider
DatabaseService, // 内部就能 @Inject 到它
],
exports: [DatabaseService], // ③ 别人要用的导出,别漏
// global: true, // 可选:全局可见(§五)
};
}
}
消费方:imports: [DatabaseModule.forRoot({ host: "...", port: 5432 })]。
2.1 关键:为什么返回对象必须有 module 字段?
这不是仪式感。本机 @nestjs/core@11.2.3 的 injector/compiler.js(ModuleCompiler)里:
js
isDynamicModule(moduleClsOrDynamic) {
return !!moduleClsOrDynamic.module; // 只要有 .module 字段 → 是动态模块
}
extractMetadata(moduleClsOrDynamic) {
const { module: type, ...dynamicMetadata } = moduleClsOrDynamic;
return { type, dynamicMetadata }; // module 当"类身份",其余当动态元数据
}
所以 forRoot({...}) 传进 imports 的其实是一个普通对象,.module 字段告诉 Nest"这个模块是谁",其余字段(providers/exports/global)被当成动态元数据,与 @Module() 上的静态声明合并。 没有 .module,这个对象就不被认作模块,直接报错------新手第一个坑。
2.2 动态与静态是"合并",不是覆盖
动态方法返回的字段叠加 在 @Module() 声明之上。常见库的写法:不变的部分放静态、依赖 options 的部分放动态。rag-server 两个模块都用了最彻底的形态------空 @Module({}) + 全部动态返回(连接/配置模块没有"不依赖 options 的部分",干脆全放 forRoot)。
三、rag-server 两个真实 forRoot 逐行对读
3.1 ConfigModule.forRoot:options 里装的是"现成的值"
ts
// config.module.ts(真实代码,节选)
export interface ConfigModuleOptions {
config: AppConfig; // core 的 config 单例
isGlobal?: boolean;
}
@Module({})
export class ConfigModule {
static forRoot(options: ConfigModuleOptions): DynamicModule {
return {
module: ConfigModule,
global: options.isGlobal ?? false,
providers: [
{ provide: APP_CONFIG, useValue: options.config }, // useValue:值直给
{ provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] }, // 派生窄 token
ConfigValidator, // 16 期的钩子自检
],
exports: [APP_CONFIG, AUTH_TOKENS],
};
}
}
三个设计点,每个都能在前几期找到出处:
APP_CONFIG用 useValue :options 里的 config 是 core 从 env 生成的单例,模块不再顶部import { config }走私------配置值由调用方传入,从"自己摸配置"变成"被带参数 import"(17 期 useValue 的标准场景);AUTH_TOKENS用 useFactory:依赖 APP_CONFIG 现算窄 token;global: options.isGlobal ?? false:@Global()装饰器退役,全局与否成为参数------19 期展开这个取舍。
3.2 VectorDbModule.forRoot:同一个骨架,另一种 options
ts
// vector-db.module.ts(真实代码,全文)
@Module({})
export class VectorDbModule {
static forRoot(options: VectorDbModuleOptions): DynamicModule {
return {
module: VectorDbModule,
global: options.isGlobal ?? false,
providers: [
{ provide: VECTOR_DB_OPTIONS, useValue: options }, // options 本身当值注入
VectorDbService, // 类 provider 保钩子(16/17 期)
],
exports: [VectorDbService],
};
}
}
注意差别:ConfigModule 的 options 里装最终值 (config 单例),VectorDbModule 的 options 装原始参数 (dbPath),内部 VectorDbService 再 @Inject(VECTOR_DB_OPTIONS) 消费它、懒建连接。前者是"值从组合根传入",后者是"参数传给模块内部的类去用"------同一骨架的两种语义。
3.3 组合根:谁调 forRoot,谁负责拍板
ts
// app.module.ts(真实代码,节选)
@Module({
imports: [
ConfigModule.forRoot({ config, isGlobal: true }), // AppModule = 组合根
VectorDbModule.forRoot({ dbPath: config.vectorDbPath, isGlobal: true }),
RagModule, HealthModule, ... // 业务模块:纯静态 import
],
})
export class AppModule {}
组合根(AppModule)是全应用唯一知道"用什么配置、怎么连线"的地方 :它把 core 的 config 单例喂给 ConfigModule、把 dbPath 喂给 VectorDbModule,业务模块只管静态 import + 注入 token,对配置来源零感知。这是"依赖注入到底注入什么"在模块层的镜像------业务模块不该知道配置从哪来,组合根全知道(05/07 期反复出现的分层思想)。
四、register / forRoot / forFeature:命名习惯,不是关键字
结构上毫无差别,差别只在语义约定:
| 命名 | 语义 | 真实生态 |
|---|---|---|
register |
通用入口,按 options 注册一次 | JwtModule.register({ secret }) |
forRoot |
根级/全局配置,通常只调一次,管应用级单例资源 | TypeOrmModule.forRoot、ConfigModule.forRoot |
forFeature |
按功能域挂载小部分资源,每个业务模块各调一次 | TypeOrmModule.forFeature([User]) |
类比:forRoot = 一次建好基础设施(连接/全局配置),forFeature = 业务模块里"领用该域的专属资源",register = 没有根/域之分时的通用入口。rag-server 两个模块都只有 forRoot------单应用、全局单份资源,没有 forFeature 的诉求。
五、options 的两种接线:同步直给 / 异步现算
rag-server 两个 forRoot 都是同步 ------options 在 import 时就已知(core 的 config 单例启动即生成)。当配置要先读环境/依赖其它 provider 时,生态惯例是提供 forRootAsync:
ts
static forRootAsync(options: DatabaseAsyncOptions): DynamicModule {
return {
module: DatabaseModule,
imports: options.imports || [], // factory 需要的依赖模块
providers: [{
provide: DB_OPTIONS,
useFactory: async (config: ConfigService) => ({ host: config.get("DB_HOST") }),
inject: [ConfigService], // ← 工厂依赖在 inject 声明(17 期纪律)
}],
...
};
}
rag-server 没有 forRootAsync :core 的 config 在进程启动早期就从 env 同步生成完了,HTTP 层拿到的永远是现成单例------没这个异步诉求,就不引入这层样板(和 15 期"没写参数装饰器=没诉求"同一个诚实口径)。
六、与 @Global 的关系 + 一条反向警戒
动态模块可以设 global: true,让"只 import 一次"的 forRoot 模块对全容器可见------TypeOrmModule.forRoot、ConfigModule.forRoot 基本都带。rag-server 的两个 forRoot 都传了 isGlobal: true。
反向警戒 :如果没有"被 2+ feature 模块共享的基础 provider"需求,别为了显高级硬上 global------那会让依赖图变隐式、更难测更难读。@Global 是"共享地基"的优化,不是模块标配 (19 期整篇展开:rag-server 为什么让 @Global() 装饰器退役、把 global 变成 forRoot 参数)。
七、常见坑
| 坑 | 说明 |
|---|---|
返回对象没有 module 字段 |
编译器 !!m.module 判定失败,不被认作模块,必报错 |
| 动态拼了 provider 却漏 export | 别的模块 @Inject 报 "not available";导出 token,不是类 |
| 同步/异步 options token 不一致 | forRoot 用 VECTOR_DB_OPTIONS、内部 @Inject 写别的 → 查表查不到 |
| 同一模块类多次 forRoot 不同 options | 每次是独立实例;provider token 相同会互相覆盖------多实例必须错开 token |
想共享却忘 global: true |
每个 import 方拿到各自独立实例,状态不共享 |
| forRoot 里塞业务 provider | 语义错了:forRoot 管全局基础,业务资源走各自模块 |
该做动态模块的触发信号:①模块需要按调用方 options 提供不同 providers;②同一模块被多处用不同配置 import;③你要发布一份可配置的库/内部 SDK。一次性业务模块别上。
八、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
| createComponent({options}) 再挂载 | forRoot(options) 返回 DynamicModule |
先配置,后挂 |
| 组件库的 Provider 组件带 props | 动态模块带 options | 参数在"挂载时"传入 |
| 应用根组件统一接线 | 组合根 AppModule | 全局拍板只有一处 |
| register/forRoot/forFeature | 组件库命名约定(如 useXxx 前缀) | 生态习惯,非语言特性 |
自测 4 题(先自己答,再看答案):
-
imports: [ConfigModule.forRoot({...})]传进去的是什么?Nest 靠什么认出它是个模块? → 一个普通对象。编译器isDynamicModule = (m) => !!m.module判定,extractMetadata把它拆成{ module: type, ...dynamicMetadata }------.module当"类身份",其余与@Module()静态声明合并(不覆盖)。 -
options 怎么变成模块内部可注入的东西?两个真实模块的语义差别? → 注册成 provider(同步 useValue,异步 useFactory+inject)。ConfigModule 的 options 装最终值(config 单例直给 APP_CONFIG);VectorDbModule 的 options 装原始参数(dbPath),内部 VectorDbService 再消费它懒建连接。
-
"组合根"是谁?它为什么是唯一调 forRoot 的地方? → AppModule。它全应用唯一知道"用什么配置、怎么连线";业务模块静态 import + 注入 token,对配置来源零感知。配置的拍板权集中在一处,业务模块保持自包含。
-
rag-server 为什么没有 forRootAsync? → core 的 config 在进程启动早期就从 env 同步生成,HTTP 层拿到的永远是现成单例,没有"要等异步配置源"的诉求------不引入用不到的样板(诚实口径:没诉求 ≠ 不会)。
九、本篇收束与下一篇
动态模块拆完,模块层与 provider 层拼图合拢:provider 四形态(17 期)解决"怎么造一个依赖",动态模块解决"模块怎么带参数复用" ------rag-server 的 ConfigModule/VectorDbModule 就是把 useValue/useFactory 装进 forRoot 骨架的标准实现,组合根一处拍板、业务模块零感知。但本期刻意按住了一个字眼没展开:global: options.isGlobal ?? false------为什么 rag-server 把 @Global() 装饰器退役、改成参数?
下一篇(19 期《@Global 与 DynamicModule 落地》)就是这个取舍的完整账:@Global 买什么(少写 N 次 import)、卖什么(依赖图隐式化)、"参数化 global"比"装饰器 global"好在哪、以及怎么判断自己的模块该不该全局。
下一篇预告:19 期《@Global 与 DynamicModule 落地》------全局模块的收益与代价,装饰器退役、参数上岗的完整决策账。
项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)