🔥 NestJS 从零到实战:一个 Todos CRUD 搞懂企业级后端框架的核心设计
摘要:本文以一个 Todos 任务管理 API 为实战项目,不只告诉你"怎么用",更带你深入理解工厂模式、依赖注入、装饰器模式的底层实现原理。读完这篇,面试官问你"NestJS 怎么工作的",你能从源码层面讲清楚。
📌 前言
最近在学后端,看了一圈框架:Express 太自由、Koa 太轻量、Django 不是 JS 生态。最后选了 NestJS --- Node.js 生态里最接近 Spring Boot 的企业级框架。
学完之后最大的感受是:NestJS 不是在教你写接口,而是在教你写「可维护的后端系统」。
但很多教程只告诉你"这样写就行",不告诉你"为什么这样写"。这篇文章用一个 Todos CRUD 项目,把 NestJS 最核心的设计思想从源码层面讲清楚。
🎯 本文适合谁
- 想入门 NestJS 但被官方文档劝退的前端同学
- 准备后端面试,需要能讲清楚框架原理
- 想理解「模块化」「依赖注入」「装饰器」底层是怎么实现的开发者
📚 一、工厂模式 --- NestFactory.create() 到底做了什么
先看用法
typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
一行 NestFactory.create(AppModule) 就启动了整个应用。看起来很简单,但背后做了很多事情。
工厂模式的本质
工厂模式的核心思想:你不需要知道对象是怎么创建的,只需要告诉工厂你要什么。
用蜜雪冰城类比:
arduino
你想喝奶茶 → 不自己做(流程代码 NO)→ 找蜜雪冰城(工厂)
NestFactory.create(AppModule) → 不手动 new 各种类 → 工厂帮你生产
但这个类比太浅了。我们来看 NestFactory 内部到底做了什么。
NestFactory.create() 的内部流程
⚠️ 以下是基于
@nestjs/core源码逻辑重构的伪代码,并非逐行摘录。目的是帮你理解框架内部的执行顺序和核心机制。
typescript
// NestFactory.create() 简化版
export class NestFactory {
static async create(module, serverOptions?) {
// 第一步:创建 IoC 容器(依赖注入的核心)
const container = new NestContainer();
// 第二步:解析模块,把 AppModule 及其所有依赖注册到容器
// 这一步会递归遍历 imports、controllers、providers
await container.addModule(module);
// 第三步:实例化所有 Controller 和 Service
// 容器会根据装饰器的元数据,自动 new 并注入依赖
const instanceLoader = new InstanceLoader(container);
await instanceLoader.createInstancesOfDependencies();
// 第四步:创建 HTTP 服务器(Express/Fastify)
const httpServer = this.createHttpServer(serverOptions);
// 第五步:注册路由 --- 把 Controller 的方法绑定到对应的 HTTP 路由
const routesResolver = new RoutesResolver(container);
routesResolver.resolve(httpServer);
return new NestApplication(httpServer, container);
}
}
关键步骤拆解:
vbnet
NestFactory.create(AppModule)
├── 1. 创建 IoC 容器(空的依赖注册表)
├── 2. 解析 AppModule
│ ├── 发现 imports: [TodosModule]
│ ├── 递归解析 TodosModule
│ │ ├── 发现 controllers: [TodosController]
│ │ └── 发现 providers: [TodosService]
│ └── 把所有类注册到容器
├── 3. 实例化依赖
│ ├── new TodosService() → 放入容器
│ └── new TodosController(todosService) → 从容器取出注入
├── 4. 创建 HTTP 服务器
└── 5. 注册路由
├── GET /todos → TodosController.findAll
├── GET /todos/:id → TodosController.findOne
├── POST /todos → TodosController.create
└── ...
面试话术
NestFactory.create() 内部做了五件事:创建 IoC 容器、递归解析模块依赖、实例化所有类并注入依赖、创建 HTTP 服务器、注册路由。它本质上是一个工厂方法,把复杂的启动流程封装成一个调用,开发者只需要传入根模块就行。这就是工厂模式的价值 --- 封装创建过程,暴露简单接口。
📚 二、依赖注入 --- 不手动 new,框架怎么知道要注入什么
先看现象
typescript
// TodosController --- 构造函数里声明了依赖
export class TodosController {
constructor(private readonly todosService: TodosService) {}
// ...
}
我们从来没有写过 new TodosService(),但 todosService 就能用了。为什么?
依赖注入的三要素
markdown
1. 声明依赖 --- 构造函数参数
2. 注册服务 --- @Injectable() + Module.providers
3. 容器解析 --- IoC 容器自动创建和注入
底层原理:Reflect Metadata
NestJS 用 TypeScript 的 reflect-metadata 来实现依赖注入。这是关键中的关键。
这里有两个独立的机制,必须分清楚:
机制一:emitDecoratorMetadata(TypeScript 编译器行为)
当 tsconfig.json 中开启了 emitDecoratorMetadata: true,只要类上有任何装饰器,TypeScript 编译器就会自动发射类型元数据:
typescript
// 你写的代码
export class TodosController {
constructor(private readonly todosService: TodosService) {}
}
// TypeScript 编译后(只要类上有任何装饰器,就会自动发射):
__metadata("design:paramtypes", [TodosService])
// 意思是:这个类的构造函数参数类型是 [TodosService]
运行时可以通过 Reflect 读取:
typescript
const dependencies = Reflect.getMetadata('design:paramtypes', TodosController);
// dependencies = [TodosService] ← 拿到了参数的类型信息!
机制二:@Injectable()(NestJS 自己的标记行为)
@Injectable() 的作用是给类打上一个 injectable: true 的标记,告诉 NestJS "这个类可以被容器管理":
typescript
@Injectable()
export class TodosService { ... }
// 等价于:
Reflect.defineMetadata('injectable', true, TodosService);
两者的关系:
java
emitDecoratorMetadata → 告诉框架"这个类需要哪些依赖"(类型信息)
@Injectable() → 告诉框架"这个类可以被容器管理"(标记信息)
它们是独立的。即使你不用 @Injectable(),只要有其他装饰器,design:paramtypes 照样会被发射。但没有 @Injectable() 标记的类不会被 NestJS 容器管理,注入不会生效。
IoC 容器的工作流程
java
1. 扫描所有 @Injectable() 的类 → 注册到容器
2. 对每个需要注入的类:
a. Reflect.getMetadata('design:paramtypes', Class) → 获取依赖列表
b. 检查容器里有没有这个依赖
c. 如果没有 → 先创建依赖(递归)
d. 如果有 → 取出来,传入构造函数
3. 完成所有实例化
用代码模拟:
typescript
// 简化版 IoC 容器
class Container {
private instances = new Map();
// 注册
register(token, Class) {
// 防重复注册
if (this.instances.has(token)) return;
// 获取构造函数的参数类型
const deps = Reflect.getMetadata('design:paramtypes', Class) || [];
// 递归创建依赖
const instances = deps.map(dep => this.resolve(dep));
// 创建实例,注入依赖
const instance = new Class(...instances);
this.instances.set(token, instance);
}
// 解析
resolve(token) {
if (!this.instances.has(token)) {
this.register(token, token);
}
return this.instances.get(token);
// 注:真实 NestJS 容器会检测循环依赖(A→B→A)
// 检测到时抛出 CircularDependencyException,避免无限递归
}
}
// 使用
const container = new Container();
container.register(TodosService, TodosService);
container.register(TodosController, TodosController);
// 此时 TodosController 内部已经有了 TodosService 的实例
为什么需要 DI?--- 对比手动创建
typescript
// ❌ 没有 DI 的情况 --- 紧耦合
export class TodosController {
private todosService: TodosService;
constructor() {
// 手动创建,耦合了具体实现
this.todosService = new TodosService();
}
}
// ❌ 如果 TodosService 的构造函数变了(需要注入 LoggerService)
// TodosController 也要改!改一个地方,所有用到它的地方都要改
// ✅ 有 DI 的情况 --- 松耦合
export class TodosController {
constructor(private readonly todosService: TodosService) {}
// 不关心怎么创建的,只关心"我需要一个 TodosService"
}
// ✅ TodosService 的构造函数变了,TodosController 完全不用改
// 框架自动处理依赖链
面试话术
NestJS 的依赖注入基于
reflect-metadata实现。当我们在构造函数里声明todosService: TodosService时,TypeScript 编译器会把参数类型信息存储到 Reflect 元数据中。框架启动时,IoC 容器读取这些元数据,自动创建 TodosService 实例并注入到 Controller 中。如果 Service 还有其他依赖,容器会递归解析,直到所有依赖都创建完毕。这种机制让代码松耦合、可测试、可维护。
📚 三、语法糖到底是什么 --- 从 JS 到装饰器
在讲装饰器之前,必须先搞清楚一个概念:语法糖(Syntactic Sugar)。
什么是语法糖?
语法糖是编程语言提供的一种简写形式。它让你用更简洁、更易读的语法写代码,但编译器/解释器会把它翻译成等价的、更啰嗦的原始写法。
一句话总结:语法糖 = 换了个好看的皮,骨子里还是同一个东西。
例子 1:箭头函数 --- 最常见的语法糖
javascript
// 你写的(语法糖)
const add = (a, b) => a + b;
// 等价的原始写法
const add = function(a, b) {
return a + b;
};
箭头函数只是把 function 关键字省掉了,加了个 =>。简单场景下两者可以互换,但它们并不完全一样:
| 特性 | 普通函数 | 箭头函数 |
|---|---|---|
this 绑定 |
动态绑定(调用时决定) | 词法绑定(定义时继承外层) |
arguments 对象 |
✅ 有 | ❌ 没有 |
| 可否用作构造函数 | ✅ 可以 new |
❌ 不可以 new |
prototype 属性 |
✅ 有 | ❌ 没有 |
最常见的坑:在对象方法中用箭头函数,this 不会指向对象本身。
javascript
const obj = {
name: 'NestJS',
// ❌ 箭头函数的 this 继承外层(window/undefined),不是 obj
greet: () => { console.log(this.name); },
// ✅ 普通函数的 this 指向调用者(obj)
greet2() { console.log(this.name); }
};
例子 2:解构赋值 --- 从对象里取值的语法糖
javascript
// 你写的(语法糖)
const { name, age } = user;
// 等价的原始写法
const name = user.name;
const age = user.age;
解构赋值让你不用重复写 user.,但本质就是属性访问。
例子 3:模板字符串 --- 拼接字符串的语法糖
javascript
// 你写的(语法糖)
const msg = `Hello, ${name}! You are ${age} years old.`;
// 等价的原始写法
const msg = 'Hello, ' + name + '! You are ' + age + ' years old.';
反引号 ${} 只是让拼接更易读,本质还是字符串连接。
例子 4:展开运算符 --- 复制/合并数组和对象的语法糖
javascript
// 你写的(语法糖)
const newArr = [...arr1, ...arr2];
const newObj = { ...oldObj, name: 'new' };
// 等价的原始写法
const newArr = arr1.concat(arr2);
const newObj = Object.assign({}, oldObj, { name: 'new' });
例子 5:class 语法 --- 面向对象的语法糖
javascript
// 你写的(语法糖)
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound.`;
}
}
// 等价的原始写法(ES5 原型链)
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
return this.name + ' makes a sound.';
};
ES6 的 class 底层仍然基于原型链实现,但引入了一些新的约束和行为差异:
| 特性 | ES5 原型链 | ES6 class |
|---|---|---|
| 变量提升 | function 声明会提升 |
❌ 不可提升(有 TDZ) |
| 严格模式 | 需手动声明 | ✅ class 内部自动严格模式 |
| 方法可枚举 | ✅ 可枚举 | ❌ 不可枚举 |
| 必须 new | 不强制(可当普通函数调用) | ✅ 必须 new,否则报错 |
所以 class 不只是"好看的皮",它在行为上有实际差异。说"底层还是 prototype"没有错,说"只是语法糖"则过于简化。
例子 6:async/await --- Promise 的语法糖
javascript
// 你写的(语法糖)
async function fetchUser() {
const res = await fetch('/api/user');
const data = await res.json();
return data;
}
// 等价的原始写法(Promise 链)
function fetchUser() {
return fetch('/api/user')
.then(function(res) { return res.json(); })
.then(function(data) { return data; });
}
async/await 让异步代码看起来像同步代码,但本质还是 Promise。
例子 7:可选链 --- 安全访问嵌套属性的语法糖
javascript
// 你写的(语法糖)
const city = user?.address?.city;
// 等价的原始写法
const city = (user && user.address && user.address.city) || undefined;
例子 8:空值合并 --- 提供默认值的语法糖
javascript
// 你写的(语法糖)
const port = config.port ?? 3000;
// 等价的原始写法
const port = (config.port !== null && config.port !== undefined)
? config.port
: 3000;
语法糖的本质
语法糖的本质:
┌─────────────────────────────────────────────┐
│ 你写的代码(简洁、易读) │
│ ↓ 编译器/解释器翻译 │
│ 等价的原始写法(啰嗦、但功能相同) │
└─────────────────────────────────────────────┘
语法糖不是新功能,是老功能的新写法。 它让代码更简洁、更易读,但不改变底层逻辑。
现在回到装饰器 --- TypeScript 的语法糖
理解了语法糖的概念,装饰器就很好理解了。
typescript
// 你写的(语法糖)
@Injectable()
export class TodosService { ... }
编译后(当 emitDecoratorMetadata 开启时):
javascript
var TodosService = /** @class */ (function () {
function TodosService() { ... }
// __decorate 会把 decorators 数组中的函数依次执行
// __metadata 虽然在同一个数组里,但它不是装饰器,而是类型元数据
TodosService = __decorate([
Injectable(), // NestJS 的标记:injectable = true
__metadata("design:paramtypes", []) // TypeScript 自动发射:构造函数参数类型
], TodosService);
return TodosService;
}());
⚠️ 注意:
__metadata("design:paramtypes", [])这行不是@Injectable()产生的,而是 TypeScript 的emitDecoratorMetadata编译选项自动发射的。只要类上有任何装饰器 ,TypeScript 就会自动发射design:type、design:paramtypes、design:returntype三种元数据。@Injectable()的作用是标记injectable: true,两者是独立的机制。虽然它们都在__decorate的同一个数组中,但__metadata会被__decorate内部识别为非函数而跳过执行。
TypeScript 编译器生成的 __decorate 辅助函数简化版:
javascript
// 简化版(注意:真实实现更复杂)
// 签名:__decorate(decorators, target, key?, descriptor?)
// 类装饰器只传 target;方法/属性装饰器还会传 key 和 descriptor
function __decorate(decorators, target, key, descriptor) {
// 关键:装饰器是从后往前执行的!
for (var i = decorators.length - 1; i >= 0; i--) {
var decorator = decorators[i];
if (decorator) {
// 根据参数数量决定调用方式:
// 类装饰器(无 key):decorator(target)
// 方法装饰器(有 key):decorator(target, key, descriptor)
var result = (key !== undefined)
? decorator(target, key, descriptor)
: decorator(target);
if (result) target = result; // 类装饰器可以替换目标类
}
}
return target;
}
💡 为什么是逆序?因为装饰器的语义是"从内到外"包装。如果多个装饰器叠加,最靠近类/方法的装饰器先执行,最外层的后执行。逆序遍历保证了这个语义。
所以 @Injectable() 就是 Injectable()(TodosService) 的语法糖。
具体来说:
typescript
@Injectable()
export class TodosService { ... }
// 等价于:
Injectable()(TodosService);
// 展开来看:
// 第一步:调用 Injectable(),返回一个函数
const fn = Injectable();
// fn = (target) => { Reflect.defineMetadata('injectable', true, target); }
// 第二步:用目标类调用这个函数
fn(TodosService);
// 等价于:
Reflect.defineMetadata('injectable', true, TodosService);
用你熟悉的语法糖类比
| 语法糖 | 你写的 | 等价的原始写法 | 注意事项 |
|---|---|---|---|
| 箭头函数 | (a, b) => a + b |
function(a, b) { return a + b; } |
this 绑定方式不同! |
| 解构赋值 | const { name } = user |
const name = user.name |
无 |
| 模板字符串`` | Hi ${name} |
'Hi ' + name |
无 |
| async/await | await fetch(url) |
fetch(url).then(...) |
错误处理行为有差异 |
| class | class A { method() {} } |
A.prototype.method = function() {} |
不可提升、严格模式、方法不可枚举 |
| 装饰器 | @Injectable() |
Injectable()(Target) |
逆序执行,类型签名不同 |
装饰器和箭头函数、解构赋值一样,都是语法糖 --- 好看的皮,相同的骨。
类装饰器 --- @Injectable() 的实现
typescript
// @Injectable() 的简化实现
export function Injectable(): ClassDecorator {
// 返回一个函数,这个函数接收目标类
return (target: Function) => {
// 在目标类上存储元数据,标记为"可注入"
Reflect.defineMetadata('injectable', true, target);
};
}
使用时:
typescript
@Injectable()
export class TodosService { ... }
// 等价于:
Injectable()(TodosService);
// 即:
(target) => { Reflect.defineMetadata('injectable', true, target); }(TodosService);
方法装饰器 --- @Get(':id') 的实现
typescript
// @Get() 的简化实现
export function Get(path?: string): MethodDecorator {
// 返回一个函数,接收三个参数:
// target --- 类的原型
// propertyKey --- 方法名
// descriptor --- 方法的属性描述符
return (target: Object, propertyKey: string, descriptor: PropertyDescriptor) => {
// 存储路由元数据
Reflect.defineMetadata('path', path, descriptor.value);
Reflect.defineMetadata('method', 'GET', descriptor.value);
};
}
使用时:
typescript
@Get(':id')
findOne(@Param('id') id: string): Todo { ... }
// 等价于:
Get(':id')(TodosController.prototype, 'findOne', descriptor);
// 即:
(target, propertyKey, descriptor) => {
Reflect.defineMetadata('path', ':id', descriptor.value);
Reflect.defineMetadata('method', 'GET', descriptor.value);
}(TodosController.prototype, 'findOne', descriptor);
参数装饰器 --- @Param('id') 的实现
typescript
// @Param() 的简化实现
export function Param(property?: string): ParameterDecorator {
// 返回一个函数,接收四个参数:
// target --- 类的原型
// propertyKey --- 方法名
// parameterIndex --- 参数在参数列表中的位置
return (target: Object, propertyKey: string, parameterIndex: number) => {
// 获取已有的参数元数据
const existingParams = Reflect.getMetadata('params', target, propertyKey) || [];
existingParams.push({
index: parameterIndex,
type: 'param',
property: property,
});
// 存储回去
Reflect.defineMetadata('params', existingParams, target, propertyKey);
};
}
使用时:
typescript
findOne(@Param('id') id: string): Todo { ... }
// 等价于:
Param('id')(TodosController.prototype, 'findOne', 0);
// 第三个参数 0 表示 id 是第一个参数
完整的装饰器类型
| 类型 | 签名 | 用途 | 示例 |
|---|---|---|---|
| 类装饰器 | (target: Function) => void |
修改类的行为 | @Injectable() @Module() |
| 方法装饰器 | (target, key, descriptor) => void |
修改方法的行为 | @Get() @Post() |
| 属性装饰器 | (target, key) => void |
修改属性 | @Inject() |
| 参数装饰器 | (target, key, index) => void |
标记参数 | @Param() @Body() |
装饰器的执行顺序
typescript
@Module({ // 1. 类装饰器(从上到下)
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {
@Get(':id') // 2. 方法装饰器(从上到下)
findOne(
@Param('id') id: string // 3. 参数装饰器(从右到左!)
) {}
}
执行顺序:
kotlin
1. 参数装饰器 @Param('id') --- 从右到左(本例只有一个)
2. 方法装饰器 @Get(':id')
3. 类装饰器 @Module({ ... })
面试话术
TypeScript 装饰器本质上就是函数语法糖。
@Injectable()编译后就是Injectable()(TodosService)--- 先调用Injectable()返回一个函数,再用目标类调用这个函数。函数内部通过Reflect.defineMetadata()存储元数据。框架启动时读取这些元数据,就知道哪些类是服务、哪些方法是路由、哪些参数需要从请求中提取。NestJS 把装饰器用到了极致 --- 类装饰器声明模块、方法装饰器声明路由、参数装饰器声明参数来源,所有元数据都通过reflect-metadata统一管理。
📚 四、实战:Todos CRUD 完整实现
理解了底层原理,我们来看完整的业务代码。
Service 层 --- 业务逻辑
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(todo => todo.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);
// 注意:Object.assign 会直接修改原数组中的对象引用(mutation)
// 本例为教学简化,生产环境建议用展开运算符创建新对象:
// const updated = { ...todo, ...patch };
// todos[index] = updated;
return todo;
}
}
Controller 层 --- 路由 + 参数
typescript
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(+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);
}
}
Module --- 组装
typescript
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller';
import { TodosService } from './todos.service';
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
请求生命周期
kotlin
客户端: GET /todos/1
↓
main.ts: NestFactory.create(AppModule) 启动的 HTTP 服务器
↓
路由匹配: GET /todos/:id → TodosController.findOne
↓
参数提取: @Param('id') → id = "1"(字符串)
↓
类型转换: +id → 1(数字)
↓
调用 Service: this.todosService.findOne(1)
↓
业务逻辑: todos.find(todo => todo.id === 1)
↓
找不到? → throw new NotFoundException → 框架返回 404
找到了? → return todo → 框架序列化为 JSON → 200 响应
📚 五、错误处理 --- 框架级的标准化
传统方式 vs NestJS 方式
typescript
// ❌ 传统方式 --- try-catch,代码冗余
try {
const todo = todos.find(t => t.id === id);
if (!todo) {
res.status(404).json({ statusCode: 404, message: 'Not Found' });
return;
}
res.json(todo);
} catch (err) {
res.status(500).json({ statusCode: 500, message: 'Internal Error' });
}
// ✅ NestJS 方式 --- 声明式,框架统一处理
findOne(id: number): Todo {
const todo = todos.find(todo => todo.id === id);
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
return todo;
}
框架内部怎么处理异常
arduino
Service 层 throw new NotFoundException('Todo 99 不存在')
↓
NestJS 异常过滤器捕获
↓
提取 statusCode: 404
↓
生成标准响应:
{
"statusCode": 404,
"message": "Todo 99 不存在",
"error": "Not Found"
}
↓
返回给客户端
内置异常类
| 异常类 | 状态码 | 场景 |
|---|---|---|
BadRequestException |
400 | 参数校验失败 |
UnauthorizedException |
401 | 未登录 |
ForbiddenException |
403 | 无权限 |
NotFoundException |
404 | 资源不存在 |
ConflictException |
409 | 资源冲突(如重复创建) |
InternalServerErrorException |
500 | 服务器内部错误 |
📚 六、TypeScript 泛型在后端的应用
Partial --- PATCH 更新的利器
typescript
@Patch(':id')
update(@Param('id') id: string, @Body() patch: Partial<Todo>): Todo {
return this.todosService.update(Number(id), patch);
}
Partial<Todo> 的含义:
typescript
// Todo 的原始类型
interface Todo {
id: number;
title: string;
completed: boolean;
}
// Partial<Todo> 变成
{
id?: number; // 可选
title?: string; // 可选
completed?: boolean; // 可选
}
为什么 PATCH 需要 Partial?
PATCH 的语义是"部分更新" --- 只更新传入的字段,不传的保持原值。如果用完整的 Todo 类型,客户端必须传所有字段,这就变成了 PUT 的语义。
typescript
// PATCH /todos/1
// Body: { "completed": true } ← 只更新 completed
// Service 层
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id); // 找到原对象
Object.assign(todo, patch); // 只覆盖传入的字段
return todo; // title 和 id 保持不变
}
💡 经验总结
1. 理解原理比会用更重要
面试官问"NestJS 怎么实现依赖注入",如果你只会说"在构造函数里声明就行",那是表面功夫。能讲清楚 reflect-metadata、IoC 容器、元数据存储,才是加分项。
2. 装饰器不神秘
@xxx 就是函数调用的语法糖。编译后变成 xxx(target) 的形式,内部通过 Reflect.defineMetadata() 存储元数据。理解了这一点,看 NestJS 的任何装饰器都不会懵。
3. 工厂模式封装了复杂性
NestFactory.create() 一行代码背后是:容器创建 → 模块解析 → 依赖实例化 → 服务器创建 → 路由注册。工厂模式的价值就是把这五步封装成一个调用。
4. Controller 只做转发
Controller 的职责是:接收请求 → 获取参数 → 调用 Service → 返回结果。不要在 Controller 里写业务逻辑。
5. 错误处理要标准化
用 NestJS 内置的异常类,不要自己写 try-catch。标准化的错误输出让前端更容易处理,也让 API 更专业。
📊 RESTful API 设计速查
| 操作 | HTTP 方法 | 路径 | 语义 |
|---|---|---|---|
| 查询所有 | GET | /todos | 获取资源集合 |
| 查询单个 | GET | /todos/1 | 获取单个资源 |
| 创建 | POST | /todos | 创建新资源 |
| 部分更新 | PATCH | /todos/1 | 更新部分字段 |
| 删除 | DELETE | /todos/1 | 删除资源 |
RESTful 核心原则:
- 资源用名词(todos),不用动词(getTodos ❌)
- HTTP 方法表达动作(GET 查询、POST 创建、DELETE 删除)
- 状态码表达结果(200 成功、404 未找到)
🔗 参考资料
💬 交流讨论
你在学 NestJS 的过程中遇到了什么问题?或者你觉得 NestJS 和其他框架相比有什么优劣?欢迎在评论区交流!
觉得有用?点个赞👍收藏⭐关注👆,后续会更新 NestJS 进阶内容:中间件、拦截器、管道、守卫、微服务架构!