大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于
Tiptap的富文本编辑、NestJS后端服务、实时协作与智能化工作流等核心模块。在这个项目的持续打磨过程中,我积累了不少实战经验,不只是
Tiptap的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信
yunmz777一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐
上一节我们已经跑通了第一个 NestJS 项目,也看到了 Controller 和 Service 是如何配合的。这一节继续往前走,专门看 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。这样路径组织和代码组织会更一致。
GET、POST、PUT、DELETE
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会进来,没有则preview为undefined(这里写了可选参数)@Body()对应报文主体,常见于POST、PUT、PATCH提交的 JSON 或表单序列化结果
更短的一句口诀是,路径用 @Param(),问号后用 @Query(),包体用 @Body()。
这样一来,控制器里很少出现"这段到底读的是 req 的哪一块"的猜测。来源都写在参数列表上,也比到处翻 req.params、req.query、req.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()。
小结
Controller 在 NestJS 里承担的是请求入口角色。它负责定义路由、读取参数、组织响应,但不应该承载过多业务逻辑。
这一篇最重要的收获,可以先落成下面几件事:
- 路由通过控制器类和方法上的装饰器来声明
GET、POST、PUT、DELETE对应不同的 HTTP 请求方式@Param()、@Query()、@Body()分别读取不同来源的参数@Headers()、@HttpCode()、@Redirect()可以影响请求处理和响应行为- 普通接口优先直接
return,原生response适合特殊控制场景
下一节,我们会继续从控制器往下走,看看 Service、Provider 和 Module 是怎样把业务能力真正组织起来的。