NestJS 入门实战:从 0 到 1 撸一个 Todo CRUD,感受装饰器与模块化的优雅

很多前端同学对 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 {}

然后在根模块 AppModuleimports 里导入 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 的价值所在。

相关推荐
阿弱1 小时前
graph-core 的边与命令模式设计
java·后端·agent
智驭未来掌门人1 小时前
利用Qt设计实现一款桌面程序
后端
长栎1 小时前
你以为抽象工厂是「创建一组对象」——其实它是「锁定产品族兼容性」
后端
烬羽1 小时前
NestJS 依赖注入:Controller 里那个没有 new 的 service,到底从哪来的?
设计模式·typescript·nestjs
长栎1 小时前
你的 AI 品控规则越来越多了——但它们已经在打架了,你没看见
后端
有脚就行1 小时前
第24篇-Go-gRPC推理服务-高性能跨语言通信
开发语言·人工智能·后端·golang
渣波1 小时前
NestJS 企业级后端架构实战:从核心代码到工程化思维的深度重构
前端·typescript·nestjs
不好听6131 小时前
NestJS 是什么?一张图看懂企业级后端的骨架
后端·nestjs
liuxiaocheng1 小时前
文本生成的进阶:generateText / streamText 里迟早会撞上的东西
前端·后端·ai编程