很多前端同学对 Node.js 后端的印象还停留在 Express/Koa 时代:几行代码起一个 HTTP 服务,路由随便写,中间件挂一堆,业务复杂后文件越写越长,最后变成谁也维护不动的"意大利面条"。
如果你也有这种感觉,那 NestJS 可能会刷新你的认知。它默认使用 TypeScript,天生就是为企业级后端架构设计的。说白了,Express 给你的是毛坯房,而 NestJS 直接给了你一套精装交付标准。
今天我们就从一个最经典的 Todo CRUD 入手,看看 NestJS 到底优雅在哪里。
一、快速跑起来
安装 NestJS 脚手架,然后创建一个新项目:
sql
npm i -g @nestjs/cli
nest new hello
cd hello
npm run start
启动后访问 http://localhost:3000,你会看到熟悉的 Hello World!。
默认生成的项目结构很简单:
ruby
src/
main.ts # 入口文件
app.module.ts # 根模块
app.controller.ts # 根控制器
app.service.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 一个对象,而是根据传入的模块,把整个应用需要的控制器、服务、中间件、管道等全部组装起来,最后返回一个可运行的应用实例。你不需要关心 HTTP Server 是怎么创建的,也不需要手动挂载路由,框架都帮你做了。
二、NestJS 的核心设计:装饰器 + 模块化
NestJS 最明显的特征就是满屏的 @,这就是装饰器模式。
装饰器模式的核心思想是:在不修改原有对象的前提下,动态地给对象叠加额外功能。在 NestJS 里,这个思想被用到了极致。
@Module:把一个类标记为模块@Controller:把一个类标记为控制器@Injectable:把一个类标记为可注入的服务@Get/@Post/@Delete/@Patch:给方法绑定 HTTP 路由
你写的只是一个普通的类,但加上装饰器后,它立刻就具备了 Web 框架需要的各种能力。 @ 一下,能力就来。
再看 app.module.ts:
python
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
这里的 @Module 装饰器接收一个配置对象,包含三个核心属性:
imports:当前模块依赖的其他模块controllers:当前模块拥有的控制器providers:当前模块拥有的服务(Provider)
也就是说,一个 Module 就是一个独立业务单元。大型后端项目最怕的不是代码多,而是模块边界不清晰。NestJS 的模块化,本质上是强制你把业务边界划清楚。
三、动手前的理论课:MVC 解耦、Partial 和 PUT/PATCH
在写代码前,先把三个概念聊透,这样再看实战就会豁然开朗。
1. NestJS 中的 MVC 与三层解耦
传统 MVC 是 Model-View-Controller。但在 NestJS 纯后端 API 场景中,View 通常由前端框架(React/Vue)负责,后端更关注 Module、Controller、Service 这三层的协作。
- Module(模块) :负责组装。它告诉 Nest "我这个模块有哪些控制器、哪些服务、依赖哪些其他模块"。它不写业务逻辑,只管边界和依赖。
- Controller(控制器) :负责对接 HTTP。接收请求、做参数校验、调 Service、返回响应。不直接碰数据库。
- Service(服务) :负责业务逻辑和数据操作。它不知道 HTTP 的存在,只关心数据从哪来、怎么处理。
为什么要分层?一句话:Controller 只管"怎么响应",Service 只管"怎么计算",Module 负责把它们拼起来。 这样每一层都能单独测试、单独替换。
比如未来要加缓存,只需要改 Service,Controller 完全不用动;要改路由路径,只动 Controller,Service 无感知。
2. Partial 是什么?
TypeScript 内置工具类型 Partial<T> 可以把一个类型的所有属性变成可选:
typescript
interface Todo {
id: number;
title: string;
completed: boolean;
}
// Partial<Todo> 等价于:
interface PartialTodo {
id?: number;
title?: string;
completed?: boolean;
}
在更新场景中,客户端可能只想改 completed,不想传完整的 title。这时用 Partial<Todo> 就能完美表达"部分更新"的语义,还保留类型检查。
3. PUT 和 PATCH 的区别
很多同学对这两个动词分不清,其实很简单:
- PUT :语义是"整体替换"。客户端需要传完整的资源对象,服务端会用这个对象覆盖原资源。比如更新 id=1 的 Todo,必须传
{ id: 1, title: '...', completed: true },漏了字段可能导致数据丢失。 - PATCH:语义是"部分更新"。客户端只需要传要修改的字段,服务端只更新这些字段,其他保持不变。
RESTful 规范里,部分更新推荐用 PATCH。所以在我们的代码中,更新接口用的是 @Patch(':id'),配合 Partial<Todo>,非常贴合。
四、实战:实现 Todos CRUD
光看概念不过瘾,我们直接动手实现一个完整的 Todo CRUD。文件结构如下:
arduino
src/
todos/
todos.module.ts
todos.controller.ts
todos.service.ts
1. 先定义数据模型和 Service
todos/todos.service.ts:
typescript
import { Injectable, NotFoundException } from '@nestjs/common';
export interface Todo {
id: number;
title: string;
completed: boolean;
}
let todos: Todo[] = [
{ id: 1, title: '学习Nestjs', completed: false },
{ id: 2, title: '学习CRUD', completed: true },
];
let nextId = 3;
@Injectable()
export class TodosService {
findAll(): Todo[] {
return todos;
}
findOne(id: number): Todo {
const todo = todos.find((t) => t.id === id);
if (!todo) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
return todo;
}
create(title: string): Todo {
const todo: Todo = { id: nextId++, title, completed: false };
todos.push(todo);
return todo;
}
remove(id: number): void {
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
todos.splice(index, 1);
}
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
}
这里有几个值得注意的点:
@Injectable()把一个普通类变成 Nest 容器可管理的服务,从而能被自动实例化并注入到任何需要它的地方。- 我们用内存数组模拟数据库,实际项目中你会在这里操作 TypeORM/Prisma 等 ORM。
Partial<Todo>是 TypeScript 内置工具类型,表示Todo的所有字段都变成可选,正好适合更新场景。
Service 层只负责数据业务,不关心 HTTP 请求和响应。 这就是 MVC 中 Model/Service 的职责分离。
2. 再写 Controller
todos/todos.controller.ts:
less
import {
Controller,
Get,
Post,
Delete,
Patch,
Param,
Body,
} from '@nestjs/common';
import { TodosService } from './todos.service';
import { type Todo } from './todos.service';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): Todo[] {
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('title') title: string): Todo {
return this.todosService.create(title);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: '删除成功' };
}
@Patch(':id')
update(@Param('id') id: string, @Body() patch: Partial<Todo>): Todo {
return this.todosService.update(Number(id), patch);
}
}
Controller 的职责很清晰:接收 HTTP 请求,做参数校验和简单逻辑处理,最后把响应返回给客户端。它不直接操作数据,而是调用 Service。
你会发现 @Controller('todos') 指定了路由前缀,所有方法都在 /todos 路径下。@Get(':id') 中的 :id 是路由参数,通过 @Param('id') 获取。@Body() 则直接拿到请求体。
3. 最后组装模块
todos/todos.module.ts:
python
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller';
import { TodosService } from './todos.service';
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
然后在根模块 AppModule 的 imports 里导入 TodosModule,整个业务就组装完成了。
五、依赖注入:为什么不需要 new?
你可能已经注意到了,在 TodosController 里我们并没有写:
ini
const todosService = new TodosService();
而是直接在构造函数里声明:
typescript
constructor(private readonly todosService: TodosService) {}
这就是 NestJS 的自动依赖注入。
@Injectable() 让 TodosService 成为 Nest 容器管理的 Provider。当 TodosController 被实例化时,Nest 会自动创建 TodosService 的实例(或者复用已有实例),并注入到构造函数中。你不需要关心对象创建的顺序和生命周期,框架帮你管得明明白白。
后端开发说白了就是:把复杂留给框架,把简单留给自己。
六、错误处理:别再用 try-catch 满天飞了
传统 Node.js 后端处理错误,经常是每个地方都要 try-catch,然后手动返回错误响应。这种写法不仅繁琐,而且错误格式五花八门,前端很难统一处理。
NestJS 提供了一套完整的异常体系。比如在 findOne 中,如果没找到数据,直接抛出一个内置的 NotFoundException:
javascript
if (!todo) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
当客户端请求一个不存在的 Todo 时,比如 GET /todos/999,会得到如下标准 JSON 响应:
json
{
"statusCode": 404,
"message": "Todo 999 不存在",
"error": "Not Found"
}
状态码、消息、错误类型一目了然。你还可以自定义异常过滤器,统一处理所有异常,甚至根据环境返回不同的错误详情。
好的错误处理,不是把 try-catch 写遍全项目,而是让错误本身成为 API 的一部分。
七、动手验证
启动项目后,你可以用 curl 或 Postman 快速验证:
bash
# 获取所有 Todo
curl http://localhost:3000/todos
# 创建一个新 Todo
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "学习Nestjs"}'
# 更新某个 Todo
curl -X PATCH http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# 删除某个 Todo
curl -X DELETE http://localhost:3000/todos/1
# 测试 404 错误
curl http://localhost:3000/todos/999
整个过程不需要配置任何路由注册代码,也没有手动解析请求体,一切都被 NestJS 的装饰器和依赖注入机制自动完成了。
总结
NestJS 把后端开发中常见的架构模式(MVC、依赖注入、模块化)和 TypeScript 的类型系统结合起来,让你写出结构清晰、可维护性强的企业级服务。
回顾一下本文的关键点:
- 工厂模式 :
NestFactory.create负责组装整个应用 - 模块化 :
@Module划定业务边界,避免代码堆成屎山 - 装饰器模式 :
@Controller/@Injectable/@Get让普通类快速获得 Web 能力 - 依赖注入 :构造函数声明类型,框架自动注入实例,告别手动
new - 标准化异常 :抛出
NotFoundException即可返回统一的错误响应
很多人觉得后端开发离前端很远,但当你真正理解了 NestJS 的这套设计后,你会发现,好的框架不是在限制你,而是在帮你把复杂问题拆解成一个个清晰的模块。这就是 NestJS 的价值所在。