用 NestJS 实现待办事项接口:RESTful CRUD 与错误处理一次讲透

学完 NestJS 的模块化思想,但不知道接口到底怎么写?这篇文章用一份完整可跑的待办事项(todos)Demo ,把后端最经典的五个接口------查全部、查一条、新增、删除、更新------从 ControllerService 逐行拆给你看,顺带把 RESTful、Partial<Todo>、依赖注入、还有后端错误处理一次性讲透。

前言

上一篇我们搞懂了 NestJS 的工厂模式、模块化和装饰器,但纸上得来终觉浅------接口到底长什么样? 这次我跟着老师把一个 todo 应用从"一个空壳"写到"五个接口全通",踩了不少坑(最经典的是:代码明明加了新接口,服务器却一直 404,最后发现是跑的旧进程没重启......)。写下来希望能帮到和我一样刚入门的同学。

你将会收获:

  • 🏗️ NestJS 三件套:Controller / Service / Module 到底怎么分工
  • 🔍 第一支接口:@Get() 查全部,逐行拆解
  • 🎯 带参数的接口:@Get(':id') + @Param 怎么取 URL 里的数字
  • 🌐 RESTful 到底是什么:URL 是名词,方法是动词
  • ➕ 新增:@Post() + @Body 怎么拿请求体
  • 🗑️ 删除:@Delete() + findIndex + splice
  • ✏️ 更新:Partial<Todo> + Object.assign 实现"部分更新"
  • 🐛 后端错误处理:throw new Error 和 NestJS 内置错误类(NotFoundException)差在哪
  • 💉 依赖注入:@Injectable() 到底在干什么

技术栈: NestJS(11)+ TypeScript + pnpm

