普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月13日首页

AI全栈入门指南:NestJs 中的 DTO 和数据校验

作者 Moment
2026年4月13日 18:59

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。

技术上可以。@Body()@Query()@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。

真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。

这就是 DTO 要解决的问题。

DTOData Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:

  • 这次请求允许出现哪些字段
  • 每个字段期望的类型是什么
  • 哪些是必填
  • 除类型以外还要满足哪些约束

拿"创建用户"来说,若没有契约,你很容易遇到:

  • name 是空字符串
  • email 根本不像邮箱
  • age 传成了 "abc"
  • 客户端悄悄带上 role: "admin"

脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。

所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。

下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:

import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";

/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(0)
  age: number;
}

这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。

class-validatorclass-transformer

NestJS 里,DTO 通常和两个库成对出现:

  • class-validator 管规则,字段对不对、满不满足约束
  • class-transformer 管形态,把普通对象转成类实例,并在需要时做类型转换

一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。

查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()parseInt 打交道。

下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()@Min(1) 收紧范围。

import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";

/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
  @IsOptional()
  @IsString()
  keyword?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。

全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。

依赖若尚未安装,在项目根目录执行:

pnpm add class-validator class-transformer

装好后,DTO 上的装饰器才有运行时意义。

ValidationPipe 的用法

光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe

把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。

默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。

最常见的做法是在 main.ts 里全局挂上管道:

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。

import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    // 能执行到这里时,body 已通过校验并按 DTO 做过转换
    return body;
  }
}

不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。

如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:

import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post("draft")
  @UsePipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: false,
    }),
  )
  saveDraft(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }
}

对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。

白名单、转换与多余字段

ValidationPipe 的价值不止于报错。whitelistforbidNonWhitelistedtransform 三个开关配合起来,可以把入口擦得很干净。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist

whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。

DTO 只有 nameemail,客户端却带了 roleisAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。

forbidNonWhitelisted

forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。

公开 API、对接第三方、强契约场景更适合打开它。

transform

transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。

例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()

实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。

20260328102554

参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。

嵌套对象与数组

请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。

常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()@ArrayMinSize() 等与集合相关的装饰器。

import { Type } from "class-transformer";
import {
  IsArray,
  IsString,
  MinLength,
  ValidateNested,
} from "class-validator";

export class AddressDto {
  @IsString()
  @MinLength(1)
  city: string;
}

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsArray()
  @IsString({ each: true })
  tags: string[];
}

嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。

从已有 DTO 派生

更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}

安装依赖:

pnpm add @nestjs/mapped-types

还有 PickTypeOmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。

DTOEntityVO 不要混用

后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。

DTOEntityVO 都可以是一组字段,但站位不同:

  • DTO 对准接口进出的契约
  • Entity 对准持久化与领域状态
  • VO 对准对外展示或某次响应的裁剪结果

同一张用户表在三层里的切片往往不一样。

UserEntity 里可能有 idnameemailpasswordHashcreatedAtupdatedAt。创建用户的 CreateUserDto 只要 nameemailpassword。返回前端的 UserProfileVo 可能只给 idnameemail。看起来都在描述用户,语义并不相同。

混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。

/** 创建接口入参 */
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
  id: string;
  name: string;
  email: string;
}

即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。

小结

这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:

接口参数不能默认可信。

DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。

若下面这些已经变成你的默认思路,这一章就到位了:

  • 控制器拿到的外部数据不要裸用
  • 入参用 DTO 声明,并配合管道校验与转换
  • 嵌套与数组要有对应的嵌套 DTO 与集合装饰器
  • 需要时用 PartialType 等工具派生,避免复制粘贴
  • DTOEntityVO 各司其职,不因字段相似就混成一类

下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。

AI 全栈指南:NestJs 中的 Service Provider 和 Module

作者 Moment
2026年4月13日 18:57

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。

这段逻辑默认放在 Service 里。

先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:

  • 数据怎么查、怎么写
  • 规则怎么判定
  • 结果怎么拼装
  • 同一套逻辑别处还要不要复用

拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。

下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:

import { Injectable } from "@nestjs/common";

/** 内存里的用户结构,仅作示意 */
interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: "1", name: "汤姆" },
    { id: "2", name: "杰瑞" },
  ];

  /** 返回全部用户 */
  findAll(): User[] {
    return this.users;
  }

  /** 按主键查找,没有则 undefined */
  findById(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }
}

