目录
使用 @nestjs/serve-static 托管静态资源
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'), // 指向 public 目录
serveRoot: '/', // 静态资源根路径
exclude: ['*'], // 排除所有路由,只有真实静态资源才会被处理
}),
UsersModule,
],
上述配置防止访问任何不存在的路径都会返回 index.html,默认 ServeStaticModule 会把所有未命中的路由都返回 index.html
interface & DTO & Entity 的区别
interface
(类型接口)
- 只是 开发阶段的类型提示
- 运行时不会存在,不能做验证、转换
- 不能加装饰器(如
@IsString()
等)
💡 用来描述“这个对象应该长什么样”,但不会被 Nest 用来做任何实际的事情。
DTO
(数据传输对象)
- 是一个 真实存在的类
- 会被
class-validator
和class-transformer
用来做 校验 + 转换 - 常用于
@Body()
、@Query()
、@Param()
等请求输入
💡 相当于“前端 → 后端”的数据过滤门卫,它可以检查输入是否合法,还能自动转类型。
Entity
(实体类)
- 用在 ORM(如 TypeORM、Prisma)中
- 是后端代码和数据库“表”的映射
- 通常用在
Repository.save()
、Repository.find()
这样的数据库操作中
💡 Entity 是数据在后端落地的模型,而 DTO 是数据“进门”之前的检查器。
关系:
[interface] ←开发阶段使用(类型提示)
[前端请求]
↓
[DTO] ← 校验、过滤、类型转换(transform + validate)
↓
[Entity] ← 存入数据库(TypeORM / Prisma)
↑
[Entity] → 转换成响应 DTO → 发送给前端
总结:
interface
是写代码时的“草图”,DTO
是请求进门前的“安检员”,Entity
是数据库里的“实名登记表”。
REST 和 CRUD 的区别
CRUD 是数据库和应用开发中最基础的四种操作,分别是:
- Create —— 创建(新增数据)
- Read —— 读取(查询数据)
- Update —— 更新(修改数据)
- Delete —— 删除(删除数据)
这四个操作涵盖了绝大多数数据的增删改查行为。
REST(Representational State Transfer,表述性状态转移)是一种设计网络服务的架构风格,它定义了一套基于 HTTP 协议的标准和原则,用于构建可扩展、可维护的网络 API。
REST 规定:
- 使用 HTTP 方法 来操作资源:
POST
用于创建资源GET
用于读取资源PUT
/PATCH
用于更新资源DELETE
用于删除资源
- 每个资源都对应一个唯一的 URL(URI)
- 无状态请求(服务器不保存客户端状态)
REST 和 CRUD 的关系?
REST API 中的操作往往映射到 CRUD 的操作上。
CRUD 是操作数据的概念,REST 是通过 HTTP 实现这些操作的设计规范。
CRUD 操作 | REST HTTP 方法 | 说明 |
---|---|---|
Create | POST | 创建新资源 |
Read | GET | 查询/读取资源 |
Update | PUT / PATCH | 修改已有资源 |
Delete | DELETE | 删除资源 |
例子
操作 | HTTP 请求 | 描述 |
---|---|---|
创建用户(Create) | POST /users | 新增用户 |
查询所有用户(Read) | GET /users | 获取用户列表 |
查询单个用户 | GET /users/{id} | 根据 ID 获取用户详情 |
更新用户 | PUT /users/{id} | 修改指定 ID 的用户 |
删除用户 | DELETE /users/{id} | 删除指定 ID 的用户 |
总结:CRUD 是数据操作的抽象概念,REST 是用 HTTP 方法来规范和实现这些操作的架构风格。
动态移除Header
在最佳实践中,建议移除或隐藏一些可能暴露后端实现细节的响应头(Header),这是提升安全性和防止信息泄露的一个常见手段。
比如暴露 X-Powered-By: Express
,攻击者知道你用的是 Express,就可能利用已知的 Express 漏洞、攻击点或中间件绕过方式。
使用中间件,动态移除一些 HTTP 响应头,比如 x-powered-by,以提升安全性。
// security.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SecurityMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 移除 x-powered-by,可以根据需求加上自定义逻辑层进行动态移除
res.removeHeader('x-powered-by');
// 还可以换上一些...
next();
}
}
在 app.module.ts 注册全局中间件
// app.module.ts
import { MiddlewareConsumer, NestModule } from '@nestjs/common';
import { SecurityMiddleware } from './middlewares/security.middleware';
// ...
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(SecurityMiddleware).forRoutes('*');
}
}
如果只是简单移除,可以在 main.ts 使用app.disable('x-powered-by');
const expressApp = app.getHttpAdapter().getInstance();
expressApp.disable('x-powered-by');
Express有可能在中间件执行后又添加一次 x-powered-by
(Express 默认行为),导致你的 removeHeader
失效。
// 推荐的安全 HTTP Header
res.setHeader('X-Content-Type-Options', 'nosniff'); // 防止 MIME 类型混淆攻击
res.setHeader('X-Frame-Options', 'DENY'); // 禁止页面被 iframe 嵌套(点击劫持防护)
res.setHeader('X-XSS-Protection', '1; mode=block'); // 启用浏览器 XSS 过滤
res.setHeader('Referrer-Policy', 'no-referrer'); // 限制 Referer 泄露
helmet
会自动添加大量安全 header,非常省心,推荐生产环境使用。
npm i helmet
import helmet from 'helmet';
const app = await NestFactory.create(AppModule);
app.use(helmet());
Request 中的 ip 和 headers
Request
的类型推断中,有时不包含 .ip
和 .headers
的类型定义(虽然运行时这些字段确实存在)。 所以会出现这种编译时的报错:
TS2339: Property 'ip' does not exist on type 'Request<...>'.
TS2339: Property 'headers' does not exist on type 'Request<...>'.
使用类型合并处理
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';
@Controller('init')
export class InitController {
@Get()
getInitInfo(@Req() request: Request & { ip: string; headers: any }) {
return {
status: 'Successful startup',
env: process.env.NODE_ENV || 'unknown',
vercel: !!process.env.VERCEL,
ip: request.ip,
'x-forwarded-for': request.headers['x-forwarded-for'] || null,
timestamp: new Date().toISOString(),
version: '1.0.0',
uptime: process.uptime(),
memory: process.memoryUsage(),
};
}
}
process.cwd() & __dirname
process.cwd()
获取 Node.js 进程的当前工作目录,即启动进程时所在的路径。
使用场景:
- 获取项目根路径(依赖启动位置)
- 读取
.env
或其他根级配置文件 - 动态构建路径(如日志存储、上传目录)
注意事项:
- 值会随着
process.chdir()
改变 - 结果与模块位置无关,依赖于 Node.js 启动命令的工作目录
__dirname
获取当前模块文件所在的目录的绝对路径。
使用场景:
- 加载与当前模块相对的资源文件(如 JSON、配置等)
- 构建模块内部专用的静态路径(如模板、图像)
注意事项:
- 值在模块中是固定的
- 在 ES Module 模式下需用
import.meta.url
处理后获取
稳定性:使用 process.cwd()
保证无论在哪个模块调用,日志目录始终定位到项目根路径。
可移植性:如果该模块作为 npm 包被引入,它仍然能把日志写入宿主项目的根目录。
一致性:与 NestJS 应用级资源和组件通常位于根路径的设计保持一致。
SOLID 原则和在NestJS 中的最佳实践
SOLID 原则简述(面向对象设计五大原则)
原则 | 全称 | 简要说明 |
---|---|---|
S | Single Responsibility 单一职责原则 | 一个类只做一件事,职责要单一 |
O | Open/Closed 开放封闭原则 | 对扩展开放,对修改封闭 |
L | Liskov Substitution 里氏替换原则 | 子类能替代父类出现在任何地方 |
I | Interface Segregation 接口隔离原则 | 不强迫实现无关接口,接口应小而精 |
D | Dependency Inversion 依赖倒置原则 | 高层模块不依赖底层模块,依赖抽象接口 |
在 NestJS 中的实践建议
原则 | NestJS 实践方式 |
---|---|
S | 控制器只负责处理请求/响应,服务负责业务逻辑,数据库操作交给仓储(Repository)等模块,职责分离 清晰 |
O | 使用 extends / implements 复用逻辑,通过模块或服务替换实现,无需修改原始逻辑即可扩展功能 |
L | 使用接口或抽象类注入不同实现类,子类可以透明替代,符合模块互换性要求 |
I | 使用专门的接口划分服务职责,避免定义过大的 DTO 或服务接口 |
D | 借助 Nest 的依赖注入容器,使用接口 + useClass / useFactory / useExisting 等方式注入依赖,依赖于抽象而非具体实现 |
示例:依赖倒置在 Nest 中的应用
// 定义抽象接口
export interface NotificationService {
send(message: string): void;
}
// 提供默认实现
@Injectable()
export class EmailService implements NotificationService {
send(message: string) {
console.log(`Send email: ${message}`);
}
}
// 模块中绑定接口与实现
@Module({
providers: [
{
provide: 'NotificationService',
useClass: EmailService,
},
],
})
export class NotifyModule {}
在消费者中使用:
@Injectable()
export class AlertService {
constructor(
@Inject('NotificationService') private readonly notifier: NotificationService,
) {}
alert(msg: string) {
this.notifier.send(msg);
}
}
总结
- SOLID 原则为模块化、可测试、可维护的架构提供理论基础
- Nest 的依赖注入系统天生支持 SOLID 实践
- 保持代码职责清晰、接口细化、依赖抽象,是编写良好 NestJS 应用的关键
DTO 和 Interface的最佳实践
在 NestJS 中,应该用 interface 还是 DTO(类)来限制数据结构?是否需要二者分离,比如 interface 给服务用,DTO 给控制器用?
在控制器处理请求时统一使用 DTO(类 + 验证器)作为类型定义,在服务层也尽量复用 DTO,除非确实需要定义不同的内部结构。
换句话说:
- 控制器用 DTO 类(含验证器)
- 服务层 优先复用 DTO(作为输入类型),但可用
interface
或Pick<>
等组合方式做适当抽象
为什么推荐统一使用 DTO?
DTO 是类,支持 运行时验证
NestJS 的 ValidationPipe
是基于 类的元数据(装饰器) 来进行运行时校验的,而接口只在 TypeScript 编译时检查,无法用于运行时校验。
@IsString()
name: string;
只有在类中才有效,interface 无法使用这些装饰器。
类可以被 Nest 扫描、序列化、文档化(如 Swagger)
如果你将来使用 @nestjs/swagger
自动生成接口文档,DTO 是必要前提,因为:
@ApiBody({ type: CreateCatDto })
只接受 class 类型,interface 无法参与元数据生成。
类型一致性:服务层使用 DTO,减少重复定义
如果你把接口专门用于服务层(如 Cat
),而控制器使用 DTO(如 CreateCatDto
),会面临两个重复数据结构:
// interface Cat
interface Cat {
name: string;
age: number;
breed: string;
}
// class CreateCatDto
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
这种重复带来维护成本,未来字段调整时需要改两份。
那什么时候使用 interface 比较合适?
场景 | 原因 |
---|---|
服务层中的内部数据结构 | 比如组合模型、只读输出对象、数据库查询结果等,不需要验证 |
泛型工具类型 | 如 Partial<Cat> 、Pick<Cat, 'name'> 等功能性类型 |
定义返回类型 | 如 Promise<Cat[]> ,用于表示服务返回内容结构 |
定义接口约束(不带装饰器) | 比如 NotificationService 接口,只用于类型抽象,不用于验证 |
推荐实践
// CreateCatDto.ts(控制器用、也可传给服务)
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
// Cat.interface.ts(仅用于返回类型或数据库模型)
export interface Cat {
id: number;
name: string;
age: number;
breed: string;
createdAt: Date;
}
控制器使用:
@Post()
create(@Body() dto: CreateCatDto) {
this.catsService.create(dto);
}
服务使用:
create(cat: CreateCatDto) {
// ...
}
返回类型使用接口:
findAll(): Cat[] {
// ...
}
总结
比较项 | DTO(类) | Interface |
---|---|---|
编译时类型检查 | ✅ | ✅ |
运行时验证 | ✅(装饰器 + Pipe) | ❌ |
Swagger 文档生成 | ✅ | ❌ |
可继承、组合 | ✅(类继承) | ✅(工具类型如 Pick) |
推荐用途 | 请求体输入、入参校验 | 返回值类型、数据库模型、只读结构 |
实战建议:
- 请求数据统一使用 DTO(含验证)
- 服务逻辑中可直接用 DTO,也可封装更抽象的接口
- 避免定义内容重复的 interface + DTO 两套系统
constructor(private catsService: CatsService)的原理
为什么写 constructor(private catsService: CatsService)
就能自动注入 CatsService 的实例?
这是 NestJS 的依赖注入机制 + TypeScript 类型信息反射 的结果。
原理简述:
Nest 会在应用启动时扫描构造函数的参数类型,并根据 CatsService
的类型,在当前模块的 providers
数组中查找是否有该类的提供者。
具体过程:
constructor(private catsService: CatsService) {}
CatsService
是类,也是一个类型(TS 允许类作为类型)- Nest 通过 TypeScript 的设计时类型信息 +
Reflect
元数据 得知你需要CatsService
- 如果它已注册为模块的 provider(如下),Nest 就会自动创建实例并注入:
@Module({
providers: [CatsService], // 👈 注册为可注入类
})
要实现这一功能,你必须:
- 类上使用
@Injectable()
装饰器(使其变为 Nest 可管理的提供者) - 在当前模块的
providers
中注册它
详细版:
export class AppController {
constructor(private readonly appService: AppService) {}
// ...
}
是谁调用了这个AppController
:**是 Nest 框架的 IoC 容器(Injector)在应用启动时调用构造函数并自动注入参数的。**这不是用户代码调用,而是 Nest 的内部框架在应用初始化阶段自动解析模块依赖图,根据类型信息自动调用构造函数,并注入正确的依赖实例。
详细还原 Nest 的 DI 注入流程:
第一步:Nest 启动时执行 AppModule
的 NestFactory.create(AppModule)
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule); // 👈 入口
await app.listen(3000);
}
这会启动一个叫做 NestApplicationContext 的容器系统。
第二步:Nest 扫描 AppModule
的元数据(通过 @Module()
装饰器)
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}
Nest 从这个模块中提取出:
- 要创建的控制器:
CatsController
- 要创建的服务提供者:
CatsService
然后它会开始构建依赖图(Dependency Graph)。
第三步:Nest 创建 CatsService
实例
@Injectable()
export class CatsService {
...
}
- Nest 检查
CatsService
是否有构造函数参数 → 没有,直接调用new CatsService()
得到实例。 - 该实例被注册进全局容器,并标记为“已就绪”(已构建)。
第四步:Nest 创建 CatsController
实例
控制器是通过 new CatsController(...)
构造的,但不是你写的,是 Nest 自动做的。
这时候 Nest 会:
1. 读取构造函数参数类型
这一关键操作依赖 TypeScript 元数据反射:
Reflect.getMetadata('design:paramtypes', CatsController);
// 👈 得到:[CatsService]
说明:构造函数需要一个类型为 CatsService
的参数。
这依赖于你在 tsconfig.json
中设置了:
"emitDecoratorMetadata": true,
"experimentalDecorators": true
2. 在容器中查找 CatsService
的实例
Nest 查询内部容器中是否有已构建的 CatsService
:
const catsServiceInstance = container.get(CatsService); // ✅ 存在,复用
3. 调用构造函数并注入实例
Nest 用反射调用:
const controllerInstance = new CatsController(catsServiceInstance);
就这样,你写的 constructor(private catsService: CatsService)
得到注入。
此时 catsService
属性已自动赋值。
Nest 构造函数注入全流程
NestFactory.create(AppModule)
↓
扫描模块元数据 (@Module)
↓
构建 Providers(CatsService)
↓
构建 Controllers(CatsController)
↓
读取 CatsController 构造函数参数类型 (Reflect.getMetadata)
↓
在容器中查找/构建 CatsService 实例
↓
调用构造函数 new CatsController(catsServiceInstance)
↓
将控制器注册进路由系统,完成依赖注入
重点:
类作为类型,是如何记录下来的?
当你写了:
constructor(private catsService: CatsService) {}
TypeScript + emitDecoratorMetadata
会生成以下运行时元数据:
{
"design:paramtypes": [CatsService]
}
这就是 Nest 能拿到参数类型信息的关键。
是谁调用了构造函数?
Nest 的内部类 Injector
调用,它在构建实例时,会:
new Constructor(...resolvedDependencies)
这些依赖是通过元数据拿到的。
总结:
问题 | 解答 |
---|---|
谁调用构造函数? | Nest 的 IoC 容器调用(非用户代码) |
何时调用? | 在模块初始化阶段,扫描 controller/providers 时 |
为什么能注入正确的类? | 利用 TS 装饰器元数据读取构造函数参数类型,并在容器中查找 |
构造函数参数后面的类是类型还是值? | 是类型,也是构造函数,TS 类兼具两种角色 |
private 有什么作用? | 简写方式,自动声明 + 初始化属性(见前面问题) |
{} 里是否能写代码? | ✅ 可以写逻辑,但不建议写副作用代码 |
private的简写
constructor(private catsService: CatsService) {}
这是 TypeScript 的简写语法,用于自动声明并初始化成员属性。
等价于以下完整写法:
class CatsController {
private catsService: CatsService;
constructor(catsService: CatsService) {
this.catsService = catsService;
}
}
所以 private catsService: CatsService
的意思是:
- 定义一个名为
catsService
的私有属性,类型为CatsService
- 同时将构造函数传入的参数赋值给这个属性
同一个控制器注入请求级和单例
控制器最终的作用域会由它依赖的“最小作用域级别”决定。请求级 > 单例,所以只要注入了请求级服务,控制器就会自动变成请求级。
控制器的作用域是由它的依赖链决定的:
- 如果你注入的 都是单例服务 → 控制器就是默认的单例
- 如果你注入了 请求级服务 → 控制器会“冒泡”变成请求级
- 如果你注入了 瞬态服务 → 控制器不会变成瞬态(瞬态不会影响上层作用域)
// UserService 是单例
@Injectable()
export class UserService {}
// LoggerService 是请求级
@Injectable({ scope: Scope.REQUEST })
export class LoggerService {}
@Controller('cats')
export class CatsController {
constructor(
private userService: UserService, // 单例
private loggerService: LoggerService // 请求级
) {}
}
上面的 CatsController
虽然注入了一个单例服务,但只要有一个是请求级,控制器本身就会被提升为请求级。
不能混用不同作用域的控制器吗?
其实 NestJS 允许你在一个控制器中注入不同作用域的服务,只要你接受这个控制器会被整体提升到“最小生命周期”(通常是请求级)。
- 是合法的
- 是常见的(比如你要用 traceId,又要查数据库)
- 但要注意性能影响,控制器每次请求都要重新创建
如果你不想控制器变成请求级怎么办?
你就不能注入请求级服务,而是要通过其他方式**“向下传递”上下文**,比如:
- 用
@Inject(REQUEST)
拿到原始请求(@Req() req: Request
) - 从控制器层级把数据传到需要的服务,而不是依赖注入
@Req() 和 @Inject() 语法糖
@Req()
本质上就是 @Inject(REQUEST)
的语法糖
// 写在 Controller 里
@Get()
handle(@Req() req: Request) {
console.log(req.headers);
}
// 等价于
@Get()
handle(@Inject(REQUEST) req: Request) {
console.log(req.headers);
}
只不过:
@Req()
是 Nest 给你包了一层,让你写得更短更直观;- 而
@Inject(REQUEST)
是低层的原理实现,你想在 Service 里用就只能用它。
“@Inject(REQUEST) 怎么向下传?” ???
它不是“向下传”,它就是直接注入进来的——只不过你平时在控制器里习惯用 @Req()
这个糖而已 🍬。
客户端请求 --> Nest 控制器方法被调用
|
|--> @Req() 自动注入 Request(语法糖)
|
|--> 你要用 req,就传下去
| 或者在某个服务里用 @Inject(REQUEST) 注入它(Nest 框架帮你搞定)
用法 | 控制器中是否可用 | 服务中是否可用 | 备注 |
---|---|---|---|
@Req() | ✅ 是 | ❌ 否 | 只能用在方法参数中 |
@Inject(REQUEST) | ✅ 是(不常见) | ✅ 是(必须是请求级) | 用于构造函数注入 |
DI子树和DI树
想象你 Nest 项目的所有服务、控制器、模块、依赖关系构成了一棵树:
- 根是 AppModule
- 中间是各个模块、控制器
- 叶子节点是各种服务(Service)
这棵树 Nest 在启动时一次性构造好,并且默认整个项目都用一套共享实例(即单例)。
现在来了个请求,Nest 就走这个大树,找到响应控制器和服务,用已有的单例实例去处理请求。 这就是默认行为。
但如果你声明了:
@Injectable({ scope: Scope.REQUEST })
export class UserContextService {}
Nest 就不能用全局的那棵树了,因为你要的这个服务必须每次请求都新建。 于是它会为你新建一棵“局部子树”,只复制那一部分需要请求级的服务。
这就是所谓的“请求级 DI 子树”。
服务类注册的两种方式
方式一:隐式绑定(推荐默认方式)
// 注册:
providers: [AppService]
// 使用:
constructor(private readonly appService: AppService) {}
特点:
- 自动以类名
AppService
作为 Token 注册和注入 - 简洁、直观、90% 场景下够用
- 对应的注入语法也是:
constructor(private xxx: AppService)
方式二:显式绑定(useClass)
// 注册:
providers: [
{
provide: 'MyServiceAlias', // 自定义 token
useClass: MyService, // 实际类
},
]
// 使用:
constructor(@Inject('MyServiceAlias') private readonly myService: MyService) {}
特点:
- 显式指定 Token 名称(可自定义、可多命名)
- 多用于需要多个实现类或做别名时
- 也可用于替换某个服务的实现(比如单元测试、运行时切换)
方式 | 语法简洁性 | 灵活性 | 推荐使用场景 |
---|---|---|---|
[AppService] | ✅ 简洁 | ❌ 不可自定义 | 常规开发、默认使用方式 |
provide + useClass | ❌ 略繁琐 | ✅ 高灵活 | 多实现切换、别名注入、测试 mock 场景 |
真实例子
比如你有两个日志服务实现类:
@Injectable()
export class FileLogger implements LoggerService {
log() {
console.log('写入文件');
}
}
@Injectable()
export class ConsoleLogger implements LoggerService {
log() {
console.log('输出控制台');
}
}
你可以在 AppModule
里动态选择使用哪一个:
providers: [
{
provide: 'LoggerService',
useClass: process.env.LOG_TO_FILE ? FileLogger : ConsoleLogger,
},
]
然后注入时统一用:
constructor(@Inject('LoggerService') private logger: LoggerService) {}
默认注册适合快速开发,useClass 适合灵活配置或更复杂的依赖管理场景。
在Module类中能写啥
虽然在大型项目中,AppModule
更多是作为“根模块”或“聚合模块”使用,但它不是不能写东西,而是:
常见用途包括:
目的 | 举例 |
---|---|
注册全局服务 | 如配置服务、日志服务、拦截器、异常过滤器等 |
提供一些“别名”或“策略”类 | 如 LoggerAliasProvider |
注册一些平台适配层 | 如注册 Express 中间件、自定义管道、动态模块 |
注册全局常量、配置项 | 如 useValue: { APP_CONFIG: xxx } |
“模块类”本身也可以注入依赖的体现,Nest 中的模块类不仅是个装饰器容器,它自己也可以作为被实例化的类被注入依赖。
@Module({
imports: [AppModule],
})
export class OtherModule {
constructor(@Inject('APP_CONFIG') private config: any) {
console.log('OtherModule 初始化时能拿到 config:', this.config);
}
}
目的不是为了“注册”,而是为了“执行副作用”或“初始化逻辑”。
适用场景:
- 在模块初始化阶段做一些副作用处理(例如连接、注册、上报)
- 动态模块时读取共享配置
- 在模块内部注册一些依赖时用到它
但这不是常规推荐做法,模块类中一般不会主动写构造函数逻辑,除非你确实需要在模块加载时就执行某些逻辑。
模块(class)本身 90% 真的不会写任何代码,@Module()
装饰器是配置区域,注册依赖,class AppModule {}
是 Nest 在内部实例化模块时需要的“外壳”
用生命周期钩子 onModuleInit()
(用于初始化逻辑)
import { Module, OnModuleInit, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class DatabaseService implements OnModuleInit {
constructor(private dataSource: DataSource) {}
async onModuleInit() {
try {
await this.dataSource.initialize();
console.log('[数据库] 已成功连接');
} catch (error) {
console.error('[数据库] 连接失败', error);
process.exit(1); // 连接失败直接终止程序
}
}
}
@Module({
providers: [DatabaseService],
})
export class AppModule {}
@Module 装饰器是注册依赖的地方,class 本体几乎不会写逻辑。真正写逻辑的是服务类里的生命周期钩子。
在 AppModule
的 constructor()
中的逻辑,会早于 onModuleInit()
生命周期钩子执行。
因为:
constructor()
是类被实例化时立刻执行的 JS 标准行为(与 Nest 无关)onModuleInit()
是 Nest 的生命周期钩子,在实例化完毕 & 依赖注入完成后才会执行
AppModule constructor() --> onModuleInit() --> [NestApplication] Nest application successfully started
@Module({})
export class AppModule implements OnModuleInit {
constructor() {
console.log('✅ AppModule constructor 执行');
}
onModuleInit() {
console.log('✅ AppModule 生命周期钩子 onModuleInit 执行');
}
}
输出:
✅ AppModule constructor 执行
✅ AppModule 生命周期钩子 onModuleInit 执行
虽然 constructor 更早执行,但你应该 避免在 constructor 里写依赖相关的逻辑,因为此时:
- 依赖注入还没完成
- 依赖项可能是
undefined
- 尤其在涉及数据库、配置服务时,容易出错
✅ 最推荐的初始化位置仍然是 onModuleInit()
,因为这时:
- 所有注入依赖都准备好了
- 可安全访问注入服务,例如
ConfigService
、DataSource
等
通常在 AppModule
的构造函数中,写一些简单的人性化日志提示,比如:XXX服务器开始启动....
生命周期钩子的两种写法
写在模块类中
@Module({
imports: [...],
providers: [...],
})
export class AppModule {
onModuleInit() {
console.log('AppModule initialized');
}
}
- ✅ 可以执行初始化逻辑
- ❌ 但是:
AppModule
不是 provider,Nest 只是「通过特殊处理」允许你定义生命周期钩子; - ❌ 无法使用依赖注入,不能访问
AppService
或其他服务; - ⚠️ 模块初始化粒度太粗,不适合做具体业务初始化。
写在 AppService
中(推荐)
@Injectable()
export class AppService implements OnModuleInit {
onModuleInit() {
console.log('AppService initialized');
// 可以访问 this.xxx,拿到数据库、配置等依赖
}
}
✅ Nest 会自动调用该钩子;
✅ 可以访问自身注入的依赖(比如 this.configService
);
✅ 更符合 单一职责原则(SRP):服务负责初始化自己,不搞模块级别控制;
✅ 更易测试、解耦、复用。
示例:全局初始化逻辑
推荐写一个 AppLifecycleService
专门负责「全局生命周期管理」:
@Injectable()
export class AppLifecycleService implements OnModuleInit {
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
await this.configService.load();
console.log('全局初始化完成');
}
}
然后在 AppModule
中注册它:
@Module({
providers: [AppLifecycleService],
})
export class AppModule {}
这样就 兼顾了“模块级初始化” + “依赖注入能力” + “结构清晰”。
只为了启动时简单打印一句话,写在模块类中没大碍,简单快捷;但一旦需求复杂,就要用 provider 来写。
完整的请求生命周期
───────────────────────────────────────────────────────────────
📦 客户端请求
│
▼
───────────────────────────────────────────────────────────────
📦 中间件 Middleware(Express/Fastify 层)
→ 处理原始请求(如CORS、静态资源、日志等)
│
▼
───────────────────────────────────────────────────────────────
📦 守卫 Guard
→ 权限验证,是否放行
└─ ❌ 若拒绝,则直接抛出异常 → 异常过滤器
│
▼
───────────────────────────────────────────────────────────────
📦 拦截器 Interceptor(前置逻辑)
→ 方法执行前的前置操作(如记录时间、缓存等)
│
▼
───────────────────────────────────────────────────────────────
📦 管道 Pipe(参数层面)
→ DTO验证、类型转换
└─ ❌ 验证失败 → 抛出异常 → 异常过滤器
│
▼
───────────────────────────────────────────────────────────────
📦 控制器方法(业务处理)
│
▼
───────────────────────────────────────────────────────────────
📦 拦截器 Interceptor(后置逻辑)
→ 控制器方法返回后,对响应数据做加工(如统一包装)
│
▼
───────────────────────────────────────────────────────────────
📦 返回响应
│
▼
📦 客户端收到响应
───────────────────────────────────────────────────────────────
⚠️ 特殊情况:任何阶段抛出的异常
→ 由 异常过滤器(Exception Filter)捕获处理
各个环节:
阶段 | 作用 |
---|---|
中间件 | Express/Fastify 层,前端请求刚进入项目 |
守卫 | 权限控制,决定是否执行控制器方法 |
拦截器 前 | 方法执行前逻辑,如日志、缓存 |
管道 | 参数校验与转换 |
控制器方法 | 业务逻辑处理 |
拦截器 后 | 方法返回后,加工返回数据 |
异常过滤器 | 任何环节异常均由其统一处理与响应 |
中间件入口处,守卫控权限,管道改参数,拦截器包全程,有错走异常过滤器,返回响应给前端。
请求缓存机制
请求缓存是一种重要的性能优化手段。通过缓存机制,可以避免重复计算、减少数据库压力、缩短接口响应时间,提升整体系统性能。
请求缓存,顾名思义,就是将某个请求(通常是接口请求)的结果进行缓存,供后续同样请求直接使用,无需再次执行服务逻辑。
简而言之:
- 第一次请求 → 正常处理,结果缓存
- 后续相同请求 → 直接返回缓存数据
请求缓存的本质是为每个请求生成唯一的“缓存键”(Key),通过键值对的方式存储与查找缓存。
- 缓存键:标识请求的唯一Key
- 缓存值:对应请求的返回结果
通过“缓存键 ↔ 缓存值”映射关系,系统可以快速判断某个请求是否已经处理过。
缓存的关键在于如何生成唯一且合适的缓存键。
默认方式(请求级缓存)
通常根据以下信息生成:
- 请求方法(GET/POST)
- 请求完整URL(含参数)
- 可选:请求头、Body、Query等
缓存键 = GET:/posts/123
粒度精确,适合数据与参数紧密相关的接口。
自定义缓存键
开发者可以手动指定缓存键,例如为整个接口或模块设置同一个缓存Key。
场景:首页推荐文章,热门榜单,不依赖具体参数的公共数据
特点:粒度粗,适合缓存整体性数据。
执行流程:
请求进入
↓
检查缓存是否存在
↓
[是] —> 直接返回缓存结果
↓
[否] —> 执行业务逻辑 → 返回 → 存入缓存
trackBy() 返回的 key 是给框架底层作为缓存查找用的“唯一标识”。
内部:
const cacheKey = this.trackBy(context);
const result = cache.get(cacheKey); // 用 key 查缓存
trackBy() 方法的任务,就是:
- 生成并返回这个请求的缓存键(key);
- 框架拿着这个 key 去查找缓存。
源码本质(伪代码)
handleRequest(context) {
const key = this.trackBy(context); // ← 关键:生成缓存 key
if (cache.has(key)) {
return cache.get(key); // ← 关键:用 key 查缓存
}
const response = await handleRequest();
cache.set(key, response);
return response;
}
trackBy(context: ExecutionContext): string | undefined {
const cacheKey = this.reflector.get<string>('cache_key', handler);
if (cacheKey) {
return cacheKey; // ← 自定义缓存 key
}
return super.trackBy(context); // ← 默认根据请求路径生成 key
}
这里的return其实就是在告诉系统:
- “当前这个请求,我要用这个 key 作为缓存的标识符。”
框架会在后台:
- 根据这个 key 去查缓存;
- 查不到就执行控制器逻辑并缓存。
trackBy() = 告诉 Nest:这次请求的“身份证号”是什么。 框架会用这个“身份证号”去数据库(缓存系统)里查找数据。
返回 key 是为了告诉缓存系统: “这次请求应该用这个 key 做缓存标识,后面有相同 key 就别再跑控制器了。”
错误理解:在前置拦截器中直接返回缓存的内容,这种思路在一些轻量框架里是对的,比如手写中间件时直接:
if (cache.has(key)) {
return cache.get(key);
}
但在 NestJS:
- CacheInterceptor 已经把“缓存查找与返回”机制封装好了;
- 你只需要生成请求的缓存标识(key);
- “查缓存、返回数据”这部分,Nest 帮你完成。
所以:Nest 的缓存机制是半自动的: 你只管“用 key 标记”,缓存的查找与返回是框架负责的。