NestJS 模块
目录
模块
模块是一个用 @Module()
装饰器标注的类。这个装饰器提供了 Nest 用于组织和管理应用结构的元数据,用于聚合相关的 controller、service、provider 等组件。
@Module()
并不执行任何逻辑,它只是告诉 Nest “这个类是个模块,模块里有谁”。
每个 Nest 应用至少有一个模块,即根模块(Root Module),它是 Nest 构建“应用依赖图”的起点。Nest 内部会基于这个模块图来解析模块之间的依赖关系。
应用的 AppModule
就是根模块(main.ts
中会传给 NestFactory.create(AppModule)
);
Nest 会从这里出发,自动“扫描”其他模块、控制器、服务,构建出完整的“依赖图”
虽然小型应用可能只有一个模块,但 Nest 强烈建议按功能拆分多个模块,每个模块封装一组紧密相关的功能。
@Module({
imports: [OtherModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
字段 | 作用 | 实战说明 |
---|---|---|
providers | 注册服务/值/工厂等提供器,供模块内部使用 | 类似依赖注入容器的“注册表” |
controllers | 控制器列表,Nest 会自动创建并路由这些控制器 | 只能放控制器类 |
imports | 导入其他模块,以便使用它们导出的 provider | 实现模块之间通信和依赖 |
exports | 把本模块中某些 provider 显式暴露出去,供其他模块使用 | 类似“公共接口”或模块的 API |
模块默认是封闭的(encapsulated),你无法直接使用其他模块中的服务,除非它们被导出(export)了。
错误用法:
@Module({
providers: [AuthService],
})
export class AuthModule {}
@Module({
imports: [AuthModule],
})
export class UserModule {
constructor(private authService: AuthService) {} // 注入失败!AuthService 没有被导出
}
正确用法:
@Module({
providers: [AuthService],
exports: [AuthService], // 显式导出
})
export class AuthModule {}
功能模块
功能模块就是把某一“业务域”相关的 controller、service、DTO、entity、interface 等封装进一个独立模块;
它体现了 单一职责原则(SOLID 原则中的 S),每个模块只负责自己的功能,耦合更低、可维护性强。
示例:
创建一个CatsModule
// cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}
该模块聚合了 CatsController + CatsService
它没有导出任何 provider,因为目前只在模块内部使用;
未来如果其他模块需要用 CatsService
,再通过 exports: [CatsService]
显式导出。
提示:用 CLI 快速生成模块
nest g module cats
这条命令自动创建了 cats/cats.module.ts
,推荐用 CLI 快速生成基础结构,保持规范一致。
接入根模块(AppModule)
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule {}
你必须在根模块中 imports
引入你的功能模块,Nest 才能“识别”并注册它;
一旦引入,CatsModule
内的 controller 和 provider 就会被自动扫描并初始化;
Nest 从 AppModule
出发构建模块依赖图,不 import 就不生效。
最终目录结构示意
src/
├── cats/
│ ├── dto/
│ │ └── create-cat.dto.ts
│ ├── interfaces/
│ │ └── cat.interface.ts
│ ├── cats.controller.ts
│ ├── cats.module.ts
│ └── cats.service.ts
├── app.module.ts
└── main.ts
共享模块
在 Nest 中,默认情况下模块是单例,因此你可以轻松地在多个模块之间共享任何提供程序的同一实例。
默认应用中只创建一个实例,整个生命周期共用,所以,只要模块正确导出,其他模块可以共享这个服务的同一个实例,而不是重复创建;
这也正是依赖注入的一个核心优势:统一生命周期管理 + 状态共享 + 内存复用。
示例:导出共享服务
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService], // 导出 CatsService
})
export class CatsModule {}
NestJS 模块是封闭的,不导出 = 其他模块看不到,只有被 exports
的 provider,才可以被 import 此模块的其他模块访问到,想象成模块是一个类,exports
就是它的对外公共 API。
示例:使用共享服务
// cats.module.ts 定义 + 导出
@Module({
providers: [CatsService],
exports: [CatsService], // 关键!导出,否则其他
})
export class CatsModule {}
// app.module.ts 导入 CatsModule
@Module({
providers: [AppService],
imports: [CatsModule],
})
export class AppModule {}
// app.service.ts 注入共享服务
@Injectable()
export class AppService {
constructor(private readonly catsService: CatsService) {
// 此处注入成功,因为 CatsService 是从 CatsModule 导出的
}
}
如果我们在每个模块中都重新注册 CatsService
,每个模块会得到一个新的实例,造成:
- 更多内存开销(多个实例驻留内存);
- 共享状态不一致(例如缓存、计数器、配置状态);
- 行为变得不可预测(你以为是单例,其实不是);
所以除非你明确需要服务是“请求级”、“瞬态”、“隔离的”,否则共享单例是推荐做法。
共享模块的两个关键条件
条件 | 解释 |
---|---|
1️⃣ 被导出(exports: [Service] ) | 告诉 Nest 哪些服务可供外部模块使用 |
2️⃣ 被导入(imports: [SharedModule] ) | 其他模块必须显式引入该模块才能使用其中导出的服务 |
注意:
@Module({
providers: [CatsService], // 在多个模块都这样写
})
export class DogsModule {}
这样会让 DogsModule
拿到的是自己的 CatsService
实例,和其他模块的不一致,因为此处又重新注册了一个 CatsService
实例。
共享模块 = 导出可复用的服务,供多个模块共享一个实例使用,这是 NestJS 的依赖注入和模块系统的最大优势之一。
模块重新导出
如上所示,模块可以导出其内部提供程序。此外,它们还可以重新导出它们所导入的模块。
在下面的示例中,CommonModule
被导入到 CoreModule
中,然后又从 CoreModule
导出,使得导入 CoreModule
的其他模块也能访问 CommonModule
的导出内容。
@Module({
imports: [CommonModule],
exports: [CommonModule],
})
export class CoreModule {}
重新导出可以理解为在模块之间建立「服务转发通道」:A 模块导入了 B 模块,并在自己的 exports 中再次导出 B 模块,那今后任何导入 A 模块的模块,也可以访问 B 模块导出的 provider,就像直接导入了 B 一样。
这就是所谓的 re-export(再导出)。
这样做通常是为了 模块解耦、逻辑清晰,假设你有一个通用模块 CommonModule
,里面封装了全局日志服务、异常处理器、转换管道等 ,所有模块都要用:
@Module({
providers: [LoggerService, TransformPipe],
exports: [LoggerService, TransformPipe],
})
export class CommonModule {}
现在你又有个中间模块 CoreModule
,它希望把所有「基础依赖」统一打包、暴露出去:
@Module({
imports: [CommonModule],
exports: [CommonModule], // 再导出 CommonModule
})
export class CoreModule {}
这样你就可以在别的模块里只导入 CoreModule,而不再关心里面用了哪些 Common 组件:
@Module({
imports: [CoreModule], // 一次性拿到所有底层依赖
})
export class FeatureModule {}
实战建议
// common.module.ts
@Module({
providers: [LoggerService, ExceptionFilter],
exports: [LoggerService, ExceptionFilter],
})
export class CommonModule {}
// core.module.ts
@Module({
imports: [CommonModule],
exports: [CommonModule], // 再导出
})
export class CoreModule {}
// users.module.ts
@Module({
imports: [CoreModule], // 需要知道 CommonModule 存在
})
export class UsersModule {}
✅ 好处:使用者只依赖 CoreModule
,不关心它内部封装了什么模块; ✅ 模块边界清晰,团队协作更流畅,结构也更稳固。
模块“重新导出”是构建稳定依赖体系的关键工具,让你可以隐藏实现细节、合并多个通用模块,并通过一个出口统一暴露依赖,尤其适合构建「核心模块」「平台模块」「中间件模块」等公共中转结构。
模块重新导出(Re-export)的本质:就是把一堆零散的服务(或模块)封装打包后,通过一个模块统一暴露出去,方便批量引入 & 隐藏细节。
场景对应关系表
想做的事 | 常规做法(麻烦) | 推荐做法(re-export) |
---|---|---|
引入多个基础服务 | 在每个模块中 imports: [CommonModule, LoggerModule, ConfigModule] | 在 CoreModule 中统一导入再导出它们,然后只引入 CoreModule |
暴露一组内部 Service | 在每个功能模块里分别导出这些服务 | 把它们集中放在一个 SharedModule 或 CoreModule,统一 export |
// 你项目中有多个底层模块:
[ConfigModule]
[LoggerModule]
[ExceptionModule]
// 在 CoreModule 中统一 re-export:
@Module({
imports: [ConfigModule, LoggerModule, ExceptionModule],
exports: [ConfigModule, LoggerModule, ExceptionModule],
})
export class CoreModule {}
// 别的模块只 import CoreModule,就能使用它们全部的 Service:
@Module({
imports: [CoreModule], // 一次搞定全部依赖
})
export class UsersModule {}
Re-export 让你可以只 import 一个模块,就像 import 了多个服务/模块一样,不仅省事,还能封装依赖、保持结构整洁,是大型 Nest 应用架构的“标配手段”。
依赖注入
模块类(@Module()
装饰的类)能否使用依赖注入(DI)
模块类也可以注入提供程序(例如,出于配置目的):
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {
constructor(private catsService: CatsService) {}
}
表面上看,这是把 CatsService
注入进了 CatsModule
,但实际上,这并不是一个有效的 Nest 注入行为。
由于循环依赖,模块类本身不能作为提供器注入。
NestJS 的模块类(如 CatsModule
):
- 不会自动成为 provider;
- 不会被 Nest 注入器处理(Nest 不会去调用它的 constructor 并解析依赖);
- 所以你在
constructor()
里声明任何依赖项,也不会真的注入任何东西,也不会报错,但你拿到的会是undefined
。
为什么模块类不能注入依赖?
从设计上讲,模块类只是一个“配置容器”,是用来告诉 Nest 这个模块有哪些 provider、controller、import/export 的。
它并不是运行时参与逻辑的实例,所以 Nest 根本就不会去「实例化模块类并注入依赖」,否则容易出现下面的问题:
经典的循环依赖场景:
- 你在模块里注入了某个服务;
- 而该服务又在自己的构造函数中依赖这个模块(或其导出的其他服务);
- 那么就形成循环依赖:模块 → 服务 → 模块。
Nest 解决这类问题的策略就是——模块类不参与依赖注入,彻底切断这条循环链。
那么什么时候需要在类中注入服务?
类类型 | 是否允许注入依赖? | 说明 |
---|---|---|
@Injectable() 的类(比如 Service) | ✅ 是 | Nest 会自动构造并注入 |
@Controller() 的类 | ✅ 是 | 控制器也被 Nest 管理 |
@Pipe() 、@Guard() 等 provider | ✅ 是 | 都可以注入依赖 |
@Module() 的类(模块类) | ❌ 否 | 它不是 provider,不参与注入 |
如果你真的需要“模块初始化行为”怎么办?
1.使用生命周期钩子 onModuleInit()
推荐
@Injectable()
export class CatsService implements OnModuleInit {
onModuleInit() {
console.log('CatsService 已初始化完成');
}
}
这种方式只适用于 provider,不适用于模块类。
2.使用自定义 provider + 工厂函数(更灵活)
@Module({
providers: [
{
provide: 'CONFIG_INITIALIZER',
useFactory: (catsService: CatsService) => {
catsService.setupSomething();
},
inject: [CatsService],
},
],
})
export class CatsModule {}
这种方式你可以在模块内部执行初始化逻辑,但逻辑发生在 provider 初始化阶段。
模块类本身不参与依赖注入,它只是一个元数据容器。如果你需要执行初始化逻辑,应该写在 provider(service)中,通过生命周期钩子或工厂函数来实现。
全局模块
如果你必须在所有地方导入相同的模块集合,这种重复操作会变得乏味。
Nest 将提供程序封装在模块作用域内,只有在导入了该模块的情况下,其他模块才能使用其提供的服务。
当你想要提供一组“开箱即用”的提供程序(例如辅助工具、数据库连接等)时,可以使用 @Global()
装饰器将模块设置为全局模块。
import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Global()
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}
@Global()
装饰器让该模块变成全局模块。 全局模块应该只注册一次,通常由根模块或核心模块注册。 在上面的例子中,CatsService
将变成应用中的全局单例,任何模块都可以注入它而无需在自己的 imports
数组中导入 CatsModule
。
全局模块的意义
- 避免在每个需要的模块中都重复导入相同的基础模块(例如数据库模块、配置模块、日志模块等)。
- 使核心服务变成全局可用,简化模块依赖配置。
设计建议
- 不建议所有模块都全局化,否则模块间依赖关系混乱、耦合度高,难以维护。
- 推荐仅将那些真正需要全局共享的核心服务设为全局模块。
- 对于其他服务,仍然应通过模块显式导入保持清晰的边界和结构。
实际使用场景示例
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
@Module({
imports: [DatabaseModule], // 只在根模块或核心模块导入一次
})
export class AppModule {}
这样,DatabaseService
在应用所有其他模块中都是可用的,无需重复导入 DatabaseModule
。
只需在应用的根模块或核心模块导入一次全局模块即可。
其他模块不需要导入,但仍可通过依赖注入使用全局服务。
如果你重复导入了全局模块,不会创建新的实例,依然是单例。
@Global()
装饰器能将模块标记为全局模块,使其导出的服务自动对整个应用可见,无需重复导入。 这极大减少了重复导入的样板代码,提升了开发效率。 但过度使用全局模块会带来耦合和维护成本,建议谨慎使用,保持模块边界清晰。
动态模块
动态模块 = 运行时按需生成的模块,支持基于传入参数(如配置或实体)动态构造 providers、exports,适合封装「数据库连接」「缓存模块」「多租户模块」这类高度可配置的通用能力。
Nest 中的动态模块允许你创建可以在运行时配置的模块。
- 静态模块:代码写死,
@Module({ ... })
中的 providers 是固定的。 - 动态模块:代码运行时动态生成,按需返回不同的模块结构。
示例:数据库模块的 forRoot()
@Module({
providers: [Connection],
exports: [Connection],
})
export class DatabaseModule {
static forRoot(entities = [], options?): DynamicModule {
const providers = createDatabaseProviders(options, entities);
return {
module: DatabaseModule,
providers: providers,
exports: providers,
};
}
}
forRoot()
是一个静态工厂方法,返回一个DynamicModule
对象;- 这个对象中可以包含一切动态元信息:module、providers、exports;
- 可以根据传入的
entities
和options
构造不同的 providers; - 用法像这样:
@Module({
imports: [DatabaseModule.forRoot([User])],
})
export class AppModule {}
这样就能针对 AppModule 的需求动态定制数据库服务内容。
动态模块还能全局注册(慎用)
return {
global: true,
module: DatabaseModule,
providers: [...],
exports: [...],
};
全局模块 + 动态模块 = 所有模块都能用它,无需 imports; 但 Nest 官方明确提示:慎用 global 模块,防止模块耦合失控。
动态模块可以被其他模块重新导出
@Module({
imports: [DatabaseModule.forRoot([User])],
exports: [DatabaseModule], // 注意:这里 exports 是模块名,不是 forRoot()
})
export class AppModule {}
Nest 会自动透传动态模块返回的 providers 和 exports,你不需要手动导出 forRoot()
。
概念 | 说明 |
---|---|
forRoot() | 通常用于返回配置好的动态模块(同步) |
forRootAsync() | 异步配置版本,适用于从 configService 获取参数 |
DynamicModule | Nest 的“可编程模块结构”接口 |
适用场景 | 数据库、Redis、MQ、ConfigService、多租户等“需传参初始化”的模块 |
动态模块让你可以把复杂、可配置的能力(如数据库)封装成“灵活工厂”,使用时传参自动生成配置好的模块,既复用代码,又保持模块化。