如果你刚开始学 NestJS,很容易一上来就被这些东西淹没:
Controller、Service、Module、Provider、DTO、Pipe、依赖注入、各种装饰器......
每个词单独看好像都能理解,但放在一起,就不知道它们到底是怎么协作的。
其实快速入门 NestJS,不应该从背概念开始。
最重要的是先搞明白一件事:
前端发来一个 HTTP 请求之后,它在 NestJS 里面到底经历了什么?
只要把这条链路跑通,NestJS 最核心的一套东西基本就串起来了。
一、先建立 NestJS 最重要的心智模型
先不要管数据库、ORM、JWT、RAG、消息队列这些东西。
我们只看一个最简单的请求:
markdown
浏览器发出请求
↓
Controller:决定哪个方法接收请求
↓
Service:执行具体业务逻辑
↓
返回结果
除此之外,还有两个重要角色:
css
main.ts
↓
启动整个 NestJS 应用
Module
↓
把 Controller、Service 等组件组织起来
也就是说,刚开始学习 NestJS,只需要先认识四个东西:
css
main.ts 启动项目
Module 组织功能
Controller 接收请求
Service 处理业务
这就是整个入门阶段最重要的骨架。
二、一次请求到底是怎么跑起来的?
NestJS 创建出来的默认项目里,一般会看到:
css
src/
├── main.ts
├── app.module.ts
├── app.controller.ts
└── app.service.ts
不要急着背这些文件。
我们按照程序真正运行的顺序看。
1. main.ts:程序从这里启动
最关键的是:
ini
const app = await NestFactory.create(AppModule);
await app.listen(3000);
第一句:
lua
NestFactory.create(AppModule)
可以先简单理解成:
根据
AppModule的配置,把整个 NestJS 应用创建出来。
第二句:
ini
app.listen(3000);
表示:
后端开始监听 3000 端口。
所以当我们访问:
arduino
http://localhost:3000
请求才能找到这个 NestJS 服务。
三、Module:不是处理业务,而是负责"组队"
然后看:
less
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
初学的时候,不要把 Module 想得太复杂。
它主要是在告诉 NestJS:
sql
这个模块有哪些 Controller?
这个模块有哪些 Service?
这个模块还依赖哪些其他 Module?
例如:
ini
controllers: [AppController]
表示这个模块里有一个负责接收请求的 AppController。
而:
ini
providers: [AppService]
表示:
AppService交给 NestJS 管理,其他组件可以使用它。
至于:
ini
imports: []
则用来引入其他模块。
所以可以先记一句:
Module 不负责具体业务,它负责组织功能。
四、Controller:决定"谁来接这个请求"
来看最简单的 Controller:
less
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
) {}
@Get()
getHello() {
return this.appService.getHello();
}
}
当浏览器访问:
sql
GET /
NestJS 会找到:
java
@Get()
然后执行下面的方法。
也就是说:
less
@Get()
getHello() {}
真正决定请求能不能进入这个方法的,不是 getHello 这个方法名。
而是:
java
@Get()
这个路由装饰器。
方法名甚至可以改成:
scss
abc()
只要 @Get() 还在,请求照样可以匹配。
所以 Controller 最核心的职责可以理解为:
接请求、拿参数、调用业务逻辑、返回结果。
五、Service:真正干活的地方
例如:
kotlin
@Injectable()
export class AppService {
getHello() {
return 'Hello World!';
}
}
Controller:
kotlin
@Get()
getHello() {
return this.appService.getHello();
}
整个过程其实就是:
kotlin
GET /
↓
AppController
↓
appService.getHello()
↓
AppService
↓
return "Hello World!"
↓
浏览器
这也是为什么业务逻辑通常不应该全塞在 Controller 里面。
假设以后你有:
上传文件
创建用户
调用大模型
查询数据库
权限检查
文档解析
如果全部写进 Controller,文件很快就会乱掉。
所以一般会形成这样的分工:
Controller
负责 HTTP 世界
Service
负责业务世界
六、为什么 Controller 能直接使用 Service?
这里就是 NestJS 非常重要的一个概念:
依赖注入
看这段代码:
typescript
constructor(
private readonly documentService: DocumentService,
) {}
刚开始看很容易疑惑:
documentService是哪里来的?
你并没有手写:
scss
new DocumentService()
原因就在 Module:
kotlin
@Module({
controllers: [DocumentController],
providers: [DocumentService],
})
export class DocumentModule {}
你已经告诉 NestJS:
DocumentService 是这个模块提供的 Provider。
而 Controller 又声明:
我需要 DocumentService。
于是 NestJS 创建 Controller 时,就会把 DocumentService 创建好,再传进来。
这就是依赖注入最直观的理解。
可以把它想象成:
markdown
Controller:
"我需要一个 DocumentService。"
↓
Module:
"我这里登记过 DocumentService。"
↓
NestJS:
"行,我创建好以后给你。"
所以入门阶段理解 DI,不需要先研究 IOC 容器的底层原理。
先记住:
需要什么就声明什么,创建和传递依赖这件事交给 NestJS。
七、真正的项目应该按功能拆 Module
一开始所有东西可能都放在:
AppController
AppService
但项目一大,这显然不现实。
例如一个知识库项目以后可能有:
用户
文档
权限
AI
聊天
搜索
所以我们会拆成:
javascript
src/
├── document/
│ ├── document.controller.ts
│ ├── document.service.ts
│ ├── document.module.ts
│ └── dto/
├── user/
├── ai/
└── app.module.ts
例如文档模块:
kotlin
@Module({
controllers: [DocumentController],
providers: [DocumentService],
})
export class DocumentModule {}
然后在根模块:
kotlin
@Module({
imports: [DocumentModule],
})
export class AppModule {}
于是整个结构逐渐变成:
AppModule
↓
DocumentModule
├── DocumentController
└── DocumentService
这才是理解 NestJS Module 的关键:
它是按照业务功能划分代码边界的。
八、路由其实就是拼出来的
例如:
kotlin
@Controller('documents')
export class DocumentController {
@Get()
getDocuments() {}
}
最终接口就是:
bash
GET /documents
因为:
less
@Controller('documents')
+
@Get()
=
GET /documents
如果写:
kotlin
@Get(':id')
那么就变成:
bash
GET /documents/:id
例如:
bash
GET /documents/1
GET /documents/2
所以看到 NestJS 路由时,可以直接把 Controller 前缀和方法路由拼起来理解。
九、@Param:从 URL 中拿数据
假设接口是:
bash
GET /documents/123
我们希望拿到 123:
less
@Get(':id')
getDocumentById(
@Param('id', ParseIntPipe) id: number,
) {
return this.documentService.getDocumentById(id);
}
其中:
kotlin
@Get(':id')
表示这里有一个动态参数。
而:
kotlin
@Param('id')
就是把它取出来。
不过这里有一个非常重要的细节:
URL 中的:
123
本质上拿到的是:
arduino
"123"
也就是字符串。
所以光写:
bash
id: number
并不会真的把它转换成数字。
TypeScript 类型只存在于开发阶段。
真正负责转换的是:
ParseIntPipe
于是流程变成:
arduino
"/documents/1"
↓
取到 "1"
↓
ParseIntPipe
↓
1
如果访问:
bash
/documents/abc
无法转成数字,Pipe 就会直接返回 400。
这一点非常值得记住:
TypeScript 类型声明不等于运行时数据校验。
十、@Body:接收前端发来的数据
创建文档时可能发送:
bash
POST /documents
请求体:
json
{
"title": "请假流程",
"content": "请提前一天提交申请"
}
Controller 可以这样接:
less
@Post()
createDocument(
@Body() dto: CreateDocumentDto,
) {
return this.documentService.createDocument(dto);
}
这里:
java
@Body()
负责取请求体。
和前面的 @Param() 放在一起看就很清楚:
less
@Param()
从 URL 路径拿数据
@Body()
从请求体拿数据
十一、为什么 NestJS 项目经常出现 DTO?
假设用户创建文档时应该发送:
json
{
"title": "员工入职指南"
}
那用户如果发送:
json
{
"title": 123
}
怎么办?
或者:
json
{
"title": " "
}
怎么办?
这就是 DTO 出现的地方。
DTO 全称:
css
Data Transfer Object
数据传输对象
它可以用来描述:
这个接口允许调用者传什么数据,这些数据需要满足什么规则。
例如:
less
export class CreateDocumentDto {
@IsString({
message: '标题必须是字符串',
})
@Matches(/\S/, {
message: '标题必须包含非空白字符',
})
title: string;
}
这里:
java
@IsString()
要求必须是字符串。
而:
typescript
@Matches(/\S/)
要求字符串中至少存在一个非空白字符。
因此:
json
{ "title": "请假流程" }
可以通过。
而:
json
{ "title": " " }
不能通过。
十二、DTO 自己并不会自动检查数据
这是一个很容易踩的坑。
你写了:
makefile
dto: CreateDocumentDto
并不代表网络传来的数据就一定符合 CreateDocumentDto。
还需要真正执行校验的人。
也就是:
ValidationPipe
例如在 main.ts:
php
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
可以先这样理解:
请求进来
↓
ValidationPipe
↓
按照 DTO 检查
↓
合格
↓
Controller
↓
Service
如果不合格:
请求
↓
ValidationPipe
↓
400
甚至不会进入 Controller。
这就让职责变得很清晰:
DTO
描述数据规则
ValidationPipe
执行规则
Controller
接收已经检查过的数据
Service
处理业务
于是 Service 不需要到处写:
csharp
if (typeof title !== 'string') {
...
}
业务代码会干净很多。
十三、用一个 CRUD 把 NestJS 串起来
理解到这里,可以做一个最小文档模块。
最终有五个接口:
| 请求 | 功能 |
|---|---|
GET /documents |
查询列表 |
GET /documents/:id |
查询详情 |
POST /documents |
创建 |
PATCH /documents/:id |
修改 |
DELETE /documents/:id |
删除 |
这五个接口已经足够把 NestJS 最核心的知识串起来。
查询列表
Controller:
kotlin
@Get()
getDocuments() {
return this.documentService.getDocuments();
}
Service:
javascript
getDocuments() {
return this.documents;
}
流程:
bash
GET /documents
↓
DocumentController
↓
DocumentService
↓
documents
↓
JSON 响应
查询详情
less
@Get(':id')
getDocumentById(
@Param('id', ParseIntPipe) id: number,
) {
return this.documentService.getDocumentById(id);
}
Service 找不到数据时:
arduino
throw new NotFoundException('文档不存在');
NestJS 会转换成:
404 Not Found
于是:
参数格式不合法
→ 400
参数合法,但是资源不存在
→ 404
这已经开始接近真正后端接口的处理方式。
创建数据
less
@Post()
createDocument(
@Body() dto: CreateDocumentDto,
) {
return this.documentService.createDocument(dto);
}
这里你会发现:
Controller 几乎没有业务代码。
它只负责:
css
接收 Body
↓
交给 Service
↓
返回结果
这正是比较健康的分工方式。
十四、PATCH 为什么要单独做 Update DTO?
创建文档时:
json
{
"title": "员工手册",
"content": "正文"
}
标题和正文可能都必须存在。
但修改时,用户可能只想改标题:
json
{
"title": "新版员工手册"
}
正文根本没有修改。
因此创建和修改的数据规则其实并不完全一样。
所以会出现:
CreateDocumentDto
UpdateDocumentDto
例如修改 DTO:
less
export class UpdateDocumentDto {
@ValidateIf((_object, value) => value !== undefined)
@IsString()
title?: string;
@ValidateIf((_object, value) => value !== undefined)
@IsString()
content?: string;
}
这里:
c
title?: string
表示标题可以不传。
而:
java
@ValidateIf(...)
表示:
传了我才校验。
于是:
{}
和:
json
{
"title": null
}
并不是一回事。
前者是:
我不修改 title。
后者是:
csharp
我要把 title 改成 null。
业务语义完全不同。
十五、DELETE:成功不一定非要返回一句"删除成功"
例如:
less
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteDocument(
@Param('id', ParseIntPipe) id: number,
) {
this.documentService.deleteDocument(id);
}
删除成功返回:
css
204 No Content
意思就是:
操作完成,但是没有响应正文。
而如果目标文档不存在:
404 Not Found
于是一个小型文档 CRUD 就完整了。
十六、现在重新看一条请求
学完前面的东西以后,再看:
bash
POST /documents
你应该能把它还原成:
bash
客户端
↓
HTTP POST /documents
↓
ValidationPipe
↓
CreateDocumentDto 校验
↓
DocumentController
↓
DocumentService
↓
创建数据
↓
Controller 返回结果
↓
NestJS 转成 HTTP 响应
↓
客户端
到这里,NestJS 就不再是一堆零散装饰器了。
而是一条完整的数据流。
十七、NestJS 最核心的几个装饰器其实没有那么复杂
快速过一遍:
kotlin
@Controller('documents')
这个类负责 /documents 相关请求。
java
@Get()
接 GET。
java
@Post()
接 POST。
kotlin
@Patch(':id')
接 PATCH。
kotlin
@Delete(':id')
接 DELETE。
kotlin
@Param('id')
从 URL 路径拿参数。
java
@Body()
从请求体拿数据。
java
@Injectable()
告诉 Nest:
这个类可以交给依赖注入系统管理。
java
@Module()
告诉 Nest:
这个模块有哪些 Controller、Provider 和其他 Module。
如果把这些装饰器放回真实请求流程里理解,它们其实一点也不神秘。
十八、初学 NestJS 最容易混淆的几个地方
1. 不要认为 id: number 会自动校验参数
不会。
bash
id: number
主要是 TypeScript 类型信息。
运行时需要:
ParseIntPipe
2. 不要把 DTO 当数据库表
DTO 描述的是:
接口传输的数据
数据库 Entity 描述的是:
数据如何存储
它们可能长得很像,但职责不同。
3. 不要把业务全塞进 Controller
更好的结构是:
Controller
接收 HTTP 请求
Service
处理业务
4. 不要只创建 Module,却忘记导入它
文件顶部:
javascript
import { DocumentModule } from './document/document.module';
只是让这个文件能够使用 DocumentModule 这个类。
真正把它加入 NestJS 应用,还需要:
css
@Module({
imports: [DocumentModule],
})
这两个 import 不是一回事。
5. 不要一开始死磕依赖注入底层
刚开始只需要理解:
typescript
constructor(
private readonly documentService: DocumentService,
) {}
表示:
Controller 需要 DocumentService。
而:
ini
providers: [DocumentService]
表示:
NestJS 可以提供这个 Service。
先会用,再研究 DI Token、useFactory、useValue 等更高级的东西。
十九、快速入门 NestJS,到什么程度算真正入门?
不是背出:
sql
Controller 是控制器
Service 是服务
Module 是模块
这没有太大意义。
真正入门的标准应该是:
看到:
bash
PATCH /documents/123
你脑子里能够自然还原:
bash
请求进入 NestJS
↓
根据路由找到 Controller
↓
ParseIntPipe 处理 id
↓
ValidationPipe 根据 DTO 校验 Body
↓
Controller 调用 Service
↓
Service 修改数据
↓
结果返回客户端
如果这条链路你能说清楚,那么 NestJS 最核心的骨架其实已经掌握了。
后面学习 PostgreSQL、TypeORM、JWT、文件上传、Redis,甚至把 LangChain、Agent、RAG 接进 NestJS,本质上都只是在这套骨架上继续增加能力。
总结
如果只用一句话概括 NestJS:
NestJS 就是在帮我们把"请求怎么进来、业务放哪里、功能怎么组织、依赖怎么连接、数据怎么校验"这几件事规范起来。
快速入门时,不需要先背几十个装饰器。
先把这条主线吃透:
css
main.ts
↓
Module
↓
Controller
↓
Pipe / DTO
↓
Service
↓
返回结果
然后亲手做一套:
sql
GET
GET /:id
POST
PATCH
DELETE
NestJS 的第一层就基本打通了。
再往后,无论接数据库还是接 AI,你都已经知道:新的代码应该放在哪里,以及一次请求到底是怎么流过整个后端的。