nestjs

NestJS 新手入门:从创建项目到第一个任务 API

从创建第一个 NestJS 项目开始,用 Controller、Service、Module 和 DTO 搭一个可测的任务 API,并搞懂请求是怎么被处理的。

约六千四百字·读约十九分钟 · English

NestJS 新手入门:从创建项目到第一个任务 API

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“哪些能力属于同一功能?谁能使用谁?”controllersprovidersimportsexports承载具体业务算法

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-validatorclass-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。随着项目增长,再加入 usersauthorders 等模块,根模块仍然只负责把它们组合起来。这正是 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 只在发生未捕获异常时运行;并且它的匹配优先级是从路由级到控制器级,再到全局级,而不是通常的全局优先。

六个常见误区及规避方式

误区为什么会变得难维护更好的起点
把查询、规则、第三方调用全写进 ControllerHTTP 细节与业务规则耦合,单测与复用都会变难让 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

Mttao GitHub ↗

探索技术与生活的智慧

相关文章

/ 评论