实战 Todo CRUD ------ 从路由到异常,手写一个完整模块
本篇基于第一篇的理论,通过 Todos 模块完整实现 CRUD,深入解析
@Get(':id')、@Body、@Putvs@Patch、Partial<T>、NotFoundException等实战细节。
一、Todos 模块的文件结构
根据笔记"Module 是 nestjs 的独立业务模块",我们创建了三个文件:
todos.module.ts------ 模块定义(组装)todos.controller.ts------ 控制器(路由)todos.service.ts------ 服务(数据业务)
二、模块定义 ------ todos.module.ts
typescript
@Module({
controllers: [TodosController],
providers: [TodosService]
})
export class TodosModule {}
注释解读:
"大型后端框架,MVC 视图层不可以直接去数据库查数据,View Controller Model"
这里说明 Nest 遵循 MVC 分层:Controller 是视图层(接收请求),Service 是模型层(处理数据)。Controller 不能直接操作数据库,必须通过 Service 层,这样保证了业务逻辑的集中和可测试性。
三、服务层 ------ Todos.service.ts 深度解析
3.1 数据模型与模拟数据库
typescript
export interface Todo {
id: number;
title: string;
complete: boolean;
}
let todos: Todo[] = [
{ id:1, title: '学习 NestJS', complete: false },
{ id:2, title: '学习 CRUD', complete: true },
];
let nextId = 3;
这里用内存数组模拟数据库,nextId 负责自增 ID。这是学习阶段常用的方式,真实项目会替换为 TypeORM 等 ORM。
3.2 findAll 和 findOne
typescript
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;
}
对注释的深度解读:
"后端业务严谨稳定 容错模块"
这提醒我们:永远不要假设数据一定存在 。如果根据 id 找不到对应的 Todo,不能返回 null 或 undefined,因为调用者可能忘记判空,导致后续访问 todo.title 时报错。使用 throw 抛出 NotFoundException,Nest 会自动捕获并返回 404 状态码,这样错误处理是强制的,调用者无法忽略。
throw 和 return 的区别 :throw 会立即中断函数执行,把控制权交给上层异常过滤器。这正是笔记中提到的"nest.js 提供了各种错误类,标准化错误输出,status 状态码,message 消息"。
3.3 create 方法
typescript
create(title: string): Todo {
const todo: Todo = {id:nextId++, title, complete: false};
todos.push(todo);
return todo;
}
这里使用了 nextId++ 来生成自增 ID,并默认 complete 为 false。
3.4 remove 方法
typescript
remove(id: number): void {
const index = todos.findIndex(t => t.id == id);
if(index == -1) throw new NotFoundException(`Todo ${id} 不存在`)
todos.splice(index, 1);
}
注释解读:
"// index"
这行注释暗示我们通过索引删除。同样,如果索引为 -1(找不到),抛异常。函数返回类型 void 表示没有返回值,但 Nest 会返回 204 状态码(如果没有额外处理)。
3.5 update 方法 ------ 理解 Partial<T>
typescript
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
Partial<Todo> 是什么?
它是 TypeScript 的工具类型,把 Todo 的所有属性变成可选的。也就是说,patch 可以是 { complete: true },也可以是 { title: '新标题' },甚至 { title: '新', complete: true },但不必提供所有字段。
为什么更新要用 Partial?
因为前端可能只修改一个字段(比如勾选完成状态),如果要求必须传全部字段,前端就要把未修改的字段也重新传一遍,增加了网络负担,也容易出错。Partial 完美支持部分更新。
Object.assign(todo, patch) 会把 patch 中的属性合并到 todo 中,未修改的字段保持不变,实现了真正的"部分更新"。
四、控制器层 ------ Todos.controller.ts 详解
4.1 路由基础
typescript
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
// ...
}
@Controller('todos') 指定了该控制器的基础路由 /todos,所有方法的路由都会以它开头。
4.2 GET /todos ------ 查询全部
typescript
@Get()
findAll(): Todo[] {
console.log('/todos controller');
return this.todosService.findAll();
}
注释解读:
"/todos" "怎么找到service? import new 实例化"
这里强调:我们并没有手动 new TodosService(),而是通过依赖注入自动获得实例。Nest 在实例化控制器时,发现构造参数需要 TodosService,就会从容器中取出已注册的实例注入进来。
4.3 GET /todos/:id ------ 查询单个
typescript
@Get(':id')
findOne(@Param('id') id: string): Todo {
console.log(id);
return this.todosService.findOne(Number(id));
}
@Get(':id')声明路由参数id。@Param('id')提取路径中的id值(字符串类型),我们通过Number(id)转为数字。- 如果
id不存在,Service 层会抛出NotFoundException,Nest 自动返回 404。
4.4 POST /todos ------ 创建
typescript
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
@Body('title')从请求体中提取title字段。如果请求体是{"title": "写文章"},则title为"写文章"。
4.5 DELETE /todos/:id ------ 删除
typescript
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: '删除成功' };
}
- 调用 Service 的
remove方法,如果删除成功,返回一个包含消息的对象。实际 RESTful 规范可能返回 204 无内容,但这里为了方便前端展示,返回了消息。
4.6 PUT /todos/:id ------ 更新(注意注释)
typescript
// Patch
@Put(':id')
update(
@Param('id') id: string,
@Body('') patch: Partial<Todo>
): Todo {
return this.todosService.update(Number(id), patch);
}
注释解读:
"// Patch"
这里写的是 @Put,但注释却写着 Patch,这暗示了一个常见误区:PUT 应该用于全量更新,PATCH 用于部分更新 。本代码中我们使用了 Partial<Todo>,只允许部分字段,这在语义上更适合 @Patch。但为了演示灵活性,我们保留了 @Put。实际开发中,请严格区分:全量用 PUT,部分用 PATCH。
@Body('') 中的空字符串表示提取整个请求体对象,赋给 patch,然后传给 Service。
五、PUT 与 PATCH 的本质区别(面试高频)
| 维度 | PUT | PATCH |
|---|---|---|
| 语义 | 全量替换资源 | 部分更新资源 |
| 请求体 | 必须包含所有字段 | 只包含需要修改的字段 |
| 未传字段 | 会被覆盖为默认值或 null | 保持不变 |
| 幂等性 | 是(多次结果一致) | 不保证(如每次 +1) |
| RESTful 规范 | 用于整体更新 | 用于局部更新 |
在我们的 Todo 示例中,如果前端只传 { complete: true },使用 PUT 可能导致 title 被清空(如果服务端直接用 Object.assign 覆盖,未传字段会丢失)。但我们用了 Object.assign 合并,所以即使 PUT 也能实现部分更新,但这是违反语义的 。正确做法是改用 @Patch。
六、异常处理 ------ 为什么不用 try/catch?
笔记中提到:
"try catch finally ts 读秒 线程会挂"
这指的是传统的 try/catch 会阻塞事件循环(虽然 Node.js 是异步,但同步的 try/catch 会影响性能,且容易遗漏)。Nest 提供了全局异常过滤器 ,我们只需要 throw 内置的异常类(如 NotFoundException),Nest 就会自动捕获并返回标准化的错误响应,状态码和消息都是统一的。
好处:
- 业务代码不用写
try/catch,更干净。 - 所有异常统一处理,便于日志记录和监控。
- 返回给前端的错误格式一致,方便前端统一处理。
七、完整请求流程总结(以 GET /todos/1 为例)
- 浏览器发送
GET http://localhost:3000/todos/1 - Nest 底层 HTTP 服务器接收到请求,匹配路由。
- 路由发现
/todos/1匹配到TodosController的findOne方法,且:id被提取为'1'。 - Nest 实例化
TodosController(如果尚未实例化),通过 DI 注入TodosService。 - 调用
findOne方法,传入@Param('id')提取的'1',转为数字。 findOne调用this.todosService.findOne(1)。- Service 在
todos数组中查找id === 1的项。 - 若找到,返回该对象;若未找到,
throw new NotFoundException。 - 若返回对象,Nest 将其序列化为 JSON 并发送 200 响应。
- 若抛出异常,Nest 全局过滤器捕获,生成 404 响应,并附带消息
"Todo 1 不存在"。
八、总结
| 技术点 | 代码体现 | 底层理解 |
|---|---|---|
| CRUD | 实现了增删改查四个接口 | 是任何数据驱动应用的基础 |
| 路由参数 | @Get(':id') + @Param('id') |
提取 URL 动态段 |
| 请求体 | @Body() |
解析 JSON 请求体 |
| 部分更新 | Partial<T> + Object.assign |
只修改传入的字段 |
| PUT vs PATCH | 实际应使用 @Patch |
语义决定安全性与幂等性 |
| 异常处理 | throw new NotFoundException() |
利用框架统一错误输出 |
| 依赖注入 | constructor(private readonly service) |
容器管理实例,解耦 |