数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。

Service 带来的直接好处主要是两条:

  • 控制器变薄,一层里不塞满所有事
  • 业务逻辑方便复用、写测试、以后改实现

习惯可以记得很短:控制器对齐请求,Service 扛起业务。

Provider 的本质

不少人初学时会把 ProviderService 混着说,其实分清也不难:Service 是很常见的一种 ProviderProvider 这个词包住的是所有"可注入实现"。

凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:

  • 业务服务,例如 UsersService
  • 仓储或数据访问类,例如 UsersRepository
  • 横切能力,例如 MailService
  • 配置对象、工厂返回值、自定义 token 绑定的实例,也都算

框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:

  • 要不要由容器负责实例化
  • 能不能被别人注入
  • 生命周期怎么配合作用域

写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。

下面两个类分工不同,在容器眼里却一视同仁,都是 Provider

import { Injectable } from "@nestjs/common";

@Injectable()
export class UsersService {
  findAll(): string[] {
    return ["汤姆", "杰瑞"];
  }
}

@Injectable()
export class MailService {
  sendWelcomeMail(email: string): string {
    return `已向 ${email} 发送欢迎邮件(示意)`;
  }
}

命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。

记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。

Module 是什么

Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。

NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。

用户、订单、认证可以各自落在 UsersModuleOrdersModuleAuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。

最小模块长这样:

import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 用户领域:对外入口 + 可注入服务 */
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider

从结构上看,可以先扫一眼下面这张图。

20260328102242

节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。

别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。

imports 等四个字段各管什么

第一次看 @Module() 里的配置,最容易缠在一起的是 importsproviderscontrollersexports。拆开看就顺了。

下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:

import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

四个键可以先记成功能分工:

  • imports 本模块依赖哪些别的模块已经 exports 出来的能力
  • providers 本模块自己要注册、仅供内部(默认可注入范围)使用的 Provider
  • controllers 本模块声明哪些 HTTP 入口
  • exports 本模块对外放行哪些 Provider,供在别处 imports 了本模块的代码继续注入

最常绊脚的一对是 providersexports

  • providers 是"家里有哪些实现"
  • exports 是"门口挂牌、准许邻居借用的有哪些"

留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports

这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。

分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。

为什么业务逻辑不能全写在 Controller

新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。

项目一大,这样最容易长胖的是控制器。

下面这个例子能跑,但已经在兼职干 Service 的活:

import { Body, Controller, Post } from "@nestjs/common";

/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    const exists = body.email === "tom@example.com";

    if (exists) {
      return { message: "该邮箱已存在" };
    }

    const user = {
      id: Date.now().toString(),
      name: body.name,
      email: body.email,
      status: "正常",
    };

    return { message: `已创建用户:${user.name}` };
  }
}

收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。

把规则挪进 Service,控制器只做转发,形态会干净很多:

import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    // 业务规则交给服务层
    return this.usersService.create(body);
  }
}

改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。

收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。

为什么 ModuleNestJS 里最核心的那一层边界

Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。

维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。

NestJSModule 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。

边界划清楚以后,好处很实在:

  • 用户、订单、支付、认证各自有落脚模块
  • 依赖不容易随便渗透到别的模块内部
  • 拆分、复用、补测试都更顺手
  • 新人找功能时有目录感
  • 大重构可以按模块切块推进

反过来,模块若只是分文件夹,ServiceController 再多也可能是一盘散沙。

所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。

顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。

这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。

小结

这篇的重点不是多记几个词,而是把三条线拧到一根绳上:

  • Service 承接大部分业务
  • Provider 是容器能注入的那类东西的统称
  • Module 划边界、装箱、再决定对外露什么

判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。

下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。

当前端开始做 Agent 后,我才知道 LangGraph 有多重要❗❗❗

作者 Moment
2026年4月13日 18:11

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

在之前的内容里面我们一直在用 LangChain 写链、写 Agent,从最简单的模型调用到工具绑定、路由分发、自定义工作流,走了一整套流程。到这里自然会遇到一个问题:随着应用逻辑越来越复杂,LangChain 原有的编排方式开始显得吃力。链是线性的,Agent 是循环的,但真实世界里的流程往往是图状的,有分支、有合并、有回环、有需要等待人工确认的节点。LangGraph 就是为了解决这个问题而出现的。

