NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理
本文基于一个真实可运行的 NestJS hello 项目(NestJS 11.0.1 + TypeScript 5.7.3),用 7 个源文件 + 5 个 CRUD 接口,把 NestJS 大厂面试高频考点一次性讲透:MVC 三层落地、三层装饰器系统、依赖注入链路、内置异常类实战。文中代码来自真实项目,运行未验证,仅用于讲解结构。
写在前面:为什么是 hello 项目
很多 NestJS 教程用「玩具例子」讲概念------单独贴一段 @Get() 你以为懂了,回到真实项目还是懵。问题不在你,在例子。本文换个思路:拿一个能跑起来的 hello 项目,7 个文件全部摆出来,5 个 CRUD 接口逐个拆,让你看到 MVC 不是教科书三层等分,而是「Controller 接 HTTP + Service 写业务 + Module 组装」的工程分工。
理解这 7 个文件的协作,就能回答 NestJS 大厂面试的 80% 问题。剩下 20% 是 TypeORM、微服务、守卫拦截器这些进阶话题,不在本文范围。
文章目录
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
一、7 个文件的全景图:谁干什么
hello 项目的目录结构如下(已省略配置文件):
text
hello/
├── src/
│ ├── main.ts # 入口:工厂启动
│ ├── app.module.ts # 根模块:三件套组装
│ ├── app.controller.ts # 根控制器:处理 GET /
│ ├── app.service.ts # 根服务:返回 Hello World
│ └── todos/
│ ├── Todos.module.ts # 业务模块:组装 todos 三件套
│ ├── Todos.controller.ts # 业务控制器:5 个 CRUD 接口
│ └── Todos.service.ts # 业务服务:5 个 CRUD 方法
└── package.json
对应到 MVC 三层:
| 文件 | MVC 角色 | 职责 |
|---|---|---|
main.ts |
启动器 | 工厂创建应用 + 监听端口 |
*.module.ts |
组装层 | 注册 Controller + Service,划分业务边界 |
*.controller.ts |
Controller | 接 HTTP 请求、提参、调 Service、返响应 |
*.service.ts |
Service + Model | 业务逻辑 + 数据存储(hello 用数组模拟) |
AppService / TodosService |
Model 抽象 | 生产环境换成 TypeORM Repository |
核心判断 :NestJS 的 V(View)不是 HTML 模板,而是 NestJS 自动把返回值 JSON.stringify 后写进 HTTP body;M(Model)在 hello 项目用模块级 let todos: TodoItem[] 数组模拟,生产换成 TypeORM。
二、main.ts:工厂模式 + 启动入口
typescript
// src/main.ts(运行未验证)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
4 个关键点:
NestFactory.create(AppModule)是工厂模式落地------你不new NestApplication(),而是把「怎么造应用」的细节交给工厂,调用方只关心「我要一个能跑的应用」。AppModule是入口参数,意味着整个应用的依赖图都从这个根模块展开。process.env.PORT ?? 3000用空值合并运算符兜底端口,云平台注入PORT环境变量时自动让位。bootstrap()是 async 函数,调用时没await------这是 Node 启动脚本的惯例,主线程跑完启动即可交出控制权。
面试角度:
NestFactory.create为什么是工厂模式?答:调用方不直接new,由工厂统一创建并初始化依赖图,符合「开闭原则」,未来换底层实现(如 Fastify)不影响调用方。
三、AppModule:三件套组装根模块
typescript
// src/app.module.ts(运行未验证)
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/Todos.module';
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@Module 装饰器接收三个关键字段:
- imports :引入其他模块,决定「哪些业务能被这个模块看到」。
AppModule导入TodosModule,相当于把 todos 业务的依赖图挂到根上。 - controllers :注册本模块的控制器类,NestJS 会扫描其中的
@Get/@Post等装饰器建立路由表。 - providers:注册本模块的 Service / Repository,被注册的类进入 IoC 容器,可被自动注入。
模块化好处(来自 main.ts 注释):如果一个文件几千行代码不行,所以要模块化;大型项目按业务划分 Module,NestJS 还会按需加载做性能优化。
四、三层装饰器系统:NestJS 的灵魂
hello 项目用到的装饰器分三类,这是大厂面试必问点:
| 类别 | 装饰器 | 作用位置 | 干什么 |
|---|---|---|---|
| 类装饰器 | @Module / @Controller / @Injectable |
类声明前 | 给类附加元数据,标记「我是模块/控制器/可注入服务」 |
| 方法装饰器 | @Get / @Post / @Put / @Delete |
类方法前 | 把方法绑到 HTTP 路由 |
| 参数装饰器 | @Param / @Body / @Query |
方法参数前 | 从请求对象提取数据注入参数 |
readme.md 评价:「装饰器模式用到极致」。装饰器本质是「在不修改原有对象的前提下,动态添加额外功能」,用 @ 表示------可以理解成「给类贴一张说明书,NestJS 看到说明书就知道怎么处理它」。
五、AppController:依赖注入语法糖
typescript
// src/app.controller.ts(运行未验证)
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
重点拆解 constructor 那一行------这是 TS 参数属性语法糖:
typescript
constructor(private readonly appService: AppService) {}
等价于这三步:
typescript
class AppController {
private readonly appService: AppService;
constructor(appService: AppService) {
this.appService = appService;
}
}
NestJS 看到这个构造参数类型是 AppService,去 IoC 容器找已注册的单例,自动传进来------程序员不用 new、不用手动传参。这就是依赖注入。
依赖注入链路 5 步(收藏资产)
text
1. @Module providers:[AppService] → 注册到 IoC 容器
2. @Injectable() 装饰 AppService 类 → 标记「我可被注入」
3. constructor 参数声明依赖 → 告诉容器「我需要 AppService」
4. 容器 new Controller 时自动传单例 → 解决依赖
5. this.appService.method() 调用 → 业务执行
面试金句 :依赖注入解决的是「组件之间耦合」的问题------Controller 不再 new Service,而是声明「我要什么」,由容器负责供给。好处是 Controller 和 Service 解耦,Service 可单测可替换。
六、TodosController:5 个 CRUD 接口实战
这是本文最核心的代码------一个文件覆盖 5 种 HTTP 方法 + 3 种参数装饰器。
typescript
// src/todos/Todos.controller.ts(运行未验证)
import { Controller, Get, Post, Param, Body, Delete, Put } from '@nestjs/common';
import { TodosService } from './Todos.service';
import { type TodoItem } from './Todos.service';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): TodoItem[] {
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): TodoItem {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('title') title: string): TodoItem {
return this.todosService.create(title);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: `删除 todoItem ${id}` };
}
@Put(':id')
update(@Param('id') id: string, @Body() patch: Partial<TodoItem>): TodoItem {
return this.todosService.update(Number(id), patch);
}
}
路由匹配对照表(收藏资产)
| HTTP 方法 | 装饰器组合 | 完整路径 | 调用方法 |
|---|---|---|---|
| GET | @Controller('todos') + @Get() |
GET /todos |
findAll |
| GET | @Controller('todos') + @Get(':id') |
GET /todos/:id |
findOne |
| POST | @Controller('todos') + @Post() |
POST /todos |
create |
| DELETE | @Controller('todos') + @Delete(':id') |
DELETE /todos/:id |
remove |
| PUT | @Controller('todos') + @Put(':id') |
PUT /todos/:id |
update |
关键细节:
- 路由拼接规则 :完整路径 =
@Controller(prefix)+@Method(subpath)。@Controller('todos')是类级前缀,所有方法共享。 - RESTful 标准 (注释原话:
restful 暴露资源的统一标准):同一资源用 HTTP 方法区分操作,URL 是名词不是动词。 - URL 参数永远是字符串 :
@Param('id')拿到的是string类型,必须Number(id)转number。如果用户访问/todos/abc,会得到NaN,Array.find返回undefined,最终抛NotFoundException→ HTTP 404。
@Body() vs @Body('field') 对比
| 用法 | 提取内容 | 类型 | 配合 TS |
|---|---|---|---|
@Body('title') title: string |
只取 body 中的 title 字段 |
单字段 | 直接指定类型 |
@Body() patch: Partial<TodoItem> |
注入整个请求体对象 | 整个 body | 配合 Partial<T> |
Partial<TodoItem> 把 TodoItem 所有属性变可选,方便 PATCH 语义(只更新传入字段)。但要注意:这会让 id 也可被改 ,存在 OWASP Mass Assignment 漏洞。生产建议用 Pick<TodoItem, 'title' | 'complete'> 限制可改字段。
import { type TodoItem } 是什么意思
typescript
import { type TodoItem } from './Todos.service';
这是 TS 的 import type 语法。原因:当 tsconfig 同时开 isolatedModules 和 emitDecoratorMetadata 时,被装饰器参数引用的 interface 必须用 import type,否则报 TS1272: A type referenced in a decorated signature must be imported with 'import type'。
七、TodosService:业务逻辑 + 错误处理
typescript
// src/todos/Todos.service.ts(运行未验证)
import { Injectable, NotFoundException } from '@nestjs/common';
export interface TodoItem {
id: number;
title: string;
complete: boolean;
}
let todos: TodoItem[] = [
{ id: 1, title: '学习 NestJS', complete: false },
{ id: 2, title: '学习 CRUD', complete: true },
];
let nextId = 3;
@Injectable()
export class TodosService {
findAll(): TodoItem[] {
return todos;
}
findOne(id: number): TodoItem {
const todoItem = todos.find(t => t.id === id);
if (!todoItem) throw new NotFoundException(`TodoItem ${id} 不存在`);
return todoItem;
}
create(title: string): TodoItem {
const todoItem: TodoItem = { id: nextId++, title, complete: false };
todos.push(todoItem);
return todoItem;
}
remove(id: number): void {
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw new NotFoundException(`TodoItem ${id} 不存在`);
todos.splice(index, 1);
}
update(id: number, patch: Partial<TodoItem>): TodoItem {
const todoItem = this.findOne(id);
Object.assign(todoItem, patch);
return todoItem;
}
}
重点 1:模拟数据库
let todos: TodoItem[] 和 let nextId = 3 是模块级变量,重启会丢失。生产环境换成 TypeORM 的 Repository,写法变化不大------这是 NestJS 分层的好处,存储层换底层不动业务逻辑。
重点 2:@Injectable() 干什么
注释原话:「把一个普通类变成 Nest 容器可管理的服务,从而能被自动实例化和注入到任何需要它的地方」。换句话说,加了 @Injectable(),这个类的实例就能被 IoC 容器统一管理,默认是单例。
重点 3:NotFoundException 错误处理(大厂面试高频)
findOne 找不到数据时抛 throw new NotFoundException(...),NestJS 自动转成 HTTP 404 响应,body 类似:
json
{
"statusCode": 404,
"message": "TodoItem 999 不存在",
"error": "Not Found"
}
throw new Error vs throw new NotFoundException 对比
| 写法 | HTTP 状态码 | 语义 | 推荐度 |
|---|---|---|---|
throw new Error('出错了') |
500 | 服务器内部错误 | ❌ 语义错误 |
throw new NotFoundException('出错了') |
404 | 资源不存在 | ✅ 语义正确 |
面试金句:查不到数据是「客户端的锅」(请求了不存在的资源),应该返回 404;如果返回 500 等于把锅甩给服务器,会让前端误判是后端崩了。
重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)
| 异常类 | 状态码 | 使用场景 |
|---|---|---|
BadRequestException |
400 | 参数校验失败 |
UnauthorizedException |
401 | 未登录 |
ForbiddenException |
403 | 登录但无权限 |
NotFoundException |
404 | 资源不存在 |
ConflictException |
409 | 资源冲突(如重复注册) |
InternalServerErrorException |
500 | 真正的服务器错误 |
所有类都继承自 HttpException,NestJS 拦截到这些异常会自动序列化成标准 JSON 响应。
这就是 readme.md 提到的「NestJS 提供各种错误类,标准化错误输出------status code 状态码 + message 消息」。面试题「请说下你是如何处理后端报错的」答案就在这里:用语义化异常类,让 NestJS 框架统一兜底。
八、完整请求链路图:从浏览器到 Service
以 POST /todos 为例(来自 Todos.controller.ts 文件底部 ASCII 注释):
text
前端 POST /todos body: {"title":"学习 React"}
↓
NestJS 路由匹配:@Controller('todos') + @Post()
↓
进入 create,@Body('title') 提取出 title = "学习 React"
↓
this.todosService.create("学习 React")
↓
service: 生成新对象 {id:3, title:"学习 React", complete:false}
↓
push 进 todos 数组
↓
NestJS 把返回值 JSON.stringify 后写入 HTTP body
↓
浏览器收到 201 + {"id":3,"title":"学习 React","complete":false}
根路径 GET / 的链路(来自 readme.md):
text
浏览器 GET http://localhost:3000
↓
main.ts app.listen(3000) 接收
↓
路由匹配 GET /
↓
找到 AppController(由 AppModule 注册)
↓
AppController 的 @Get() 装饰器匹配
↓
调用 getHello()
↓
调用 this.appService.getHello()
↓
AppService.getHello() 返回 'Hello World!'
↓
浏览器显示 Hello World!
九、大厂面试高频问题清单
整理 8 个高频考点,对应文中事实编号:
- NestJS MVC 三层在 hello 项目怎么落地 ------M 是数组模拟、V 是 NestJS 自动 JSON、C 是
@Controller+ HTTP 方法装饰器。 - NestJS 请求处理流程------浏览器 → main.ts → 路由匹配 → Controller → Service → JSON 响应。
- 三类装饰器分别干什么------类装饰器附加元数据、方法装饰器绑路由、参数装饰器提参。
- 依赖注入链路 5 步 ------providers 注册 →
@Injectable标记 → constructor 声明 → 容器传单例 → 调用方法。 @Body()vs@Body('field')区别 ------整体注入 vs 单字段提取,前者配合Partial<T>,后者适合只要单字段。- URL 参数为什么是字符串 ------HTTP 协议层就是文本,
/todos/abc拿到的是'abc',需要Number()转换,转换失败得NaN,最终触发 404。 - NestJS 内置错误类解决什么问题------标准化错误输出(status + message),让前端按状态码分支处理。
throw new Errorvsthrow new NotFoundException------500 vs 404,语义正确性决定接口设计质量。
十、自检清单:你真的看懂 hello 项目了吗
读完本文,对照 8 个自检点,能答 6 个以上才算真懂:
-
NestFactory.create为什么是工厂模式?调用方不直接new,由工厂统一创建。 -
@Module的imports / controllers / providers分别装什么? -
constructor(private readonly appService: AppService)这一行展开是几步?3 步。 -
@Controller('todos') + @Get(':id')匹配的完整路径是什么?GET /todos/:id。 -
@Param('id')拿到的是什么类型?string,必须Number()转。 -
@Body('title')和@Body()区别是什么?单字段 vs 整体注入。 -
NotFoundException对应 HTTP 几?404。 -
Partial<TodoItem>有什么安全风险?让id也可被改,存在 Mass Assignment 漏洞。
收尾:从「背文档」到「看懂项目」
NestJS 的学习陷阱在于:装饰器语法看起来简单,但理解「为什么这么设计」比记住「怎么写」重要得多。hello 项目 7 个文件 + 5 个 CRUD 接口把这个「为什么」完整跑通------main.ts 工厂启动、AppModule 三件套组装、TodosController 5 种 HTTP 方法装饰器、TodosService 5 个 CRUD 方法 + NotFoundException 容错。每个概念都能在真实代码里找到对应位置,比单独看装饰器、依赖注入、异常处理的孤岛文档要扎实得多。
下一步建议:把 hello 项目 clone 下来跑一遍,再用 Postman 打 5 个接口,对照本文的链路图看 console.log 输出。当你能凭记忆画出「浏览器 → Controller → Service → JSON 响应」的完整链路,NestJS 大厂面试的 MVC + 装饰器 + DI + 错误处理这四大考点就稳了。
进阶方向(不在本文范围):TypeORM 替换数组存储、自定义 ExceptionFilter 统一错误格式、Guard 守卫做鉴权、Interceptor 拦截器做日志。这些是 hello 项目跑通后的下一站。
标签:NestJS,MVC 架构,依赖注入,装饰器,后端面试
NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理
本文基于一个真实可运行的 NestJS hello 项目(NestJS 11.0.1 + TypeScript 5.7.3),用 7 个源文件 + 5 个 CRUD 接口,把 NestJS 大厂面试高频考点一次性讲透:MVC 三层落地、三层装饰器系统、依赖注入链路、内置异常类实战。文中代码来自真实项目,运行未验证,仅用于讲解结构。
写在前面:为什么是 hello 项目
很多 NestJS 教程用「玩具例子」讲概念------单独贴一段 @Get() 你以为懂了,回到真实项目还是懵。问题不在你,在例子。本文换个思路:拿一个能跑起来的 hello 项目,7 个文件全部摆出来,5 个 CRUD 接口逐个拆,让你看到 MVC 不是教科书三层等分,而是「Controller 接 HTTP + Service 写业务 + Module 组装」的工程分工。
理解这 7 个文件的协作,就能回答 NestJS 大厂面试的 80% 问题。剩下 20% 是 TypeORM、微服务、守卫拦截器这些进阶话题,不在本文范围。
文章目录
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
一、7 个文件的全景图:谁干什么
hello 项目的目录结构如下(已省略配置文件):
text
hello/
├── src/
│ ├── main.ts # 入口:工厂启动
│ ├── app.module.ts # 根模块:三件套组装
│ ├── app.controller.ts # 根控制器:处理 GET /
│ ├── app.service.ts # 根服务:返回 Hello World
│ └── todos/
│ ├── Todos.module.ts # 业务模块:组装 todos 三件套
│ ├── Todos.controller.ts # 业务控制器:5 个 CRUD 接口
│ └── Todos.service.ts # 业务服务:5 个 CRUD 方法
└── package.json
对应到 MVC 三层:
| 文件 | MVC 角色 | 职责 |
|---|---|---|
main.ts |
启动器 | 工厂创建应用 + 监听端口 |
*.module.ts |
组装层 | 注册 Controller + Service,划分业务边界 |
*.controller.ts |
Controller | 接 HTTP 请求、提参、调 Service、返响应 |
*.service.ts |
Service + Model | 业务逻辑 + 数据存储(hello 用数组模拟) |
AppService / TodosService |
Model 抽象 | 生产环境换成 TypeORM Repository |
核心判断 :NestJS 的 V(View)不是 HTML 模板,而是 NestJS 自动把返回值 JSON.stringify 后写进 HTTP body;M(Model)在 hello 项目用模块级 let todos: TodoItem[] 数组模拟,生产换成 TypeORM。
二、main.ts:工厂模式 + 启动入口
typescript
// src/main.ts(运行未验证)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
4 个关键点:
NestFactory.create(AppModule)是工厂模式落地------你不new NestApplication(),而是把「怎么造应用」的细节交给工厂,调用方只关心「我要一个能跑的应用」。AppModule是入口参数,意味着整个应用的依赖图都从这个根模块展开。process.env.PORT ?? 3000用空值合并运算符兜底端口,云平台注入PORT环境变量时自动让位。bootstrap()是 async 函数,调用时没await------这是 Node 启动脚本的惯例,主线程跑完启动即可交出控制权。
面试角度:
NestFactory.create为什么是工厂模式?答:调用方不直接new,由工厂统一创建并初始化依赖图,符合「开闭原则」,未来换底层实现(如 Fastify)不影响调用方。
三、AppModule:三件套组装根模块
typescript
// src/app.module.ts(运行未验证)
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/Todos.module';
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@Module 装饰器接收三个关键字段:
- imports :引入其他模块,决定「哪些业务能被这个模块看到」。
AppModule导入TodosModule,相当于把 todos 业务的依赖图挂到根上。 - controllers :注册本模块的控制器类,NestJS 会扫描其中的
@Get/@Post等装饰器建立路由表。 - providers:注册本模块的 Service / Repository,被注册的类进入 IoC 容器,可被自动注入。
模块化好处(来自 main.ts 注释):如果一个文件几千行代码不行,所以要模块化;大型项目按业务划分 Module,NestJS 还会按需加载做性能优化。
四、三层装饰器系统:NestJS 的灵魂
hello 项目用到的装饰器分三类,这是大厂面试必问点:
| 类别 | 装饰器 | 作用位置 | 干什么 |
|---|---|---|---|
| 类装饰器 | @Module / @Controller / @Injectable |
类声明前 | 给类附加元数据,标记「我是模块/控制器/可注入服务」 |
| 方法装饰器 | @Get / @Post / @Put / @Delete |
类方法前 | 把方法绑到 HTTP 路由 |
| 参数装饰器 | @Param / @Body / @Query |
方法参数前 | 从请求对象提取数据注入参数 |
readme.md 评价:「装饰器模式用到极致」。装饰器本质是「在不修改原有对象的前提下,动态添加额外功能」,用 @ 表示------可以理解成「给类贴一张说明书,NestJS 看到说明书就知道怎么处理它」。
五、AppController:依赖注入语法糖
typescript
// src/app.controller.ts(运行未验证)
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
重点拆解 constructor 那一行------这是 TS 参数属性语法糖:
typescript
constructor(private readonly appService: AppService) {}
等价于这三步:
typescript
class AppController {
private readonly appService: AppService;
constructor(appService: AppService) {
this.appService = appService;
}
}
NestJS 看到这个构造参数类型是 AppService,去 IoC 容器找已注册的单例,自动传进来------程序员不用 new、不用手动传参。这就是依赖注入。
依赖注入链路 5 步(收藏资产)
text
1. @Module providers:[AppService] → 注册到 IoC 容器
2. @Injectable() 装饰 AppService 类 → 标记「我可被注入」
3. constructor 参数声明依赖 → 告诉容器「我需要 AppService」
4. 容器 new Controller 时自动传单例 → 解决依赖
5. this.appService.method() 调用 → 业务执行
面试金句 :依赖注入解决的是「组件之间耦合」的问题------Controller 不再 new Service,而是声明「我要什么」,由容器负责供给。好处是 Controller 和 Service 解耦,Service 可单测可替换。
六、TodosController:5 个 CRUD 接口实战
这是本文最核心的代码------一个文件覆盖 5 种 HTTP 方法 + 3 种参数装饰器。
typescript
// src/todos/Todos.controller.ts(运行未验证)
import { Controller, Get, Post, Param, Body, Delete, Put } from '@nestjs/common';
import { TodosService } from './Todos.service';
import { type TodoItem } from './Todos.service';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): TodoItem[] {
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): TodoItem {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('title') title: string): TodoItem {
return this.todosService.create(title);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: `删除 todoItem ${id}` };
}
@Put(':id')
update(@Param('id') id: string, @Body() patch: Partial<TodoItem>): TodoItem {
return this.todosService.update(Number(id), patch);
}
}
路由匹配对照表(收藏资产)
| HTTP 方法 | 装饰器组合 | 完整路径 | 调用方法 |
|---|---|---|---|
| GET | @Controller('todos') + @Get() |
GET /todos |
findAll |
| GET | @Controller('todos') + @Get(':id') |
GET /todos/:id |
findOne |
| POST | @Controller('todos') + @Post() |
POST /todos |
create |
| DELETE | @Controller('todos') + @Delete(':id') |
DELETE /todos/:id |
remove |
| PUT | @Controller('todos') + @Put(':id') |
PUT /todos/:id |
update |
关键细节:
- 路由拼接规则 :完整路径 =
@Controller(prefix)+@Method(subpath)。@Controller('todos')是类级前缀,所有方法共享。 - RESTful 标准 (注释原话:
restful 暴露资源的统一标准):同一资源用 HTTP 方法区分操作,URL 是名词不是动词。 - URL 参数永远是字符串 :
@Param('id')拿到的是string类型,必须Number(id)转number。如果用户访问/todos/abc,会得到NaN,Array.find返回undefined,最终抛NotFoundException→ HTTP 404。
@Body() vs @Body('field') 对比
| 用法 | 提取内容 | 类型 | 配合 TS |
|---|---|---|---|
@Body('title') title: string |
只取 body 中的 title 字段 |
单字段 | 直接指定类型 |
@Body() patch: Partial<TodoItem> |
注入整个请求体对象 | 整个 body | 配合 Partial<T> |
Partial<TodoItem> 把 TodoItem 所有属性变可选,方便 PATCH 语义(只更新传入字段)。但要注意:这会让 id 也可被改 ,存在 OWASP Mass Assignment 漏洞。生产建议用 Pick<TodoItem, 'title' | 'complete'> 限制可改字段。
import { type TodoItem } 是什么意思
typescript
import { type TodoItem } from './Todos.service';
这是 TS 的 import type 语法。原因:当 tsconfig 同时开 isolatedModules 和 emitDecoratorMetadata 时,被装饰器参数引用的 interface 必须用 import type,否则报 TS1272: A type referenced in a decorated signature must be imported with 'import type'。
七、TodosService:业务逻辑 + 错误处理
typescript
// src/todos/Todos.service.ts(运行未验证)
import { Injectable, NotFoundException } from '@nestjs/common';
export interface TodoItem {
id: number;
title: string;
complete: boolean;
}
let todos: TodoItem[] = [
{ id: 1, title: '学习 NestJS', complete: false },
{ id: 2, title: '学习 CRUD', complete: true },
];
let nextId = 3;
@Injectable()
export class TodosService {
findAll(): TodoItem[] {
return todos;
}
findOne(id: number): TodoItem {
const todoItem = todos.find(t => t.id === id);
if (!todoItem) throw new NotFoundException(`TodoItem ${id} 不存在`);
return todoItem;
}
create(title: string): TodoItem {
const todoItem: TodoItem = { id: nextId++, title, complete: false };
todos.push(todoItem);
return todoItem;
}
remove(id: number): void {
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw new NotFoundException(`TodoItem ${id} 不存在`);
todos.splice(index, 1);
}
update(id: number, patch: Partial<TodoItem>): TodoItem {
const todoItem = this.findOne(id);
Object.assign(todoItem, patch);
return todoItem;
}
}
重点 1:模拟数据库
let todos: TodoItem[] 和 let nextId = 3 是模块级变量,重启会丢失。生产环境换成 TypeORM 的 Repository,写法变化不大------这是 NestJS 分层的好处,存储层换底层不动业务逻辑。
重点 2:@Injectable() 干什么
注释原话:「把一个普通类变成 Nest 容器可管理的服务,从而能被自动实例化和注入到任何需要它的地方」。换句话说,加了 @Injectable(),这个类的实例就能被 IoC 容器统一管理,默认是单例。
重点 3:NotFoundException 错误处理(大厂面试高频)
findOne 找不到数据时抛 throw new NotFoundException(...),NestJS 自动转成 HTTP 404 响应,body 类似:
json
{
"statusCode": 404,
"message": "TodoItem 999 不存在",
"error": "Not Found"
}
throw new Error vs throw new NotFoundException 对比
| 写法 | HTTP 状态码 | 语义 | 推荐度 |
|---|---|---|---|
throw new Error('出错了') |
500 | 服务器内部错误 | ❌ 语义错误 |
throw new NotFoundException('出错了') |
404 | 资源不存在 | ✅ 语义正确 |
面试金句:查不到数据是「客户端的锅」(请求了不存在的资源),应该返回 404;如果返回 500 等于把锅甩给服务器,会让前端误判是后端崩了。
重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)
| 异常类 | 状态码 | 使用场景 |
|---|---|---|
BadRequestException |
400 | 参数校验失败 |
UnauthorizedException |
401 | 未登录 |
ForbiddenException |
403 | 登录但无权限 |
NotFoundException |
404 | 资源不存在 |
ConflictException |
409 | 资源冲突(如重复注册) |
InternalServerErrorException |
500 | 真正的服务器错误 |
所有类都继承自 HttpException,NestJS 拦截到这些异常会自动序列化成标准 JSON 响应。
这就是 readme.md 提到的「NestJS 提供各种错误类,标准化错误输出------status code 状态码 + message 消息」。面试题「请说下你是如何处理后端报错的」答案就在这里:用语义化异常类,让 NestJS 框架统一兜底。
八、完整请求链路图:从浏览器到 Service
以 POST /todos 为例(来自 Todos.controller.ts 文件底部 ASCII 注释):
text
前端 POST /todos body: {"title":"学习 React"}
↓
NestJS 路由匹配:@Controller('todos') + @Post()
↓
进入 create,@Body('title') 提取出 title = "学习 React"
↓
this.todosService.create("学习 React")
↓
service: 生成新对象 {id:3, title:"学习 React", complete:false}
↓
push 进 todos 数组
↓
NestJS 把返回值 JSON.stringify 后写入 HTTP body
↓
浏览器收到 201 + {"id":3,"title":"学习 React","complete":false}
根路径 GET / 的链路(来自 readme.md):
text
浏览器 GET http://localhost:3000
↓
main.ts app.listen(3000) 接收
↓
路由匹配 GET /
↓
找到 AppController(由 AppModule 注册)
↓
AppController 的 @Get() 装饰器匹配
↓
调用 getHello()
↓
调用 this.appService.getHello()
↓
AppService.getHello() 返回 'Hello World!'
↓
浏览器显示 Hello World!
九、大厂面试高频问题清单
整理 8 个高频考点,对应文中事实编号:
- NestJS MVC 三层在 hello 项目怎么落地 ------M 是数组模拟、V 是 NestJS 自动 JSON、C 是
@Controller+ HTTP 方法装饰器。 - NestJS 请求处理流程------浏览器 → main.ts → 路由匹配 → Controller → Service → JSON 响应。
- 三类装饰器分别干什么------类装饰器附加元数据、方法装饰器绑路由、参数装饰器提参。
- 依赖注入链路 5 步 ------providers 注册 →
@Injectable标记 → constructor 声明 → 容器传单例 → 调用方法。 @Body()vs@Body('field')区别 ------整体注入 vs 单字段提取,前者配合Partial<T>,后者适合只要单字段。- URL 参数为什么是字符串 ------HTTP 协议层就是文本,
/todos/abc拿到的是'abc',需要Number()转换,转换失败得NaN,最终触发 404。 - NestJS 内置错误类解决什么问题------标准化错误输出(status + message),让前端按状态码分支处理。
throw new Errorvsthrow new NotFoundException------500 vs 404,语义正确性决定接口设计质量。
十、自检清单:你真的看懂 hello 项目了吗
读完本文,对照 8 个自检点,能答 6 个以上才算真懂:
-
NestFactory.create为什么是工厂模式?调用方不直接new,由工厂统一创建。 -
@Module的imports / controllers / providers分别装什么? -
constructor(private readonly appService: AppService)这一行展开是几步?3 步。 -
@Controller('todos') + @Get(':id')匹配的完整路径是什么?GET /todos/:id。 -
@Param('id')拿到的是什么类型?string,必须Number()转。 -
@Body('title')和@Body()区别是什么?单字段 vs 整体注入。 -
NotFoundException对应 HTTP 几?404。 -
Partial<TodoItem>有什么安全风险?让id也可被改,存在 Mass Assignment 漏洞。
收尾:从「背文档」到「看懂项目」
NestJS 的学习陷阱在于:装饰器语法看起来简单,但理解「为什么这么设计」比记住「怎么写」重要得多。hello 项目 7 个文件 + 5 个 CRUD 接口把这个「为什么」完整跑通------main.ts 工厂启动、AppModule 三件套组装、TodosController 5 种 HTTP 方法装饰器、TodosService 5 个 CRUD 方法 + NotFoundException 容错。每个概念都能在真实代码里找到对应位置,比单独看装饰器、依赖注入、异常处理的孤岛文档要扎实得多。
下一步建议:把 hello 项目 clone 下来跑一遍,再用 Postman 打 5 个接口,对照本文的链路图看 console.log 输出。当你能凭记忆画出「浏览器 → Controller → Service → JSON 响应」的完整链路,NestJS 大厂面试的 MVC + 装饰器 + DI + 错误处理这四大考点就稳了。
进阶方向(不在本文范围):TypeORM 替换数组存储、自定义 ExceptionFilter 统一错误格式、Guard 守卫做鉴权、Interceptor 拦截器做日志。这些是 hello 项目跑通后的下一站。
标签:NestJS,MVC 架构,依赖注入,装饰器,后端面试
NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理
本文基于一个真实可运行的 NestJS hello 项目(NestJS 11.0.1 + TypeScript 5.7.3),用 7 个源文件 + 5 个 CRUD 接口,把 NestJS 大厂面试高频考点一次性讲透:MVC 三层落地、三层装饰器系统、依赖注入链路、内置异常类实战。文中代码来自真实项目,运行未验证,仅用于讲解结构。
写在前面:为什么是 hello 项目
很多 NestJS 教程用「玩具例子」讲概念------单独贴一段 @Get() 你以为懂了,回到真实项目还是懵。问题不在你,在例子。本文换个思路:拿一个能跑起来的 hello 项目,7 个文件全部摆出来,5 个 CRUD 接口逐个拆,让你看到 MVC 不是教科书三层等分,而是「Controller 接 HTTP + Service 写业务 + Module 组装」的工程分工。
理解这 7 个文件的协作,就能回答 NestJS 大厂面试的 80% 问题。剩下 20% 是 TypeORM、微服务、守卫拦截器这些进阶话题,不在本文范围。
文章目录
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
- [NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理](#NestJS MVC 实战:用 hello 项目 7 个文件讲清分层、装饰器、依赖注入与错误处理)
-
- [写在前面:为什么是 hello 项目](#写在前面:为什么是 hello 项目)
- [一、7 个文件的全景图:谁干什么](#一、7 个文件的全景图:谁干什么)
- [二、main.ts:工厂模式 + 启动入口](#二、main.ts:工厂模式 + 启动入口)
- 三、AppModule:三件套组装根模块
- [四、三层装饰器系统:NestJS 的灵魂](#四、三层装饰器系统:NestJS 的灵魂)
- 五、AppController:依赖注入语法糖
-
- [依赖注入链路 5 步(收藏资产)](#依赖注入链路 5 步(收藏资产))
- [六、TodosController:5 个 CRUD 接口实战](#六、TodosController:5 个 CRUD 接口实战)
-
- 路由匹配对照表(收藏资产)
- [`@Body() vs @Body('field')` 对比](#
@Body() vs @Body('field')对比) - [`import { type TodoItem }` 是什么意思](#
import { type TodoItem }是什么意思)
- [七、TodosService:业务逻辑 + 错误处理](#七、TodosService:业务逻辑 + 错误处理)
-
- [重点 1:模拟数据库](#重点 1:模拟数据库)
- [重点 2:`@Injectable()` 干什么](#重点 2:
@Injectable()干什么) - [重点 3:`NotFoundException` 错误处理(大厂面试高频)](#重点 3:
NotFoundException错误处理(大厂面试高频)) -
- [`throw new Error` vs `throw new NotFoundException` 对比](#
throw new Errorvsthrow new NotFoundException对比)
- [`throw new Error` vs `throw new NotFoundException` 对比](#
- [重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)](#重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产))
- [八、完整请求链路图:从浏览器到 Service](#八、完整请求链路图:从浏览器到 Service)
- 九、大厂面试高频问题清单
- [十、自检清单:你真的看懂 hello 项目了吗](#十、自检清单:你真的看懂 hello 项目了吗)
- 收尾:从「背文档」到「看懂项目」
一、7 个文件的全景图:谁干什么
hello 项目的目录结构如下(已省略配置文件):
text
hello/
├── src/
│ ├── main.ts # 入口:工厂启动
│ ├── app.module.ts # 根模块:三件套组装
│ ├── app.controller.ts # 根控制器:处理 GET /
│ ├── app.service.ts # 根服务:返回 Hello World
│ └── todos/
│ ├── Todos.module.ts # 业务模块:组装 todos 三件套
│ ├── Todos.controller.ts # 业务控制器:5 个 CRUD 接口
│ └── Todos.service.ts # 业务服务:5 个 CRUD 方法
└── package.json
对应到 MVC 三层:
| 文件 | MVC 角色 | 职责 |
|---|---|---|
main.ts |
启动器 | 工厂创建应用 + 监听端口 |
*.module.ts |
组装层 | 注册 Controller + Service,划分业务边界 |
*.controller.ts |
Controller | 接 HTTP 请求、提参、调 Service、返响应 |
*.service.ts |
Service + Model | 业务逻辑 + 数据存储(hello 用数组模拟) |
AppService / TodosService |
Model 抽象 | 生产环境换成 TypeORM Repository |
核心判断 :NestJS 的 V(View)不是 HTML 模板,而是 NestJS 自动把返回值 JSON.stringify 后写进 HTTP body;M(Model)在 hello 项目用模块级 let todos: TodoItem[] 数组模拟,生产换成 TypeORM。
二、main.ts:工厂模式 + 启动入口
typescript
// src/main.ts(运行未验证)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
4 个关键点:
NestFactory.create(AppModule)是工厂模式落地------你不new NestApplication(),而是把「怎么造应用」的细节交给工厂,调用方只关心「我要一个能跑的应用」。AppModule是入口参数,意味着整个应用的依赖图都从这个根模块展开。process.env.PORT ?? 3000用空值合并运算符兜底端口,云平台注入PORT环境变量时自动让位。bootstrap()是 async 函数,调用时没await------这是 Node 启动脚本的惯例,主线程跑完启动即可交出控制权。
面试角度:
NestFactory.create为什么是工厂模式?答:调用方不直接new,由工厂统一创建并初始化依赖图,符合「开闭原则」,未来换底层实现(如 Fastify)不影响调用方。
三、AppModule:三件套组装根模块
typescript
// src/app.module.ts(运行未验证)
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/Todos.module';
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@Module 装饰器接收三个关键字段:
- imports :引入其他模块,决定「哪些业务能被这个模块看到」。
AppModule导入TodosModule,相当于把 todos 业务的依赖图挂到根上。 - controllers :注册本模块的控制器类,NestJS 会扫描其中的
@Get/@Post等装饰器建立路由表。 - providers:注册本模块的 Service / Repository,被注册的类进入 IoC 容器,可被自动注入。
模块化好处(来自 main.ts 注释):如果一个文件几千行代码不行,所以要模块化;大型项目按业务划分 Module,NestJS 还会按需加载做性能优化。
四、三层装饰器系统:NestJS 的灵魂
hello 项目用到的装饰器分三类,这是大厂面试必问点:
| 类别 | 装饰器 | 作用位置 | 干什么 |
|---|---|---|---|
| 类装饰器 | @Module / @Controller / @Injectable |
类声明前 | 给类附加元数据,标记「我是模块/控制器/可注入服务」 |
| 方法装饰器 | @Get / @Post / @Put / @Delete |
类方法前 | 把方法绑到 HTTP 路由 |
| 参数装饰器 | @Param / @Body / @Query |
方法参数前 | 从请求对象提取数据注入参数 |
readme.md 评价:「装饰器模式用到极致」。装饰器本质是「在不修改原有对象的前提下,动态添加额外功能」,用 @ 表示------可以理解成「给类贴一张说明书,NestJS 看到说明书就知道怎么处理它」。
五、AppController:依赖注入语法糖
typescript
// src/app.controller.ts(运行未验证)
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
重点拆解 constructor 那一行------这是 TS 参数属性语法糖:
typescript
constructor(private readonly appService: AppService) {}
等价于这三步:
typescript
class AppController {
private readonly appService: AppService;
constructor(appService: AppService) {
this.appService = appService;
}
}
NestJS 看到这个构造参数类型是 AppService,去 IoC 容器找已注册的单例,自动传进来------程序员不用 new、不用手动传参。这就是依赖注入。
依赖注入链路 5 步(收藏资产)
text
1. @Module providers:[AppService] → 注册到 IoC 容器
2. @Injectable() 装饰 AppService 类 → 标记「我可被注入」
3. constructor 参数声明依赖 → 告诉容器「我需要 AppService」
4. 容器 new Controller 时自动传单例 → 解决依赖
5. this.appService.method() 调用 → 业务执行
面试金句 :依赖注入解决的是「组件之间耦合」的问题------Controller 不再 new Service,而是声明「我要什么」,由容器负责供给。好处是 Controller 和 Service 解耦,Service 可单测可替换。
六、TodosController:5 个 CRUD 接口实战
这是本文最核心的代码------一个文件覆盖 5 种 HTTP 方法 + 3 种参数装饰器。
typescript
// src/todos/Todos.controller.ts(运行未验证)
import { Controller, Get, Post, Param, Body, Delete, Put } from '@nestjs/common';
import { TodosService } from './Todos.service';
import { type TodoItem } from './Todos.service';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): TodoItem[] {
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): TodoItem {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('title') title: string): TodoItem {
return this.todosService.create(title);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: `删除 todoItem ${id}` };
}
@Put(':id')
update(@Param('id') id: string, @Body() patch: Partial<TodoItem>): TodoItem {
return this.todosService.update(Number(id), patch);
}
}
路由匹配对照表(收藏资产)
| HTTP 方法 | 装饰器组合 | 完整路径 | 调用方法 |
|---|---|---|---|
| GET | @Controller('todos') + @Get() |
GET /todos |
findAll |
| GET | @Controller('todos') + @Get(':id') |
GET /todos/:id |
findOne |
| POST | @Controller('todos') + @Post() |
POST /todos |
create |
| DELETE | @Controller('todos') + @Delete(':id') |
DELETE /todos/:id |
remove |
| PUT | @Controller('todos') + @Put(':id') |
PUT /todos/:id |
update |
关键细节:
- 路由拼接规则 :完整路径 =
@Controller(prefix)+@Method(subpath)。@Controller('todos')是类级前缀,所有方法共享。 - RESTful 标准 (注释原话:
restful 暴露资源的统一标准):同一资源用 HTTP 方法区分操作,URL 是名词不是动词。 - URL 参数永远是字符串 :
@Param('id')拿到的是string类型,必须Number(id)转number。如果用户访问/todos/abc,会得到NaN,Array.find返回undefined,最终抛NotFoundException→ HTTP 404。
@Body() vs @Body('field') 对比
| 用法 | 提取内容 | 类型 | 配合 TS |
|---|---|---|---|
@Body('title') title: string |
只取 body 中的 title 字段 |
单字段 | 直接指定类型 |
@Body() patch: Partial<TodoItem> |
注入整个请求体对象 | 整个 body | 配合 Partial<T> |
Partial<TodoItem> 把 TodoItem 所有属性变可选,方便 PATCH 语义(只更新传入字段)。但要注意:这会让 id 也可被改 ,存在 OWASP Mass Assignment 漏洞。生产建议用 Pick<TodoItem, 'title' | 'complete'> 限制可改字段。
import { type TodoItem } 是什么意思
typescript
import { type TodoItem } from './Todos.service';
这是 TS 的 import type 语法。原因:当 tsconfig 同时开 isolatedModules 和 emitDecoratorMetadata 时,被装饰器参数引用的 interface 必须用 import type,否则报 TS1272: A type referenced in a decorated signature must be imported with 'import type'。
七、TodosService:业务逻辑 + 错误处理
typescript
// src/todos/Todos.service.ts(运行未验证)
import { Injectable, NotFoundException } from '@nestjs/common';
export interface TodoItem {
id: number;
title: string;
complete: boolean;
}
let todos: TodoItem[] = [
{ id: 1, title: '学习 NestJS', complete: false },
{ id: 2, title: '学习 CRUD', complete: true },
];
let nextId = 3;
@Injectable()
export class TodosService {
findAll(): TodoItem[] {
return todos;
}
findOne(id: number): TodoItem {
const todoItem = todos.find(t => t.id === id);
if (!todoItem) throw new NotFoundException(`TodoItem ${id} 不存在`);
return todoItem;
}
create(title: string): TodoItem {
const todoItem: TodoItem = { id: nextId++, title, complete: false };
todos.push(todoItem);
return todoItem;
}
remove(id: number): void {
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw new NotFoundException(`TodoItem ${id} 不存在`);
todos.splice(index, 1);
}
update(id: number, patch: Partial<TodoItem>): TodoItem {
const todoItem = this.findOne(id);
Object.assign(todoItem, patch);
return todoItem;
}
}
重点 1:模拟数据库
let todos: TodoItem[] 和 let nextId = 3 是模块级变量,重启会丢失。生产环境换成 TypeORM 的 Repository,写法变化不大------这是 NestJS 分层的好处,存储层换底层不动业务逻辑。
重点 2:@Injectable() 干什么
注释原话:「把一个普通类变成 Nest 容器可管理的服务,从而能被自动实例化和注入到任何需要它的地方」。换句话说,加了 @Injectable(),这个类的实例就能被 IoC 容器统一管理,默认是单例。
重点 3:NotFoundException 错误处理(大厂面试高频)
findOne 找不到数据时抛 throw new NotFoundException(...),NestJS 自动转成 HTTP 404 响应,body 类似:
json
{
"statusCode": 404,
"message": "TodoItem 999 不存在",
"error": "Not Found"
}
throw new Error vs throw new NotFoundException 对比
| 写法 | HTTP 状态码 | 语义 | 推荐度 |
|---|---|---|---|
throw new Error('出错了') |
500 | 服务器内部错误 | ❌ 语义错误 |
throw new NotFoundException('出错了') |
404 | 资源不存在 | ✅ 语义正确 |
面试金句:查不到数据是「客户端的锅」(请求了不存在的资源),应该返回 404;如果返回 500 等于把锅甩给服务器,会让前端误判是后端崩了。
重点 4:NestJS 内置 HTTP 异常类对照表(收藏资产)
| 异常类 | 状态码 | 使用场景 |
|---|---|---|
BadRequestException |
400 | 参数校验失败 |
UnauthorizedException |
401 | 未登录 |
ForbiddenException |
403 | 登录但无权限 |
NotFoundException |
404 | 资源不存在 |
ConflictException |
409 | 资源冲突(如重复注册) |
InternalServerErrorException |
500 | 真正的服务器错误 |
所有类都继承自 HttpException,NestJS 拦截到这些异常会自动序列化成标准 JSON 响应。
这就是 readme.md 提到的「NestJS 提供各种错误类,标准化错误输出------status code 状态码 + message 消息」。面试题「请说下你是如何处理后端报错的」答案就在这里:用语义化异常类,让 NestJS 框架统一兜底。
八、完整请求链路图:从浏览器到 Service
以 POST /todos 为例(来自 Todos.controller.ts 文件底部 ASCII 注释):
text
前端 POST /todos body: {"title":"学习 React"}
↓
NestJS 路由匹配:@Controller('todos') + @Post()
↓
进入 create,@Body('title') 提取出 title = "学习 React"
↓
this.todosService.create("学习 React")
↓
service: 生成新对象 {id:3, title:"学习 React", complete:false}
↓
push 进 todos 数组
↓
NestJS 把返回值 JSON.stringify 后写入 HTTP body
↓
浏览器收到 201 + {"id":3,"title":"学习 React","complete":false}
根路径 GET / 的链路(来自 readme.md):
text
浏览器 GET http://localhost:3000
↓
main.ts app.listen(3000) 接收
↓
路由匹配 GET /
↓
找到 AppController(由 AppModule 注册)
↓
AppController 的 @Get() 装饰器匹配
↓
调用 getHello()
↓
调用 this.appService.getHello()
↓
AppService.getHello() 返回 'Hello World!'
↓
浏览器显示 Hello World!
九、大厂面试高频问题清单
整理 8 个高频考点,对应文中事实编号:
- NestJS MVC 三层在 hello 项目怎么落地 ------M 是数组模拟、V 是 NestJS 自动 JSON、C 是
@Controller+ HTTP 方法装饰器。 - NestJS 请求处理流程------浏览器 → main.ts → 路由匹配 → Controller → Service → JSON 响应。
- 三类装饰器分别干什么------类装饰器附加元数据、方法装饰器绑路由、参数装饰器提参。
- 依赖注入链路 5 步 ------providers 注册 →
@Injectable标记 → constructor 声明 → 容器传单例 → 调用方法。 @Body()vs@Body('field')区别 ------整体注入 vs 单字段提取,前者配合Partial<T>,后者适合只要单字段。- URL 参数为什么是字符串 ------HTTP 协议层就是文本,
/todos/abc拿到的是'abc',需要Number()转换,转换失败得NaN,最终触发 404。 - NestJS 内置错误类解决什么问题------标准化错误输出(status + message),让前端按状态码分支处理。
throw new Errorvsthrow new NotFoundException------500 vs 404,语义正确性决定接口设计质量。
十、自检清单:你真的看懂 hello 项目了吗
读完本文,对照 8 个自检点,能答 6 个以上才算真懂:
-
NestFactory.create为什么是工厂模式?调用方不直接new,由工厂统一创建。 -
@Module的imports / controllers / providers分别装什么? -
constructor(private readonly appService: AppService)这一行展开是几步?3 步。 -
@Controller('todos') + @Get(':id')匹配的完整路径是什么?GET /todos/:id。 -
@Param('id')拿到的是什么类型?string,必须Number()转。 -
@Body('title')和@Body()区别是什么?单字段 vs 整体注入。 -
NotFoundException对应 HTTP 几?404。 -
Partial<TodoItem>有什么安全风险?让id也可被改,存在 Mass Assignment 漏洞。
收尾:从「背文档」到「看懂项目」
NestJS 的学习陷阱在于:装饰器语法看起来简单,但理解「为什么这么设计」比记住「怎么写」重要得多。hello 项目 7 个文件 + 5 个 CRUD 接口把这个「为什么」完整跑通------main.ts 工厂启动、AppModule 三件套组装、TodosController 5 种 HTTP 方法装饰器、TodosService 5 个 CRUD 方法 + NotFoundException 容错。每个概念都能在真实代码里找到对应位置,比单独看装饰器、依赖注入、异常处理的孤岛文档要扎实得多。
下一步建议:把 hello 项目 clone 下来跑一遍,再用 Postman 打 5 个接口,对照本文的链路图看 console.log 输出。当你能凭记忆画出「浏览器 → Controller → Service → JSON 响应」的完整链路,NestJS 大厂面试的 MVC + 装饰器 + DI + 错误处理这四大考点就稳了。
进阶方向(不在本文范围):TypeORM 替换数组存储、自定义 ExceptionFilter 统一错误格式、Guard 守卫做鉴权、Interceptor 拦截器做日志。这些是 hello 项目跑通后的下一站。
标签:NestJS,MVC 架构,依赖注入,装饰器,后端面试