摘要
拆解NestJS模块化MVC架构,以Todos CRUD演示@Get/@Post/@Patch/@Delete路由、依赖注入、参数提取及NotFoundException标准化错误处理。
NestJS 将后端开发组织为一个个独立的业务模块,每个模块遵循 MVC 的三层结构:Controller 负责接收请求和参数校验,Service 负责业务逻辑和数据处理,Module 负责将前两者组装在一起。本篇以 Todos 模块的完整 CRUD 为例,展示从入口到业务层到错误处理的完整链路。
入口:工厂模式创建应用
main.ts 是 NestJS 应用的入口,使用 NestFactory 创建应用实例:
typescript
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) 是工厂模式的具体应用------NestFactory 是一个工厂类,create 方法接收根模块 AppModule,返回一个配置好的 NestJS 应用实例。开发者不需要知道工厂内部如何组装中间件、解析器、异常过滤器等底层细节,只需要告诉工厂"用哪个模块"。
app.listen(process.env.PORT ?? 3000) 启动 HTTP 服务,默认监听 3000 端口。process.env.PORT 允许通过环境变量覆盖端口号,这是部署到云平台时的标准做法。
根模块:模块的组装与依赖声明
AppModule 是应用的根模块,使用 @Module() 装饰器声明了应用的所有组成部分:
typescript
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:声明依赖的其他模块。这里导入TodosModule,意味着 Todos 模块的控制器和服务可以在整个应用中被访问。controllers:注册该模块下的控制器,负责处理 HTTP 请求。providers:注册服务提供者,NestJS 的 IoC 容器会管理这些服务的实例化和注入。
imports: [TodosModule] 是关键------NestJS 通过模块导入建立依赖图,AppModule 不需要知道 TodosModule 内部有哪些控制器和服务,只需要声明"我依赖 TodosModule"。NestJS 在启动时扫描所有模块,递归解析依赖关系,构建完整的应用上下文。
TodosModule:独立业务模块
TodosModule 是一个独立的业务模块,只负责 Todo 相关的功能:
typescript
import { Module } from '@nestjs/common';
import { TodosService } from './Todos.service';
import { TodosController } from './Todos.controller';
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
这个模块没有 imports,因为它不依赖其他模块。controllers 和 providers 各注册了一个类------TodosController 处理 HTTP 请求,TodosService 处理业务逻辑。这种"一个模块一个业务领域"的设计让代码的职责边界非常清晰。
Controller:路由与参数提取
TodosController 使用装饰器声明了五个 RESTful 接口:
typescript
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): Todo[] {
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
@Patch(':id')
update(@Param('id') id: string, @Body() patch: Partial<Todo>): Todo {
return this.todosService.update(Number(id), patch);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: '删除成功' };
}
}
@Controller('todos') 将路径前缀设为 /todos,所有方法路由自动添加此前缀。
constructor(private readonly todosService: TodosService) {} 是依赖注入的声明。private readonly 是 TypeScript 的语法糖------声明一个私有只读属性并自动赋值。NestJS 的 IoC 容器在创建 TodosController 实例时,检测到构造函数的参数类型是 TodosService,于是从 providers 中找到对应的类,创建实例并注入。
@Get(':id') 中的 :id 是路径参数占位符。@Param('id') id: string 从请求路径中提取 id 参数的值,作为 id 变量传入方法。Number(id) 将字符串转换为数字,因为 @Param() 提取的值始终是字符串类型。
@Body('title') title: string 从请求体中提取 title 字段。@Body() patch: Partial<Todo> 获取整个请求体对象,Partial<Todo> 表示这个对象只包含 Todo 接口的部分属性。
每个方法做的事情都很薄------接收请求参数,调用 Service 对应的方法,返回结果。Controller 不包含业务逻辑,只做"分发"。
Service:业务逻辑与异常处理
TodosService 使用 @Injectable() 装饰器标记为可注入的服务:
typescript
export interface Todo {
id: number;
title: string;
complete: boolean;
}
let todos: Todo[] = [
{ id: 1, title: '学习nestjs', complete: false },
{ id: 2, title: '学习CRUD', complete: false },
];
let nextId = 3;
@Injectable()
export class TodosService {
findAll(): Todo[] {
return todos;
}
findOne(id: number): Todo {
const todo = todos.find(t => t.id === id);
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
return todo;
}
create(title: string): Todo {
const todo: Todo = { id: nextId++, title, complete: false };
todos.push(todo);
return todo;
}
remove(id: number): void {
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw new NotFoundException(`Todo ${id} 不存在`);
todos.splice(index, 1);
}
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
}
@Injectable() 告诉 NestJS:"这个类可以被依赖注入系统管理"。没有这个装饰器,TodosController 的构造函数中 private readonly todosService: TodosService 就无法自动注入。
数据使用内存数组 todos 存储,nextId 自增生成唯一 ID。这种设计适合原型开发和快速验证,生产环境替换为数据库查询即可。
NotFoundException:标准化错误处理
findOne 和 remove 方法中使用了 NotFoundException:
typescript
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
NotFoundException 是 NestJS 内置的 HTTP 异常类,继承自 HttpException。当 throw 抛出这个异常时,NestJS 的异常过滤器会自动捕获并返回一个标准化的 JSON 响应:
json
{
"statusCode": 404,
"message": "Todo 3 不存在",
"error": "Not Found"
}
statusCode 是 HTTP 状态码 404,message 是自定义的错误描述,error 是 NestJS 自动填充的错误类型。这种标准化输出让前端和后端在错误处理上达成一致------前端可以统一解析 statusCode 和 message,而不是面对各种不规则的错误格式。
手动处理的话,每一处都需要写 try/catch/finally 和自定义响应格式。NestJS 的异常类让开发者只需要关注"什么时候抛异常",而不需要关心"异常怎么格式化输出"。NestJS 还提供了其他内置异常类:
| 异常类 | 状态码 | 使用场景 |
|---|---|---|
BadRequestException |
400 | 参数校验失败 |
UnauthorizedException |
401 | 未登录 |
ForbiddenException |
403 | 无权限 |
NotFoundException |
404 | 资源不存在 |
ConflictException |
409 | 资源冲突(如重复创建) |
InternalServerErrorException |
500 | 服务器内部错误 |
请求流程:从路由到响应的完整链路
一个 GET /todos/1 请求在 NestJS 中的完整处理流程:
- HTTP 请求到达 NestJS 内置的 HTTP 服务器
- NestJS 路由解析器匹配到
@Controller('todos')+@Get(':id'),路由到TodosController.findOne @Param('id')从 URL 中提取id = "1"- Controller 调用
this.todosService.findOne(1) - Service 在
todos数组中查找id === 1的元素 - 找到后返回 Todo 对象,Controller 直接返回,NestJS 自动序列化为 JSON
- 如果未找到,Service 抛出
NotFoundException - NestJS 异常过滤器捕获异常,返回标准的 404 JSON 响应
这条链路体现了 MVC 的核心思想:Controller 薄(只做参数提取和路由分发),Service 厚(包含核心业务逻辑),异常标准化(统一错误格式)。
总结
NestJS 的模块化 MVC 架构通过 @Module() 装饰器组装业务单元,@Controller() 处理 HTTP 请求和参数提取,@Injectable() 服务层封装业务逻辑,NotFoundException 等内置异常类实现标准化错误输出。Controller 负责"薄控制"------参数校验、调用 Service、返回结果;Service 负责"厚业务"------数据查询、业务规则、异常抛出。这种分层让每个类的职责单一、可测试、可替换。