从零到精通 NestJS:深度剖析待办事项(Todos)项目,全面解析 Nest 架构、模块与数据流
文章以实战为导向,从创建 Nest 项目到构建 Todos 模块,详细拆解每个文件的作用与数据流转逻辑,涵盖 Controller 接口设计、Service 业务封装、Module 组织方式及单元测试编写,全面展现 NestJS 清晰、可维护的现代化后端开发范式。
引言
“Nest 不是蜂巢,但它的结构比蜂巢还整齐!”
—— 一位刚学会 Nest 的开发者在深夜提交代码后如是说。
什么是 NestJS?
NestJS(简称 Nest)是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的渐进式框架。它使用 TypeScript 编写(当然也支持纯 JavaScript),并深受 Angular 的启发——模块化、依赖注入、装饰器……这些前端熟悉的词汇,在后端世界里也能大放异彩!
Nest 的核心思想是 “结构清晰 + 职责分离”。它不像 Express 那样自由奔放(有时甚至混乱),而是像搭乐高一样,把应用拆成一个个小积木:Controller(控制器)、Service(服务)、Module(模块),各司其职,井然有序。
更重要的是,Nest 建立在 Express(默认)或 Fastify 之上,这意味着你既能享受现代框架的工程化优势,又不会失去底层灵活性。
今天,我们就通过一个超实用的 待办事项(Todos)项目,手把手带你走进 Nest 的世界!我们将逐行分析每一个文件,解释每个 API 的作用,并完整追踪一次请求从接收到响应的全过程。
第一步:创建你的第一个 Nest 项目
创建项目只需几步:
# 安装 Nest CLI(全局)
npm i -g @nestjs/cli
# 查看版本(确认安装成功)
nest --version
# 创建新项目
nest new nest-test-demo
# 进入项目目录
cd nest-test-demo
# 安装依赖(这里用 pnpm)
pnpm i
# 安装 dotenv(用于读取 .env 环境变量)
pnpm i dotenv
然后在项目根目录创建 .env 文件:
PORT=1234
最后运行项目:
pnpm run start
打开浏览器访问:
http://localhost:1234/hellohttp://localhost:1234/welcome- 或发送 POST 请求到
/login(带username和password)
一切就绪!现在,让我们深入代码,看看 Nest 是如何组织这一切的。
Nest 项目的三大核心模块(MVC 的现代化演进)
Nest 应用由三大核心构件组成,它们共同构成了一个高度解耦、可测试、可维护的系统:
1. Module(模块) —— 应用的“行政区划”
- 用
@Module()装饰器定义。 - 告诉 Nest:哪些 Controller、Service、其他 Module 属于这个区域。
- 模块可以嵌套、导入、导出 Provider,形成清晰的依赖树。
- 比如
AppModule是根模块,TodosModule是专门管待办事项的“自治区”。
2. Controller(控制器) —— HTTP 请求的“接待员”
- 用
@Controller()装饰器定义。 - 负责接收请求(GET/POST/DELETE)、解析参数、调用 Service、返回响应。
- 控制器不应包含业务逻辑,只做“协调”工作。
- 比如
AppController处理/hello,TodosController处理/todos。
3. Service(服务) —— 业务逻辑的“大脑”
- 用
@Injectable()装饰器定义。 - 封装核心逻辑:操作数据、调用数据库、处理业务规则。
- 服务是无状态的,易于单元测试。
- 比如
AppService返回欢迎语,TodosService管理待办列表。
💡 依赖注入(DI) 是 Nest 的灵魂:Controller 不直接创建 Service,而是通过构造函数“声明需求”,Nest 自动“送货上门”。这使得代码高度解耦,便于替换和测试。
此外,Nest 还支持:
- Middleware(中间件):处理请求前后的通用逻辑(如日志、认证)。
- Pipe(管道):验证和转换路由参数、请求体等。
- Guard(守卫):权限控制。
- Interceptor(拦截器):包装请求/响应生命周期。
- Exception Filter(异常过滤器):统一错误处理。
但在本项目中,我们主要聚焦于 Module + Controller + Service 这一核心三角。
实战:Todos 待办事项模块详解
现在,我们聚焦 todos 功能,逐行解析每个文件,看数据如何流动。
todos.service.ts —— 业务逻辑中心(纯内存实现)
import { Injectable } from '@nestjs/common'
export interface Todo { id: number; title: string; completed: boolean;}
@Injectable()
export class TodosService {
private todos: Todo[] = [
{ id: 1, title: '周五狂欢', completed: false },
{ id: 2, title: '三角洲首胜', completed: true }
]
getTodos() {
return this.todos
}
addTodo(title: string) {
const todo: Todo = { id: + Date.now(), title, completed: false }
this.todos.push(todo);
return todo;
}
deleteTodo(id: number) {
this.todos = this.todos.filter(todo => todo.id !== id);
return { message: 'Todo deleted', code: 200 }
}
}
详细解析:
@Injectable():标记该类为可被 Nest 容器管理的服务。没有它,Nest 无法自动注入此服务。Todo接口:定义待办事项的数据结构,确保类型安全。- 初始数据:两个硬编码的待办项,用于演示。
getTodos():直接返回私有数组引用(⚠️ 注意:实际项目应返回副本以避免外部修改)。addTodo(title: string):- 使用
+Date.now()生成唯一 ID(时间戳转数字)。 - 创建新
Todo对象,completed默认为false。 - 推入数组,并返回新对象(方便前端立即使用)。
- 使用
deleteTodo(id: number):- 使用
filter创建新数组(不可变更新)。 - 返回一个包含消息和状态码的对象(模拟 RESTful 响应)。
- 使用
⚠️ 重要提示:此实现使用内存存储,服务重启后数据丢失。生产环境应连接数据库(如 PostgreSQL,项目已配置)。
todos.controller.ts —— HTTP 接口层(RESTful API)
import { Controller, Get, Post, Body, Delete, Param, ParseIntPipe, // 用于将路由参数转换为整数} from '@nestjs/common';
import { TodosService } from './todos.service';
@Controller('todos')
export class TodosController{
constructor(private readonly todosService: TodosService){}
@Get()
getTodos() {
return this.todosService.getTodos();
}
@Post()
addTodo(@Body('title') title:string) {
return this.todosService.addTodo(title);
}
@Delete(':id')
deleteTodo(@Param('id', ParseIntPipe) id: number) {
// return "111" // 打印 id 类型,用于调试
console.log(typeof id,'id');
// 调用 todosService.deleteTodo 方法,删除指定 ID 的待办事项
return this.todosService.deleteTodo(id);
}
}
详细解析:
@Controller('todos'):所有路由以/todos为前缀。- 构造函数注入:
private readonly todosService: TodosService是 TypeScript 的简写语法,等价于:
Nest 会自动提供private todosService: TodosService; constructor(todosService: TodosService) { this.todosService = todosService; }TodosService实例。
三个 API 详解:
-
@Get()→ GET /todos- 无参数。
- 调用
this.todosService.getTodos()。 - 返回 JSON 数组:
[ {"id":1,"title":"周五狂欢","completed":false}, {"id":2,"title":"三角洲首胜","completed":true} ]
-
@Post()→ POST /todos- 使用
@Body('title')从请求体中提取title字段。- 例如,请求体为
{ "title": "学习 Nest" },则title = "学习 Nest"。
- 例如,请求体为
- 调用
this.todosService.addTodo(title)。 - 返回新创建的 Todo 对象:
{"id":1700000000000,"title":"学习 Nest","completed":false}
- 使用
-
@Delete(':id')→ DELETE /todos/123:id是路径参数占位符。@Param('id', ParseIntPipe):ParseIntPipe是 Nest 内置管道,自动将字符串'123'转为数字123。- 若传入非数字(如
/todos/abc),会抛出400 Bad Request错误。
console.log(typeof id, 'id'):调试用,确认id是number类型。- 调用
this.todosService.deleteTodo(id)。 - 返回删除成功消息:
{"message":"Todo deleted","code":200}
✅ 最佳实践:使用
ParseIntPipe避免类型错误,这是 Nest 强类型优势的体现!
todos.module.ts —— 模块注册中心
import { Module} from '@nestjs/common';
import { TodosController} from './todos.controller'
import { TodosService} from './todos.service'
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule{}
详细解析:
@Module()装饰器配置模块元数据:controllers: 声明该模块拥有的控制器。providers: 声明该模块提供的服务(会被注册到 DI 容器)。
- 此模块未导出任何内容(
exports为空),意味着其他模块只能通过导入TodosModule来间接使用其功能,不能直接注入TodosService。 - 当
AppModule导入TodosModule,Nest 会自动注册其控制器和服务。
根模块:AppModule 如何整合一切?
看看 app.module.ts:
import { Module } from '@nestjs/common'; // 引入 Nest 模块装饰器
import { AppController } from './app.controller'; // 引入 AppController 类
import { AppService } from './app.service'; // 引入 AppService 类
import { TodosModule } from './todos/todos.module'; // 引入 TodosModule 类
import { DatabaseModule } from './database/database.module'; // 引入 DatabaseModule 类
// mvc 设计模式 模型-视图-控制器
// 一个文件一个类
// 装饰器模式 让AppModule类成为一个模块
@Module({ // 定义 AppModule 类,作为 Nest 应用的根模块
imports: [ TodosModule, DatabaseModule ], // 引入其他模块,如果是空数组表示不引入其他模块
// controllers后端路由 控制逻辑 处理 HTTP 请求 参数校验 逻辑处理
controllers: [AppController], // 定义 AppController 类,作为 Nest 应用的控制器controllers(处理 HTTP 请求)
// providers 服务提供者 处理业务逻辑 数据库操作 调用其他服务 数据
providers: [AppService], // 定义 AppService 类,作为 Nest 应用的服务providers(处理业务逻辑)
})
export class AppModule {}
详细解析:
imports:TodosModule:引入待办事项功能。DatabaseModule:引入数据库连接(全局模块)。
controllers: 注册AppController,处理根路径请求。providers: 提供AppService,供AppController使用。
✅ 模块化设计:
AppModule不关心TodosModule内部实现,只需知道“它提供了/todos接口”,完美解耦!
数据库模块:DatabaseModule(全局 Provider)
Todos 目前用内存存储,但项目已准备好 PostgreSQL 支持!
database.module.ts:
import { Module, Global } from '@nestjs/common'; // 引入 NestJS 模块和全局装饰器
// 数据库驱动
import { Pool } from 'pg'; // 引入 pg 模块,用于连接 PostgreSQL 数据库,使用pnpm i pg 安装
import * as dotenv from 'dotenv'; // 引入 dotenv 模块,用于加载环境变量,使用pnpm i dotenv 安装
dotenv.config(); // 加载 .env 文件中的环境变量
// 数据库基础服务
@Global() // 全局服务
@Module({
providers:[ {
provide: 'PG_CONNECTION', // 连接池
useValue: new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || '5432', 10),
})
} ],
exports: ['PG_CONNECTION']
})
export class DatabaseModule {}
详细解析:
dotenv.config():在模块加载时立即读取.env文件(需提前安装dotenv)。@Global():标记为全局模块。一旦在根模块AppModule中导入,其providers和exports对所有模块可见,无需重复导入。provide: 'PG_CONNECTION':- 使用字符串 Token(而非类)作为 Provider 标识。
useValue直接提供一个pg.Pool实例(连接池)。
- 环境变量:从
.env读取数据库配置(需用户自行设置DB_USER,DB_HOST等)。 exports: ['PG_CONNECTION']:允许其他模块注入此连接池。
⚠️ 注意:
.env文件应加入.gitignore,避免泄露敏感信息!
主入口:main.ts 与环境变量加载
最后看启动文件 main.ts:
import { NestFactory } from '@nestjs/core';
// 模块化
import { AppModule } from './app.module';
import { config } from 'dotenv';
config();
async function bootstrap() {
// server app,工厂模式创建 Nest 应用实例
// NestFactory 是 Nest 应用的工厂类,用于创建 Nest 应用实例
// 根模型
const app = await NestFactory.create(AppModule);
// 3000 是默认端口,也可以通过环境变量 PORT 来指定,用于监听请求
// 3000 node 进程对象process.env.PORT 环境变量 PORT 的值
// 该项目内设置的环境变量 PORT 的值是 1234
console.log('process.env.PORT', process.env.PORT);
// ?? 空值合并运算符,来自ES2020,当 process.env.PORT 为 null 或 undefined 时,使用 3000 作为默认值
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
详细解析:
config():再次加载.env(虽然DatabaseModule已加载,但这里确保主进程能读取PORT)。NestFactory.create(AppModule):创建基于根模块AppModule的应用实例。process.env.PORT ?? 3000:- 使用 空值合并运算符
??(ES2020 特性)。 - 仅当
PORT为null或undefined时才用3000,若PORT=''(空字符串)则仍使用空字符串(但app.listen会报错)。 - 更健壮的写法可能是
parseInt(process.env.PORT, 10) || 3000,但当前代码已满足需求。
- 使用 空值合并运算符
console.log:打印实际监听端口,方便调试。
数据流动全景图(以 DELETE /todos/1 为例)
让我们完整追踪一次请求的生命周期:
-
客户端发起请求:
DELETE http://localhost:1234/todos/1 -
Nest 路由匹配:
- 找到
TodosController(因@Controller('todos'))。 - 匹配
@Delete(':id') deleteTodo方法。
- 找到
-
参数解析与转换:
- URL 中的
'1'被@Param('id', ParseIntPipe)捕获。 ParseIntPipe将其转为数字1。console.log(typeof id, 'id')输出number id。
- URL 中的
-
依赖注入:
- Nest 自动提供
TodosService实例给TodosController。
- Nest 自动提供
-
调用 Service:
this.todosService.deleteTodo(1)被执行。TodosService内部过滤掉id === 1的项。
-
返回响应:
deleteTodo返回{ message: 'Todo deleted', code: 200 }。- Nest 自动将其序列化为 JSON 并设置
Content-Type: application/json。 - HTTP 状态码默认为
200 OK(可通过@HttpCode()修改)。
-
客户端收到响应:
{ "message": "Todo deleted", "code": 200 }
整个过程:HTTP 请求 → Controller(参数解析)→ Service(业务逻辑)→ JSON 响应,清晰无副作用!
深度解析:NestJS 单元测试机制与 AppController 测试详解
在 NestJS 项目中,可测试性是核心设计原则之一。得益于其基于依赖注入(DI)和模块化架构的设计,开发者可以轻松对任意组件(Controller、Service、Guard 等)进行隔离式单元测试,而无需启动 HTTP 服务器或连接真实数据库。
完整测试代码回顾
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return greeting message in Chinese', () => {
expect(appController.getHello()).toBe('你好yeah!!!');
});
});
});
详细解析
第一部分:导入依赖(Imports)
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@nestjs/testing是 Nest 提供的专用测试工具包,包含:Test:用于创建模拟的 Nest 应用上下文。TestingModule:编译后的测试模块实例,支持.get()获取组件。
AppController和AppService是被测目标及其依赖。✅ 关键点:测试文件只导入实际需要的类,不引入整个
AppModule,体现了“最小依赖”原则。
第二部分:测试套件定义(describe('AppController', ...))
- 使用 Jest(Nest 默认测试框架)的
describe定义一个测试套件(Test Suite),聚焦于AppController类。 - 声明
appController变量用于在多个测试用例中复用,避免重复创建。
第三部分:测试前准备(beforeEach
这是整个测试的核心机制,我们拆解如下:
1. Test.createTestingModule(...)
- 调用 Nest 的测试工厂方法,创建一个虚拟的、轻量级的 Nest 模块环境。
- 配置对象与普通
@Module()几乎一致:controllers: 注册待测控制器。providers: 注册其依赖的服务(此处为AppService)。
- 不包含
imports、exports等,因为单元测试应隔离外部依赖。
2. .compile()
- 异步编译测试模块,完成:
- 依赖图构建(Dependency Graph)
- Provider 实例化(包括单例管理)
- 控制器与服务的依赖注入(通过构造函数自动完成)
- 返回
TestingModule实例,具备完整的 DI 容器能力。
3. app.get<AppController>(AppController)
- 从测试容器中获取
AppController的实例。 - Nest 自动完成以下操作:
- 创建
AppService实例(因在providers中声明) - 调用
new AppController(appService)(假设构造函数为constructor(private appService: AppService)) - 将结果赋值给
appController
- 创建
- 类型断言
<AppController>确保 TypeScript 类型安全。
💡 为什么不用
new AppController(new AppService())手动创建?
因为真实项目中依赖可能多层嵌套(如 Service A 依赖 Service B 依赖 DB),手动模拟极其繁琐。Nest 的测试模块自动处理整个依赖链,保持与生产环境一致的注入行为。
第四部分:测试用例(it 块)
describe('root', () => {
it('should return greeting message in Chinese', () => {
expect(appController.getHello()).toBe('你好yeah!!!');
});
});
断言逻辑
appController.getHello()调用控制器方法。- 控制器内部通常调用
this.appService.getHello()(根据标准 Nest 结构)。 AppService.getHello()返回硬编码字符串'你好yeah!!!'(见app.service.ts)。expect(...).toBe(...)使用 Jest 的严格相等断言(===)。
🎉 结语:Nest,让后端开发像搭积木一样快乐!
通过这个 Todos 项目,我们看到了 Nest 如何用 模块化 + 依赖注入 + 装饰器 构建出结构清晰、易于维护的后端应用。
- Controller 只管“接待”;
- Service 专注“干活”;
- Module 负责“划区管理”;
- DatabaseModule 提供“基础设施”。
而你,作为开发者,只需关注业务逻辑本身,剩下的交给 Nest!
下次当你听到“Nest 太重了”,你可以微笑着说:
“不,它只是把混乱藏起来了,留给你一片整洁的代码花园。”
🚀 动手试试吧!
克隆这个项目,完成以下挑战:
项目源码地址:lesson_zp/project/ai_fullstack/nest-test-demo/src: AI + 全栈学习仓库
- 添加
updateTodo接口(PATCH /todos/:id)。 - 将 Todos 存入 PostgreSQL(利用已配置的
PG_CONNECTION)。 - 为
addTodo添加验证:标题不能为空且长度 ≥ 2。 - 修复单元测试中的断言错误。
你会发现,Nest 的世界,远比想象中精彩!
更多推荐
所有评论(0)