为什么需要 LangGraph

LangChainAgentExecutor 时,底层逻辑是一个简单循环:调模型、看要不要用工具、用完工具再回来、再调模型。这个模型对于简单的工具调用场景足够用,但一旦遇到以下几种情况,就开始捉襟见肘。

第一种是多步骤分支。假设需要先判断用户意图,然后根据意图走完全不同的子流程,子流程结束后还需要汇总结果再回复用户。AgentExecutor 的循环模型表达这类逻辑,需要把分支全部塞进提示词,或者用条件回调硬写,代码很快就乱成一团。

第二种是状态持久化。用户和 Agent 聊了几十轮,中途关掉了页面,下次再打开希望从上次停下的地方继续。LangChain 本身没有原生的持久化机制,记忆模块只是把消息列表临时存在内存里,进程一停就没了。

第三种是人机协同。工作流执行到某个敏感节点,需要暂停下来等人类审核,审核通过后才能继续往下跑。这种"执行中途打断、人工介入、再恢复"的场景,在 AgentExecutor 里几乎无法干净地实现。

LangGraph 把上面这些问题都纳入了核心设计。它的思路是把整个 Agent 或工作流建模成一张图,节点是计算步骤,边是流转路径,状态是在整张图上流动的数据。图可以有条件边,可以有回边,可以在任意节点打断并恢复,状态可以持久化到数据库。

LangGraph 的核心思路

理解 LangGraph 最好的方式是先搞清楚它的三个基本概念:状态、节点和边。

状态是图执行过程中一直流动的数据对象,可以把它想象成贯穿整个流程的"共享变量包",每个节点都可以读取里面的内容,也可以往里写新的内容。最常用的状态定义是 MessagesAnnotation,它把状态简化为一个消息列表,非常适合对话类应用。如果需要追踪工具调用次数、用户身份、中间计算结果等自定义字段,也可以用 Annotation 自己定义状态结构。

节点是图里的计算单元,每个节点就是一个普通的异步函数,接收当前状态作为参数,执行完后返回需要更新的状态字段。节点可以承担调用模型、执行工具、查询数据库、等待人工审核等任何有意义的计算步骤。

边是节点之间的连接。普通的边直接指向下一个节点,条件边则根据当前状态的内容动态决定下一跳,类似代码里的 if/else。图的执行从特殊的 __start__ 节点开始,到 __end__ 节点结束。

执行时,用户消息随状态流入 callModel 节点,模型回复追加到消息列表后随状态流出,整个过程一进一出,结构极其简单。如需在代码里取出结果,用 result.messages.at(-1) 拿最后一条即可。

下面这张图把五个关键步骤画在一条主线上,如下图所示。

20260317073347

用户发消息进入状态,callModel 节点读取、调用模型、追加回复,状态带着结果流到终点。

再复杂一点,加上工具调用和条件路由,图就具备了循环能力,如下图所示。

20260316231826

加入工具节点和条件边后,调用模型、执行工具、再次调模型形成完整的回路,整个逻辑一眼就能读懂。

LangGraph 和 LangChain 怎么分工

LangGraph 负责"流程怎么跑",它本身不绑定任何模型供应商,也不提供工具的具体实现,只管图的执行调度、状态的流转与持久化。LangChain 负责"工具和模型是什么",它提供的 ChatOpenAItoolHumanMessage、提示模板、检索器这些组件,是节点函数里真正要调用的东西。

两者的关系是分层叠加,而不是二选一,如下图所示。

20260317073508

LangGraph 在上层负责调度与状态,LangChain 在下层提供模型与工具,两者分工明确、协同运作。

如果不确定自己的场景该用哪个,可以对照下面这张表。

场景 推荐
单次问答、简单链式调用 LangChain
一个模型加几个工具的轻量 Agent LangChain
多步骤、有明确分支的工作流 LangGraph
需要持久化对话或状态可回溯 LangGraph
多 Agent 协作、任务拆解 LangGraph
人机协同、需要中途暂停等待审核 LangGraph

