CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
NestJS 实战入门/课时/Controller 与路由
RawBlame
课时目录12 节

NestJS 实战入门

  1. 01NestJS 是什么
  2. 02项目创建与结构
  3. 03Module 与依赖注入
  4. 04Controller 与路由
  5. 05Service 层
  6. 06Pipe 与校验
  7. 07Guard 与认证
  8. 08ConfigModule
  9. 09数据库接入入门
  10. 10异常与过滤器
  11. 11测试
  12. 12综合练习

Controller 与路由

@Controller、@Get/@Post、参数装饰器、DTO。

35 分钟

Controller 与路由

这一课你将能做到

  1. 用 @Controller 搭配 @Get / @Post / @Patch / @Delete 声明题库 REST 路由
  2. 用 @Param / @Query / @Body / @Headers 取参数,并用 DTO 类描述请求体
  3. 设置全局前缀 api,并正确返回 201(已创建)、204(无内容)状态码

为什么要学

Controller(控制器)人话:只负责「HTTP 请求怎么映射到方法」——路径、动词、取参、状态码;真正的业务规则交给 Service。

在 Express 里你写的是 app.get("/questions/:id", ...);Nest 把路由声明在类与方法上,参数取值也有专门的装饰器。好处是路由集中可读、自动可扫描,后续接 Swagger 生成接口文档也顺理成章。Controller 保持薄,是整套分层的第一条纪律。

先建立图像

GET    /api/questions            → findAll(difficulty, tag, page)
GET    /api/questions/:id        → findOne(id)
POST   /api/questions            → create(dto)            → 201
PATCH  /api/questions/:id        → update(id, dto)
DELETE /api/questions/:id        → remove(id)             → 204

@Param("id")   ← 路径参数
@Query("tag")  ← 查询串 ?tag=js
@Body()        ← JSON 请求体,交给 DTO 类
@Headers()     ← 请求头

Express 对照:

ExpressNestJS
app.get("/questions/:id")@Get(":id") + @Param("id")
req.query.difficulty@Query("difficulty")
req.body@Body() dto: CreateQuestionDto
res.status(201).send(...)@HttpCode(201)(POST 默认即 201)

跟着做

步骤 1:定义 DTO 类

新建 src/questions/dto/create-question.dto.ts:

export class CreateQuestionDto {
  stem: string;
  options: string[];
  answerIndex: number;
  difficulty: "easy" | "medium" | "hard";
  tags?: string[];
}

新建 src/questions/dto/update-question.dto.ts(更新时字段都可选):

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

export class UpdateQuestionDto extends PartialType(CreateQuestionDto) {}

注意 DTO 用的是 class 而不是 interface:interface 编译后会被擦除,而后续 ValidationPipe 需要运行时存在的类。

步骤 2:写完整的题库控制器

替换 src/questions/questions.controller.ts:

import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  Param,
  Patch,
  Post,
  Query,
} from "@nestjs/common";
import { QuestionsService } from "./questions.service";
import { CreateQuestionDto } from "./dto/create-question.dto";
import { UpdateQuestionDto } from "./dto/update-question.dto";

@Controller("questions")
export class QuestionsController {
  constructor(private readonly questions: QuestionsService) {}

  // GET /api/questions?difficulty=easy&tag=ts&page=1
  @Get()
  findAll(
    @Query("difficulty") difficulty?: string,
    @Query("tag") tag?: string,
    @Query("page") page = "1",
  ) {
    return this.questions.search({
      difficulty: difficulty as "easy" | "medium" | "hard" | undefined,
      tag,
      page: Number(page) || 1,
    });
  }

  // GET /api/questions/1
  @Get(":id")
  findOne(@Param("id") id: string) {
    return this.questions.findOne(Number(id));
  }

  // POST /api/questions → 201
  @Post()
  @HttpCode(201)
  create(@Body() dto: CreateQuestionDto) {
    return this.questions.create(dto);
  }

  // PATCH /api/questions/1
  @Patch(":id")
  update(@Param("id") id: string, @Body() dto: UpdateQuestionDto) {
    return this.questions.update(Number(id), dto);
  }

  // DELETE /api/questions/1 → 204
  @Delete(":id")
  @HttpCode(204)
  remove(@Param("id") id: string) {
    this.questions.remove(Number(id));
  }
}

Service 暂时沿用模块课的内存版;search、create、update、remove 方法会在下一课补全。

步骤 3:用 curl 打通创建接口

curl -i -X POST http://localhost:3000/questions -H "Content-Type: application/json" -d "{\"stem\":\"Nest 中做权限控制用什么?\",\"options\":[\"Guard\",\"Pipe\",\"Filter\"],\"answerIndex\":0,\"difficulty\":\"medium\",\"tags\":[\"nest\"]}"

应看到状态 201,响应体为新建的题目(id 由 Service 分配)。再执行 GET /questions?difficulty=medium 应能筛出它。

步骤 4:加全局前缀 api

修改 src/main.ts:

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("api"); // 所有路由统一加上 /api
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

此后接口全部变为 /api/questions。前端和网关统一前缀,版本演进时也可以改成 api/v1。

步骤 5:实现作答提交接口的取参

src/submissions/submissions.controller.ts:

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