目录

  • 一、先看全貌:三件套怎么分工
  • 二、查全部:你的第一支接口
  • [三、查一条:@Param 怎么取 URL 里的 id](#三、查一条:@Param 怎么取 URL 里的 id "#%E4%B8%89%E6%9F%A5%E4%B8%80%E6%9D%A1param-%E6%80%8E%E4%B9%88%E5%8F%96-url-%E9%87%8C%E7%9A%84-id")
  • [四、RESTful:URL 是名词,方法是动词](#四、RESTful:URL 是名词,方法是动词 "#%E5%9B%9Brestfulurl-%E6%98%AF%E5%90%8D%E8%AF%8D%E6%96%B9%E6%B3%95%E6%98%AF%E5%8A%A8%E8%AF%8D")
  • [五、新增:@Post + @Body](#五、新增:@Post + @Body "#%E4%BA%94%E6%96%B0%E5%A2%9Epost--body")
  • [六、删除:findIndex + splice](#六、删除:findIndex + splice "#%E5%85%AD%E5%88%A0%E9%99%A4findindex--splice")
  • [七、更新:Partial<Todo> + Object.assign](#七、更新:Partial + Object.assign "#%E4%B8%83%E6%9B%B4%E6%96%B0partialtodo--objectassign")
  • 八、错误处理:后端报错了怎么办
  • [九、依赖注入:@Injectable 到底在干什么](#九、依赖注入:@Injectable 到底在干什么 "#%E4%B9%9D%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5injectable-%E5%88%B0%E5%BA%95%E5%9C%A8%E5%B9%B2%E4%BB%80%E4%B9%88")
  • [十、常见坑 2 个](#十、常见坑 2 个 "#%E5%8D%81%E5%B8%B8%E8%A7%81%E5%9D%91-2-%E4%B8%AA")
  • [十一、面试高频 4 问](#十一、面试高频 4 问 "#%E5%8D%81%E4%B8%80%E9%9D%A2%E8%AF%95%E9%AB%98%E9%A2%91-4-%E9%97%AE")
  • 总结

一、先看全貌:三件套怎么分工

后端开发有一个铁打的约定------MVC。在 NestJS 里具体化成三件套:

kotlin 复制代码
顾客:GET /todos
   │
   ▼
┌──────────────┐
│ Controller   │ 前台接待:接单、做简单校验,但不下厨
└──────────────┘
   │ return this.todosService.findAll()
   ▼
┌──────────────┐
│ Service      │ 后厨师傅:真正干活(查数据、改数据)
└──────────────┘
   │ return todos
   ▼
顾客拿到数据
文件 角色 大白话
xx.controller.ts 前台接待 接请求、做校验,转给 Service,不碰数据
xx.service.ts 后厨师傅 真正干活:CRUD、操作数据,不接请求
xx.module.ts 门店 把前台 + 后厨组织成一家店,声明"这家店提供什么"

💡 一句话记住:Controller 接单,Service 做菜,Module 开店。

这次的 Demo 就是一套完整的待办事项:三个文件都在 src/todos/ 目录下,实现五个接口。我们一个一个写。


二、查全部:你的第一支接口

Controller 里先开一个"窗口"

typescript 复制代码
// Todos.controller.ts
import {
    Controller,
    Get,
} from '@nestjs/common';
import { TodosService } from './Todos.service';
import { Todo } from './Todos.service';

@Controller('todos')                    // ① 路由前缀:所有接口以 /todos 开头
export class TodosController {          // ② 定义控制器
    constructor(private readonly todosService: TodosService) {}  // ③ 把后厨"注入"进来

    @Get()                              // ④ 接 GET /todos 的请求
    findAll(): Todo[] {                 // ⑤ 方法:返回 Todo 数组
        return this.todosService.findAll();  // ⑥ 转给后厨,自己不干活
    }
}

逐行拆:

  • @Controller('todos') → 给这个类贴上"我是控制器,管 /todos 这块"的标签
  • constructor(private readonly todosService: TodosService)把后厨(Service)塞进前台手里,前台随时能喊他(这就是依赖注入,后面第九章细讲)
  • @Get() → 贴上"我处理 GET 请求"的标签,默认匹配 /todos
  • return this.todosService.findAll()前台转身喊后厨:"把 todos 全端出来!"

Service 里真正干活

typescript 复制代码
// Todos.service.ts
import { Injectable } from '@nestjs/common';

export interface Todo {            // ① 定义数据长什么样
    id: number;
    title: string;
    completed: boolean;
}
let todos: Todo[] = [              // ② 内存数组当"数据库"(以后换成 MySQL)
    { id: 1, title: '学习nestjs', completed: false },
    { id: 2, title: '学习springboot', completed: false },
]

@Injectable()                      // ③ 允许被注入(能被别人调用)
export class TodosService {
    findAll() {                    // ④ 查全部
        return todos;
    }
}

启动后浏览器访问 http://localhost:3000/todos,返回:

json 复制代码
[
  {"id":1,"title":"学习nestjs","completed":false},
  {"id":2,"title":"学习springboot","completed":false}
]

💡 第一条接口打通了。流程就一句话:请求 → Controller 接 → Service 干 → 数据端上来。


三、查一条:@Param 怎么取 URL 里的 id

痛点:怎么让 /todos/2 返回 id 是 2 的那条?

/todos/2 里那个 2,在前后端术语里叫路由参数(path 参数) ------它不是固定地址,而是"我要哪一条"的编号。要接这种单,得开一个带参数的窗口

typescript 复制代码
@Get(':id')                              // ① 带参数的窗口:冒号表示"这里是变量"
findOne(@Param('id') id: string): Todo | Error {   // ② 把 URL 里的 2 抓出来放进 id
    console.log(id);
    return this.todosService.findOne(Number(id));  // ③ 转给后厨,字符串转数字
}

逐行拆:

  • @Get(':id'):id占位符/todos/2/todos/99 都能命中这里
  • @Param('id') → 从 URL 里2 抓出来 ,放进 id 变量(注意:URL 参数拿到的是字符串 '2',不是数字 2
  • Number(id) → 字符串转数字,因为数组里的 idnumber 类型,不转就匹配不上

Service 里对应的 findOne

typescript 复制代码
findOne(id: number): Todo {
    const todo = todos.find(t => t.id === id);   // 挨个找,找到 id 相等的返回
    // 后端业务严谨稳定,容错模块
    if (!todo) {
        throw new Error(`Todo with id ${id} not found`);
    }
    return todo;
}

todos.find(...) 是 JS 数组方法------挨个比对,找到返回那一条,找不到返回 undefined

⚠️ 面试考点:@Param('id') 取的是 URL 里的参数 (/todos/2 的 2)。还有 @Query()?a=1&b=2 的查询参数、@Body() 取请求体,三者别搞混。


四、RESTful:URL 是名词,方法是动词

写到第三个接口,该停下来认识一个词了------RESTful。你其实已经在不知不觉写它了。

RESTful = 用 URL 表示"对哪个数据"(名词),用 HTTP 方法表示"做什么操作"(动词)。

把数据想成货架上的商品

bash 复制代码
动作(方法)     货架(URL)          结果
GET           /todos            把货架上所有东西拍个照
GET           /todos/2          只拍第 2 个
POST          /todos            放一件新商品上去
PUT / PATCH   /todos/2          把第 2 个商品改一下
DELETE        /todos/2          把第 2 个商品扔掉

同一个 URL /todos/2,配不同方法 = 不同操作。 这就是 RESTful。

方法 动作 URL 带 id 吗 幂等吗(发两次结果一样吗)
GET 可选
POST 不带(id 服务器生成) ❌ 会造两条
PUT 整体换
PATCH 改一部分
DELETE

❌ 不 RESTful 的写法(新手常见):GET /getTodosGET /deleteTodo/2------URL 里全是动词。URL 只用名词,动词交给 HTTP 方法。
💡 一句话记住:GET 查、POST 增、PUT 换、PATCH 改、DELETE 删。


五、新增:@Post + @Body

新增一条,跟前几个不同:数据要由调用方传进来,而不是写在代码里。数据放哪?放**请求体(Body)**里。

typescript 复制代码
@Post()                                // ① 接 POST /todos
create(@Body('title') title: string): Todo | Error {   // ② 从请求体里取 title
    return this.todosService.create(title);            // ③ 转给后厨
}

@Body('title')从请求体里把 title 字段抓出来放进变量。

Service 里:

typescript 复制代码
let nextId = 3;                        // ① 记录下一条的 id(模拟自增主键)

create(title: string): Todo {
    const todo: Todo = { id: nextId++, title, completed: false };  // ② 拼一条新数据
    todos.push(todo);                  // ③ 塞进数组
    return todo;                       // ④ 把新建的返回给调用方
}

调用方式(用 curl 模拟):

bash 复制代码
curl -X POST http://localhost:3000/todos \
     -H "Content-Type: application/json" \
     -d '{"title":"学习RESTful"}'

返回:

json 复制代码
{"id":3,"title":"学习RESTful","completed":false}

💡 POST 是"造新的" :id 由服务器生成(nextId++),发两次会得到两条不同的数据------这就是它"不幂等"的原因。


六、删除:findIndex + splice

删除一条,Service 里要先找到它在数组里的位置,然后"剪掉"。

typescript 复制代码
@Delete(':id')                         // ① 接 DELETE /todos/2
remove(@Param('id') id: string): { message: string } | Error {
    this.todosService.remove(Number(id));       // ② 转给后厨
    return { message: '删除成功' };             // ③ 返回一个结果给调用方
}

Service 里:

typescript 复制代码
remove(id: number): void {
    const index = todos.findIndex(t => t.id === id);  // ① 找到第几个位置
    if (index === -1) {                              // ② -1 = 没找到
        throw new Error(`Todo with id ${id} not found`);
    }
    todos.splice(index, 1);                          // ③ 从数组里"剪掉"这一条
}

两个新数组方法:

方法 意思 找不到返回
todos.find(...) 返回那一条 undefined
todos.findIndex(...) 返回位置下标 -1
todos.splice(index, 1) index 位置删掉 1 个 ---

💡 find 是"要东西",findIndex 是"要位置"。删东西得先知道位置,所以用 findIndex + splice


七、更新:Partial + Object.assign

更新是最有意思的一个。先问个问题:更新的时候,要不要传完整数据?

改一条待办,用户可能只想改标题、或者只想勾完成状态。如果要求调用方把三个字段全传,太死板。所以我们要用 TypeScript 的 Partial<Todo>

typescript 复制代码
import { PartialType } ...   // 不,Partial 是 TS 自带的,不用 import

@Put(':id')                              // ① 接 PUT /todos/2
update(
    @Param('id') id: string,
    @Body() patch: Partial<Todo>         // ② 请求体里的所有字段,全都可选
): Todo | Error {
    return this.todosService.update(Number(id), patch);  // ③ 转给后厨
}

Partial 是什么

它是 TypeScript 内置的工具类型 :把 Todo 的每个属性都变成"可选"。

typescript 复制代码
export interface Todo {
    id: number;          // 必填
    title: string;       // 必填
    completed: boolean;  // 必填
}

// Partial<Todo> 等价于:
{
    id?: number;          // 可填可不填
    title?: string;       // 可填可不填
    completed?: boolean;  // 可填可不填
}

所以调用方可以只传要改的字段

json 复制代码
{"title":"改成新标题"}        // 只改标题
{"completed":true}          // 只勾完成状态

❌ 如果类型写成完整的 Todo,TypeScript 会报错:"你少了 completed!"------但改一条本来就只需要传要改的部分。

Object.assign 实现"部分更新"

Service 里:

typescript 复制代码
update(id: number, patch: Partial<Todo>): Todo {
    const todo = this.findOne(id);      // ① 先找到那条(顺便复用 findOne 的容错)
    Object.assign(todo, patch);         // ② 把 patch 里的字段"盖"到 todo 上
    return todo;                        // ③ 返回更新后的
}

Object.assign(原数据, 改动) = 改动 的属性合并到 原数据

typescript 复制代码
let todo = { id: 2, title: '学习springboot', completed: false };
let patch = { completed: true };        // 只带了一个字段

Object.assign(todo, patch);
// todo → { id: 2, title: '学习springboot', completed: true }
//          ↑ patch 里没有的 title 原样保留

💡 Object.assign(todo, patch) = 用"改动"盖到"原数据"上,只盖你给了的字段。 这就是"部分更新"的实现。
📌 关于 PUT vs PATCH:严格 RESTful 里,PUT 是整体替换 (要传完整对象),PATCH 才是部分修改Partial)。很多项目(包括我这个 Demo)图省事用 PUT 做部分更新,面试官较真时知道区别即可。


八、错误处理:后端报错了怎么办

这是后端开发的重头戏,也是你笔记里的面试题:"请说下你是如何处理后端报错的?"

前端 vs 后端,对错误的态度完全不同

前端 后端
出错时 页面崩了,用户刷新一下 整个服务挂掉 = 所有用户全崩
处理方式 弹个提示 必须容错,把错误变成"能看的响应"

两种写法对比

❌ 写法一:throw new Error() ------ 会把服务搞成 500

typescript 复制代码
// Service 里查不到那条数据
if (!todo) {
    throw new Error(`Todo with id ${id} not found`);
}

这样确实"抛了错",但 Nest 会把它当成服务器内部错误,返回:

json 复制代码
{
    "statusCode": 500,
    "message": "Internal server error",
    "error": "Internal Server Error"
}

问题在哪?明明是"找不到资源"(404),却被说成"服务器炸了"(500)------语义完全错了,前端也没法根据状态码做处理。

✅ 写法二:NestJS 内置错误类 ------ 标准化输出

typescript 复制代码
import { NotFoundException } from '@nestjs/common';

// Service 里
if (!todo) {
    throw new NotFoundException(`Todo with id ${id} not found`);
}

NestJS 提供了一大堆内置错误类,每个都对应一个 HTTP 状态码:

内置错误类 状态码 场景
NotFoundException 404 资源不存在
BadRequestException 400 参数不对、格式错误
UnauthorizedException 401 没登录
ForbiddenException 403 没权限
ConflictException 409 冲突(如用户名已存在)

它们会把错误变成标准格式statusCode(状态码)+ message(消息),前端一看状态码就知道怎么处理。

json 复制代码
{
    "statusCode": 404,
    "message": "Todo with id 99 not found",
    "error": "Not Found"
}

⚠️ 面试考点 :后端错误处理的三板斧------ ① 用 try / catch / finally 兜住可能崩的代码(数据库连接、IO); ② 不要裸 throw new Error(),会变成 500,要抛 NestJS 内置错误类 ,让状态码语义正确; ③ NestJS 会把错误标准化输出statusCode + message,前端友好。
💡 一句话记住:后端错误 = 语义要准(该 404 就 404),格式要标准(statusCode + message)。


九、依赖注入:@Injectable 到底在干什么

你肯定注意到 Service 类头顶上有个 @Injectable(),Controller 构造函数里又有个 todosService。这就是 NestJS 的依赖注入(DI)

没有 DI 时,你要自己 new

typescript 复制代码
// ❌ 手动 new:Controller 得自己把 Service 造出来
export class TodosController {
    private todosService = new TodosService();   // 自己 new,耦合死了
    findAll() {
        return this.todosService.findAll();
    }
}

问题:Controller 和 Service 绑死了。以后 Service 构造要加参数,这里也要跟着改。

有了 DI,Nest 帮你"递"过来

typescript 复制代码
@Injectable()                                    // ① 标记:这个类可以被注入
export class TodosService {
    findAll() { return todos; }
}

@Controller('todos')
export class TodosController {
    constructor(private readonly todosService: TodosService) {}  // ② Nest 自动塞进来
    // 不用 new!Nest 启动时自动创建一个 TodosService 实例递给你
}

类比:你没去后厨找师傅,是店长(Nest)把师傅(Service)直接安排到前台(Controller)的工位上。 前台不用管师傅从哪来、怎么培养,只管喊他干活。

💡 依赖注入 = 你需要什么,框架给你递什么,而不是你自己去造。 @Injectable() 就是"我允许别人使用我"的标签。


十、常见坑 2 个

坑 1:代码加了新接口,访问却一直 404

这是我真实踩的坑,也是最迷惑的一个------代码明明是对的,但 /todos/2 一直 404

原因:跑着的是旧进程npm run start 是"编译一次就运行",改代码不生效;只有 npm run start:dev 才监听文件变化、自动重启。

  • npm run start:改代码后必须手动重启
  • npm run start:dev:保存即热更新,开发专用

📌 开发时用 start:dev,别用 start 如果还 404,netstat -ano | grep 3000 看下端口是不是被旧进程占着,taskkill //F //PID <号码> 干掉重来。

坑 2:Module 写了但没"上架",接口 404

写了 TodosModule,但根模块 app.module.tsimports 里没有它------Nest 根本不知道这个模块存在。

typescript 复制代码
// app.module.ts
@Module({
  imports: [TodosModule],   // 关键!不写这个,/todos 直接 404
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

📌 模块写好了要装进根模块的 imports 里才算"上架"。 就像新品要进点单系统,顾客才能点到。


十一、面试高频 4 问

① RESTful 的核心是什么?POST 和 PUT 的区别?

RESTful 核心:URL 表示资源(名词),HTTP 方法表示动作(动词)。POST 是新增,不幂等(发两次两条数据);PUT 是整体替换,幂等(发两次结果一样)。另一个易混的 PATCH 是部分更新(用 Partial 只传要改的字段)。
② @Param、@Query、@Body 有什么区别?

@Param 取 URL 路径里的参数(/todos/2 的 2);@Query 取 URL 问号后的查询参数(?a=1&b=2);@Body 取请求体(POST/PUT 的 JSON 数据)。三者是"参数放在不同位置"。
③ 后端错误处理怎么做才规范?

① try/catch/finally 兜住可能崩的代码;② 不要裸 throw new Error(会变 500),抛 NestJS 内置错误类(NotFoundException→404、BadRequestException→400 等),让状态码语义正确;③ 错误标准化输出为 statusCode + message。
④ @Injectable() 和依赖注入是什么?

@Injectable() 标记"这个类可以被注入"。依赖注入 = 你需要什么对象,框架(Nest)自动创建并递给你,而不是自己 new。好处是解耦:Controller 不用管 Service 怎么创建,改 Service 构造参数也不用动 Controller。


总结

核心概念速查表

概念 一句话
Controller / Service / Module 前台接单 / 后厨做菜 / 门店开店
RESTful URL 是名词(资源),方法是动词(动作)
@Get/@Post/@Put/@Delete 查 / 增 / 换 / 删
@Param / @Query / @Body 取路径参数 / 查询参数 / 请求体
Partial<Todo> 所有属性变可选,适配"部分更新"
Object.assign(todo, patch) 把 patch 字段盖到 todo 上,没传的不动
NotFoundException NestJS 内置错误类,返回标准 404(statusCode + message)
@Injectable + DI 需要什么,框架递什么,不用自己 new

一句口诀

Controller 接单,Service 做菜;RESTful 靠方法,更新用 Partial;报错用内置错误类,别裸抛。

核心代码骨架

typescript 复制代码
@Controller('todos')
export class TodosController {
    constructor(private readonly todosService: TodosService) {}

    @Get() findAll() { return this.todosService.findAll(); }
    @Get(':id') findOne(@Param('id') id: string) { return this.todosService.findOne(Number(id)); }
    @Post() create(@Body('title') title: string) { return this.todosService.create(title); }
    @Put(':id') update(@Param('id') id: string, @Body() patch: Partial<Todo>) {
        return this.todosService.update(Number(id), patch);
    }
    @Delete(':id') remove(@Param('id') id: string) { return this.todosService.remove(Number(id)); }
}

结尾

  • 本文源码:hjf-ai/backend/nestjs/hello/src/todos/ 下的三个文件(controller / service / module)
  • 想自己跑:nest new hellonpm run start:dev → 访问 http://localhost:3000/todos
  • 下一篇可以聊:连接真实数据库(TypeORM/Prisma)、全局异常过滤器(ExceptionFilter)、管道校验(ValidationPipe)------想看哪个评论区告诉我

希望这篇文章对你有帮助!有问题欢迎在评论区交流。如果觉得有收获,点赞收藏一下 🔥

相关推荐
蔓越莓1 小时前
打包工具:编译器ESBuild
前端·面试
Escalating_xu1 小时前
【Linux】进程信号:从产生、阻塞与递达到 sigaction、可重入与内核返回路径
linux·运维·面试·职场和发展
JL152 小时前
Java并发编程面试全攻略-从synchronized到AQS底层原理
java·开发语言·面试·并发编程
程序员爱钓鱼3 小时前
Go 编程实战:数组 Array——固定长度的数据集合
后端·面试·go
ShineWinsu10 小时前
对于C++:auto_ptr、unique_ptr、shared_ptr的模拟实现
c++·面试·笔试·智能指针·unique_ptr·shared_ptr·auto_ptr
黄敬峰13 小时前
一文搞懂 NestJS 后端框架:工厂模式、模块化与装饰器
面试
码匠许师傅14 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试
kyriewen15 小时前
面试官说"打开你的AI工具"——我才发现,他考的根本不是写代码
前端·人工智能·面试