背完 NestJS 文档还是答不上大厂面试?hello 项目 7 个文件讲清 MVC 实战

62 阅读7分钟

背完 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()/todosfindAll
GET@Controller('todos') + @Get(':id')/todos/:idfindOne
POST@Controller('todos') + @Post()/todoscreate
DELETE@Controller('todos') + @Delete(':id')/todos/:idremove
PUT@Controller('todos') + @Put(':id')/todos/:idupdate

注释原话 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 异常类(收藏资产)

异常类状态码场景
BadRequestException400参数校验失败
UnauthorizedException401未登录
ForbiddenException403登录但无权限
NotFoundException404资源不存在
ConflictException409资源冲突
InternalServerErrorException500真正的服务器错误

所有类继承自 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 个问题:

  1. 资源找不到时,抛的是 NotFoundException 还是 new Error?
  2. URL 参数有没有 Number() 转换?转换失败会进 404 还是 500?
  3. @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