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 } 就只改 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 的核心开发模式:

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

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

相关推荐
金銀銅鐵2 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路3 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x3 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn5 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈6 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780516 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123457 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒7 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记7 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598787 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端