记一些Nest.js的一些坑或技巧
约 6612 字大约 22 分钟
2025-06-24
使用 @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 helmetimport 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": true2. 在容器中查找 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 层,前端请求刚进入项目 |
| 守卫 | 权限控制,决定是否执行控制器方法 |
| 拦截器 前 | 方法执行前逻辑,如日志、缓存 |
| 管道 | 参数校验与转换 |
| 控制器方法 | 业务逻辑处理 |
| 拦截器 后 | 方法返回后,加工返回数据 |
| 异常过滤器 | 任何环节异常均由其统一处理与响应 |
中间件入口处,守卫控权限,管道改参数,拦截器包全程,有错走异常过滤器,返回响应给前端。