背完 NestJS 文档还是答不上大厂面试?hello 项目 7 个文件讲清 MVC 实战
学 NestJS 最常见的反噬是:跟着官方教程写完 @Get() @Post(),回到真实项目还是不知道怎么组织代码——Service 怎么注入?路由怎么拼?错误怎么返?文档都懂,面试一问就懵。
问题不在你,在例子。教程里的「单独贴一段装饰器」是孤岛,看不到协作。本文换一种打法:拿一个能跑起来的 NestJS hello 项目,7 个源文件全部摆出来,5 个 CRUD 接口逐个拆,让你看清 MVC 不是教科书三层等分,而是「Controller 接 HTTP + Service 写业务 + Module 组装」的工程分工。
核心判断:理解这 7 个文件的协作,就能回答 NestJS 大厂面试的 80% 问题。文中代码来自真实项目,运行未验证,仅用于讲解结构。
这 7 个文件到底各干什么
hello/src/
├── main.ts # 入口:NestFactory 工厂启动
├── app.module.ts # 根模块:三件套(imports/controllers/providers)
├── app.controller.ts # 根控制器:处理 GET /
├── app.service.ts # 根服务:返回 Hello World
└── todos/
├── Todos.module.ts # 业务模块:组装 todos 三件套
├── Todos.controller.ts # 业务控制器:5 个 CRUD 接口
└── Todos.service.ts # 业务服务:5 个 CRUD 方法 + 异常处理
把 MVC 三层映射进去:
- M(Model):hello 用模块级
let todos: TodoItem[]数组模拟,生产换 TypeORM Repository。 - V(View):NestJS 自动把返回值
JSON.stringify写进 HTTP body。NestJS 的 View 不是 HTML 模板,这点和传统后端框架不一样。 - C(Controller):
@Controller+ HTTP 方法装饰器。
关键认知:hello 项目 7 个文件就是 NestJS 工程分工的最小完整样本,不是玩具,是真实项目骨架的精简版。
入口为何是工厂:NestFactory.create 在做什么
// 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();
NestFactory.create(AppModule) 是工厂模式落地。为什么不直接 new NestApplication()? 因为创建一个能跑的应用需要初始化依赖图、扫描模块、注册路由——这些细节交给工厂,调用方只说「我要一个能跑的应用」。未来底层换 Fastify 适配器,调用方代码不动。
process.env.PORT ?? 3000 用空值合并兜底端口:云平台注入 PORT 时让位,本地默认 3000。bootstrap() 没 await 调用是 Node 启动脚本惯例——主线程跑完启动即可交出控制权。
三层装饰器系统:NestJS 把 @ 用到极致
hello 项目里的装饰器分三类,这是面试必问点:
| 类别 | 装饰器 | 贴在哪 | 干什么 |
|---|---|---|---|
| 类装饰器 | @Module @Controller @Injectable | 类声明前 | 附加元数据,告诉 NestJS「我是模块/控制器/可注入服务」 |
| 方法装饰器 | @Get @Post @Put @Delete | 类方法前 | 把方法绑到 HTTP 路由 |
| 参数装饰器 | @Param @Body @Query | 方法参数前 | 从请求对象提取数据注入参数 |
装饰器本质是「在不修改原有对象的前提下动态添加额外功能」,可以理解成给类贴一张说明书,NestJS 看到说明书就知道怎么处理它。readme.md 评价 NestJS「装饰器模式用到极致」——确实,整个框架的路由、注入、错误处理、守卫拦截器全靠装饰器驱动。
依赖注入语法糖:constructor 那一行藏着什么
// src/app.controller.ts(运行未验证)
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
constructor(private readonly appService: AppService) {} 看着是一行,其实是 TS 参数属性语法糖,等价于三步:
class AppController {
private readonly appService: AppService;
constructor(appService: AppService) {
this.appService = appService;
}
}
NestJS 看到构造参数类型是 AppService,去 IoC 容器找已注册的单例,自动传进来——程序员不用 new、不用手动传参。
依赖注入链路 5 步(收藏资产)
1. @Module providers:[AppService] → 注册到 IoC 容器
2. @Injectable() 装饰 AppService 类 → 标记「我可被注入」
3. constructor 参数声明依赖 → 告诉容器「我需要 AppService」
4. 容器 new Controller 时自动传单例 → 解决依赖
5. this.appService.method() 调用 → 业务执行
面试金句:依赖注入解决的是组件耦合问题——Controller 不再 new Service,而是声明「我要什么」,由容器负责供给。好处是 Service 可单测可替换,Controller 测试时能注入 Mock。
AppModule:根模块三件套
// src/app.module.ts(运行未验证)
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
三件套各自的活:
- imports:引入其他模块。
AppModule导入TodosModule,相当于把 todos 业务的依赖图挂到根上。 - controllers:注册本模块控制器,NestJS 扫描其中
@Get/@Post建路由表。 - providers:注册 Service / Repository,进入 IoC 容器可被注入。
模块化动机(来自 main.ts 注释):一个文件几千行不行,要模块化;大型项目按业务划分 Module,NestJS 按需加载做性能优化。
5 个 CRUD 接口实战:覆盖 5 种 HTTP 方法
// src/todos/Todos.controller.ts(运行未验证)
@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);
}
}
路由匹配规则(收藏资产)
完整路径 = @Controller(prefix) + @Method(subpath)。@Controller('todos') 是类级前缀,所有方法共享。
| HTTP | 装饰器组合 | 路径 | 调用 |
|---|---|---|---|
| GET | @Controller('todos') + @Get() | /todos | findAll |
| GET | @Controller('todos') + @Get(':id') | /todos/:id | findOne |
| POST | @Controller('todos') + @Post() | /todos | create |
| DELETE | @Controller('todos') + @Delete(':id') | /todos/:id | remove |
| PUT | @Controller('todos') + @Put(':id') | /todos/:id | update |
注释原话 restful 暴露资源的统一标准:RESTful 用 HTTP 方法区分操作,URL 是名词不是动词。
@Body() vs @Body('field'):差一个字段
| 用法 | 提取内容 | 适用场景 |
|---|---|---|
@Body('title') title: string | 只取 title 字段 | 接口只需要单字段,类型直接指定 |
@Body() patch: Partial<TodoItem> | 注入整个请求体 | PATCH 语义,配合 Partial<T> 接收部分字段 |
Partial<TodoItem> 把所有属性变可选。但要注意:这让 id 也可被改,存在 OWASP Mass Assignment 漏洞。生产建议用 Pick<TodoItem, 'title' | 'complete'> 限制可改字段。
URL 参数为什么是 string
@Param('id') 拿到的永远是字符串——HTTP 协议层 URL 就是文本。所以代码里要 Number(id) 转换。
坑点:用户访问 /todos/abc,Number('abc') 得到 NaN,Array.find(t => t.id === NaN) 返回 undefined,触发 findOne 抛 NotFoundException → HTTP 404。这其实是符合预期的容错行为,别用 parseInt 不判 NaN 把锅甩给服务器。
import { type TodoItem } 是什么写法
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'。这是 hello 项目踩过的真实坑。
错误处理:NotFoundException 为什么是 404 不是 500
// src/todos/Todos.service.ts(运行未验证)
@Injectable()
export class TodosService {
findOne(id: number): TodoItem {
const todoItem = todos.find(t => t.id === id);
if (!todoItem) throw new NotFoundException(`TodoItem ${id} 不存在`);
return todoItem;
}
// ...其他方法
}
throw new NotFoundException(...) 被 NestJS 拦截后自动转成 HTTP 404 响应,body 类似:
{
"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 等于把锅甩给服务器,前端会误判后端崩了。
NestJS 内置 HTTP 异常类(收藏资产)
| 异常类 | 状态码 | 场景 |
|---|---|---|
BadRequestException | 400 | 参数校验失败 |
UnauthorizedException | 401 | 未登录 |
ForbiddenException | 403 | 登录但无权限 |
NotFoundException | 404 | 资源不存在 |
ConflictException | 409 | 资源冲突 |
InternalServerErrorException | 500 | 真正的服务器错误 |
所有类继承自 HttpException,NestJS 拦截后自动序列化为标准 JSON 响应。
readme.md 那道面试题「请说下你是如何处理后端报错的」答案就在这:用语义化异常类,让 NestJS 框架统一兜底。不要再写 try catch finally 把错误吞掉返回 200 + {success: false} 这种反 RESTful 的写法。
完整调用链路图
以 POST /todos 为例(来自 Todos.controller.ts 底部 ASCII 注释):
前端 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}
一个立即能做的检查:你的接口语义对了吗
打开你的 NestJS 项目,挑一个查资源的接口问自己 3 个问题:
- 资源找不到时,抛的是
NotFoundException还是new Error? - URL 参数有没有
Number()转换?转换失败会进 404 还是 500? @Body()接收整个对象时,有没有用Pick限制可改字段,防 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 拦截器做日志。
标签:NestJS、后端面试、TypeScript、Node.js