LangGraph 的官方文档自己也在说,如果你的 Agent 只是一个简单的"模型加工具循环",用 LangChaincreateReactAgent 快速搞定就好,没必要一开始就引入图的概念。但凡流程复杂到需要明确画出来才能讲清楚,就是 LangGraph 发力的时候了。

最小可运行的骨架

先把三个依赖装好。

pnpm add @langchain/langgraph @langchain/core @langchain/openai

然后搭出下面三个文件的骨架,后面章节的示例都会在这个基础上扩展。

src/model.ts 负责模型初始化,集中管理密钥与接口地址,方便在多个图文件里复用。

// src/model.ts
import { ChatOpenAI } from "@langchain/openai";

export const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
});

src/graph.ts 定义图的结构,目前只有一个调用模型的节点。

// src/graph.ts
import { StateGraph } from "@langchain/langgraph";
import { MessagesAnnotation } from "@langchain/core/messages";
import { model } from "./model";

async function callModel(state: typeof MessagesAnnotation.State) {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("callModel", callModel)
  .addEdge("__start__", "callModel")
  .addEdge("callModel", "__end__");

export const app = graph.compile();

src/index.ts 是入口,执行一次图并打印模型回复。

// src/index.ts
import { HumanMessage } from "@langchain/core/messages";
import { app } from "./graph";

const result = await app.invoke({
  messages: [new HumanMessage("你好,介绍一下 LangGraph")],
});

console.log(result.messages.at(-1)?.content);

现在这个骨架已经是真正可以运行的 LangGraph 应用了:输入一条用户消息,callModel 节点调用模型后把响应追加到状态里,图执行完后取出最后一条消息打印。下一章的 Quickstart 会在这个基础上加入工具绑定、条件边和 checkpointer 持久化,让图逐渐"活"起来。

小结

LangGraph 出现是因为 LangChain 的链式和循环模型在多分支、持久化、人机协同这类复杂场景下力不从心,它用状态、节点、边三个概念把工作流建模成图,状态贯穿全图流动,节点负责处理状态,边决定下一跳的走向。LangChainLangGraph 不是竞争关系,前者提供模型与工具,后者负责编排与调度,两者叠加才是完整的应用架构。后面所有章节的示例都会在 model.tsgraph.tsindex.ts 这三个文件的骨架上扩展。

昨天以前首页

AI全栈入门指南:使用 NestJs 创建第一个后端项目

作者 Moment
2026年4月9日 09:21

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

前两篇把认知铺好了,这一篇进入上手阶段。

如果你是第一次接触 NestJS,最推荐的方式不是手动搭目录,而是直接使用官方 CLI 创建项目。原因很简单,CLI 不只是帮你生成几个文件,它还会顺手把一个标准可运行的项目骨架搭好。这样你第一次接触时,不会一上来就被配置细节打断。

下面的示例统一使用 pnpm。如果你平时用的是 npmyarn,只要把命令替换成自己习惯的包管理器即可。

在终端中执行下面这条命令,就可以创建一个新的 NestJS 项目:

pnpm dlx @nestjs/cli new hello-nest

执行之后,CLI 会提示你选择包管理器。直接选择自己当前项目里最常用的那个就行。如果你平时主要使用 pnpm,这里继续选 pnpm 会更顺手。

这一步完成之后,你会得到一个已经初始化好的项目目录。依赖会自动安装,基础文件也会一并生成,所以不需要你再从零创建入口文件、路由文件和配置文件。

如果你之前没有用过 dlx,可以把它理解成临时拉起来跑一下某个命令行工具,不必先全局安装 @nestjs/cli,用完就走,比较适合第一次体验。

认识默认目录结构

项目创建完成后,先不要急着写代码。更重要的是先看一眼默认目录结构,因为这会直接帮助你理解 NestJS 是怎么组织应用的。

一个刚创建出来的项目,核心目录大致会像下面这样:

20260328094755

第一次看这些文件时,可以先抓最重要的几个:

  • src/main.ts 是应用入口,负责把整个 NestJS 应用启动起来
  • src/app.module.ts 是根模块,用来组织当前应用的基础结构
  • src/app.controller.ts 是控制器,负责接收请求和返回结果
  • src/app.service.ts 是服务层,负责承载具体业务逻辑

你会发现,哪怕只是一个最简单的 Hello World 项目,NestJS 也没有把所有逻辑都塞进一个文件里。它一开始就把入口、模块、控制器、服务拆开了。这正是前面提到的结构约束。

也就是说,NestJS 希望你从第一个项目开始,就用一种更接近真实业务系统的方式来组织代码,而不是先随便写,等项目变大后再重构。

如果你现在只记一句话,可以先记这个:

src/main.ts 负责启动,Module 负责组织,Controller 负责接请求,Service 负责写逻辑。

后面无论项目变得多复杂,这套基础分工都不会变。

启动开发环境

进入项目目录后,就可以把开发环境跑起来了:

cd hello-nest
pnpm run start:dev

这条命令会以开发模式启动项目,并开启监听。也就是说,你修改 src 里的代码后,服务通常会自动重新编译并重启,不需要你每次手动停掉再启动。

项目启动成功后,终端一般会看到类似 Nest application successfully started 的提示。默认情况下,服务会监听 3000 端口。

20260328094827

这时候你可以先用浏览器打开下面这个地址:

http://localhost:3000

如果一切正常,你会看到默认返回的 Hello World!。这说明项目已经跑起来了。

这一步的意义不只是确认环境没问题,更重要的是让你先建立一个非常直接的印象:

一个刚生成出来的 NestJS 项目,本身就是可运行的。

也正因为默认项目能立刻跑起来,后面改代码时,你能很直观地对照改了哪个文件、行为发生了什么变化。

写第一个接口

默认项目已经有一个最基础的接口,只是它的业务非常简单。为了真正理解 NestJS 的组织方式,最好的做法不是新建一堆复杂模块,而是先把这个默认接口改一遍。

先看服务层。把 src/app.service.ts 改成下面这样:

import { Injectable } from "@nestjs/common";

@Injectable()
export class AppService {
  getHello(): { message: string; from: string } {
    return {
      message: "Hello NestJS",
      from: "AppService",
    };
  }
}

这段代码的重点不是返回什么内容,而是让你看到,真正的业务结果是由 AppService 提供的。控制器不直接写死所有内容,而是去调用服务层。

接着修改 src/app.controller.ts

import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get("hello")
  getHello(): { message: string; from: string } {
    return this.appService.getHello();
  }
}

