NestJS CRUD 实战:用 Todos 模块打通 Controller-Service-Module 三层

上一篇我们讲了 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 } 就只改 completedtitle 不变。

异常处理方式 返回结果
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 的核心开发模式:

  1. 定义接口和模型Todo 接口 + 内存数据,确定数据结构
  2. 编写 Service@Injectable() + CRUD 方法 + NotFoundException 异常处理
  3. 编写 Controller@Controller('todos') + 路由装饰器 + 参数提取装饰器
  4. 组装 Module@Module() 注册 Controller 和 Service
  5. 引入根模块AppModuleimports 中加入子模块
  6. 编写测试Test.createTestingModule() 隔离测试

这套 Module → Controller → Service 的三件套模式,是 NestJS 开发所有业务模块的标准范式。无论是用户认证、订单系统还是支付网关,换的只是业务逻辑,架构骨架不变。掌握了这个套路,就能以不变应万变地构建任何后端服务。

相关推荐
东风破_1 小时前
NestJS 框架入门:从工厂模式到模块化架构
后端
灯澜忆梦1 小时前
【基于GO的Web开发4】html/template 模板语法
后端·golang·html
一只叫煤球的猫1 小时前
Spring AI 2.0 源码解析(三):ChatClient 的 Fluent API 如何构建请求?
java·后端·面试
掘金者阿豪2 小时前
数据库迁移工具选了半年,最后发现决策的起点错了
后端
卷无止境2 小时前
FastAPI查询参数模型:把散落的参数收拢成一个整齐的盒子
后端·python
gis开发之家3 小时前
Spring Boot 4 深度解析——参数接收大全:@RequestParam、@PathVariable、@RequestBody
java·spring boot·后端·spring
NutShell Wang3 小时前
Rust 1.97 实战迁移:v0 符号重整、Cargo 警告治理与位运算新 API
人工智能·后端·性能优化·rust·vibe coding
阑梦清川3 小时前
零成本把笔记转成双人播客:WorkBuddy + 腾讯云 TTS 完整教程
后端
赫媒派3 小时前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发