Controller 与路由
这一课你将能做到
- 用
@Controller 搭配 @Get / @Post / @Patch / @Delete 声明题库 REST 路由
- 用
@Param / @Query / @Body / @Headers 取参数,并用 DTO 类描述请求体
- 设置全局前缀
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 对照:
| Express | NestJS |
|---|
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
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(),框架的默认返回处理会被你接管,不要在普通接口里滥用。
讲清楚要点
- Controller 是适配层:不写 SQL、不写复杂规则,只做取参、调用 Service、约定状态码。
- REST 用动词加路径表达意图:查询 GET、创建 POST、局部更新 PATCH、删除 DELETE,而不是全部 POST。
- DTO 是类型锚点:请求体用 class 描述,TypeScript 类型提示与运行时校验都有附着点。
- 全局前缀统一入口:
setGlobalPrefix("api") 让网关、前端的路径约定保持一致。
- 路由匹配有顺序:静态段必须放在
:param 之前,否则会被参数路由吞掉。
- 状态码是契约的一部分:创建 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 |
卡点急救
- 请求返回 404:先确认全局前缀(是否要带
/api)、Controller 路径拼写、HTTP 动词是否一致。
- body 拿到的是 undefined:确认请求头有
Content-Type: application/json,且发送的是合法 JSON;表单提交需要另外配置。
- 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。
自测三问
@Param、@Query、@Body 分别从请求的哪个位置取值?
- 全局前缀
api 加在哪个文件、影响哪些路由?
- 为什么静态路由要写在参数路由
:id 之前?
小结一句话
装饰器路由、参数装饰器加 DTO,构成类型化的 REST 适配层;业务规则仍然留在 Service。
延伸