Skip to content
目录

使用 @nestjs/serve-static 托管静态资源

ts
imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'), // 指向 public 目录
      serveRoot: '/', // 静态资源根路径
      exclude: ['*'], // 排除所有路由,只有真实静态资源才会被处理
    }),
    UsersModule,
  ],

上述配置防止访问任何不存在的路径都会返回 index.html,默认 ServeStaticModule 会把所有未命中的路由都返回 index.html

interface & DTO & Entity 的区别

interface(类型接口)

  • 只是 开发阶段的类型提示
  • 运行时不会存在,不能做验证、转换
  • 不能加装饰器(如 @IsString() 等)

💡 用来描述“这个对象应该长什么样”,但不会被 Nest 用来做任何实际的事情。

DTO(数据传输对象)

  • 是一个 真实存在的类
  • 会被 class-validatorclass-transformer 用来做 校验 + 转换
  • 常用于 @Body()@Query()@Param() 等请求输入

💡 相当于“前端 → 后端”的数据过滤门卫,它可以检查输入是否合法,还能自动转类型。

Entity(实体类)

  • 用在 ORM(如 TypeORM、Prisma)中
  • 是后端代码和数据库“表”的映射
  • 通常用在 Repository.save()Repository.find() 这样的数据库操作中

💡 Entity 是数据在后端落地的模型,而 DTO 是数据“进门”之前的检查器。

关系

text
[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 方法说明
CreatePOST创建新资源
ReadGET查询/读取资源
UpdatePUT / PATCH修改已有资源
DeleteDELETE删除资源

例子

操作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,以提升安全性。

ts
// 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 注册全局中间件

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');

ts
const expressApp = app.getHttpAdapter().getInstance();
expressApp.disable('x-powered-by');

Express有可能在中间件执行后又添加一次 x-powered-by(Express 默认行为),导致你的 removeHeader 失效。

ts
// 推荐的安全 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,非常省心,推荐生产环境使用。

bash
npm i helmet
ts
import helmet from 'helmet';

const app = await NestFactory.create(AppModule);
app.use(helmet());

Request 中的 ip 和 headers

Request 的类型推断中,有时不包含 .ip.headers 的类型定义(虽然运行时这些字段确实存在)。 所以会出现这种编译时的报错:

json
TS2339: Property 'ip' does not exist on type 'Request<...>'.
TS2339: Property 'headers' does not exist on type 'Request<...>'.

使用类型合并处理

ts
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 原则简述(面向对象设计五大原则)

原则全称简要说明
SSingle Responsibility 单一职责原则一个类只做一件事,职责要单一
OOpen/Closed 开放封闭原则对扩展开放,对修改封闭
LLiskov Substitution 里氏替换原则子类能替代父类出现在任何地方
IInterface Segregation 接口隔离原则不强迫实现无关接口,接口应小而精
DDependency Inversion 依赖倒置原则高层模块不依赖底层模块,依赖抽象接口

在 NestJS 中的实践建议

原则NestJS 实践方式
S控制器只负责处理请求/响应,服务负责业务逻辑,数据库操作交给仓储(Repository)等模块,职责分离 清晰
O使用 extends / implements 复用逻辑,通过模块或服务替换实现,无需修改原始逻辑即可扩展功能
L使用接口或抽象类注入不同实现类,子类可以透明替代,符合模块互换性要求
I使用专门的接口划分服务职责,避免定义过大的 DTO 或服务接口
D借助 Nest 的依赖注入容器,使用接口 + useClass / useFactory / useExisting 等方式注入依赖,依赖于抽象而非具体实现

示例:依赖倒置在 Nest 中的应用

ts
// 定义抽象接口
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 {}

在消费者中使用:

ts
@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(作为输入类型),但可用 interfacePick<> 等组合方式做适当抽象

为什么推荐统一使用 DTO?

DTO 是类,支持 运行时验证

NestJS 的 ValidationPipe 是基于 类的元数据(装饰器) 来进行运行时校验的,而接口只在 TypeScript 编译时检查,无法用于运行时校验。

ts
@IsString()
name: string;

只有在类中才有效,interface 无法使用这些装饰器。

类可以被 Nest 扫描、序列化、文档化(如 Swagger)

如果你将来使用 @nestjs/swagger 自动生成接口文档,DTO 是必要前提,因为:

ts
@ApiBody({ type: CreateCatDto })

只接受 class 类型,interface 无法参与元数据生成。

类型一致性:服务层使用 DTO,减少重复定义

如果你把接口专门用于服务层(如 Cat),而控制器使用 DTO(如 CreateCatDto),会面临两个重复数据结构:

ts
// 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 接口,只用于类型抽象,不用于验证

推荐实践

ts
// 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;
}

