Nest 使用笔记
第三章:IoC 与依赖注入——为什么构造函数里写个类型就能直接用
打开 learnhub 的 PostService(7 个真实依赖)和 RedisModule(useFactory),在真实代码里讲透 IoC 容器怎么工作、provider 的四种形式、单例与 REQUEST scope 的取舍、为什么 ConfigService 能随处注入。每段代码都指向 learnhub 的真实文件。
- Nest
- IoC
- 入门
前两章里你可能有个疑惑:controller 里只是写了 constructor(private readonly helloService: HelloService),从来没 new HelloService(),它怎么就能用了?这一章解开这个谜——它叫 IoC(控制反转)/ 依赖注入,是 Nest 的核心。和「写个 HelloService 演示一遍注入」不同,这一章直接打开 learnhub 已经在生产里跑的 PostService——它的构造函数挂着 7 个依赖,比任何玩具都能说清 IoC 到底解决了什么。
先搞懂:IoC 是什么 / 为什么需要 / Nest 怎么做
IoC 是什么:IoC(Inverse of Control,控制反转)的思路一句话能说清——把「创建和组装对象」的活交给一个容器,你只声明「我需要什么」,容器负责 new、负责把依赖传进去。这种「被动接收依赖」的方式叫 依赖注入(DI,Dependency Injection)。控制「反转」在哪:对象的创建不再由你自己控制,而是反转给容器。
为什么需要:手写 new 在小项目里没问题,对象一多就三个痛点一起爆——
- 你得记牢谁依赖谁、先
new哪个、是不是单例,组装逻辑散得满项目都是; - 改一个依赖要动一堆地方(比如
EmailService多要一个参数,所有new EmailService(...)都得改); - 配置(数据库账号、Redis 地址)没地方统一管,容易硬编码进代码。
Nest 怎么做(三步走,下面每一步都在 learnhub 真做一遍):
- 标记可注入:类上加
@Injectable(),告诉容器「这个类可以被注入、也可以注入别人」; - 登记进容器:在 module 的
providers里写上它,容器才会管它; - 构造函数声明依赖:在 controller/service 的构造函数参数里写「我需要 X」,容器启动时自动
newX、传进来。
容器实例化 provider 时默认是单例(DEFAULT scope)——整个应用只 new 一次,所有注入方共享同一个实例。这一点很关键,下面第四步讲为什么 learnhub 全程用单例。
这一章你会做出什么
- 用一个「手动 new」的反面例子,亲眼看到不用 IoC 有多烦。
- 打开 learnhub 的
PostService(7 个真实依赖),看 IoC 怎么撑起一个非玩具的业务类。 - 在
RedisModule真实代码里吃透useFactory:动态创建 provider、用别的 provider 当参数、字符串 token 怎么注入。 - 理解 provider scope:DEFAULT(单例)vs REQUEST(每请求一个)的取舍,为什么 learnhub 全用 DEFAULT。
- 搞懂
ConfigService为什么随处可注入(全局动态模块)。
前置:第一、二章的 hello-nest 能正常跑,learnhub 代码已拉到本地能打开看。
第一步:先看痛点——手动 new 有多烦
假设 UserService 要发邮件,依赖一个 EmailService;EmailService 自己又要读 SMTP 配置。没有 IoC 时你得自己组装:
// 反面教材:不用 IoC,手动创建和组装(别学这个)
const config = { host: "smtp.qq.com", port: 587, user: "账号", pass: "授权码" };
const emailService = new EmailService(config);
const userService = new UserService(emailService); // 手动把依赖传进去
userService.register("小明");
对象一多,问题就来了:你得记住谁依赖谁、先 new 哪个、是不是单例、配置从哪来……代码里到处是 new 和组装逻辑,改一个依赖要动一堆地方。
IoC 的解法是:你只写 constructor(private email: EmailService),剩下交给容器。一张图对比两种写法:
第二步:声明依赖——打开 learnhub 的 PostService
这是和「写个 HelloService 演示」差距最大的一节。learnhub 的 PostService 是一个真实在跑的业务类,它的构造函数挂了 7 个依赖——比任何玩具都能说明 IoC 在干嘛。
// 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,
private readonly userService: UserService,
private readonly ranking: RankingService,
private readonly amqp: AmqpService,
private readonly searchService: SearchService,
) {}
// ...
}
7 个形参,逐个看容器是怎么把它们塞进来的——
postRepo/tagRepo:Repository<Post>/Repository<Tag>不是普通的 service,是 TypeORM 的仓库。@InjectRepository(Post)这种类 token 之外的特殊 token 是必须的——因为同一个Repository<T>泛型,Post 和 Tag 各有一份,必须用这个装饰器指定是哪个实体的。这些 repo 是在PostModule.imports里的TypeOrmModule.forFeature([Post, Tag])注册进容器的(细节第八章讲)。dataSource:TypeORM 的DataSource本体,事务要用(create方法靠它)。@InjectDataSource()同样是个特殊装饰器。userService/ranking/amqp/searchService:四个普通的 service,没有加任何装饰器。这是因为它们的类型本身就是 class——Nest 拿 TS 反射出的类型当 token,去容器里找同名 class 的 provider,找到就注入。class token 的注入可以省@Inject(...),写起来最干净。
这 7 个依赖分两组:TypeORM 那三个靠装饰器注入(特殊 token),后四个靠类型注入(class token)。背后是同一个机制——容器在 new PostService 之前,先扫构造函数的参数列表,一个个去 providers 里找对应 token,全部就绪后一次性 new PostService(...)。任何一个找不到,启动时就报 Nest can't resolve dependencies of PostService (...),连不上就是连不上,不会等到运行时才炸。
PostService 自己是怎么被容器管的?三件事,缺一不可:
(1)类标了 @Injectable():见上面代码顶部,告诉容器「我可以被 new、可以注入别人」。
(2)在 module 的 providers 里登记:
// learnhub/src/modules/post/post.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
controllers: [PostController],
providers: [PostService], // 登记:容器要管这个对象
})
export class PostModule {}
providers: [PostService] 是简写,完整形式是 { provide: PostService, useClass: PostService }——意思是「需要 PostService 这个 token 时,容器 new 一个 PostService 类的实例」。
(3)容器启动时自动 new:Nest 启动时做这件事——扫到 PostController 构造函数需要 PostService → 去 PostModule.providers 里找 → 看到 PostService 是 useClass → 自动 new PostService(...) → 注入到 controller。你从没写过 new PostService(),但它就是能用。
注意 PostModule.imports 里还有 UserModule、RankingModule——它们各自 export 了 UserService、RankingService,PostModule 才能拿到这两个依赖注入给 PostService。跨模块共享 provider 是第四步讲的 exports / imports 机制;AmqpService / SearchService 来自被标了 @Global() 的模块,所以不用写进 imports(第五步讲为什么)。
思考:如果 PostService 明天要加一个新依赖(比如新写的 NotificationService),你具体要改什么?——只在构造函数里加一行 private readonly notify: NotificationService,前提是 NotificationService 已经在某个 module 的 providers 里登记、并且 PostModule 能看到它(要么 PostModule 自己 import 那个模块,要么那个模块是 @Global())。符合这两条,容器自动 new 它、传进来,业务代码一行不改。这就是 IoC 的核心好处:加依赖只改一行,组装逻辑一行不写。
第三步:provider 的四种形式——以 RedisModule 为锚
平时写 providers: [PostService] 是简写,等价于 { provide: PostService, useClass: PostService }。完整形式有四种,先看一张表,下面用 learnhub 真实代码展开 useFactory:
| 形式 | 写法 | 用途 |
|---|---|---|
useClass | { provide: PostService, useClass: PostService } | 默认,容器帮你 new 这个类 |
useValue | { provide: "TOKEN", useValue: { ... } } | 注入一个现成对象 / 常量 |
useFactory | { provide: "TOKEN", useFactory: (dep) => ..., inject: [Dep] } | 动态创建,可异步、可用别的 provider 当参数 |
useExisting | { provide: "B", useExisting: "A" } | 给已有 provider 起个别名 |
learnhub 真在用的是 useClass(到处都是)和 useFactory(接外部系统的地方都用它);useValue 项目里没用——配置走 ConfigService(更灵活,第五步讲),常量没必要单独做成 provider;useExisting 是给别名用的边角,知道有这么个东西就行。
useFactory:动态创建——以 RedisModule 为锚
凡是「provider 的实例要靠运行时才能造出来」的场景,都用 useFactory。最典型就是接外部系统的客户端:Redis、MinIO、Elasticsearch——它们的构造参数(host、port、密码)要从 ConfigService 读,而 ConfigService 自己也是个 provider,得等 IoC 容器起来才能注入。learnhub 的 RedisModule 就是一个干净的最小样本:
// learnhub/src/modules/redis/redis.module.ts
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: REDIS_CLIENT, // token 是个字符串常量
inject: [ConfigService], // 工厂函数的依赖:ConfigService
useFactory: (config: ConfigService) =>
new Redis({ // 工厂函数:用 ConfigService 读配置、new 出 ioredis 客户端
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 {}
读这张 provider 注册的几个要点——
- token 是字符串(
REDIS_CLIENT,定义见redis.constants.ts里的export const REDIS_CLIENT = 'REDIS_CLIENT')。class token(比如PostService)Nest 能用 TS 类型反射自动匹配,字符串 token 必须显式@Inject(...),下面马上看。 inject: [ConfigService]:工厂函数依赖ConfigService。容器会先确保ConfigService就绪,再把它作为参数传给useFactory。这条inject顺序和useFactory的形参顺序一一对应——要两个依赖就写inject: [A, B],形参(a, b) => ...。useFactory可以是async:要await初始化(比如连数据库握手)就在工厂里return await ...。Nest 会等工厂的 Promise resolve 再继续装配下游。learnhub 这里是同步返回 client、靠 ioredis 内部重连,所以没写 async。
字符串 token 怎么注入:必须 @Inject
RedisService 消费这个 REDIS_CLIENT:
// 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);
}
// ...
}
@Inject(REDIS_CLIENT) 是关键——字符串 token 没有类型可反射,不加 @Inject Nest 就不知道要注入哪个 provider,启动会报 Nest can't resolve dependencies of RedisService。class token(如 PostService 里的 UserService)才能省掉这个装饰器。
注意:RedisModule 的 exports: [RedisService] 只导出了 RedisService,没导出 REDIS_CLIENT 这个 token——也就是说别的模块只能注入 RedisService,不能直接注入原始的 ioredis client。这是有意的封装:外部代码只该用 RedisService 提供的封装方法(incr / zincrby / getdel…),需要原始 client 时走 redisService.getClient()。把实现细节(ioredis 的 API 表面)关在模块内部,是后面第八章「模块边界」要展开的话题。
另外注意模块头上的 @Global() 装饰器——它让 RedisModule 不必被每个使用方写进 imports,下一节细讲。
第四步:provider scope——为什么 learnhub 全用 DEFAULT(单例)
这是旧版课程完全跳过、但上线一定会问的一节。provider 注册时可以选 scope(生命周期),Nest 给三种:
DEFAULT(默认):整个应用一个实例,所有注入方共享。单例。REQUEST:每个 HTTP 请求各new一个实例,请求结束销毁。TRANSIENT:每次注入都new一个新的。
learnhub 所有 provider 都是 DEFAULT——不是图省事,是有意为之。看 PostService:它持有 postRepo、userService、amqp 这些依赖,这些依赖下面又各自持有连接、客户端。单例意味着这些重型资源(数据库连接池、Redis client、AMQP channel)整个应用只造一次、所有请求共享,启动成本摊到第一次注入,之后零开销。RedisService 那个 ioredis client 全应用就一个,几万次请求复用同一条连接,这是 Redis 性能的前提。
REQUEST scope 听起来「隔离干净、每个请求一个全新的 service」,代价其实很高:
- 每个请求都要重新
new一整条依赖链(PostService依赖UserService,UserService又依赖一堆……整条链都要重建),GC 压力陡增; - 更致命的是它会让下游所有 provider 也被迫变成 REQUEST scope——单例不能依赖一个比它生命周期短的对象(不然单例持有的 service 在请求结束后会变成悬空引用)。一旦
PostService标 REQUEST,它的 7 个依赖也全部跟着降级,连Repository这种本该全局共享的东西都被迫每请求重建,数据库连接瞬间打爆。
那「每个请求需要隔离的数据」(当前用户、租户 id、trace id)怎么办?learnhub 的做法是把请求数据挂在 request 对象上,service 保持单例、靠参数传递请求级数据。具体落地:守卫解析 JWT 后把 user 挂到 req.user,@CurrentUser() 装饰器从 req.user 取出来、作为方法参数传给 service(第十一章细讲)。service 本身始终是单例,不持有任何请求级状态——所以并发安全。
注意:你 99% 的时间都用 DEFAULT,不要因为「听起来隔离更好」就给 service 加 REQUEST scope——它带来的性能代价远超收益。REQUEST scope 真正合理的场景非常少(比如基于请求的租户隔离缓存),不属于这一章的范围。
第五步:ConfigService 为什么随处可注入——全局动态模块
回头看 AuthService,它的构造函数直接注入了 ConfigService,但 AuthModule 里并没写 imports: [ConfigModule]:
// learnhub/src/modules/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService, // 随处可注入,为什么?
) {}
async login(dto: LoginDto): Promise<TokenPair> {
// ...
const salt = this.config.get<string>("passwordSalt"); // 读 .env 里的 passwordSalt
// ...
}
}
秘密在 AppModule 的 imports 第一项:
// learnhub/src/app.module.ts
ConfigModule.forRoot({
isGlobal: true, // 关键:全局模块,所有模块都能注入 ConfigService,不用逐个 import
envFilePath: ".env",
load: [configuration],
}),
isGlobal: true 让 ConfigModule 变成全局模块——它导出的 ConfigService 在整个应用里随处可注入,任何 module 都不必写 imports: [ConfigModule]。这就是上一节 RedisModule 头上那个 @Global() 装饰器干的同一件事:省掉每个使用方写 import 的繁琐。learnhub 把基础设施类的模块(Config、Redis、MinIO、Search、Amqp、Etcd)都做成了全局,业务模块(Post、Comment、Tag)则正常用 imports 显式声明依赖——边界更清楚。
ConfigService 的能力很直白:config.get<string>("passwordSalt") 读 .env 里的 PASSWORD_SALT、config.get<number>("mysql.port") 读 MYSQL_PORT。learnhub 在 src/config/configuration.ts 里把扁平的 env 变量解析成嵌套对象(mysql.host / jwt.secret / redis.port 这种点路径),所以 config.get("mysql.host") 能拿到值。这是配置管理的标准做法——.env 不进 git、按环境(开发/测试/生产)切换,比把配置写进代码、或塞进 useValue 当常量注入灵活得多(这也是上面 learnhub 不用 useValue 的原因)。
JwtModule 的注册方式也值得看一眼,它是动态模块的标准写法:
// learnhub/src/modules/auth/auth.module.ts
JwtModule.registerAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("jwt.secret"),
signOptions: { expiresIn: config.get<string>("jwt.accessExpires") },
}),
}),
registerAsync 和上面的 forRootAsync、RedisModule 的 useFactory 是同一套机制——等 ConfigService 就绪 → 跑工厂函数 → 用返回值配置 JwtModule → JwtModule 内部按这个配置注册自己的 provider(JwtService)。注意它 global: true,所以全局的 JwtAuthGuard 也能直接注入 JwtService。动态模块是下一章的主题,这里先认个脸。
第六步:循环依赖与 forwardRef
真实项目偶尔会撞上:A 依赖 B、B 又依赖 A。比如 PostService 要发通知调 NotificationService,而 NotificationService 又要查帖子信息调 PostService——两边构造函数互相要对方,容器 new A 时发现要先 new B、new B 时又发现要先 new A,启动直接报环。
Nest 给的出路是 forwardRef:两边在构造函数里都写 @Inject(forwardRef(() => OtherService)),告诉容器「这个依赖先别管,先把自己造出来,回头再补」。容器就能解开这个环。
但 forwardRef 是创可贴不是解药——它说明模块边界画错了。绝大多数循环依赖可以靠把公共逻辑抽到第三个 service(A、B 都依赖 C,A 和 B 之间不再互相依赖)或者改成方法内按需取(不在构造函数注入、用到时从容器拿)来化解。learnhub 全项目搜不到一处 forwardRef——它的模块边界经过了拆分(PostService 只依赖 UserService 而不反向、SearchService 只被 PostService 调而不调它),从源头避开了环。
循环依赖、动态模块(forRoot / forFeature / register 三件套)、@Global 模块怎么自己写一个、OnModuleInit / OnModuleDestroy 生命周期钩子——这些都是下一章的主题。
这一章的成果
- 用「手动 new」的反面例子看到了不用 IoC 的痛;理解 IoC 把对象创建/组装交给容器,DI 是被动接收依赖。
- 在 learnhub 真实的
PostService(7 个依赖)里吃透 IoC 三件套:@Injectable()标记 +providers登记 + 构造函数声明,容器自动 new 和注入;分清 class token(可省@Inject)和字符串 token(必须@Inject)。 - 在
RedisModule真实代码里吃透useFactory:动态创建、inject声明依赖、@Global模块、exports封装边界。 - 知道 provider scope 三档,为什么 learnhub 全用 DEFAULT(单例),以及 REQUEST scope 为什么几乎不该用。
- 搞懂
ConfigService为什么随处可注入(ConfigModule.forRoot({ isGlobal: true })),以及JwtModule.registerAsync这类动态模块的标准写法。
常见问题
- 报
Nest can't resolve dependencies of XxxService:多半是字符串 token 没加@Inject(...)(看RedisService的@Inject(REDIS_CLIENT)),或者忘了在 module 的providers里登记,或者跨模块时被依赖的 module 没exports那个 provider。 - 跨模块注入失败:默认一个 provider 只在当前 module 能注入。要么定义处
exports+ 使用处imports(像 PostModule imports UserModule),要么定义处加@Global()(像 RedisModule)。 useClass/useValue/useFactory怎么选:平时用useClass(简写providers: [Xxx]就行);接外部系统、要按配置或异步创建的用useFactory(看 RedisModule);要注入一个静态常量对象用useValue(learnhub 没用,配置统一走 ConfigService)。- 该不该用 REQUEST scope:默认别用。它会让整条依赖链每请求重建,性能代价大。请求级数据走
req.user/@CurrentUser()这类机制按参数传递,service 保持单例。 - 构造函数里能干活吗:尽量别。构造函数只该「声明依赖、存好引用」。要做的初始化(连数据库握手、读文件、起定时器)放进
OnModuleInit.onModuleInit()——它由容器在所有 provider 都 new 完之后调用,这时你的依赖也都就绪了。learnhub 的PrismaService/AmqpService/SearchService/MinioService都用了这个钩子,下一章细讲。
下一章讲 Module 进阶:全局模块(@Global)、动态模块(forRoot / forFeature / register / registerAsync)、循环依赖(forwardRef)、OnModuleInit / OnModuleDestroy 生命周期——这是接 TypeORM、JWT、Redis 等所有库的基础,也是这一章反复预告过的那一章。