Nest 使用笔记
第四章:Module 进阶——动态、全局、生命周期与循环依赖
打开 learnhub 的真实 app.module.ts、PostModule、RedisModule、MinioModule,讲透 @Module 的四字段、forRootAsync/useFactory 的动态模块、@Global 的取舍、OnModuleInit/OnModuleDestroy 的优雅降级、循环依赖。每段代码都能在 learnhub 里指到对应文件。
- Nest
- Module
第三章讲了 provider 跨模块要 exports/imports。这一章解决五个真实问题:模块怎么打包成一个可装配单元、连数据库这种需要配置的模块怎么传参、Redis/MinIO 这种基础设施怎么做成全局省得每个模块都 import、服务启动和关闭时怎么优雅地建连和断连、两个 service 互相依赖时怎么办。每段代码都从 learnhub 真实在跑的模块里抠出来。
先搞懂:Module 是什么 / 为什么需要 / 企业级怎么用
Module 是什么:一个用 @Module(...) 装饰的类,把一组 controller、provider、子模块导入、导出打包成一个可装配单元。Nest 应用就是一棵模块树——根 AppModule 在 imports 里挂上各业务模块,各业务模块再挂自己的子模块或基础设施模块,Nest 启动时按这棵树搭 IoC 容器。
为什么需要:几百个 service 和 controller 的项目,全堆在根 AppModule 里没法维护——一个文件几千行、改一个功能翻半天、团队没法并行。模块化按业务域切(learnhub 切成 PostModule / CommentModule / AuthModule / UserModule / FavoriteModule …),每个模块自己管自己的 controller/service/provider,对外只 exports 别人需要的东西,内部实现藏在模块里。
企业级怎么用(下面每一条这一章都在 learnhub 里真做一遍):
- 严格按业务域分模块,模块内的 provider 默认私有,要给别的模块用必须显式
exports; - 跨模块的基础设施(Redis、MinIO、ES、DB 连接)用 @Global + 动态模块,注册一次全局可用;
- 需要配置才能初始化的模块(TypeORM、Mongoose、GraphQL)走 forRootAsync + useFactory,依赖
ConfigService; - 启动/关闭要做的事(建 bucket、断连接)放 OnModuleInit / OnModuleDestroy 钩子;
- 循环依赖优先重构避免,
forwardRef是兜底。
这一章你会做出什么
- 打开 learnhub 的
post.module.ts,讲清@Module的四字段各干啥。 - 看懂
app.module.ts里的TypeOrmModule.forRootAsync为什么是 async、useFactory怎么拿到ConfigService。 - 在
RedisModule/MinioModule里看 @Global + token provider 的真实写法,并理解它的代价。 - 在
MinioService/RedisService里看 OnModuleInit / OnModuleDestroy 怎么做优雅降级。 - 看 learnhub 怎么主动避免
PostService↔RankingService的循环依赖,以及forwardRef作为兜底怎么写。 - 最后回到根
AppModule,把 imports / 全局 AOP providers / 空的configure()占位串起来,预告第五章 AOP。
第一步:@Module 的四字段——imports / providers / controllers / exports
先看 learnhub 里一个典型的业务模块——帖子模块:
// learnhub/src/modules/post/post.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Post } from './entities/post.entity';
import { Tag } from '../tag/entities/tag.entity';
import { PostController } from './post.controller';
import { PostService } from './post.service';
import { UserModule } from '../user/user.module';
import { RankingModule } from '../ranking/ranking.module';
@Module({
imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
controllers: [PostController],
providers: [PostService],
})
export class PostModule {}
四个字段(这里只用到三个,第四个 exports 马上讲):
controllers:这个模块对外暴露的 HTTP 入口,Nest 会把它们注册成路由。PostController定义了/api/v1/posts下的发帖、列表、详情、改、删。providers:本模块要造出来并由 IoC 容器管理的服务。PostService就在这。默认模块私有——别的模块拿不到。imports:本模块要用别的模块的导出,就得 import 进来。这里UserModule给PostService提供UserService(校验帖子作者是不是 admin),RankingModule提供RankingService(记浏览量)。TypeOrmModule.forFeature([Post, Tag])是动态模块的写法,下面专门讲。exports:本模块要把哪些 provider 暴露给别的模块用。PostModule没有exports,所以PostService别的模块注入不到——这是合理的,帖子业务逻辑只在帖子模块和它自己的 controller 里用。
注意:providers 默认私有是新人最容易踩的坑——「我明明 providers: [XxxService] 写了,怎么别处注入报 Nest can't resolve dependencies」,原因就是没 exports。一条规则:想让别的模块用,必须既 providers 又 exports。@Global 模块也不例外,下面会看到 RedisModule 照样要 exports: [RedisService]。
第二步:动态模块——forRootAsync + useFactory,配置走 ConfigService
@Module({...}) 是静态的——内容写死。但很多模块初始化要传参:数据库连接要 host/port/password,Redis 要 host/port。这些参数还得从 ConfigService 拿,而 ConfigService 自己也是个 provider,要等 IoC 容器起来才能注入——静态 @Module 拿不到。
Nest 的解法是动态模块:模块类上写一个静态方法(约定叫 register / forRoot / forFeature),返回一个 DynamicModule 对象,Nest 启动时调这个方法,拿到返回值再当成普通模块装配。异步版本(forRootAsync / registerAsync)多一个 useFactory,工厂函数的参数就是 inject 里声明的依赖。learnhub 接 MySQL 用的是 TypeOrmModule.forRootAsync:
// learnhub/src/app.module.ts
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('mysql.host'),
port: config.get<number>('mysql.port'),
username: config.get<string>('mysql.username'),
password: config.get<string>('mysql.password'),
database: config.get<string>('mysql.database'),
autoLoadEntities: true, // 自动收集各模块 forFeature 注册的 Entity
synchronize: false, // 永远 false,靠 migration
logging: config.get('nodeEnv') === 'development',
timezone: '+08:00',
charset: 'utf8mb4',
}),
}),
useFactory 的返回值就是 TypeORM 接 MySQL 的所有配置。inject: [ConfigService] 告诉 Nest:等 IoC 容器造出 ConfigService 后,把它作为参数传给工厂函数。imports: [ConfigModule] 是因为动态模块自己有个小 IoC 作用域,要让 ConfigService 在这个作用域里可见得显式 import(即便根 AppModule 已经 import 了全局的 ConfigModule,这是惯例写法,确保依赖可用)。
为什么不用静态 forRoot?因为 forRoot 的参数是同步求值的——你写 forRoot({ host: config.get(...) }) 那一刻 config 还不存在(IoC 容器没起来),直接报错。forRootAsync 把「等配置就绪 → 再造连接」这件事交给 Nest 自己调度。learnhub 的 MongooseModule.forRootAsync、GraphQLModule.forRootAsync 都是同一套路:inject: [ConfigService] + useFactory。
方法名约定(Nest 社区默契,第三方包基本都遵守):
forRoot(options)/forRootAsync(options):全局只调一次,配置整个应用共享的东西(DB 连接、GraphQL schema、定时任务调度器)。在根AppModule里调。forFeature(...):每个业务模块调自己的,配合forRoot用。比如TypeOrmModule.forFeature([Post, Tag])告诉 TypeORM「这个模块要用 Post 和 Tag 两个实体」。register(options)/registerAsync(options):每次注册配置不同,比如多租户每个租户一套配置。- 都有
xxxAsync异步版本,对应useFactory+inject模式。
第三步:forFeature + autoLoadEntities——实体注册
第二步的 forRootAsync 只配了连接,没说哪些实体要映射成表。TypeORM 注册实体有两种方式:
- 在
forRoot里列全:entities: [User, Post, Comment, Tag, ...]。每加一个实体要回根模块改一次,模块化形同虚设。 autoLoadEntities: true(learnhub 用的):各模块在自己的imports里用forFeature注册本模块用到的实体,TypeORM 启动时自动收集。
learnhub 选第二种——根 AppModule 的 forRootAsync 里 autoLoadEntities: true,各业务模块自己 forFeature:
// learnhub/src/modules/post/post.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
controllers: [PostController],
providers: [PostService],
})
export class PostModule {}
forFeature([Post, Tag]) 干两件事:把 Post、Tag 登记进 autoLoadEntities 的收集清单(启动后 TypeORM 知道这俩是实体),同时把 Repository<Post>、Repository<Tag> 注册成当前模块可注入的 provider。PostService 构造函数里就能拿到:
// learnhub/src/modules/post/post.service.ts
@Injectable()
export class PostService {
constructor(
@InjectRepository(Post) private readonly postRepo: Repository<Post>,
@InjectRepository(Tag) private readonly tagRepo: Repository<Tag>,
@InjectDataSource() private readonly dataSource: DataSource,
// ...
) {}
}
@InjectRepository(Post) 是「按实体拿它的 Repository」;@InjectDataSource() 拿到 DataSource 本身(事务要用,见第八章)。注意 Service 注入的是 Repository 和 DataSource,不直接 import 任何 SQL。
思考:为什么 TypeORM 在根模块用 forRootAsync、在业务模块用 forFeature?——因为它们管的是两件不同粒度的事。forRootAsync 配的是整个应用一份的连接参数(host/port/password 这种全局配置),只在根模块调一次;forFeature 配的是本模块要用哪些实体,跟随模块加载——新加一个 CommentModule 就在自己模块里 forFeature([Comment]),根模块不用动。模块化的核心就是:全局配置和局部注册分开,新加业务模块不需要回根模块改任何东西。
第四步:@Global——全局模块的诱惑和代价
到这步有个新问题:Redis、MinIO、ES、Prisma、Etcd、Amqp 这种基础设施,几乎每个业务模块都要用。按第三步的规矩老老实实 imports,每个模块都得写一行 imports: [RedisModule],十几个模块就重复十几次。
Nest 的解法是 @Global()——标记一个模块为全局,它 exports 的 provider 全局可见,不用任何模块再 import。learnhub 的 Redis 模块:
// learnhub/src/modules/redis/redis.module.ts
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Redis({
host: config.get<string>('redis.host') || '127.0.0.1',
port: config.get<number>('redis.port') || 6379,
retryStrategy: (times) => Math.min(times * 500, 3000),
maxRetriesPerRequest: null,
enableOfflineQueue: true,
}),
},
RedisService,
],
exports: [RedisService],
})
export class RedisModule {}
这个模块做了三件事:
@Global()让它全局可见——根AppModule.imports只 import 一次,任何模块直接注入RedisService就能用。所以post.module.ts的imports里压根没有RedisModule,但PostService通过RankingService(间接用 Redis)照常用。- 用一个 token provider 把 ioredis 的
Redis实例注册成REDIS_CLIENT(字符串常量,见redis.constants.ts)。这是 DI token 的典型用法——第三方类(Redis)不是@Injectable,要进 IoC 容器就得用字符串 token 包一层。 useFactory注入ConfigService,从配置读连接参数——和 TypeORM 的forRootAsync是同一个套路。
MinioModule(MINIO_CLIENT)、SearchModule(ES_CLIENT)、EtcdModule、AmqpModule、PrismaModule 全是这套写法:@Global() + token provider + useFactory 注入 ConfigService,exports 自己的 service。
注意:@Global 是双刃剑。便利的另一面是隐藏了依赖来源——读 PostService 代码时看到一堆 service,你得全局搜一下才知道哪个是 @Global 来的、哪个是 imports 来的;显式 imports: [RedisModule] 至少在模块顶部告诉你「这个模块依赖 Redis」。规则:真正全局的基础设施用 @Global(Redis、MinIO、DB 连接、日志、配置中心),只在 1-2 个模块用的 service 老老实实 imports——否则等于全局变量满天飞,模块边界名存实亡。
第五步:生命周期钩子——OnModuleInit / OnModuleDestroy 做优雅降级
Nest 启动和关闭时按顺序触发一组 hook,service 实现 OnModuleInit / OnModuleDestroy 等接口,对应钩子就会被调:
| Hook | 触发时机 | 典型用途 |
|---|---|---|
onModuleInit | 本模块所有 provider 实例化后 | 初始化连接、建资源 |
onApplicationBootstrap | 所有模块 init 完、即将监听端口前 | 订阅消息、预热缓存 |
onModuleDestroy | 应用关闭时(SIGTERM / app.close()) | 断开连接、flush 缓冲 |
onApplicationShutdown | 关闭最末尾 | 关 DataSource |
learnhub 用这组钩子做了一个生产里很关键的模式——优雅降级:基础设施(MinIO/Redis/ES)没起来时,应用照样启动,等真正调用到对应接口时再报错,而不是整个应用 boot 失败。开发时只起 MySQL 不起 MinIO 也能跑大部分接口,生产上某个基础设施短暂故障也不至于拖垮整个服务启动。
MinIO 是典型——启动时检查 bucket 不存在就建,但 MinIO 挂了不抛、只警告:
// learnhub/src/modules/upload/minio.service.ts
@Injectable()
export class MinioService implements OnModuleInit {
private readonly logger = new Logger(MinioService.name);
private readonly bucket: string;
constructor(
@Inject(MINIO_CLIENT) private readonly client: Client,
private readonly config: ConfigService,
) {
this.bucket = this.config.get<string>('minio.bucket') || 'learnhub-assets';
}
async onModuleInit(): Promise<void> {
try {
const exists = await this.client.bucketExists(this.bucket);
if (!exists) {
await this.client.makeBucket(this.bucket);
this.logger.log(`bucket 已创建:${this.bucket}`);
}
try {
await this.client.setBucketPolicy(this.bucket, publicReadPolicy(this.bucket));
} catch {
/* 部分 minio 版本策略语法差异,失败忽略 */
}
} catch (e) {
this.logger.warn(`MinIO 不可用,上传功能将不可用:${(e as Error).message}`);
}
}
// ... putObject / presignedPut / presignedGet / publicUrl
}
关键就在 try/catch——bucketExists / makeBucket 失败(MinIO 没起、网络不通)只是 logger.warn,不重新抛出。onModuleInit 不抛,应用就继续 boot,监听端口、跑 controller。上传接口真正被调用时才会因为 client.putObject 失败报错——但那是接口级失败,不影响其他功能。
Redis 服务是反方向的钩子——关闭时主动断开连接:
// learnhub/src/modules/redis/redis.service.ts
@Injectable()
export class RedisService implements OnModuleDestroy {
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}
/** 应用关闭时断开连接,避免测试/停机时连接泄漏 */
async onModuleDestroy(): Promise<void> {
await this.client.quit().catch(() => undefined);
}
// ... incr / get / zincrby / ztopWithScores / ...
}
注意 .quit().catch(() => undefined)——关闭时 Redis 已经挂了的话 quit 会 reject,这里 catch 掉避免关闭流程报错。优雅降级是双向的:启动时基础设施挂了不阻断 boot,关闭时基础设施挂了不阻断 shutdown。
onApplicationBootstrap learnhub 暂时没用(订阅 MQ、预热缓存这类场景用得上);onModuleDestroy / onApplicationShutdown 还要配合 main.ts 里 app.enableShutdownHooks() 才能在收到 SIGTERM 时触发——容器编排 K8s 发 SIGTERM 时优雅退出就靠它,部署章节细讲。
第六步:循环依赖——forwardRef 是兜底,更好的做法是重构
两个 service 互相注入就会循环:A 的构造函数要 B,B 的构造函数要 A,IoC 容器不知道先造哪个,启动直接报 Nest can't resolve dependencies of AaaService (BbbService, ...)。
learnhub 里有一个本来会循环的真实场景:PostService 记录帖子浏览量要调 RankingService;而 RankingService 定时把攒在 Redis 的浏览量 flush 进数据库时,要更新 Post 的 viewCount——直观写法是 RankingService 注入 PostService 调 update,于是 PostService ↔ RankingService 循环。但 learnhub 的 RankingService 故意不注入 PostService:
// learnhub/src/modules/ranking/ranking.module.ts
/**
* - 注册 Post 实体(flush 时 increment viewCount);
* - exports RankingService:PostService 记录浏览量要用;
* - 依赖 RedisService(全局,无需 import)。
* 单向依赖:PostModule → RankingModule(RankingModule 不反向依赖 PostModule)。
*/
@Module({
imports: [TypeOrmModule.forFeature([Post])],
controllers: [RankingController],
providers: [RankingService],
exports: [RankingService],
})
export class RankingModule {}
注意 imports: [TypeOrmModule.forFeature([Post])]——RankingService flush 时直接拿 Repository<Post> 做 increment('viewCount'),只依赖 Post 实体和 Repository,不依赖 PostService。ranking.service.ts 文件头注释写得明白:「解耦:本服务只依赖 Redis + Post 实体(flush 用),不反向依赖 PostService,避免循环依赖」。
这是生产里处理循环依赖的首选做法:把循环拆开。A 和 B 都依赖的公共逻辑,抽到一个两边都能注入的 C(这里是 Repository<Post> + Redis),而不是 A↔B 互相调对方的方法。
如果实在拆不开(比如两个 service 必须互相调对方的业务方法),Nest 提供 forwardRef 做延迟引用——告诉容器「这个依赖先别立刻解析,等两边都造出来再回填」:
// 兜底写法(learnhub 里不需要,因为循环已经被上面那行注释的设计拆掉了)
import { Inject, forwardRef, Injectable } from '@nestjs/common';
@Injectable()
export class PostService {
constructor(
@Inject(forwardRef(() => RankingService)) private ranking: RankingService,
) {}
}
@Injectable()
export class RankingService {
constructor(
@Inject(forwardRef(() => PostService)) private posts: PostService,
) {}
}
forwardRef(() => XxxService) 两边都得加,只加一边还是会报错。注意:forwardRef 是兜底不是常规手段——它让依赖图变成运行时才能解析,启动变慢、错误信息更难懂。能像 learnhub 这样重构拆开就别用 forwardRef。
第七步:回到根 AppModule——imports / 全局 AOP / configure 全貌
每个机制都讲过了,最后把 learnhub 的根 AppModule 完整看一遍——前面看到的 forRootAsync / @Global / 全局 AOP providers / 空的 configure() 都在这一处汇总:
// learnhub/src/app.module.ts
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', load: [configuration] }),
TypeOrmModule.forRootAsync({ /* 第二步讲过 */ }),
ScheduleModule.forRoot(),
MongooseModule.forRootAsync({ /* 和 TypeORM 同套路 */ }),
// 业务模块
AuthModule, UserModule, RbacModule, PostModule, CommentModule,
TagModule, FavoriteModule,
// @Global 基础设施模块(第四步讲过)
MinioModule, UploadModule, RedisModule, RankingModule,
SearchModule, PrismaModule, AmqpModule, EtcdModule,
// GraphQL(code-first,schema 自动生成)
GraphQLModule.forRootAsync<ApolloDriverConfig>({ driver: ApolloDriver, useFactory: () => ({ /* ... */ }) }),
AnalyticsModule, LoggerModule, SseModule, I18nModule,
],
controllers: [AppController],
providers: [
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: BehaviorLogInterceptor },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
],
})
export class AppModule implements NestModule {
// 阶段1 占位:阶段18 会在这里注册全局/路由 Middleware
configure(_consumer: MiddlewareConsumer) {}
}
三个地方值得再说一遍:
imports里的分组有讲究:先全局配置(ConfigModule)、再各 DB/调度(TypeOrmModule/MongooseModule/ScheduleModule)、再业务模块、再 @Global 基础设施、最后 GraphQL 和日志。新人加模块时按这个分组找位置,模块树长期不会乱。providers里APP_FILTER/APP_INTERCEPTOR/APP_GUARD是 Nest 的全局 AOP 注册 token——useClass指定的类会被实例化并注册成全应用生效的过滤器 / 拦截器 / 守卫。一个请求进来,先被JwtAuthGuard/PermissionGuard(认证 + 鉴权)拦,进 controller 前后被LoggingInterceptor/TransformInterceptor/BehaviorLogInterceptor(日志、统一返回结构、行为记录)包,出错被HttpExceptionFilter统一处理。这就是下一章 AOP 的全部内容,这里先认个脸。- 最后那个空的
configure(_consumer: MiddlewareConsumer) {}不是多余的。AppModule实现NestModule接口、提供configure,是 Nest 注册全局 Middleware 的入口——consumer.apply(SomeMiddleware).forRoutes('*')就写在这里。learnhub 当前还没用全局 Middleware(守卫和拦截器已经覆盖了它的常见用途),所以是个空占位,注释里写明「阶段18 会在这里填」。第五章讲 AOP 五件套时会回来动它。
这一章的成果
@Module的四字段(imports/providers/controllers/exports)各管什么;provider 默认私有,要让别处用必须既providers又exports。- 动态模块
forRootAsync+useFactory+inject怎么把ConfigService注入进连接配置;forRoot/forFeature/register各自的语义和适用场景。 autoLoadEntities: true+ 各模块forFeature为什么比在根模块列全entities更模块化。@Global+ token provider 的写法(REDIS_CLIENT/MINIO_CLIENT/ES_CLIENT),以及它的双刃——基础设施用 @Global,业务 service 老老实实imports。OnModuleInit/OnModuleDestroy的优雅降级模式:基础设施挂了不阻断 boot、关闭时断连不阻断 shutdown。- 循环依赖首选重构拆开(learnhub 的
RankingService不反向依赖PostService),forwardRef是兜底。 - 根
AppModule的三个全局 AOP 注册 token(APP_FILTER/APP_INTERCEPTOR/APP_GUARD)和空的configure()占位,预告了下一章 AOP。
常见问题
Nest can't resolve dependencies of XxxService(YyyService, ...):YyyService没注入进来。三步排查——XxxModule.imports里有没有 import 提供YyyService的模块;那个模块有没有exports: [YyyService];YyyService是不是 @Global 模块导出的(如果是就不用 import,但那个 @Global 模块本身得在根AppModule.imports里出现过一次)。- 动态模块
forRootAsync报Nest can't resolve dependencies of useFactory(ConfigService):inject: [ConfigService]声明了但imports: [ConfigModule]没写,或ConfigModule不是isGlobal: true。工厂函数的依赖要显式声明。 forFeature([Post])注入了Repository<Post>但启动后表没建:根forRootAsync里autoLoadEntities是不是true(没有的话forFeature注册的实体会被忽略),或者synchronize: false且没跑 migration(生产正常情况,要npm run migration:run,见第八章)。- @Global 模块
exports了但别处注入不到:检查这个 @Global 模块是不是真的被根AppModule.importsimport 过一次——@Global 不是「不需要 import」,而是「import 一次后全局可用」。 forwardRef加了一边还报错:循环依赖要两边都加forwardRef,只加一边容器还是解析不动。更建议先想能不能像RankingService那样重构拆开。onModuleDestroy不触发:默认情况下 Nest 不监听SIGTERM/SIGINT。要在main.ts里app.enableShutdownHooks(),关闭信号才会触发 destroy 钩子链。
下一章讲 AOP 五件套——Middleware / Guard / Pipe / Interceptor / ExceptionFilter。这一章结尾那个空的 configure(_consumer) 就是 Middleware 的注册入口,APP_GUARD / APP_INTERCEPTOR / APP_FILTER 也都会展开成真实代码——把日志、鉴权、参数校验、统一返回、异常处理这些横切关注点从业务里抽出来。