控制器使用:

ts
@Post()
create(@Body() dto: CreateCatDto) {
  this.catsService.create(dto);
}

服务使用:

ts
create(cat: CreateCatDto) {
  // ...
}

返回类型使用接口:

ts
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 数组中查找是否有该类的提供者。

具体过程:

ts
constructor(private catsService: CatsService) {}
  • CatsService 是类,也是一个类型(TS 允许类作为类型)
  • Nest 通过 TypeScript 的设计时类型信息 + Reflect 元数据 得知你需要 CatsService
  • 如果它已注册为模块的 provider(如下),Nest 就会自动创建实例并注入:
ts
@Module({
  providers: [CatsService], // 👈 注册为可注入类
})

要实现这一功能,你必须:

  1. 类上使用 @Injectable() 装饰器(使其变为 Nest 可管理的提供者)
  2. 在当前模块的 providers 中注册它

详细版

ts
export class AppController {
    constructor(private readonly appService: AppService) {}
    // ...
}

是谁调用了这个AppController:**是 Nest 框架的 IoC 容器(Injector)在应用启动时调用构造函数并自动注入参数的。**这不是用户代码调用,而是 Nest 的内部框架在应用初始化阶段自动解析模块依赖图,根据类型信息自动调用构造函数,并注入正确的依赖实例。

详细还原 Nest 的 DI 注入流程

第一步:Nest 启动时执行 AppModuleNestFactory.create(AppModule)

ts
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule); // 👈 入口
  await app.listen(3000);
}

这会启动一个叫做 NestApplicationContext 的容器系统。

第二步:Nest 扫描 AppModule 的元数据(通过 @Module() 装饰器)

ts
@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}

Nest 从这个模块中提取出:

  • 要创建的控制器:CatsController
  • 要创建的服务提供者:CatsService

然后它会开始构建依赖图(Dependency Graph)。

第三步:Nest 创建 CatsService 实例

ts
@Injectable()
export class CatsService {
  ...
}
  • Nest 检查 CatsService 是否有构造函数参数 → 没有,直接调用 new CatsService() 得到实例。
  • 该实例被注册进全局容器,并标记为“已就绪”(已构建)。

第四步:Nest 创建 CatsController 实例

控制器是通过 new CatsController(...) 构造的,但不是你写的,是 Nest 自动做的。

这时候 Nest 会:

1. 读取构造函数参数类型

这一关键操作依赖 TypeScript 元数据反射:

ts
Reflect.getMetadata('design:paramtypes', CatsController);
// 👈 得到:[CatsService]

说明:构造函数需要一个类型为 CatsService 的参数。

这依赖于你在 tsconfig.json 中设置了:

json
"emitDecoratorMetadata": true,
"experimentalDecorators": true

2. 在容器中查找 CatsService 的实例

Nest 查询内部容器中是否有已构建的 CatsService

ts
const catsServiceInstance = container.get(CatsService); // ✅ 存在,复用

3. 调用构造函数并注入实例

Nest 用反射调用:

ts
const controllerInstance = new CatsController(catsServiceInstance);

就这样,你写的 constructor(private catsService: CatsService) 得到注入。

此时 catsService 属性已自动赋值。

Nest 构造函数注入全流程

text
NestFactory.create(AppModule)

扫描模块元数据 (@Module)

构建 Providers(CatsService)

构建 Controllers(CatsController)

读取 CatsController 构造函数参数类型 (Reflect.getMetadata)

在容器中查找/构建 CatsService 实例

调用构造函数 new CatsController(catsServiceInstance)

将控制器注册进路由系统,完成依赖注入

重点

类作为类型,是如何记录下来的?

当你写了:

ts
constructor(private catsService: CatsService) {}

TypeScript + emitDecoratorMetadata 会生成以下运行时元数据:

ts
{
  "design:paramtypes": [CatsService]
}

这就是 Nest 能拿到参数类型信息的关键。

是谁调用了构造函数?

Nest 的内部类 Injector 调用,它在构建实例时,会:

