Skip to content

J2 · 控制器、路由、DTO 与校验 ​

第 2 天 · 11 月 10 日周二 · 交付物:内存版项目增删改查

Nest 的控制器和 @RestController 很像:带装饰器的方法接收请求,返回的对象被序列化成 JSON。真正的区别在别处: TypeScript 的类型在运行时会消失。所以输入校验完全依赖带装饰器的 DTO 类和一个已启用的 ValidationPipe。 没有它,什么都不会被检查。

目标 ​

  • 声明路由、选择状态码、给 API 加版本号。
  • 用 class-validator 写带校验的 DTO,用 class-transformer 做类型转换。
  • 配置一个严格的全局 ValidationPipe(whitelist、forbidNonWhitelisted、transform)。
  • 用 PartialType、PickType、OmitType 派生更新用的 DTO。
  • 交付一个存在内存里的 projects 增删改查,支持查询参数分页和过滤。

Spring → NestJS ​

SpringNestJS要点
@RestController + @RequestMapping("/projects")@Controller('projects')路径前缀写在类上
@GetMapping("/{id}")、@PostMapping@Get(':id')、@Post()路径参数写成 :id
@PathVariable、@RequestParam、@RequestBody、@RequestHeader@Param('id')、@Query()、@Body()、@Headers()不带名字的 @Query() 拿到整个查询对象
@ResponseStatus(NO_CONTENT)@HttpCode(HttpStatus.NO_CONTENT)默认 200,POST 默认 201
ResponseEntity<T>直接返回对象(或 Promise)有 @Res(),但它会关掉 Nest 的统一处理
@Valid + Bean Validation(@NotNull、@Size)ValidationPipe + class-validator(@IsNotEmpty()、@MaxLength())必须启用这个管道,否则不做任何校验
Jackson(JSON ↔ 对象)class-transformer请求体到达时只是普通对象,transform: true 才会把它变成 DTO 实例

原理 ​

路由与装饰器 ​

@Controller('projects') 定前缀,每个方法再加上 HTTP 方法和路径。Nest 启动时读取这些装饰器,把路由注册到 HTTP 适配器里(默认是 Express 5)。启动日志会列出每条注册的路由(上下文是 RouterExplorer),某个接口返回 404 时, 先看这里。

处理函数可以返回普通值、Promise 或 Observable:Nest 等结果出来,序列化成 JSON,再设置状态码。你不用自己操作 响应对象,正因为如此,拦截器和全局序列化才能统一生效。

状态码 ​

GET、PATCH、DELETE 返回 200,POST 返回 201。要改状态码用 @HttpCode():不带响应体的 DELETE 返回 204, 不创建资源的 POST /projects/:id/archive 返回 200。要返回错误,就抛异常(NotFoundException、 ConflictException 等),Nest 会把它转成响应,相当于 Spring 的 ResponseStatusException。

API 版本 ​

TaskFlow 在 setupApp() 里启用了 URI 版本: app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' })。所有路由都变成 /v1/..., 控制器不用改。某条路由可以用 version: VERSION_NEUTRAL 不参与版本(比如 /health,见 J11)。也可以按请求头或 media type 区分版本,但 URI 方式最容易调试。

DTO、class-validator 与 class-transformer ​

DTO 是类,不是接口:只有类在运行时存在,也只有类能挂装饰器。class-validator 提供约束(@IsString()、 @IsNotEmpty()、@MaxLength(100)、@IsUUID()、@IsEnum() 等),相当于 Bean Validation。 class-transformer 把收到的 JSON 对象转换成类的实例,并执行 @Type() / @Transform()。

TypeScript 类型不做任何校验

create(@Body() dto: CreateProjectDto) 挡不住 { "name": 42, "admin": true } 这样的请求。类型在编译时就被擦除了。 只有 ValidationPipe 读取 DTO 上的装饰器后,才会拒绝这个请求。

全局 ValidationPipe ​

TaskFlow 只在整个应用里注册一次,用的是 APP_PIPE provider(这样从 AppModule 启动的端到端测试里也会生效):

ts
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
  {
    provide: APP_PIPE,
    useValue: new ValidationPipe({
      whitelist: true, // strip properties without a decorator
      forbidNonWhitelisted: true, // ...or reject the request: 400
      transform: true, // plain JSON -> DTO instances, "2" -> 2
    }),
  },
  { provide: APP_GUARD, useClass: ThrottlerGuard },
  { provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
  { provide: APP_GUARD, useClass: RolesGuard },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
],
选项作用不开会怎样
whitelist: true去掉没有装饰器的属性客户端能塞进意料之外的字段
forbidNonWhitelisted: true直接拒绝请求(400),而不是悄悄去掉未知字段被悄悄丢掉
transform: true生成 DTO 实例,需要时把 "2" 转成 2dto 还是普通对象,query.page 还是字符串

开了 forbidNonWhitelisted 后,字段名拼错会得到一个明确的 400,而不是被静默忽略,客户端更容易排查。

PartialType、PickType、OmitType ​

不要复制粘贴字段,用派生的方式生成 DTO:

  • PartialType(CreateProjectDto):字段相同,全部变成可选,校验规则保留(用于 PATCH)。
  • PickType(CreateProjectDto, ['name']):只要部分字段。
  • OmitType(CreateProjectDto, ['description']):去掉部分字段。
  • 可以组合:PartialType(OmitType(CreateUserDto, ['password']))。

这些函数来自 @nestjs/mapped-types。装了 Swagger 之后(J11),改从 @nestjs/swagger 导入,这样还能保留接口文档的元数据。

TaskFlow 案例 ​

生成资源 ​

bash
nest g resource projects                      # 选择 "REST API",再对 CRUD 入口选 "Yes"
nest g resource projects --type rest --crud   # 结果相同,不提问

这条命令会生成 projects.module.ts、projects.controller.ts、projects.service.ts、DTO 和一个空实体,并把模块加进 AppModule 的 imports。随后用下面的代码替换掉这个骨架。生成文件的完整列表见 Nest CLI 速查。

创建和更新用的 DTO ​

ts
// Bean Validation on a class, checked at runtime by the global ValidationPipe.
// The TypeScript types alone validate nothing: they are erased at runtime.
export class CreateProjectDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  name: string;

  @IsOptional()
  @IsString()
  @MaxLength(2000)
  description?: string;
}
ts
// Same fields, all optional. Import PartialType from @nestjs/swagger once
// Swagger is set up (from @nestjs/mapped-types before that).
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

不合法的请求在到达控制器之前就被拒绝:

http
POST /v1/projects
Content-Type: application/json

{ "name": "" }
json
{
  "status": 400,
  "code": "BAD_REQUEST",
  "message": ["name should not be empty"],
  "path": "/v1/projects",
  "timestamp": "2026-11-10T09:12:44.120Z",
  "correlationId": "9f1c0c6e-3a51-4d6b-9f0e-2a7c51d8e0b4"
}

problem+json 格式来自 J6 的全局过滤器。端到端测试还验证了:未知字段 ("owner": "me")会返回 400。

增删改查控制器 ​

ts
// @RestController + @RequestMapping("/projects"). With URI versioning the
// full path is /v1/projects.
@ApiTags('projects')
@ApiBearerAuth()
@Controller('projects')
export class ProjectsController {
  constructor(private readonly projects: ProjectsService) {}

  @Post() // 201 by default for POST
  create(@Body() dto: CreateProjectDto, @CurrentUser() user: AuthUser) {
    return this.projects.create(dto, user);
  }

  @Get()
  findAll(@Query() query: ProjectQueryDto, @CurrentUser() user: AuthUser) {
    return this.projects.findAll(query, user);
  }

  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
    return this.projects.findOne(id, user);
  }

  @Patch(':id')
  update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateProjectDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.projects.update(id, dto, user);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
    return this.projects.remove(id, user);
  }

  @Post(':id/archive')
  @HttpCode(HttpStatus.OK)
  archive(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
    return this.projects.archive(id, user);
  }

  @Post(':id/members')
  @HttpCode(HttpStatus.NO_CONTENT)
  addMember(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: AddMemberDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.projects.addMember(id, dto.userId, user);
  }
}
  • @Param('id', ParseUUIDPipe) 会转换并校验参数:/v1/projects/not-a-uuid 在进入处理函数前就返回 400(管道详见 J6)。
  • @CurrentUser() 是 J8 加的自定义参数装饰器。J2 还没有认证,方法只接收 DTO 和 id。
  • 控制器里没有任何业务规则:它只负责把 HTTP 请求转成对 service 的调用。

内存版增删改查 ​

J2 里项目存在一个 Map 中。TaskFlow 保留了这个实现:单元测试用它,它也是 J3 抽象出来的接口的一个实现。

ts
// The J2 version: an array in memory. Still useful in unit tests, and the
// service and controller do not know which store they get.
@Injectable()
export class InMemoryProjectsStore implements ProjectsStore {
  private readonly projects = new Map<string, Project>();
  private readonly members = new Set<string>();

  async create(data: CreateProjectDto, ownerId: string): Promise<Project> {
    const now = new Date();
    const project = Object.assign(new Project(), {
      id: randomUUID(),
      name: data.name,
      description: data.description ?? null,
      archived: false,
      createdAt: now,
      updatedAt: now,
      deletedAt: null,
    });
    this.projects.set(project.id, project);
    this.members.add(`${project.id}:${ownerId}`);
    return project;
  }

  async findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>> {
    const search = query.search?.toLowerCase();
    const matching = [...this.projects.values()]
      .filter((p) => !p.deletedAt)
      .filter((p) => !memberId || this.members.has(`${p.id}:${memberId}`))
      .filter((p) => !search || p.name.toLowerCase().includes(search))
      .filter((p) => query.archived === undefined || p.archived === query.archived)
      .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
    return {
      items: matching.slice(query.skip, query.skip + query.limit),
      total: matching.length,
      page: query.page,
      limit: query.limit,
    };
  }

  async findById(id: string): Promise<Project | null> {
    const project = this.projects.get(id);
    return project && !project.deletedAt ? project : null;
  }

  async update(id: string, changes: UpdateProjectDto): Promise<Project | null> {
    const project = await this.findById(id);
    if (!project) return null;
    Object.assign(project, changes, { updatedAt: new Date() });
    return project;
  }

  async softDelete(id: string): Promise<boolean> {
    const project = await this.findById(id);
    if (!project) return false;
    project.deletedAt = new Date();
    return true;
  }

  async archive(id: string): Promise<Project | null> {
    const project = await this.findById(id);
    if (project) project.archived = true;
    return project;
  }

  async isMember(projectId: string, userId: string): Promise<boolean> {
    return this.members.has(`${projectId}:${userId}`);
  }

  async addMember(projectId: string, userId: string): Promise<void> {
    this.members.add(`${projectId}:${userId}`);
  }
}

用 QueryDto 做分页和过滤 ​

查询参数到达时永远是字符串。用一个查询 DTO 来校验和转换:

ts
// Query strings are always strings: @Type(() => Number) plus the global
// ValidationPipe({ transform: true }) turn "?page=2" into the number 2.
export class PaginationQueryDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit = 20;

  get skip(): number {
    return (this.page - 1) * this.limit;
  }
}

/** What Spring Data calls Page<T>; TypeORM has no built-in equivalent. */
export interface Page<T> {
  items: T[];
  total: number;
  page: number;
  limit: number;
}
ts
export class ProjectQueryDto extends PaginationQueryDto {
  @IsOptional()
  @IsString()
  @MaxLength(100)
  search?: string;

  // "?archived=false" arrives as the string "false", which is truthy.
  @IsOptional()
  @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
  @IsBoolean()
  archived?: boolean;
}

pagination.dto.ts 的 .dto.ts 后缀不能省:J11 的 Swagger 插件只读取 *.dto.ts 和 *.entity.ts 文件,正因为这个后缀, page 和 limit 才会出现在 OpenAPI 文档里(J11)。

http
GET /v1/projects?search=app&page=1&limit=1
json
{
  "items": [
    {
      "id": "5b0c…",
      "name": "Web app",
      "description": null,
      "archived": false,
      "createdAt": "2026-11-10T09:15:02.481Z",
      "updatedAt": "2026-11-10T09:15:02.481Z",
      "deletedAt": null
    }
  ],
  "total": 2,
  "page": 1,
  "limit": 1
}

?page=0 会返回 400(@Min(1))。注意 ?archived=false:字符串 "false" 在 JavaScript 里是真值,所以要显式写 @Transform,这里光靠 @Type(() => Number) 那种写法不够。

场景速查 ​

  • DELETE 要返回 204 → @HttpCode(HttpStatus.NO_CONTENT)(状态码)。
  • 未知字段要被拒绝 → ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })(全局管道)。
  • 更新 DTO 重复了创建 DTO 的字段 → PartialType(CreateXDto)(mapped types)。
  • ?page=2 收到的是字符串 → @Type(() => Number) 加 transform: true(分页)。
  • 查询参数里的布尔值(?archived=false)总是真 → 显式 @Transform(分页)。
  • :id 参数必须是 UUID → @Param('id', ParseUUIDPipe)(控制器)。
  • 想让所有路由都在 /v1 下 → enableVersioning(API 版本)。
  • 想用 @Res() 设置一个响应头 → 改用 @Header() 或拦截器(陷阱)。

陷阱 ​

把 DTO 写成 interface

interface 在运行时不留任何痕迹,没法校验。DTO 必须是类。

没有全局 ValidationPipe,或只在部分地方生效

没有全局管道时,每个控制器都得自己加 @UsePipes(),漏一个就什么都能进来。用 APP_PIPE 声明一次(或者在 main.ts 里 app.useGlobalPipes(),但端到端测试不会执行 main.ts,它就不生效了)。

在控制器里用 @Res()

用 @Res() 注入 Express 的响应对象后,处理函数就进入“手动”模式:Nest 不再发送返回值,响应拦截器和序列化 (ClassSerializerInterceptor)都不再生效。真的需要时(比如流式输出),用 @Res({ passthrough: true })。

  • 没开 transform: true 时,@Query() 里的数字全是字符串:page > 1 这类比较会悄悄算错,不报任何错。
  • @IsOptional() 会放行 null 和 undefined:要和类型约束(比如 @IsString())一起用,后者只在值存在时生效。

练习 ​

  • [ ] 运行 nest g resource projects,再把骨架换成 CreateProjectDto / UpdateProjectDto。
  • [ ] 完成存在内存数组或 Map 里的完整增删改查。
  • [ ] 用 @Query() 和一个 QueryDto 实现分页(page、limit)和过滤(search)。
  • [ ] 用 curl 验证:未知字段 → 400,name 为空 → 400,?page=0 → 400,DELETE → 204。
  • [ ] 分别用 PickType 和 PartialType 写 UpdateProjectDto,比较两者的区别。
  • [ ] 在 LEARNINGS.md 里记下和 @Valid + Bean Validation 不同的地方。

延伸阅读 ​