NestJS 新手入门:从创建项目到第一个任务 API
从创建第一个 NestJS 项目开始,用 Controller、Service、Module 和 DTO 搭一个可测的任务 API,并搞懂请求是怎么被处理的。
约六千四百字·读约十九分钟 · English
NestJS 的价值不止于装饰器语法。它通过清晰的模块边界、依赖注入与标准化请求处理,帮你把后端做成更易测试、更易演进、也更便于协作的系统。
本文面向具备 JavaScript 或 TypeScript 基础、了解 HTTP 与 npm,并希望入门 Node.js 后端的读者。即使没有 Express 或其他后端框架经验,也可以顺着读下去。
NestJS 是什么?为什么值得作为入门框架?
NestJS 用来构建高效、可扩展的 Node.js 服务端应用。它以 TypeScript 构建并完整支持 TypeScript,也可以写纯 JavaScript;默认建立在 Express 之上,也能切换到 Fastify。更重要的是,它在底层 HTTP 框架之上提供了统一的应用架构,却不会把底层能力完全藏起来。
如果你曾在一个 Express 项目里把路由、参数校验、数据库查询、鉴权和错误处理逐渐堆进同一个文件,就已经遇到 NestJS 要解决的问题:架构不只是“代码能执行”,还是“代码能长期变化”。 NestJS 的设计受 Angular 启发,强调可测试、可扩展、低耦合和易维护的应用结构。
| 维度 | 直接使用底层 HTTP 框架时常见的做法 | NestJS 的默认引导方式 | 对新手的意义 |
|---|---|---|---|
| 路由 | 手动注册路径与回调函数 | 用 Controller 和 HTTP 方法装饰器声明路由 | 能直观看到接口归属 |
| 业务逻辑 | 容易混入路由回调 | 抽到 Service / Provider | 更易复用与单测 |
| 依赖协作 | 手动 new 对象或传递实例 | 由 IoC 容器注入依赖 | 减少对象装配噪声 |
| 组织方式 | 按文件类型或随意堆放 | 按业务能力拆分 Module | 便于团队协作与扩展 |
| 输入安全 | 容易遗漏校验 | DTO + Pipe 可统一处理 | 在入口尽早拒绝坏数据 |
这并不意味着 Express “过时”,也不等于 NestJS 必然更适合每个项目。一个极小的脚本、一次性 API,或需要高度自由装配的服务,直接使用底层框架可能更简洁。NestJS 更适合希望从第一天起就养成后端工程化习惯,并预计项目会增加接口、功能或协作者的场景。
开始前:环境与第一个项目
官方文档当前要求:运行 Nest 应用需要 Node.js v20.19 或更高(22.x 线则需 v22.12+)。用 CLI 创建项目时,建议直接安装最新 Active LTS。
对初学者,推荐通过 Nest CLI 创建项目:CLI 会准备常规的 TypeScript 工程配置、初始源码和测试文件。创建时如果希望启用更严格的 TypeScript 配置,可以加上 --strict。CLI 也会询问 CommonJS 还是 ESM;ESM 起步项目默认使用 Vitest 和 oxlint。本文示例按常见的 npm run start:dev / npm run test 流程来写,两种脚手架都能跟上。
# 安装 Nest CLI
npm i -g @nestjs/cli
# 创建项目;--strict 对新手很有价值,能更早暴露类型问题
nest new nest-beginner --strict
cd nest-beginner
npm run start:dev
启动后访问 http://localhost:3000/。开发模式会在代码变更后重新编译并重启应用,便于你通过浏览器、curl 或 API 客户端持续验证接口行为。建议先确认这一最小闭环正常工作,再逐步接入数据库或 JWT。
CLI 生成项目后,src/ 的核心文件并不多。理解它们的分工,是读懂 NestJS 的第一道门槛。
| 文件 | 初学阶段应如何理解 | 你通常会如何修改它 |
|---|---|---|
main.ts | 应用的引导入口,创建应用并监听端口 | 配置全局 Pipe、全局前缀、CORS 等 |
app.module.ts | 根模块,Nest 从这里构建应用关系图 | 导入各业务模块 |
app.controller.ts | 一个演示控制器 | 通常会被具体业务模块中的控制器替代 |
app.service.ts | 一个演示服务 | 通常会被具体业务模块中的服务替代 |
app.controller.spec.ts | 控制器的单元测试样例 | 按功能保留并扩展测试 |
main.ts 中最关键的两行是 NestFactory.create(AppModule) 与 app.listen(...):前者基于根模块创建 Nest 应用实例,后者启动 HTTP 监听。可以把根模块理解成后端应用的“装配清单”,而 main.ts 则是应用的启动入口。
认识三个核心概念:Controller、Provider 和 Module
学习 NestJS 时,最容易犯的错误是先记住 @Get()、@Post() 的写法,却不知道这些装饰器放在什么边界里才合理。更稳定的理解方式是:Controller 面向 HTTP;Provider 面向业务协作;Module 面向功能边界。
| 概念 | 它回答的问题 | 典型内容 | 不应该承担的主要职责 |
|---|---|---|---|
| Controller | “哪个接口接收这个请求?” | 路径、HTTP 方法、参数读取、状态码和响应协议 | 复杂业务规则、数据库细节 |
| Provider / Service | “这项业务究竟怎么做?” | 业务规则、数据访问协调、调用其他服务 | 声明 HTTP 路由 |
| Module | “哪些能力属于同一功能?谁能使用谁?” | controllers、providers、imports、exports | 承载具体业务算法 |
Controller 负责处理传入请求并向客户端发送响应。@Controller('tasks') 会为相关路由设定 /tasks 前缀,@Get()、@Post() 等方法装饰器再决定 HTTP 方法和细分路径。默认的标准响应模式下,返回对象或数组会被 Nest 自动序列化为 JSON;普通处理器默认返回 200,而 POST 默认返回 201。
Provider 是可以被注入的依赖。服务、仓储、工厂和辅助类都可成为 Provider;带有 @Injectable() 的服务可以由 Nest 的 IoC 容器管理,并通过构造函数注入到控制器或其他服务中。换句话说,业务类不需要到处 new 依赖对象,Nest 负责按已声明的关系装配它们。
Module 则是封装边界。每个 Nest 应用至少有一个根模块,Nest 从根模块构建用于解析模块和 Provider 关系的应用图。模块默认封装其 Provider;其他模块只有在导入该模块且该 Provider 被显式 exports 时,才能使用它。因此,exports 可以被视为模块对外公开的 API。
构建一个最小且结构清晰的任务 API
下面用一个不接数据库的 Tasks 功能,完整演示上述三层。示例刻意将任务存在内存数组中,目的是把注意力放在 NestJS 的结构,而不是 ORM 配置。重启应用后数据会消失,这在演示中是正常现象。
首先安装验证所需依赖。官方的 ValidationPipe 基于 class-validator 和 class-transformer,所以它们需要显式安装。
npm i class-validator class-transformer
# 可选:让 CLI 帮你创建基础文件,再按下文补全实现
nest g module tasks
nest g controller tasks
nest g service tasks
在应用入口配置统一规则
在 src/main.ts 中启用全局验证。whitelist: true 会移除 DTO 中没有验证装饰器的多余字段;与 forbidNonWhitelisted: true 同用时,含有额外字段的请求会直接失败。transform: true 则允许框架按 DTO 或参数的类型进行转换。
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
用 DTO 定义可进入系统的数据
DTO(Data Transfer Object)不是数据库实体,也不是随意的 TypeScript 类型别名。它是接口边界上的输入契约:客户端需要提供什么、字段必须满足什么规则,都在这里表达。请使用具体的 class 来定义 DTO;官方特别指出,TypeScript 的接口和泛型不会保留运行时元数据,ValidationPipe 因而可能无法正确校验它们。
// src/tasks/dto/create-task.dto.ts
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
@MaxLength(120)
title!: string;
}
将业务行为放入 Service
Service 不关心请求来自 HTTP、消息队列还是命令行;它只关心“创建任务”“查询任务”等业务动作。下面的 Task 是内部返回模型,因此可以使用 TypeScript 接口;真正需要运行时校验的输入仍然是上面的 DTO class。
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
export interface Task {
id: number;
title: string;
completed: boolean;
}
@Injectable()
export class TasksService {
private readonly tasks: Task[] = [];
private nextId = 1;
findAll(): Task[] {
return this.tasks;
}
create(input: CreateTaskDto): Task {
const task: Task = {
id: this.nextId++,
title: input.title,
completed: false,
};
this.tasks.push(task);
return task;
}
}
@Injectable() 的作用是声明该类可由 Nest 的 IoC 容器管理。Provider 默认通常跟随应用生命周期;更复杂的场景也可以使用请求作用域,但在刚开始时不必为了“看起来高级”而引入它。
由 Controller 将 HTTP 请求映射为业务调用
控制器只做很薄的一层翻译:从请求中取得数据,调用服务,并返回结果。@Body() 是 Nest 提供的专用参数装饰器;同类常用装饰器还包括 @Param() 和 @Query()。相比手动读取底层 request 对象,这种写法更明确,也更利于校验和测试。
// src/tasks/tasks.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { Task, TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(): Task[] {
return this.tasksService.findAll();
}
@Post()
create(@Body() dto: CreateTaskDto): Task {
return this.tasksService.create(dto);
}
}
请注意构造函数中的 TasksService。这里不是你自己创建服务实例,而是声明“这个控制器依赖什么”。只要服务已在模块中注册,Nest 就会解析这个依赖并注入实例。
通过 Module 完成装配与封装
最后,将控制器和服务放进同一个功能模块,再由根模块导入它。providers 是本模块可由注入器创建的依赖集合,controllers 是本模块的控制器集合,imports 则导入其他模块公开的能力。
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [TasksModule],
})
export class AppModule {}
此时的目录结构按业务能力聚合:tasks 内同时拥有该功能的 DTO、Controller、Service 和 Module。随着项目增长,再加入 users、auth、orders 等模块,根模块仍然只负责把它们组合起来。这正是 feature module 的价值。
src/
├── main.ts
├── app.module.ts
└── tasks/
├── dto/
│ └── create-task.dto.ts
├── tasks.controller.ts
├── tasks.module.ts
└── tasks.service.ts
验证接口行为
运行 npm run start:dev 后,另开一个终端执行下面的命令。第一次请求应返回 201 和新任务;第二次请求应返回数组。第三次请求故意携带多余字段,在本文的全局校验配置下应得到 400,从而证明输入边界生效。
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"学习 NestJS 的模块边界"}'
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"不该出现的字段示例", "isAdmin": true}'
一条请求在 NestJS 中如何被处理?
当你能写出 Controller 和 Service 后,下一个关键问题不是立刻学习几十个装饰器,而是理解请求何时经过哪些层。官方将这条路径称为请求生命周期:通常,请求按顺序经过 middleware、guard、interceptor、pipe、控制器与服务;响应生成后再回到 interceptor。未捕获异常会转到 exception filter。
请求
│
├── Middleware 最早的预处理:日志、简单上下文
├── Guard 能不能进入:认证、角色、权限
├── Interceptor(前) 包住处理器:计时、缓存、统一包装
├── Pipe 转换与质检:DTO 校验、字符串转数字
├── Controller → Service
├── Interceptor(后) 响应再经过同一层包装
└── Exception Filter 只处理未被捕获的异常
实际执行还会区分全局、控制器和路由级绑定;完整顺序以官方文档为准。
| 机制 | 把它当成什么 | 最适合解决的问题 | 初学者先记住的边界 |
|---|---|---|---|
| Middleware | 最早的请求预处理层 | 请求日志、简单上下文附加 | 不适合承载路由级授权决策 |
| Guard | “能不能进入?”的门卫 | 认证、角色和权限判断 | 返回是否允许继续处理 |
| Interceptor | 围绕处理器的一层包装 | 响应统一包装、耗时记录、缓存 | 可在请求前后都做事 |
| Pipe | 输入的转换器与质检员 | DTO 校验、字符串转数字 | 应尽早拒绝无效输入 |
| Exception Filter | 未捕获异常的统一出口 | 错误响应格式、异常映射 | 只处理未被捕获的异常 |
这里有两个容易混淆的细节。第一,Guard 通常发生在 Pipe 之前,因此认证与授权应放在 Guard,而不是为了“看起来统一”塞进 DTO 校验。第二,Filter 只在发生未捕获异常时运行;并且它的匹配优先级是从路由级到控制器级,再到全局级,而不是通常的全局优先。
六个常见误区及规避方式
| 误区 | 为什么会变得难维护 | 更好的起点 |
|---|---|---|
| 把查询、规则、第三方调用全写进 Controller | HTTP 细节与业务规则耦合,单测与复用都会变难 | 让 Controller 调用 Service;让 Service 协调业务 |
在 Controller 内手动 new Service | 绕过 IoC 容器,替换依赖和测试 mock 更麻烦 | 用构造函数注入,并在模块中注册 Provider |
| 用 interface 当输入 DTO | 接口在运行时不存在,验证器拿不到所需元数据 | 用带验证装饰器的 DTO class |
| 只相信前端校验 | 任意客户端都能绕过前端并直接请求 API | 在入口使用 ValidationPipe 与 DTO |
不理解 exports 就把所有模块设为全局 | 依赖来源变得隐蔽,模块边界失去意义 | 默认封装,仅显式导出真正共享的 Provider |
过早注入 @Res() 手动拼响应 | 容易放弃框架的标准响应处理;与自动序列化混用还可能出错 | 绝大多数路由直接 return 数据 |
这些建议的共同目标是让依赖关系清楚:HTTP 进入 Controller,业务进入 Service,能力归入 Module,输入在边界校验。 当你遇到“这段代码应该放哪里”的问题,先用这四句话判断,通常比搜索某个装饰器更有帮助。
测试:先确认业务逻辑是否符合预期
测试并不是为了增加代码量,而是为了在修改功能后,快速确认原有行为没有被意外破坏。以任务模块为例,我们最关心的不是页面或 HTTP 请求能否打开,而是“创建任务后,是否返回了正确的数据”。这类只验证某个类或某段业务逻辑的测试,称为单元测试。
NestJS 的结构很适合做单元测试:业务规则放在 Service 中,Controller 只负责接收请求并调用 Service。因此,可以先单独测试 TasksService,无需启动 HTTP 服务,也无需连接真实数据库。这样一来,测试运行更快,定位问题也更直接。
下例验证 create() 方法是否能正确创建任务。beforeEach 会在每一条测试执行前运行一次,重新准备一个干净的 TasksService 实例,避免前一条测试留下的数据影响下一条测试。
// src/tasks/tasks.service.spec.ts
import { Test } from '@nestjs/testing';
import { TasksService } from './tasks.service';
describe('TasksService', () => {
let service: TasksService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [TasksService],
}).compile();
service = moduleRef.get(TasksService);
});
it('创建任务后,应返回带有递增 ID 的任务', () => {
const task = service.create({ title: '写第一个测试' });
expect(task).toEqual({
id: 1,
title: '写第一个测试',
completed: false,
});
});
});
这段代码可以按下面的顺序理解:
| 代码 | 作用 | 可以这样理解 |
|---|---|---|
Test.createTestingModule() | 创建一个仅用于测试的 Nest 模块 | 搭建一个小型、隔离的运行环境 |
providers: [TasksService] | 注册本次要测试的服务 | 告诉 Nest:“这次只需要这个服务” |
compile() | 完成测试模块的初始化 | 让 Nest 创建并准备好服务实例 |
moduleRef.get(TasksService) | 从测试模块中取得服务 | 拿到待测试的 TasksService |
expect(...).toEqual(...) | 比较实际结果与预期结果 | 判断功能是否按要求工作 |
在默认脚手架项目中,可以运行 npm run test 执行测试。刚开始时,不必追求覆盖所有代码;先为每个 Service 中最重要的业务方法写一两条测试即可。例如,为“创建任务”“完成任务”“删除任务”分别验证输入和输出是否正确。
当 Service 未来需要访问数据库、调用第三方接口或发送消息时,也不建议在单元测试中直接连接真实服务。应将这些外部能力作为 Provider 注入,并在测试中用模拟对象替换它们。这样测试验证的始终是业务逻辑本身,而不会因为网络、数据库状态或第三方服务暂时不可用而失败。
NestJS 微服务入门:让多个服务一起工作
微服务可以简单理解为:把一个大型应用中相对独立的功能,拆成多个小服务分别运行。例如,任务管理、消息通知和操作记录可以由不同服务负责。它们各自完成自己的工作,再通过消息互相通信。
不过,微服务并不是项目变复杂后的唯一答案。刚开始时,更重要的是先把一个 NestJS 应用中的模块划分清楚。只有当某个功能需要独立发布、单独扩容,或确实需要与其他功能分开维护时,再把它拆成一个独立服务。
浏览器
│
└── API 服务(HTTP)
│ send / emit
├── 任务服务
│ │ emit task.created
├── 消息服务
└── 审计服务
用户请求先进入对外的 API 服务,再由它调用任务服务;任务创建成功后,还可以通知消息服务和审计服务继续处理。
什么时候需要微服务?
先使用一个 NestJS 应用并不是“落后”的做法。对于功能较少、规则变化频繁的项目,把代码放在一个应用中通常更容易开发和排查问题。微服务适合解决更明确的问题,而不是为了追求架构名称而拆分项目。
| 当前情况 | 更合适的做法 | 原因 |
|---|---|---|
| 只是想让代码更整齐 | 使用 Module 和 Service | 模块已经可以把功能分开管理 |
| 某个功能访问量很大,例如发送通知或处理文件 | 考虑拆成单独服务 | 这个服务可以按自己的需要扩容 |
| 下单后需要发通知、记积分、写操作记录 | 使用事件通知多个服务 | 主流程不必等待所有后续操作完成 |
| 不同团队维护相对稳定的功能 | 考虑按功能拆服务 | 团队可以独立修改和发布 |
| 功能仍在频繁调整 | 先保留在一个应用中 | 过早拆分会增加联调和排查成本 |
简单原则:先把功能分成清晰的 NestJS 模块,再决定是否需要拆成独立服务。代码分文件,不等于必须拆成多个进程。
两种常见的服务通信方式
服务之间并不一定都要“发请求并等待结果”。在 NestJS 中,最常见的是下面两种方式。
| 方式 | 什么时候使用 | NestJS 写法 | 可以这样理解 |
|---|---|---|---|
| 等待结果的调用 | 需要马上得到答案,例如查询任务、检查库存 | @MessagePattern() 与 client.send() | “请帮我做这件事,做完告诉我结果。” |
| 发送通知 | 只想告诉其他服务一件事已经发生 | @EventPattern() 与 client.emit() | “任务已经创建,谁需要处理就去处理。” |
例如,用户点击“查看任务列表”时,API 服务需要从任务服务拿到数据,因此使用 send()。任务创建成功后,如果还要发通知或记录日志,任务服务只需发出 task.created 事件,其他服务各自处理即可,不需要阻塞用户的请求。
第一步:创建一个只负责任务的服务
先安装 NestJS 的微服务包:
npm i @nestjs/microservices
新建一个 tasks-service 项目。它不再提供 /tasks 这样的 HTTP 地址,而是等待其他服务发送消息。下面使用 TCP 作为本地学习时的通信方式,因为它不需要额外安装消息队列。
// tasks-service/src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { TasksModule } from './tasks/tasks.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
TasksModule,
{
transport: Transport.TCP,
options: {
host: process.env.TASKS_HOST ?? '127.0.0.1',
port: Number(process.env.TASKS_PORT ?? 8877),
},
},
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen();
}
bootstrap();
这段代码的重点只有两件事:Transport.TCP 表示本例通过 TCP 通信;port: 8877 是任务服务等待消息的端口。前文的 TasksService 和 DTO 可以继续使用,只需将原来的 HTTP Controller 改为消息 Controller。
// tasks-service/src/tasks/tasks.message-controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { CreateTaskDto } from './dto/create-task.dto';
import { Task, TasksService } from './tasks.service';
@Controller()
export class TasksMessageController {
constructor(private readonly tasksService: TasksService) {}
@MessagePattern({ cmd: 'tasks.findAll' })
findAll(): Task[] {
return this.tasksService.findAll();
}
@MessagePattern({ cmd: 'tasks.create' })
create(@Payload() dto: CreateTaskDto): Task {
return this.tasksService.create(dto);
}
}
@MessagePattern() 中的 { cmd: 'tasks.create' } 可以理解为消息名称。调用方发送同样的名称,NestJS 就会把消息交给对应的方法处理。这个 Controller 仍要注册到 TasksModule 中,而业务逻辑仍放在 TasksService 中。
// tasks-service/src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksMessageController } from './tasks.message-controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksMessageController],
providers: [TasksService],
})
export class TasksModule {}
第二步:创建对外提供 HTTP 接口的 API 服务
浏览器和前端通常仍通过 HTTP 调用后端。因此,可以保留一个 API 服务专门接收 /tasks 请求,再由它把请求转给任务服务。这个对外接收请求的服务通常也被称为 API 网关,此处可以先把它理解成“接口转发层”。
在 API 服务中注册任务服务的连接信息:
// api-gateway/src/tasks/tasks.gateway.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { TasksGatewayController } from './tasks.gateway.controller';
@Module({
imports: [
ClientsModule.register([
{
name: 'TASKS_SERVICE',
transport: Transport.TCP,
options: {
host: process.env.TASKS_HOST ?? '127.0.0.1',
port: Number(process.env.TASKS_PORT ?? 8877),
},
},
]),
],
controllers: [TasksGatewayController],
})
export class TasksGatewayModule {}
再让网关项目的根模块导入该功能模块:
// api-gateway/src/app.module.ts
import { Module } from '@nestjs/common';
import { TasksGatewayModule } from './tasks/tasks.gateway.module';
@Module({
imports: [TasksGatewayModule],
})
export class AppModule {}
然后在 Controller 中注入 TASKS_SERVICE,并把 HTTP 请求转成消息。两个项目中的消息名称必须保持一致,例如下面的 { cmd: 'tasks.findAll' } 与任务服务中的写法完全相同。
// api-gateway/src/tasks/tasks.gateway.controller.ts
import { Body, Controller, Get, Inject, Post } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksGatewayController {
constructor(
@Inject('TASKS_SERVICE') private readonly tasksClient: ClientProxy,
) {}
@Get()
findAll() {
return this.tasksClient.send({ cmd: 'tasks.findAll' }, {});
}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tasksClient.send({ cmd: 'tasks.create' }, dto);
}
}
启动时,先运行 tasks-service,再运行 api-gateway。此后,用户访问 GET /tasks 时,API 服务会向任务服务发送 tasks.findAll 消息;任务服务处理完成后,结果再回到 API 服务,最后作为 HTTP 响应返回给用户。
第三步:用事件通知其他服务
如果任务创建后还要发送通知、记录操作日志,不建议让任务服务一个个同步调用所有其他服务。更简单的方式是:任务创建完成后,发出一个“任务已创建”的消息;需要这条消息的服务自行处理。
下面是通知服务接收事件的示例:
// notifications-service/src/tasks-events.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
interface TaskCreatedEvent {
taskId: number;
title: string;
}
@Controller()
export class TasksEventsController {
@EventPattern('task.created')
async notify(@Payload() event: TaskCreatedEvent) {
console.log(`发送任务创建通知:${event.taskId} ${event.title}`);
}
}
任务服务使用 client.emit('task.created', event) 发送事件;通知服务使用 @EventPattern('task.created') 接收事件。以后即使新增审计服务或积分服务,也可以订阅同一个事件,而无需修改任务服务的主要逻辑。
先在一个项目中尝试,也可以
如果暂时不想维护多个项目,可以让同一个 NestJS 应用同时接收 HTTP 请求和微服务消息。这种做法适合练习或逐步迁移:先验证消息调用是否可行,等功能和边界稳定后,再拆成独立服务。
// 同一个应用同时提供 HTTP 接口和 TCP 消息服务
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>(
{
transport: Transport.TCP,
options: { port: 8877 },
},
{ inheritAppConfig: true },
);
await app.startAllMicroservices();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
这里的 { inheritAppConfig: true } 表示复用主应用的全局设置,例如前文配置的 ValidationPipe。如果没有这项配置,微服务不会自动使用主应用的全局校验、守卫或拦截器。
什么时候再接入 RabbitMQ?
前面的 TCP 示例足够帮助你理解服务如何互相调用。等你需要“一个服务发出事件,多个服务都能收到”,或需要更可靠地处理异步任务时,再考虑 RabbitMQ 等消息队列。
npm i amqplib amqp-connection-manager
RabbitMQ 中有一个很重要的概念:确认消息。可以把它理解为消费者处理完一条消息后,向 RabbitMQ 回复“这条消息已经处理好了”。只有收到确认后,RabbitMQ 才会删除消息;如果服务在确认前断开,消息可以再次被发送给其他可用消费者。
// notifications-service/src/notifications.controller.ts
import { Controller } from '@nestjs/common';
import {
Ctx,
MessagePattern,
Payload,
RmqContext,
} from '@nestjs/microservices';
@Controller()
export class NotificationsController {
@MessagePattern('notifications')
async handle(
@Payload() payload: { taskId: number },
@Ctx() context: RmqContext,
) {
await this.sendNotification(payload);
// 业务处理成功后,再确认消息。
const channel = context.getChannelRef();
const message = context.getMessage();
channel.ack(message);
}
private async sendNotification(_payload: { taskId: number }) {
// 调用实际通知通道
}
}
使用手动确认时,配置中需要设置 noAck: false。实际项目中,还应考虑消息重复、服务超时和失败重试等情况。
| 上线前需要考虑的内容 | 用简单的话说 |
|---|---|
| 超时与重试 | 某个服务没有及时响应时,不能无限等待 |
| 防止重复处理 | 同一条消息可能再次送达,不能重复扣款或重复发奖品 |
| 日志与监控 | 出错时要能看出消息经过了哪些服务 |
| 消息格式管理 | 服务之间要约定字段名称和数据格式,修改时避免影响旧服务 |
| 失败后的处理方式 | 连续处理失败的消息应有专门的去处和人工处理流程 |
微服务最难的部分不在代码写法,而在多个服务出错时如何处理。建议先完成一个最小流程:API 服务调用任务服务,再让任务服务发送一个事件给通知服务。跑通这一流程后,再逐步加入消息队列、监控和自动部署。
接下来怎么学:先做一个小项目,再逐步增加功能
刚开始学习 NestJS 时,不需要一次学完数据库、登录、Docker、微服务和 GraphQL。更好的方式是先完成一个小项目,然后在这个项目上逐步增加功能。每学到一个新知识点,都能立刻看到它解决了什么问题,理解会更牢固。
可以继续使用本文的任务 API 作为练习项目。先让它能够创建、查询、修改和删除任务;再慢慢接入数据库、用户登录和测试。下面的顺序可作为参考,不必严格按时间完成。
| 学习阶段 | 可以做什么 | 重点理解什么 |
|---|---|---|
| 第一步 | 完成任务的增、删、改、查,并保留输入校验 | Controller、Service、Module、DTO 和 Pipe 的基本分工 |
| 第二步 | 接入数据库,保存真实数据 | Service 如何调用数据库,错误如何处理 |
| 第三步 | 增加用户登录和需要登录才能访问的接口 | Guard 如何判断用户是否有访问权限 |
| 第四步 | 为主要功能补充测试,并生成接口文档 | 如何确认代码修改后功能仍然正常 |
| 后续 | 按实际需要学习缓存、队列、WebSocket、微服务或 GraphQL | 根据需求选择合适的功能,而不是盲目增加技术栈 |
自查方法:当你能说清楚“这个接口为什么放在 Controller,这段业务代码为什么放在 Service,这个功能为什么需要单独的 Module”时,说明你已经掌握了 NestJS 最重要的基本思路。
结语
NestJS 并不要求你记住所有装饰器和配置。学习时,先把重点放在代码应该放在哪里:接收请求的代码放在 Controller,业务处理放在 Service,相关功能放在 Module,用户提交的数据用 DTO 和 Pipe 进行校验。
当这些基本分工变得清楚后,接入数据库、增加登录、编写测试或拆分微服务都会容易很多。建议从本文的任务 API 开始,先完成一个可以运行的小功能,再不断改进它。与其收集大量模板,不如亲手写完一个小项目,并理解每一部分代码为什么这样组织。
参考资料
Mttao GitHub ↗
探索技术与生活的智慧