ts
new Constructor(...resolvedDependencies)

这些依赖是通过元数据拿到的。

总结:

问题解答
谁调用构造函数?Nest 的 IoC 容器调用(非用户代码)
何时调用?在模块初始化阶段,扫描 controller/providers 时
为什么能注入正确的类?利用 TS 装饰器元数据读取构造函数参数类型,并在容器中查找
构造函数参数后面的类是类型还是值?是类型,也是构造函数,TS 类兼具两种角色
private 有什么作用?简写方式,自动声明 + 初始化属性(见前面问题)
{} 里是否能写代码?✅ 可以写逻辑,但不建议写副作用代码

private的简写

ts
constructor(private catsService: CatsService) {}

这是 TypeScript 的简写语法,用于自动声明并初始化成员属性。

等价于以下完整写法:

ts
class CatsController {
  private catsService: CatsService;

  constructor(catsService: CatsService) {
    this.catsService = catsService;
  }
}

所以 private catsService: CatsService 的意思是:

  • 定义一个名为 catsService 的私有属性,类型为 CatsService
  • 同时将构造函数传入的参数赋值给这个属性

同一个控制器注入请求级和单例

控制器最终的作用域会由它依赖的“最小作用域级别”决定。请求级 > 单例,所以只要注入了请求级服务,控制器就会自动变成请求级。

控制器的作用域是由它的依赖链决定的

  • 如果你注入的 都是单例服务 → 控制器就是默认的单例
  • 如果你注入了 请求级服务 → 控制器会“冒泡”变成请求级
  • 如果你注入了 瞬态服务 → 控制器不会变成瞬态(瞬态不会影响上层作用域)
ts
// 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) 的语法糖

ts
// 写在 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() 这个糖而已 🍬。

ts
客户端请求 --> Nest 控制器方法被调用
              |
              |--> @Req() 自动注入 Request(语法糖)
              |
              |--> 你要用 req,就传下去
              |    或者在某个服务里用 @Inject(REQUEST) 注入它(Nest 框架帮你搞定)
用法控制器中是否可用服务中是否可用备注
@Req()✅ 是❌ 否只能用在方法参数中
@Inject(REQUEST)✅ 是(不常见)✅ 是(必须是请求级)用于构造函数注入

DI子树和DI树

想象你 Nest 项目的所有服务、控制器、模块、依赖关系构成了一棵树:

  • 根是 AppModule
  • 中间是各个模块、控制器
  • 叶子节点是各种服务(Service)

这棵树 Nest 在启动时一次性构造好,并且默认整个项目都用一套共享实例(即单例)

现在来了个请求,Nest 就走这个大树,找到响应控制器和服务,用已有的单例实例去处理请求。 这就是默认行为。

但如果你声明了:

ts
@Injectable({ scope: Scope.REQUEST })
export class UserContextService {}

Nest 就不能用全局的那棵树了,因为你要的这个服务必须每次请求都新建。 于是它会为你新建一棵“局部子树”,只复制那一部分需要请求级的服务。

这就是所谓的“请求级 DI 子树”。

服务类注册的两种方式

方式一:隐式绑定(推荐默认方式)

ts
// 注册:
providers: [AppService]

// 使用:
constructor(private readonly appService: AppService) {}

特点:

  • 自动以类名 AppService 作为 Token 注册和注入
  • 简洁、直观、90% 场景下够用
  • 对应的注入语法也是:constructor(private xxx: AppService)

方式二:显式绑定(useClass)

ts
// 注册:
providers: [
  {
    provide: 'MyServiceAlias', // 自定义 token
    useClass: MyService,       // 实际类
  },
]

// 使用:
constructor(@Inject('MyServiceAlias') private readonly myService: MyService) {}

特点:

  • 显式指定 Token 名称(可自定义、可多命名)
  • 多用于需要多个实现类或做别名
  • 也可用于替换某个服务的实现(比如单元测试、运行时切换)
方式语法简洁性灵活性推荐使用场景
[AppService]✅ 简洁❌ 不可自定义常规开发、默认使用方式
provide + useClass❌ 略繁琐✅ 高灵活多实现切换、别名注入、测试 mock 场景

真实例子

比如你有两个日志服务实现类:

ts
@Injectable()
export class FileLogger implements LoggerService {
  log() {
    console.log('写入文件');
  }
}

