上一篇我们讲了 NestJS 的工厂模式、装饰器模式和模块化架构。这篇直接上手写代码------从零实现一个完整的 Todos CRUD 模块,覆盖增删改查全流程,理解 Module、Controller、Service 三层如何协作。
目标:一个完整的 Todos API
我们要实现的接口如下:
| HTTP 方法 | 路由 | 功能 | 请求体 |
|---|---|---|---|
| GET | /todos |
获取所有待办 | - |
| GET | /todos/:id |
获取单个待办 | - |
| POST | /todos |
创建待办 | { "title": "xxx" } |
| PATCH | /todos/:id |
更新待办 | { "completed": true } |
| DELETE | /todos/:id |
删除待办 | - |
这是一套标准的 RESTful 风格。下面逐层实现。
第一步:定义数据模型
在 src/todos/todos.service.ts 中定义数据结构:
ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// 初始数据
let todos: Todo[] = [
{ id: 1, title: '学习 nestjs', completed: false },
{ id: 2, title: '学习 CRUD', completed: true },
];
let nextId = 3; // 自增 ID
这里用内存数组模拟数据库,专注于理解框架机制。实际项目中会接入 TypeORM、Prisma 等数据库工具,但三层的协作方式完全一样。
第二步:Service 层------业务逻辑
Service 是数据操作的核心,所有 CRUD 逻辑都在这里:
ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { type Todo } from './todos.service'; // 类型导入
@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 not found: ${id}`);
return todo;
}
// 创建
create(title: string): Todo {
const todo: Todo = { id: nextId++, title, completed: 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 的依赖注入容器管理。加上这个装饰器后,Controller 就能通过构造函数自动拿到它的实例。
NotFoundException:NestJS 内置的异常类。抛出它后,框架会自动返回一个标准的 HTTP 错误响应:
json
{
"statusCode": 404,
"message": "todo not found: 999",
"error": "Not Found"
}
Partial<Todo> :TypeScript 的工具类型,表示 Todo 的所有属性都变成可选的。这样 update 方法可以只传需要修改的字段,而不是整个对象。
Object.assign :把 patch 对象的属性合并到 todo 上,只覆盖传了的部分。比如传 { completed: true } 就只改 completed,title 不变。
| 异常处理方式 | 返回结果 |
|---|---|
throw new NotFoundException(msg) |
HTTP 404 + 标准 JSON 错误体 |
throw new Error(msg) |
HTTP 500 + 非标准错误响应 |
手动 res.status(404).json(...) |
需要自己处理,不符合 NestJS 风格 |
NestJS 提供了完整的内置异常类体系:BadRequestException(400)、UnauthorizedException(401)、ForbiddenException(403)、NotFoundException(404)、InternalServerErrorException(500) 等。直接 throw 即可,框架负责把它转换为标准 HTTP 响应。
第三步:Controller 层------路由与参数提取
Controller 是请求的入口,负责接收 HTTP 请求、提取参数、调用 Service、返回响应:
ts
import {
Body, // 从请求体提取数据
Controller,
Get,
Param, // 从 URL 路径参数提取
Post,
Delete,
Patch
} from '@nestjs/common';
import { TodosService } from './todos.service';
import { type Todo } from './todos.service';
@Controller('todos') // 路由前缀: /todos
export class TodosController {
// 依赖注入:构造函数声明,NestJS 自动实例化
constructor(private readonly todosService: TodosService) {}
// GET /todos
@Get()
findAll(): Todo[] {
return this.todosService.findAll();
}
// GET /todos/1
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
// POST /todos body: { "title": "xxx" }
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
// DELETE /todos/1
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: '删除成功' };
}
// PATCH /todos/1 body: { "completed": true }
@Patch(':id')
update(
@Param('id') id: string,
@Body() patch: Partial<Todo>
): Todo {
return this.todosService.update(Number(id), patch);
}
}
参数提取装饰器对照
| 装饰器 | 提取来源 | 示例 | 得到的值 |
|---|---|---|---|
@Param('id') |
URL 路径参数 | GET /todos/1 → @Param('id') |
"1"(string 类型) |
@Body('title') |
请求体的指定字段 | { "title": "吃饭" } → @Body('title') |
"吃饭" |
@Body() |
整个请求体 | { "completed": true } → @Body() |
{ completed: true } |
@Query() |
URL 查询参数 | GET /todos?page=1 → @Query('page') |
"1" |
注意:
@Param()提取的值始终是string类型,即使 URL 里传的是数字1,拿到的是"1"。需要用Number(id)转换。
路由匹配规则
@Controller('todos') 设定了前缀,配合方法装饰器形成完整路由:
| 方法装饰器 | 路由前缀 | 完整路由 |
|---|---|---|
@Get() |
/todos |
GET /todos |
@Get(':id') |
/todos |
GET /todos/:id |
@Post() |
/todos |
POST /todos |
@Patch(':id') |
/todos |
PATCH /todos/:id |
@Delete(':id') |
/todos |
DELETE /todos/:id |
NestJS 按从上到下的顺序匹配路由。@Get() 在 @Get(':id') 前面,所以 GET /todos 会匹配到 findAll() 而不是把 "todos" 当作 id 传给 findOne()。
第四步:Module 层------组装模块
Module 负责把 Controller 和 Service 组装成一个可被根模块引入的单元:
ts
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller';
import { TodosService } from './todos.service';
@Module({
controllers: [TodosController], // 注册控制器
providers: [TodosService] // 注册服务(可被注入)
})
export class TodosModule {}
然后在根模块中引入:
ts
// 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], // 引入 Todos 子模块
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
数据流全链路
以 GET /todos/1 为例,完整的请求处理流程:
kotlin
客户端请求 GET /todos/1
│
▼
NestFactory 创建的 Express 实例收到请求
│
▼
路由匹配 → @Controller('todos') + @Get(':id')
│
▼
@Param('id') 提取出 id = "1"
│
▼
TodosController.findOne("1")
│
▼
调用 this.todosService.findOne(1) ← 依赖注入
│
▼
TodosService.findOne(1)
│
▼
todos.find(t => t.id === 1) ← 数据查找
│
├── 找到 → return todo 对象
│ │
│ ▼
│ Controller 返回 JSON → 客户端收到 200
│
└── 没找到 → throw NotFoundException
│
▼
NestJS 异常过滤器拦截
│
▼
返回 404 + 错误 JSON → 客户端收到 404
三层职责总结
| 层 | 文件 | 关心什么 | 不关心什么 |
|---|---|---|---|
| Controller | todos.controller.ts |
路由匹配、参数提取、调用 Service | 数据怎么来的 |
| Service | todos.service.ts |
数据操作、业务逻辑、异常抛出 | HTTP 请求细节 |
| Module | todos.module.ts |
注册和组装 Controller + Service | 具体业务逻辑 |
这种分层的好处:Controller 只管"接电话",Service 只管"干活",Module 只管"排座位"。每一层职责单一,修改一层不影响其他层。
测试:单元测试结构
NestJS 脚手架自带 Jest 测试框架。app.controller.spec.ts 是一个标准的单元测试示例:
ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
// 创建测试模块,替代真实的 NestFactory
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
关键点:
Test.createTestingModule()创建一个轻量测试容器,不需要启动完整的 HTTP 服务- 可以在
providers中替换真实的 Service 为 mock 实现,实现隔离测试 - 运行
npm test即可执行所有*.spec.ts测试文件
验证:用 curl 测试接口
项目跑起来后(npm run start:dev),用 curl 快速验证:
bash
# 查询全部
curl http://localhost:3000/todos
# [{"id":1,"title":"学习 nestjs","completed":false},{"id":2,"title":"学习 CRUD","completed":true}]
# 查询单个
curl http://localhost:3000/todos/1
# {"id":1,"title":"学习 nestjs","completed":false}
# 创建
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"写博客"}'
# {"id":3,"title":"写博客","completed":false}
# 更新(标记完成)
curl -X PATCH http://localhost:3000/todos/3 \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# {"id":3,"title":"写博客","completed":true}
# 删除
curl -X DELETE http://localhost:3000/todos/3
# {"message":"删除成功"}
# 查询不存在的 ID → 触发 NotFoundException
curl http://localhost:3000/todos/999
# {"statusCode":404,"message":"todo not found: 999","error":"Not Found"}
小结
这个 Todos 模块虽然简单,但完整展示了 NestJS 的核心开发模式:
- 定义接口和模型 :
Todo接口 + 内存数据,确定数据结构 - 编写 Service :
@Injectable()+ CRUD 方法 +NotFoundException异常处理 - 编写 Controller :
@Controller('todos')+ 路由装饰器 + 参数提取装饰器 - 组装 Module :
@Module()注册 Controller 和 Service - 引入根模块 :
AppModule的imports中加入子模块 - 编写测试 :
Test.createTestingModule()隔离测试
这套 Module → Controller → Service 的三件套模式,是 NestJS 开发所有业务模块的标准范式。无论是用户认证、订单系统还是支付网关,换的只是业务逻辑,架构骨架不变。掌握了这个套路,就能以不变应万变地构建任何后端服务。