接触 NestJS 之前,可以先把它和 Next.js 放在一起看。两者都跑在 Node 上,名字也像,但分工不同:Next.js 是 React 的全栈框架,服务端渲染、路由、API 路由都做;NestJS 则只做后端,默认用 TypeScript,把「模块化」当作组织代码的第一原则。如果 Node 后端需要一套能支撑企业级服务的结构,NestJS 是目前比较主流的选择。
后端开发在做哪些事
先想清楚后端这个位置要承担什么,再看框架怎么帮上忙。
- 提供 API 接口,承接 Web 端请求
- 系统集成、并发、底层服务,以及 AI Infra 这类基础设施
- 微服务拆分
这些事共享一个需求:代码量上来之后,得有办法把不同职责拆开,否则几千行堆在一个文件里,改一处就要读懂全部。NestJS 的回答是模块化和依赖注入,下面顺着一个最小应用看它具体怎么做。
一个最小应用长什么样
安装只有两步:
css
npm i -g @nestjs/cli
nest new hello
生成的项目,src 目录下有两个关键文件:
main.ts是入口,负责启动整个应用app.module.ts是根模块,负责把各个部分组装起来
main.ts 的代码很短:
javascript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
NestFactory.create(AppModule) 这一行值得单独看。它没有手写 new App(),而是把一个「说明书」交出去,让工厂方法按这份说明书把应用装配出来。这就是工厂模式:调用方不关心实例怎么构造,只关心拿到的是一个可用的应用。对 NestJS 来说,AppModule 就是这份说明书,里面写清楚了有哪些控制器、哪些服务、依赖哪些外部模块。
模块是独立业务单元
NestJS 的代码组织是层层递进的:
java
App
└─ Modules
├─ @nestjs/common 的 Module 类
├─ import 依赖项
├─ controller 控制器:参数校验、简单逻辑、返回 response
└─ service 服务:返回数据
一个业务模块通常由三个文件组成,以项目里的 todos 为例:
todos.module.ts负责定义和组装,声明这个模块有哪些控制器、哪些服务Todos.controller.ts是控制器,处理路由和参数Todos.service.ts是 provider,承载数据业务
todos.module.ts 只是把这两者绑在一起:
kotlin
@Module({
controllers: [TodosController],
providers: [TodoService],
})
export class TodosModule {}
然后在 app.module.ts 的 imports 里挂载这个模块,应用就多了一组 /todos 相关的接口。新增业务时,重复这个动作即可:写 service、写 controller、在 module 里组装、在 AppModule 里 import。每个模块边界清晰,不会互相侵入。
这套分工对应的是后端最常见的 MVC:Model 抽象数据库,View 是响应层,Controller 负责接收输入、校验参数、做简单逻辑,最后把数据交给 Service 返回。Controller 不直接碰数据库,Service 不直接碰 HTTP,各自守着一条边界。
装饰器给类加行为
模块化要落地,还缺一个让「说明书」生效的机制,这就是装饰器。装饰器的作用是在不修改原有对象的前提下,动态给对象附加额外功能。TypeScript 原生支持它,NestJS 把它用到了各处:
@Module声明一个模块@Controller('todos')声明一个控制器,并指定路由前缀@Get、@Post、@Patch、@Delete把类方法映射成对应的 HTTP 接口@Param('id')、@Body()把请求里的参数取出来,塞进方法参数@Injectable标记一个类可以被自动注入
看 Todos.controller.ts 里一个接口的完整形态:
less
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
@Get(':id') 声明了路由,@Param('id') 声明了参数来源,方法体里只剩业务。HTTP 的解析、路由的匹配、参数的提取这些机械动作,都被装饰器接走了,Controller 不用自己写。
依赖注入让 service 自动到位
Controller 里用到 this.todosService,但这个字段并没有被手动 new 出来,它来自构造函数:
typescript
constructor(private readonly todosService: TodoService) {}
TodoService 被 @Injectable() 标记后,NestJS 的 IoC 容器会在实例化 TodosController 时,自动把 TodoService 的实例塞进构造参数。Controller 不关心 service 怎么构造、依赖了谁,只需要声明「我需要一个 TodoService」。
这个注入是自动的:只要 service 加了 @Injectable(),它就能被注入到 controller,或者任何用到它的地方。依赖方向从「自己去找依赖」变成「声明依赖,由容器提供」,类之间不再直接耦合到具体实现。
错误处理走标准化的类
最后一个问题是出错时怎么办。手写 Node 服务常见 try / catch / finally 逐层包裹,但每个开发者报错格式不一致,前端拿到的东西不稳定。
NestJS 内置了一组错误类,NotFoundException 是其中之一。在 service 里,查不到数据时直接抛出:
javascript
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
框架会把这个异常转成结构化的 HTTP 响应,统一带出 statusCode 状态码和 message 消息,而不是让每个接口自己拼 JSON。业务代码里的错误处理从「捕获并格式化」简化成「抛出语义化异常」,输出格式由框架统一保证。这对一个多人协作、接口数量不断增长的工程来说,比约定俗成的 try/catch 更稳定。
收束
把上面的东西连起来,NestJS 提供的是一套固定的组织方式:入口通过工厂模式启动根模块,业务被拆成相互独立的 Module,每个 Module 内部用 Controller 接请求、用 Service 承载数据,装饰器负责把路由、参数、注入这些机械动作接走,异常则统一抛语义化错误类交给框架格式化。
这套结构不解决「后端要做什么业务」,它解决的是「业务变多之后代码怎么放、边界怎么守」。当接口从几个涨到几十个、参与的人从一个变成几个时,模块化的价值才开始显现。