@Injectable()
export class ConsoleLogger implements LoggerService {
  log() {
    console.log('输出控制台');
  }
}

你可以在 AppModule 里动态选择使用哪一个:

ts
providers: [
  {
    provide: 'LoggerService',
    useClass: process.env.LOG_TO_FILE ? FileLogger : ConsoleLogger,
  },
]

然后注入时统一用:

ts
constructor(@Inject('LoggerService') private logger: LoggerService) {}

默认注册适合快速开发useClass 适合灵活配置或更复杂的依赖管理场景

在Module类中能写啥

虽然在大型项目中,AppModule 更多是作为“根模块”或“聚合模块”使用,但它不是不能写东西,而是:

常见用途包括

目的举例
注册全局服务如配置服务、日志服务、拦截器、异常过滤器等
提供一些“别名”或“策略”类LoggerAliasProvider
注册一些平台适配层如注册 Express 中间件、自定义管道、动态模块
注册全局常量、配置项useValue: { APP_CONFIG: xxx }

模块类”本身也可以注入依赖的体现,Nest 中的模块类不仅是个装饰器容器,它自己也可以作为被实例化的类被注入依赖

ts
@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()(用于初始化逻辑)

ts
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 本体几乎不会写逻辑。真正写逻辑的是服务类里的生命周期钩子

AppModuleconstructor() 中的逻辑,会早于 onModuleInit() 生命周期钩子执行。

因为:

  • constructor()类被实例化时立刻执行的 JS 标准行为(与 Nest 无关)
  • onModuleInit() 是 Nest 的生命周期钩子,在实例化完毕 & 依赖注入完成后才会执行
ts
AppModule constructor() --> onModuleInit() --> [NestApplication] Nest application successfully started
ts
@Module({})
export class AppModule implements OnModuleInit {
  constructor() {
    console.log('✅ AppModule constructor 执行');
  }

  onModuleInit() {
    console.log('✅ AppModule 生命周期钩子 onModuleInit 执行');
  }
}

输出:

ts
✅ AppModule constructor 执行
✅ AppModule 生命周期钩子 onModuleInit 执行

虽然 constructor 更早执行,但你应该 避免在 constructor 里写依赖相关的逻辑,因为此时:

  • 依赖注入还没完成
  • 依赖项可能是 undefined
  • 尤其在涉及数据库、配置服务时,容易出错

✅ 最推荐的初始化位置仍然是 onModuleInit(),因为这时:

  • 所有注入依赖都准备好了
  • 可安全访问注入服务,例如 ConfigServiceDataSource

通常在 AppModule 的构造函数中,写一些简单的人性化日志提示,比如:XXX服务器开始启动....

生命周期钩子的两种写法

写在模块类中

ts
@Module({
  imports: [...],
  providers: [...],
})
export class AppModule {
  onModuleInit() {
    console.log('AppModule initialized');
  }
}
  • 可以执行初始化逻辑
  • ❌ 但是:AppModule 不是 provider,Nest 只是「通过特殊处理」允许你定义生命周期钩子;
  • ❌ 无法使用依赖注入,不能访问 AppService 或其他服务;
  • ⚠️ 模块初始化粒度太粗,不适合做具体业务初始化。

写在 AppService 中(推荐)

ts
@Injectable()
export class AppService implements OnModuleInit {
  onModuleInit() {
    console.log('AppService initialized');
    // 可以访问 this.xxx,拿到数据库、配置等依赖
  }
}

✅ Nest 会自动调用该钩子;

✅ 可以访问自身注入的依赖(比如 this.configService);

✅ 更符合 单一职责原则(SRP):服务负责初始化自己,不搞模块级别控制;

✅ 更易测试、解耦、复用。

示例:全局初始化逻辑

推荐写一个 AppLifecycleService 专门负责「全局生命周期管理」:

ts
@Injectable()
export class AppLifecycleService implements OnModuleInit {
  constructor(private readonly configService: ConfigService) {}

  async onModuleInit() {
    await this.configService.load();
    console.log('全局初始化完成');
  }
}

然后在 AppModule 中注册它:

ts
@Module({
  providers: [AppLifecycleService],
})
export class AppModule {}

这样就 兼顾了“模块级初始化” + “依赖注入能力” + “结构清晰”

只为了启动时简单打印一句话,写在模块类中没大碍,简单快捷;但一旦需求复杂,就要用 provider 来写