class SubmitAnswerDto {
  questionId: number;
  selectedIndex: number;
}

@Controller("submissions")
export class SubmissionsController {
  constructor(private readonly submissions: SubmissionsService) {}

  // POST /api/submissions,暂用固定 userId=1001,鉴权课后从登录态取
  @Post()
  submit(@Body() dto: SubmitAnswerDto) {
    return this.submissions.submit(dto.questionId, 1001, dto.selectedIndex);
  }
}

提交后返回 correct: true/false,判分逻辑由 Service 调用 QuestionsService 完成。

步骤 6:路由顺序陷阱(亲手验证一次)

如果同时声明 @Get("random") 和 @Get(":id"),把参数路由放在上面,请求 /questions/random 时 random 会被当作 id(Number("random") 得到 NaN)。规则:静态路径写在参数路径之前。调整顺序后再试。

步骤 7:认识 @Res 的使用边界

绝大多数情况直接 return 对象,框架负责序列化。只有需要精细控制底层响应(如设置特殊响应头、流式输出)时才注入 @Res():

import { Res } from "@nestjs/common";
import { Response } from "express";

@Get("export")
exportCsv(@Res() res: Response) {
  res.setHeader("Content-Type", "text/csv");
  res.send("id,stem\n1,示例");
}

一旦使用 @Res(),框架的默认返回处理会被你接管,不要在普通接口里滥用。

讲清楚要点

  1. Controller 是适配层:不写 SQL、不写复杂规则,只做取参、调用 Service、约定状态码。
  2. REST 用动词加路径表达意图:查询 GET、创建 POST、局部更新 PATCH、删除 DELETE,而不是全部 POST。
  3. DTO 是类型锚点:请求体用 class 描述,TypeScript 类型提示与运行时校验都有附着点。
  4. 全局前缀统一入口:setGlobalPrefix("api") 让网关、前端的路径约定保持一致。
  5. 路由匹配有顺序:静态段必须放在 :param 之前,否则会被参数路由吞掉。
  6. 状态码是契约的一部分:创建 201、删除无内容 204,不要一律返回 200。

你可能会懵的地方

  • 返回对象后谁负责转成 JSON? Nest 默认把返回值序列化为 JSON(响应头 application/json);直接返回字符串则按文本处理。一般返回对象或数组即可。

  • @Query 取到的为什么都是字符串? HTTP 查询串本质就是字符串。所以 page 需要手动 Number(...),更稳妥的做法是下一课的 ParseIntPipe。

  • 想让详情查不到时返回 404 怎么做? 由 Service 抛 NotFoundException,框架自动映射为 404;不要在 Controller 手写 res.status(404)。

  • PartialType 是干什么的? 它把 CreateQuestionDto 的所有字段变成可选,避免更新 DTO 重复定义。它来自 @nestjs/mapped-types,纯 TS 项目也可以安装 @nestjs/swagger 的同名版本。

  • @Body() 可以同时用在多个参数上吗? 一个请求体只有一个,@Body() 只出现一次;想从 body 里取单个字段可以写 @Body("stem"),但通常直接拿整个 DTO。

错法对照

错法问题正解
Controller 里直接操作数组或写 SQL难测试、职责混乱一律转调 Service
DTO 用 interface运行时元数据被擦除用 class 定义
:id 写在静态路由前面静态路径被当参数静态段在上
POST 成功永远返回 200与客户端契约含糊创建用 201
普通接口注入 @Res 又 return 值返回可能不生效不接管响应,直接 return

卡点急救

  1. 请求返回 404:先确认全局前缀(是否要带 /api)、Controller 路径拼写、HTTP 动词是否一致。
  2. body 拿到的是 undefined:确认请求头有 Content-Type: application/json,且发送的是合法 JSON;表单提交需要另外配置。
  3. id 参数行为怪异:路径参数永远是字符串,务必显式转 number;转换失败的处理在下一课用管道解决。

面试常问

  • NestJS Controller 与 Service 如何分工? Controller 处理 HTTP 适配:路由、取参、状态码;Service 承载业务规则与数据编排。Controller 保持薄,业务才可被复用与单独测试。

  • NestJS 的 DTO 为什么推荐用 class? class 在运行时仍然存在,ValidationPipe 和 class-transformer 依赖它读取元数据;interface 编译后不存在,无法支撑运行时校验。

  • 常见 HTTP 状态码如何选择? 成功查询 200、创建成功 201、删除成功且无返回体 204、请求格式错误 400、未认证 401、无权限 403、资源不存在 404、冲突 409。

自测三问

  1. @Param、@Query、@Body 分别从请求的哪个位置取值?
  2. 全局前缀 api 加在哪个文件、影响哪些路由?
  3. 为什么静态路由要写在参数路由 :id 之前?

小结一句话

装饰器路由、参数装饰器加 DTO,构成类型化的 REST 适配层;业务规则仍然留在 Service。

延伸

  • Controllers(官方控制器文档)
  • Mapped Types(PartialType 等)
  • 下一课预告:把 CRUD 业务全部沉到 Service,处理查重、判分规则,并认识语义化异常。
加载本节练习…
← 上一课Module 与依赖注入下一课 →Service 层