这里有两个点值得第一次接触时特别留意。

第一,@Controller()@Get("hello") 这类装饰器,标的是这个类、这个方法在 HTTP 这一层各自干什么。

第二,控制器通过构造函数拿到 AppService,而不是自己手动 new AppService()。这就是依赖注入最直观的体现。你声明自己需要什么,框架负责把依赖准备好。

改完之后,重新访问下面这个地址:

http://localhost:3000/hello

如果一切正常,你会看到类似下面这样的返回结果:

20260328095005

看到这里,其实你已经完成了自己的第一个 NestJS 接口。虽然它非常简单,但最关键的骨架已经出现了。

从 Hello World 看 NestJS 的基本组织方式

一个最简单的 Hello World 也能看出 NestJS 的分层习惯,请求不会随便落进某个函数,而是按约定往前走。最短路径可以先想成下面五步:

  • 客户端发起请求
  • 路由命中控制器方法
  • 控制器调用服务层
  • 服务层返回业务结果
  • 框架把结果写回客户端

20260401083712

在这五步之上还要叠上两块,根 Module(例如 AppModule)把 ControllerService 登记到同一个模块里,src/main.ts 创建应用实例并监听端口,进程才真正跑起来。接请求、写业务、做装配、拉起监听,是同一条最小链路上的不同环节。

对应到代码里,先记住四个角色就够:

  • Controller 接住请求
  • Service 处理业务
  • Module 把相关角色收进同一个模块
  • src/main.ts 把应用跑起来并监听端口

这和先把逻辑全塞进一个文件再说的写法差别很大,NestJS 从第一个接口就在推你把入口、业务和装配拆开。返回值具体写了哪句文案反而不那么重要,更值得留意的是三件事已经成习惯:入口和业务分开、依赖交给框架装配、结构按角色长而不是按临时想法堆。

把这条轮廓记熟,后面再学模块拆分、参数校验、异常处理、数据库接入,都容易挂回同一套形状里。

小结

第一个 NestJS 项目的重点,不是把服务跑起来本身,而是借这个最小示例看清它的基本骨架。

通过 CLI 创建项目,你拿到的是一套标准初始结构。通过修改默认接口,你能看到 ControllerService、依赖注入和应用入口是如何协同工作的。

如果你现在已经能理解下面这几件事,这一篇的目标就达到了:

  • 如何用 CLI 创建一个 NestJS 项目
  • 默认目录里几个核心文件分别负责什么
  • 怎样启动开发环境并访问默认服务
  • 怎样改出自己的第一个接口
  • 为什么这个最小例子已经体现了 NestJS 的基本组织方式

接下来会从这个最小项目出发,看 Controller 和路由是怎么对应起来的。

AI全栈入门指南:一文搞清楚NestJs 中的 Controller 和路由

作者 Moment
2026年4月10日 09:48

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

上一节我们已经跑通了第一个 NestJS 项目,也看到了 ControllerService 是如何配合的。这一节继续往前走,专门看 Controller 到底负责什么,以及路由在 NestJS 里是怎么声明出来的。

如果只用一句话概括,Controller 做的是"把一个 HTTP 请求接进来,再把结果返回出去"。至于真正的业务逻辑,通常不应该堆在控制器里,而是交给 Service

定义路由

NestJS 里,路由不是写在一张单独的表里,而是直接声明在控制器类和它的方法上。

类上的 @Controller() 用来定义这一组接口的公共路径。方法上的 @Get()@Post() 这类装饰器,用来定义具体某个接口对应的请求方式和子路径。

下面这段代码演示了一个很常见的写法:

import { Controller, Get } from "@nestjs/common";

@Controller("users")
export class UsersController {
  @Get()
  findAll(): string {
    return "all users";
  }

  @Get("profile")
  findProfile(): string {
    return "user profile";
  }
}

这段代码的含义分别是:

  • @Controller('users') 表示这一组接口都挂在 /users 下面
  • @Get() 对应 GET /users
  • @Get('profile') 对应 GET /users/profile

你可以把控制器理解成"某一类资源或某一块功能的入口集合"。比如用户相关接口放进 UsersController,订单相关接口放进 OrdersController。这样路径组织和代码组织会更一致。

GETPOSTPUTDELETE

NestJS 对常见 HTTP 方法都提供了对应装饰器。最常用的就是 @Get()@Post()@Put()@Delete()

下面这个例子把常见写法放在一起看,会更直观:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
} from "@nestjs/common";

interface CreateUserDto {
  name: string;
  email: string;
}

interface UpdateUserDto {
  name?: string;
}

@Controller("users")
export class UsersController {
  @Get()
  findAll(): string {
    return "get all users";
  }

  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }

  @Put(":id")
  update(
    @Param("id") id: string,
    @Body() body: UpdateUserDto,
  ): { id: string; body: UpdateUserDto } {
    return { id, body };
  }

  @Delete(":id")
  remove(@Param("id") id: string): { deletedId: string } {
    return { deletedId: id };
  }
}

第一次看这段代码时,先不要急着记所有装饰器。先抓住一个核心规律:

不同 HTTP 方法,本质上就是在告诉框架,"同样是某个路径,这次应该用哪种请求方式来匹配它"。

通常可以先这样理解它们的语义:

  • GET 用来读取数据
  • POST 用来创建数据
  • PUT 用来整体更新数据
  • DELETE 用来删除数据

这不是绝对规则,但它是最常见的约定。按照这个约定设计接口,团队协作时会更容易理解。

Path 参数、Query 参数、Body 参数

写接口时大半时间在跟入参打交道。浏览器和客户端把数据放在 URL 路径里、问号后面或请求体里,NestJS 用三种装饰器一一对应,名字和业务含义基本对齐,读方法签名就能猜出数据从哪来。

下面这段代码放在同一个 PostsController 里:上面是带路径段和查询串的 GET,下面是读 JSON 体的 POST

import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";

interface CreatePostDto {
  title: string;
  content: string;
}

@Controller("posts")
export class PostsController {
  @Get(":id")
  findOne(
    @Param("id") id: string,
    @Query("preview") preview?: string,
  ): { id: string; preview?: string } {
    return { id, preview };
  }

  @Post()
  create(@Body() body: CreatePostDto): CreatePostDto {
    return body;
  }
}