完整的请求生命周期

plaintext
───────────────────────────────────────────────────────────────
📦 客户端请求


───────────────────────────────────────────────────────────────
📦 中间件 Middleware(Express/Fastify 层)
    → 处理原始请求(如CORS、静态资源、日志等)


───────────────────────────────────────────────────────────────
📦 守卫 Guard
    → 权限验证,是否放行
    └─ ❌ 若拒绝,则直接抛出异常 → 异常过滤器


───────────────────────────────────────────────────────────────
📦 拦截器 Interceptor(前置逻辑)
    → 方法执行前的前置操作(如记录时间、缓存等)


───────────────────────────────────────────────────────────────
📦 管道 Pipe(参数层面)
    → DTO验证、类型转换
    └─ ❌ 验证失败 → 抛出异常 → 异常过滤器


───────────────────────────────────────────────────────────────
📦 控制器方法(业务处理)



───────────────────────────────────────────────────────────────
📦 拦截器 Interceptor(后置逻辑)
    → 控制器方法返回后,对响应数据做加工(如统一包装)


───────────────────────────────────────────────────────────────
📦 返回响应


📦 客户端收到响应
───────────────────────────────────────────────────────────────

⚠️ 特殊情况:任何阶段抛出的异常
    → 由 异常过滤器(Exception Filter)捕获处理

各个环节:

阶段作用
中间件Express/Fastify 层,前端请求刚进入项目
守卫权限控制,决定是否执行控制器方法
拦截器 前方法执行前逻辑,如日志、缓存
管道参数校验与转换
控制器方法业务逻辑处理
拦截器 后方法返回后,加工返回数据
异常过滤器任何环节异常均由其统一处理与响应

中间件入口处,守卫控权限,管道改参数,拦截器包全程,有错走异常过滤器,返回响应给前端。

请求缓存机制

请求缓存是一种重要的性能优化手段。通过缓存机制,可以避免重复计算、减少数据库压力、缩短接口响应时间,提升整体系统性能。

请求缓存,顾名思义,就是将某个请求(通常是接口请求)的结果进行缓存,供后续同样请求直接使用,无需再次执行服务逻辑。

简而言之:

  • 第一次请求 → 正常处理,结果缓存
  • 后续相同请求 → 直接返回缓存数据

请求缓存的本质是为每个请求生成唯一的“缓存键”(Key),通过键值对的方式存储与查找缓存。

  • 缓存键:标识请求的唯一Key
  • 缓存值:对应请求的返回结果

通过“缓存键 ↔ 缓存值”映射关系,系统可以快速判断某个请求是否已经处理过。

缓存的关键在于如何生成唯一且合适的缓存键

默认方式(请求级缓存)

通常根据以下信息生成:

  • 请求方法(GET/POST)
  • 请求完整URL(含参数)
  • 可选:请求头、Body、Query等
ruby
缓存键 = GET:/posts/123

粒度精确,适合数据与参数紧密相关的接口。

自定义缓存键

开发者可以手动指定缓存键,例如为整个接口或模块设置同一个缓存Key。

场景:首页推荐文章,热门榜单,不依赖具体参数的公共数据

特点:粒度粗,适合缓存整体性数据。

执行流程

text
请求进入

检查缓存是否存在

[是] —> 直接返回缓存结果

[否] —> 执行业务逻辑 → 返回 → 存入缓存

trackBy() 返回的 key 是给框架底层作为缓存查找用的“唯一标识”

内部:

ts
const cacheKey = this.trackBy(context);
const result = cache.get(cacheKey);  // 用 key 查缓存

trackBy() 方法的任务,就是:

  • 生成并返回这个请求的缓存键(key)
  • 框架拿着这个 key 去查找缓存

源码本质(伪代码)

ts
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;
}
ts
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 就别再跑控制器了。”

错误理解:在前置拦截器中直接返回缓存的内容,这种思路在一些轻量框架里是对的,比如手写中间件时直接:

ts
if (cache.has(key)) {
  return cache.get(key);
}

但在 NestJS

  • CacheInterceptor 已经把“缓存查找与返回”机制封装好了;
  • 你只需要生成请求的缓存标识(key)
  • “查缓存、返回数据”这部分,Nest 帮你完成。

所以:Nest 的缓存机制是半自动的: 你只管“用 key 标记”,缓存的查找与返回是框架负责的。

最后更新时间: