大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Module 与依赖注入
这一课你将能做到
- 写出带
controllers/providers/exports的特征模块(题库模块、作答模块) - 用构造器注入 Service,并解释默认单例作用域
- 故意漏注册 Provider,读懂启动报错并独立修好
为什么要学
Module(模块)是 Nest 的组织单元。人话:一个业务包(如题库 questions)把自己的 Controller、Service 列在 @Module({...}) 清单里;需要把 Service 分享给别的包时,再通过 exports 显式开放。
依赖注入(DI,Dependency Injection)人话:类不自己 new 依赖,而是在构造器里声明「我需要 QuestionsService」,由 Nest 容器创建实例并塞进来。好处是依赖关系一目了然、单元测试时可以随时换成 mock。
这和 Spring 的 IoC 容器、@Service Bean 是同一套思想,只是语法换成了 TypeScript 装饰器。
先建立图像
AppModule
imports: [QuestionsModule, SubmissionsModule]
│
▼
QuestionsModule
controllers: [QuestionsController]
providers: [QuestionsService]
exports: [QuestionsService] ← 别人 import 后才能注入
SubmissionsModule
imports: [QuestionsModule] ← 判分要查题目标准答案
│
▼
SubmissionsService
constructor(private readonly questions: QuestionsService) {}
@Module 四个字段:
| 字段 | 作用 | Spring 类比 |
|---|---|---|
imports | 引入其他模块导出的能力 | 引入其他 Configuration |
controllers | 本模块的 HTTP 入口集合 | @RestController Bean |
providers | 本模块内部可注入的类 | @Service / @Component |
exports | 把本模块 Provider 开放给其他模块 | 公开暴露 Bean |
跟着做
步骤 1:用 CLI 生成题库资源
在 quiz-api 项目根目录执行:
nest g module questions
nest g controller questions --no-spec
nest g service questions --no-spec
src/questions/ 下会出现三个文件,且新版本 CLI 会自动把 QuestionsModule 加入根模块的 imports。再生成作答模块:
nest g module submissions
nest g service submissions --no-spec
nest g controller submissions --no-spec
步骤 2:写最小 Service 与 Module
src/questions/questions.service.ts:
import { Injectable } from "@nestjs/common";
export interface Question {
id: number;
stem: string; // 题干
options: string[]; // 选项
answerIndex: number; // 正确选项下标
difficulty: "easy" | "medium" | "hard";
}
@Injectable()
export class QuestionsService {
private items: Question[] = [
{
id: 1,
stem: "TypeScript 中,用来描述对象形状的类型是?",
options: ["interface", "class", "enum", "namespace"],
answerIndex: 0,
difficulty: "easy",
},
];
findAll(): Question[] {
return this.items;
}
findOne(id: number): Question | undefined {
return this.items.find((q) => q.id === id);
}
getAnswerIndex(id: number): number {
const q = this.findOne(id);
return q ? q.answerIndex : -1;
}
}
src/questions/questions.module.ts:
import { Module } from "@nestjs/common";
import { QuestionsController } from "./questions.controller";
import { QuestionsService } from "./questions.service";
@Module({
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService], // 作答模块要靠它判分
})
export class QuestionsModule {}
步骤 3:Controller 构造器注入
src/questions/questions.controller.ts:
import { Controller, Get, Param } from "@nestjs/common";
import { QuestionsService } from "./questions.service";
@Controller("questions")
export class QuestionsController {
// 人话:不要 new QuestionsService(),交给容器
constructor(private readonly questions: QuestionsService) {}
@Get()
findAll() {
return this.questions.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.questions.findOne(Number(id));
}
}
启动后访问 http://localhost:3000/questions,应看到种子题目数组。
步骤 4:制造并修复「未注册 Provider」
临时把 QuestionsModule 中 providers 里的 QuestionsService 删掉,保存。终端会报类似错误:
Nest can't resolve dependencies of the QuestionsController (?).
Potential solutions:
- Is QuestionsService a provider?
- If ... add it to the providers list.
加回 providers: [QuestionsService] 即恢复。结论:这类报错指向装配清单,不是路由写错。
步骤 5:跨模块注入(作答模块判分)
src/submissions/submissions.module.ts:
import { Module } from "@nestjs/common";
import { QuestionsModule } from "../questions/questions.module";
import { SubmissionsController } from "./submissions.controller";
import { SubmissionsService } from "./submissions.service";
@Module({
imports: [QuestionsModule], // 必须导入,才能看到 exports
controllers: [SubmissionsController],
providers: [SubmissionsService],
})
export class SubmissionsModule {}
src/submissions/submissions.service.ts:
import { Injectable } from "@nestjs/common";
import { QuestionsService } from "../questions/questions.service";
export interface Submission {
questionId: number;
userId: number;
selectedIndex: number;
correct: boolean;
}
@Injectable()
export class SubmissionsService {
private records: Submission[] = [];
constructor(private readonly questions: QuestionsService) {}
submit(questionId: number, userId: number, selectedIndex: number): Submission {
const answerIndex = this.questions.getAnswerIndex(questionId);
const record: Submission = {
questionId,
userId,
selectedIndex,
correct: answerIndex === selectedIndex,
};
this.records.push(record);
return record;
}
}
Nest 的可见性是模块边界:只在根模块引入没用,使用方模块自己也必须 imports。
步骤 6:确认默认单例作用域
在任意两处注入 QuestionsService,默认拿到的是同一个实例(整个应用生命周期内单例)。所以 Service 上的内存数组会被所有请求共享——演示方便,但多实例部署时不要把它当数据库。
步骤 7:认识自定义 Provider(选学形状)
除了直接写类,Provider 还可以是值、工厂:
@Module({
providers: [
QuestionsService,
{ provide: "PASS_SCORE", useValue: 60 },
{
provide: "ID_GENERATOR",
useFactory: () => () => Math.floor(Math.random() * 1_000_000),
},
],
exports: ["PASS_SCORE"],
})
export class QuestionsModule {}
注入字符串令牌时配合 @Inject("PASS_SCORE")。测试中最常用的 useValue 就是这个机制。
讲清楚要点
- 一个业务域一个 Module:题库、作答各自成包,避免全部塞进 AppModule。
- 按构造器类型解析依赖:参数类型必须对应一个已注册 Provider,配合
emitDecoratorMetadata才能工作。 - 跨模块两步走:提供方
exports,使用方imports,缺一不可。 - 默认单例:可变内存状态会被所有请求共享,入门阶段避免在生产依赖这一特性。
- 测试红利来自 DI:换 Provider 实现(mock)即可控制依赖行为,和 Spring 替换 Bean 的思路一致。
- 显式优于自动扫描:Nest 不像 Spring 靠包扫描自动发现一切,模块树更啰嗦,但边界更清晰。
你可能会懵的地方
-
providers 和 exports 有何区别? providers 让本模块内部能注入;exports 允许其他模块注入。只 providers 不 exports,外部模块拿不到。
-
为什么不能把别人的 Service 直接写进自己的 providers? 重复登记会产生两个实例、破坏单一所有权,也违背模块边界。正确做法是 import 对方模块、使用其 exports。
-
两个模块互相引用会怎样? 形成循环依赖,容器初始化时可能报错。初学者遇到这种情况优先重构边界;实在拆不开时才用
forwardRef应急。 -
@Injectable() 的括号能省略吗? 带参数时必须括号;规范写法统一加括号。被注入的类都应该标注它。
-
Nest 为什么不像 Spring 那样自动扫描所有 @Service? Nest 选择显式模块树,强制你声明依赖与可见性,大型项目里边界更可控、也更容易做按需加载。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
Controller 里 new QuestionsService() | 绕过容器、单例失效、难测试 | 构造器注入 |
| 跨模块用 Service 却不 exports | 运行期解析失败 | exports + 对方 imports |
| 所有类都塞进 AppModule | 根模块膨胀、边界消失 | 按业务域拆特征模块 |
漏写 @Injectable() | 元数据缺失、注入异常 | 补装饰器并注册 |
| 用 providers 重复登记别的模块的 Service | 出现多实例、状态不一致 | 通过模块导出共享 |
卡点急救
- 报
can't resolve dependencies:按报错中的类名,依次检查它的构造器参数类型是否已在当前模块 providers 或 imports 的 exports 中。 - 注入得到 undefined:检查循环依赖、参数属性写法(
private readonly x: X)以及是否误用了 interface 当运行时令牌。 - CLI 没有自动更新 AppModule:确认生成命令在项目根目录执行;手工在 AppModule 的 imports 补上即可。
面试常问
-
NestJS 的模块可见性规则是什么? Service 默认仅在声明它的模块内可见;必须在
exports中暴露、消费模块在imports中引入后才能跨模块注入。这与 Spring Bean 默认全局可见不同。 -
NestJS Provider 的默认作用域是什么? 默认是 DEFAULT(应用级单例)。此外还有 REQUEST(每请求一个实例)和 TRANSIENT(每次注入新建),后两者有性能开销,按需使用。
-
依赖注入在测试时带来什么好处? 可以用
useValue/useClass把数据库、网络客户端替换成假实现,让单元测试快速、确定、不依赖外部环境。
自测三问
providers与exports分别解决谁的「看得见」问题?- 启动报无法解析 QuestionsController 依赖时,你先查哪两处?
- 默认单例作用域对放在 Service 字段里的内存数据有什么影响?
小结一句话
Module 打包 Controller 与 Provider,DI 按构造器类型注入,跨模块协作靠 exports 加 imports。
延伸
- Modules(官方模块文档)
- Providers / Dependency Injection
- 下一课预告:学习 Controller 与路由细节,用
@Param/@Query/@Body取参,用 DTO 描述创建题目的请求体。