学完 NestJS 的模块化思想,但不知道接口到底怎么写?这篇文章用一份完整可跑的待办事项(todos)Demo ,把后端最经典的五个接口------查全部、查一条、新增、删除、更新------从
Controller到Service逐行拆给你看,顺带把 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)→ 字符串转数字,因为数组里的id是number类型,不转就匹配不上
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 /getTodos、GET /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.ts 的 imports 里没有它------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 hello→npm run start:dev→ 访问http://localhost:3000/todos - 下一篇可以聊:连接真实数据库(TypeORM/Prisma)、全局异常过滤器(ExceptionFilter)、管道校验(ValidationPipe)------想看哪个评论区告诉我
希望这篇文章对你有帮助!有问题欢迎在评论区交流。如果觉得有收获,点赞收藏一下 🔥