CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
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 Beanproviders本模块内部可注入的类 @Service / @Componentexports把本模块 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。
延伸