NestJS 企业级后端开发入门:从工厂模式、装饰器到模块化 CRUD
- 前言
- [1. NestJS 解决了什么问题](#1. NestJS 解决了什么问题)
-
- [1.1 从"能写接口"到"能维护服务"](#1.1 从“能写接口”到“能维护服务”)
- [1.2 安装、创建与运行](#1.2 安装、创建与运行)
- [2. 启动入口与工厂模式](#2. 启动入口与工厂模式)
-
- [2.1 从 main.ts 看应用如何启动](#2.1 从 main.ts 看应用如何启动)
- [2.2 工厂模式为什么适合创建应用](#2.2 工厂模式为什么适合创建应用)
- [3. 模块化、Controller 与依赖注入](#3. 模块化、Controller 与依赖注入)
-
- [3.1 根模块如何组合业务模块](#3.1 根模块如何组合业务模块)
- [3.2 Controller 和 Service 各自负责什么](#3.2 Controller 和 Service 各自负责什么)
- [3.3 依赖注入到底自动了什么](#3.3 依赖注入到底自动了什么)
- [4. 装饰器:@ 背后的运行机制](#4. 装饰器:@ 背后的运行机制)
-
- [4.1 @ 本质上是一个函数调用入口](#4.1 @ 本质上是一个函数调用入口)
- [4.2 装饰器语法与装饰器设计模式的区别](#4.2 装饰器语法与装饰器设计模式的区别)
- [5. MVC 与 NestJS 分层应该怎样理解](#5. MVC 与 NestJS 分层应该怎样理解)
-
- [5.1 MVC 的职责划分](#5.1 MVC 的职责划分)
- [5.2 一次请求经过哪些层](#5.2 一次请求经过哪些层)
- [6. Todo CRUD、异常处理与输入校验](#6. Todo CRUD、异常处理与输入校验)
-
- [6.1 CRUD 路由如何映射](#6.1 CRUD 路由如何映射)
- [6.2 用 Pipe、DTO 和异常建立稳定边界](#6.2 用 Pipe、DTO 和异常建立稳定边界)
- [7. 测试、工程边界与学习路线](#7. 测试、工程边界与学习路线)
-
- [7.1 单元测试和端到端测试](#7.1 单元测试和端到端测试)
- [7.2 从演示服务走向可用后端](#7.2 从演示服务走向可用后端)
- 总结
前言
Node.js 能够创建 HTTP 服务,但当业务从几个接口增长到用户、订单、权限、缓存、消息队列等多个领域时,只靠路由回调很容易出现职责混乱、依赖失控和测试困难。NestJS 的价值不是替代 Node.js,而是在 Node.js 之上提供一套结构化的服务端工程方案:它以 TypeScript 为主要开发语言,通过模块、控制器、Provider、装饰器和依赖注入组织应用,同时可以运行在 Express 或 Fastify 等 HTTP 平台之上。
需要先分清两个容易混淆的名字:NestJS 是面向服务端应用的 Node.js 框架;Next.js 是围绕 React 构建的全栈 Web 框架。前者常见 @Module()、@Controller() 和 @Injectable(),后者常见 app/page.tsx、React Server Components 与 Route Handlers。本文讨论的是 NestJS 后端开发。
1. NestJS 解决了什么问题
1.1 从"能写接口"到"能维护服务"
后端开发远不只是返回一段 JSON。一个可长期维护的服务通常需要承担 API 设计、参数校验、身份认证、业务编排、数据库访问、异常处理、日志监控、缓存、任务队列和第三方系统集成。进入更复杂的场景后,还可能涉及微服务通信、实时推送以及 AI Infra 中的模型调用、向量检索与异步任务调度。
Node.js 的事件循环非常适合 I/O 密集型并发,例如同时等待数据库、网络和缓存响应;它并不意味着单个 JavaScript 线程适合直接执行大量 CPU 密集计算。图像处理、模型推理或大规模计算通常应交给工作线程、任务队列或独立计算服务。
| 能力 | 直接使用 Node.js/Express 时常见做法 | NestJS 提供的工程抽象 |
|---|---|---|
| HTTP 路由 | 手动注册路由回调 | Controller 与路由装饰器 |
| 对象依赖 | 手动 new 和传参 |
IoC 容器与依赖注入 |
| 业务拆分 | 自定义目录和约定 | Module、Controller、Provider |
| 参数处理 | 在回调中手动判断 | Pipe、DTO、ValidationPipe |
| 异常响应 | 自己拼装状态码和 JSON | HttpException 与 Exception Filter |
| 横切能力 | 在多处重复实现 | Guard、Interceptor、Middleware |
| 测试 | 手动组装依赖 | TestingModule 与依赖替换 |
企业级框架的关键不在于功能"更多",而在于它能给团队提供稳定的边界、统一的约定和可替换的依赖。
1.2 安装、创建与运行
可以全局安装 Nest CLI:
bash
npm install -g @nestjs/cli
nest new hello
cd hello
npm run start:dev
也可以避免全局安装,使用 pnpm 临时执行:
bash
pnpm dlx @nestjs/cli new hello
cd hello
pnpm run start:dev
常用脚本如下:
| 命令 | 用途 |
|---|---|
pnpm run start |
普通方式启动应用 |
pnpm run start:dev |
监听源码变化并自动重启 |
pnpm run build |
将 TypeScript 编译到 dist |
pnpm run start:prod |
运行构建后的 dist/main |
pnpm run test |
运行单元测试 |
pnpm run test:e2e |
运行端到端测试 |
nest run start 不是标准启动写法。开发阶段优先使用 pnpm run start:dev;生产启动前应先执行 pnpm run build。
2. 启动入口与工厂模式
2.1 从 main.ts 看应用如何启动
NestJS 应用从 src/main.ts 开始:
typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
NestFactory.create(AppModule) 接收根模块,创建 Nest 应用实例。这个过程会扫描模块元数据、建立 IoC 容器、解析 Provider 依赖、创建 Controller,并把装饰器描述的路由注册到底层 HTTP 平台。create() 返回 Promise,因此启动函数使用 async/await。
process.env.PORT ?? 3000 使用空值合并运算符:环境变量不是 null 或 undefined 时使用指定端口,否则监听 3000。请求进入应用后并不是"先交给 AppModule 执行业务",而是由已经建立好的路由表找到对应 Controller;AppModule 的职责是描述和组装应用。
| 启动阶段 | NestJS 的主要工作 |
|---|---|
| 载入模块 | 读取 @Module() 中的 imports、controllers、providers |
| 解析依赖 | 建立 Provider 之间的依赖图 |
| 创建实例 | 按作用域实例化 Service 和 Controller |
| 注册路由 | 读取 @Controller()、@Get() 等元数据 |
| 启动监听 | 让 Express/Fastify 接收 HTTP 请求 |
2.2 工厂模式为什么适合创建应用
工厂模式把"创建哪一种对象、如何创建对象"的细节集中到工厂中,调用方只面向稳定入口。下面用饮品工厂说明:
typescript
interface Drink {
show(): void;
}
class IceCream implements Drink {
show() {
console.log('冰激凌 3 元');
}
}
class LemonTea implements Drink {
show() {
console.log('柠檬水 4 元');
}
}
type DrinkType = 'ice' | 'lemon';
class MixueFactory {
static create(type: DrinkType): Drink {
switch (type) {
case 'ice':
return new IceCream();
case 'lemon':
return new LemonTea();
}
throw new Error('Unknown drink type');
}
}
const drink = MixueFactory.create('ice');
drink.show();
调用者只需要认识 MixueFactory.create() 和共同接口 Drink,不需要了解每种饮品的构造过程。使用字符串联合类型限制 type,还能避免未知类型导致 undefined。NestFactory 的思路类似:开发者提供根模块,工厂负责选择平台适配器、创建容器并组装应用。
需要注意,工厂模式解决的是对象创建耦合,并不等于 NestJS 的全部架构。NestJS 还综合使用了依赖注入、装饰器、模块化和面向切面等思想。
3. 模块化、Controller 与依赖注入
3.1 根模块如何组合业务模块
根模块负责组合应用:
typescript
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Todo 领域再拥有独立模块:
typescript
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
imports 用来引入其他模块,controllers 注册 HTTP 控制器,providers 注册由容器管理的服务,exports 则把本模块中的 Provider 暴露给其他模块。模块不是一个请求处理函数,而是依赖边界和业务边界。
text
AppModule
├── AppController
├── AppService
└── TodosModule
├── TodosController
└── TodosService
业务扩展后,可以继续拆出 UsersModule、AuthModule 和 OrdersModule。每个模块尽量围绕一个业务领域组织,而不是简单按照"所有 Controller 放一起、所有 Service 放一起"进行横向堆积。
3.2 Controller 和 Service 各自负责什么
Controller 处理 HTTP 协议层工作:
typescript
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(+id);
}
}
这里 @Controller('todos') 声明路由前缀,@Get(':id') 声明 GET 方法和动态路径,@Param('id') 把 URL 参数注入形参。Controller 应关注输入、校验、身份上下文和响应,不应堆积复杂业务或直接书写大量 SQL。
Service 承担可复用的业务逻辑:
typescript
@Injectable()
export class TodosService {
findOne(id: number): Todo {
const todo = todos.find(item => item.id === id);
if (!todo) {
throw new NotFoundException('Todo ' + id + ' not found');
}
return todo;
}
}
在真实服务中,Service 通常继续调用 Repository 或 ORM 访问数据库。这样 Controller 不依赖数据库细节,业务逻辑也能被 HTTP、定时任务和消息消费者复用。
| 层次 | 主要职责 | 不建议承担的职责 |
|---|---|---|
| Controller | 接收参数、触发校验、调用 Service、组织响应 | 复杂业务、事务、SQL |
| Service | 业务规则、业务编排、事务边界 | HTTP 请求对象和页面渲染 |
| Repository/ORM | 查询和持久化数据 | HTTP 状态码和路由 |
| Module | 注册并组合依赖 | 直接处理某次请求 |
3.3 依赖注入到底自动了什么
下面的构造函数没有手动 new TodosService():
typescript
constructor(private readonly todosService: TodosService) {}
这是 TypeScript 的"参数属性"语法,等价于声明属性并在构造函数中赋值。Nest 在启动阶段读取构造参数的类型信息,从当前模块的 providers 中找到 TodosService,创建实例后传给 Controller。
text
TodosController 需要 TodosService
↓
容器在 providers 中查找令牌
↓
创建或复用 TodosService 实例
↓
调用 TodosController 构造函数完成注入
@Injectable() 表示这个类可以参与依赖注入,但它并不等于"无条件自动生效"。Provider 通常还要注册在某个 Module 中;跨模块使用时,还需要在提供方 exports,并在使用方 imports。默认作用域下 Provider 通常是单例,因此多个请求会复用同一个 Service 实例。
依赖注入的核心不是少写一个 new,而是把对象创建权交给容器,让业务类只声明自己需要什么。
4. 装饰器:@ 背后的运行机制
4.1 @ 本质上是一个函数调用入口
@ 不是 NestJS 独有的特殊符号,而是 TypeScript 的装饰器语法。它允许一个函数作用于类、方法、属性或参数。以 @Controller('todos') 为例,可以用下面的心智模型理解:
typescript
const decorator = Controller('todos');
decorator(TodosController);
Controller('todos') 是装饰器工厂,它先接收路由前缀,再返回真正作用于类的函数。简化实现如下:
typescript
function Controller(path: string) {
return function (target: Function) {
Reflect.defineMetadata('controller:path', path, target);
};
}
装饰器通常在模块载入阶段记录元数据;NestFactory.create() 随后扫描这些元数据并完成组件组装。请求到达时,框架直接使用启动阶段建立的路由映射,而不是每次重新执行一遍类装饰器。
| 装饰器 | 作用位置 | 告诉 NestJS 什么 |
|---|---|---|
@Module() |
类 | 模块包含哪些依赖和组件 |
@Controller('todos') |
类 | 这是控制器,前缀为 /todos |
@Get(':id') |
方法 | 该方法处理 GET 动态路由 |
@Body() |
参数 | 从请求体中提取值 |
@Param('id') |
参数 | 从路径中提取 id |
@Injectable() |
类 | 该类可以参与依赖注入 |
TypeScript 配置中的 experimentalDecorators 开启传统装饰器语法,emitDecoratorMetadata 负责生成类型元数据。NestJS 再配合 reflect-metadata,才能在运行时得知 Controller 构造函数依赖哪个类。
4.2 装饰器语法与装饰器设计模式的区别
经典装饰器设计模式通常通过对象包装,在不改变被包装对象接口的前提下叠加能力;TypeScript 的 @Decorator 则是一种语言级扩展机制,可以记录元数据,也可以修改属性描述符甚至替换类。两者都体现"在主体之外附加能力"的思想,但不能简单画等号。
NestJS 中的路由装饰器主要用于声明元数据。Guard、Interceptor 等机制才更直接地体现请求前后增强,例如鉴权、日志、耗时统计和响应转换。
5. MVC 与 NestJS 分层应该怎样理解
5.1 MVC 的职责划分
MVC 全称是 Model、View、Controller:
- Model 管理领域数据、状态与业务规则,并不只是"数据库表"的别名。
- View 负责把结果展示给用户,例如 HTML 模板或图形界面。
- Controller 接收输入、协调 Model,并选择响应形式。
传统服务端 MVC 会由后端渲染 HTML。纯 API 服务通常返回 JSON,没有明显的服务端 View,因此 NestJS 项目更常采用 Controller、Service、Repository 分层。Module 也不属于 MVC 三者之一,它是 NestJS 用来组织组件和依赖的边界。
| 经典 MVC | NestJS API 中常见对应 | 说明 |
|---|---|---|
| Model | Entity、DTO、领域对象、Repository | 表达数据并负责持久化协作 |
| View | JSON 响应或独立前端 | API 项目通常不渲染 HTML |
| Controller | @Controller() 类 |
接收 HTTP 请求并调用业务层 |
| 非经典 MVC 概念 | Service、Module | 业务编排与依赖组织 |
5.2 一次请求经过哪些层
访问 GET /todos/1 时,请求链路如下:
text
客户端
↓ GET /todos/1
Express 平台适配器
↓
Nest 路由系统
↓
TodosController.findOne('1')
↓ 参数转换
TodosService.findOne(1)
↓
查询数据或抛出异常
↓
Nest 序列化响应
Controller 中的 this 指向当前 Controller 实例,this.todosService 是容器注入的属性,并不指向 Module。Module 只负责建立组合关系,不参与每次方法调用。
6. Todo CRUD、异常处理与输入校验
6.1 CRUD 路由如何映射
Todo 模块已经展示了读取、删除和局部更新等操作:
| HTTP 请求 | Controller 方法 | Service 方法 | 语义 |
|---|---|---|---|
GET /todos |
findAll() |
findAll() |
查询全部任务 |
GET /todos/:id |
findOne() |
findOne() |
查询单个任务 |
POST /todos |
create() |
create() |
创建任务 |
PATCH /todos/:id |
update() |
update() |
局部更新任务 |
DELETE /todos/:id |
remove() |
remove() |
删除任务 |
PATCH 适合只修改部分字段。Partial<Todo> 会在类型层面把所有属性变为可选,Object.assign(todo, patch) 再把传入字段覆盖到目标对象:
typescript
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
执行顺序是先复用 findOne() 保证任务存在,再修改对象。这里也有两个风险:Partial<Todo> 允许客户端修改 id,而 TypeScript 类型在运行时不会阻止恶意字段。企业应用应使用独立 DTO 和白名单校验,只开放允许更新的属性。
创建接口还需要真正调用 Service:
typescript
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
Nest 对 POST 默认使用 201 状态码。仅声明空方法虽然能匹配路由,却不会保存任务,也不会返回创建结果。
6.2 用 Pipe、DTO 和异常建立稳定边界
+id 能把字符串转成数字,但 +'abc' 会得到 NaN,错误可能直到业务层才暴露。使用 Nest 内置 ParseIntPipe 可以在 Controller 边界完成转换和校验:
typescript
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Todo {
return this.todosService.findOne(id);
}
请求 GET /todos/abc 会直接得到 400,而不会把 NaN 传入 Service。请求体则适合使用 DTO:
bash
pnpm add class-validator class-transformer
typescript
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateTodoDto {
@IsString()
@IsNotEmpty()
title: string;
}
在入口开启全局校验:
typescript
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
whitelist 会移除 DTO 未声明的属性,transform 支持必要的类型转换。Controller 最终只负责接收已通过边界校验的数据:
typescript
@Post()
create(@Body() dto: CreateTodoDto): Todo {
return this.todosService.create(dto.title);
}
查询不到任务时,Service 抛出 NotFoundException:
typescript
if (!todo) {
throw new NotFoundException('Todo ' + id + ' not found');
}
Nest 的全局异常处理层会把它转换为标准 404 响应。对于可预期业务错误,优先抛出合适的 HTTP 异常或领域异常,不需要在每个 Controller 中重复 try/catch。try/catch/finally 仍然有价值:try 捕获当前调用链中的异常,catch 做恢复或转换,finally 释放连接等资源;它不会自动捕获没有 await 的异步任务,也不能替代统一异常边界。
| 场景 | 推荐处理方式 |
|---|---|
| 路径参数格式错误 | Pipe,例如 ParseIntPipe |
| 请求体不符合规则 | DTO + ValidationPipe |
| 资源不存在 | NotFoundException |
| 权限不足 | Guard + ForbiddenException |
| 统一响应与日志 | Interceptor |
| 未知异常统一收口 | Exception Filter 与全局日志 |
7. 测试、工程边界与学习路线
7.1 单元测试和端到端测试
NestJS 的 TestingModule 可以构造一个轻量依赖容器。单元测试直接获取 Controller 并调用方法,适合验证类内部行为:
typescript
const app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
const controller = app.get<AppController>(AppController);
expect(controller.getHello()).toBe('Hello World!');
端到端测试则创建完整 Nest 应用,再通过 Supertest 发出 HTTP 请求:
typescript
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
| 测试类型 | 是否经过 HTTP | 主要验证目标 | 特点 |
|---|---|---|---|
| 单元测试 | 否 | 单个 Controller 或 Service 的行为 | 快、定位问题清晰 |
| 集成测试 | 不一定 | 多个 Provider 的协作 | 覆盖模块内部组合 |
| E2E 测试 | 是 | 路由、校验、业务和响应整条链路 | 最接近真实调用 |
7.2 从演示服务走向可用后端
当前 Todo 数据保存在模块级数组中,适合理解 CRUD,却不适合生产:进程重启后数据消失,多实例之间无法共享状态,并发写入也缺少数据库事务保证。继续学习时可以按下面顺序演进:
- 先补齐
POST /todos,为 ID 使用ParseIntPipe,并为请求体增加 DTO 校验。 - 将数组替换为 Repository,再接入 Prisma、TypeORM 或其他数据库访问方案。
- 把
todos和nextId收进 Service,减少模块级可变状态;生产环境则交给数据库生成主键。 - 为
findOne()、create()、update()和remove()编写单元测试,再补齐 Todo E2E 用例。 - 学习 ConfigModule、日志、Swagger、认证 Guard、Interceptor、缓存和任务队列。
- 当单体模块边界稳定、确实存在独立扩缩容或团队自治需求时,再考虑拆分微服务。
微服务不是"更企业级"的同义词。模块化单体通常更容易开发、测试和部署;只有边界、规模与运维收益足够明确时,拆分才真正有价值。
总结
NestJS 把 Node.js 后端中反复出现的对象创建、依赖管理、路由声明和分层协作收敛为统一约定。NestFactory 负责创建应用,AppModule 负责组合业务模块,Controller 管理 HTTP 边界,Service 承担业务规则,Repository 负责持久化;装饰器记录元数据,依赖注入容器再完成实例组装。理解这些机制后,Todo CRUD 就不再只是几个接口,而是一条从请求校验、业务执行、异常转换到自动化测试的完整工程链路。继续扩展时,应优先补齐 DTO、Pipe、数据库边界和测试,再逐步学习鉴权、日志、缓存、消息队列与微服务。