对应关系可以这样记:

  • @Param() 对应路径里的动态段,/posts/123 里的 123 会进 id
  • @Query() 对应 ? 后面的键值,/posts/123?preview=true 里的 preview 会进来,没有则 previewundefined(这里写了可选参数)
  • @Body() 对应报文主体,常见于 POSTPUTPATCH 提交的 JSON 或表单序列化结果

更短的一句口诀是,路径用 @Param(),问号后用 @Query(),包体用 @Body()

这样一来,控制器里很少出现"这段到底读的是 req 的哪一块"的猜测。来源都写在参数列表上,也比到处翻 req.paramsreq.queryreq.body 更直观。

Header、状态码、重定向

除了读路径和请求体,控制器有时候还需要读取请求头、设置状态码,或者做重定向。NestJS 也提供了比较声明式的写法。

先看请求头的读取方式:

import { Controller, Get, Headers } from "@nestjs/common";

@Controller("info")
export class InfoController {
  @Get()
  getClient(@Headers("user-agent") userAgent?: string): { userAgent?: string } {
    return { userAgent };
  }
}

这里的 @Headers('user-agent') 就是在读取请求头中的 user-agent

如果你想显式设置状态码,也可以这样写:

import { Controller, HttpCode, Post } from "@nestjs/common";

@Controller("users")
export class UsersController {
  @Post("login")
  @HttpCode(200)
  login(): { message: string } {
    return { message: "login success" };
  }
}

这个例子里,虽然是 POST 请求,但我们明确把返回状态码设成了 200。这在登录接口这类场景里很常见。

如果你想做重定向,可以使用 @Redirect()

import { Controller, Get, Redirect } from "@nestjs/common";

@Controller()
export class AppController {
  @Get("docs")
  @Redirect("https://docs.nestjs.com", 302)
  goDocs(): void {}
}

这段代码的意思是,当用户访问 /docs 时,服务端直接把请求重定向到指定地址。

所以这一节可以先总结成一句话:

控制器不只负责匹配路径,它还负责把请求中的关键信息拿出来,并按需要影响最终响应行为。

返回值与原生 response 的区别

这是很多初学者刚接触 NestJS 时容易困惑的一点。

在大多数情况下,你只需要"直接返回值"就够了。比如返回对象、数组、字符串,NestJS 会帮你把这些结果自动序列化并发送给客户端。

例如下面这种写法,就是最推荐的默认方式:

import { Controller, Get } from "@nestjs/common";

@Controller("health")
export class HealthController {
  @Get()
  check(): { status: string } {
    return { status: "ok" };
  }
}

它的好处是,代码简洁,也更容易和 Interceptor、异常过滤器、状态码装饰器这些机制配合。

NestJS 也允许你拿到原生响应对象,比如 Express 下的 response。这种方式适合你需要手动控制响应细节的场景,比如流式输出、文件下载、特殊响应头等。

写法通常像这样:

import { Controller, Get, Res } from "@nestjs/common";
import type { Response } from "express";

@Controller("download")
export class DownloadController {
  @Get()
  download(@Res() res: Response): void {
    res.status(200).json({ message: "manual response" });
  }
}

一旦你使用了 @Res(),就意味着这一段响应由你自己接管。框架不会再按默认方式帮你自动返回结果。

所以两种方式的区别可以先这样理解:

  • 直接 return,更符合 NestJS 的默认风格,日常接口优先使用
  • 使用原生 response,控制力更强,但你需要自己负责响应的发送

对初学者来说,有一个很实用的判断标准:

如果只是普通的 JSON 接口,优先直接 return。 只有当你确实需要精细控制响应过程时,再考虑使用 @Res()

小结

ControllerNestJS 里承担的是请求入口角色。它负责定义路由、读取参数、组织响应,但不应该承载过多业务逻辑。

这一篇最重要的收获,可以先落成下面几件事:

  • 路由通过控制器类和方法上的装饰器来声明
  • GETPOSTPUTDELETE 对应不同的 HTTP 请求方式
  • @Param()@Query()@Body() 分别读取不同来源的参数
  • @Headers()@HttpCode()@Redirect() 可以影响请求处理和响应行为
  • 普通接口优先直接 return,原生 response 适合特殊控制场景

下一节,我们会继续从控制器往下走,看看 ServiceProviderModule 是怎样把业务能力真正组织起来的。

❌
❌