NestJS 依赖注入与模块设计:容器如何接管你的对象
NestJS 用模块组织资源、用容器创建实例:构造函数里声明依赖,容器负责注入。本文讲清 Provider 的各种写法、作用域、全局模块、分层、横切能力的执行顺序与常见报错的排查路径。
- NestJS
- 概念
- 入门
- 更新于 2026-10-31
很多人在 NestJS 里第一次遇到「不能自己 new 一个依赖」时会觉得别扭:为什么不能 new PrismaService()?答案在于,NestJS 的价值恰恰在于替你管理对象的创建与生命周期——一旦你手动 new,依赖注入带来的可测试、可替换、可配置就全部失效了。
理解 NestJS 只需要抓住两条约定:一个资源一个模块,实例由容器创建。这两条一旦想明白,NestJS 的目录结构、报错信息、启动顺序就都不再是玄学。这篇把容器的运行机制、Provider 的各种写法、作用域、分层、横切能力执行顺序,以及最常见的那条报错该怎么查,全部讲透。
一、容器到底做了什么
NestJS 应用启动时,会扫描所有模块,解析出一张依赖图,然后按拓扑顺序实例化 Provider:
- 扫描
@Module()装饰器,收集 controllers、providers、imports、exports; - 读取每个 Provider 构造函数参数的依赖类型;
- 按依赖顺序创建实例:先创建被依赖的,再创建依赖它的;
- 把实例缓存起来,后续注入时直接复用。
这就是「控制反转」——对象的创建权从业务代码手里交给了容器。带来的直接收益:
| 收益 | 具体表现 |
|---|---|
| 可测试 | 单测里换成 mock Provider,业务代码一行不改 |
| 可替换 | 换实现只改 Provider 的绑定,使用方无感 |
| 不重复 | 默认单例作用域,一份实例处处复用,不会到处 new |
| 生命周期统一 | 配置、连接、清理都有明确钩子,不用散落在各处的副作用代码 |
容器管的是三件事:实例从哪来、活多久、谁能拿到。它不替你写业务逻辑,也不替你决定路由怎么设计。它只保证一件事:当 CardsService 声明「我要一个 PrismaService」时,容器能立刻递上一个已经初始化好的实例。
还有一个容易被忽略的好处:依赖缺失在启动阶段就暴露,而不是等到第一个请求打进来才报错。容器建不出实例就根本起不来,于是「忘了写 imports」这类问题在你打开浏览器之前就已经被拦下了。
二、模块:结构的最小单位
一个模块用 @Module() 声明四件事:
// server/src/cards/cards.module.ts
import { Module } from '@nestjs/common';
import { CardsController } from './cards.controller';
import { CardsService } from './cards.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [CardsController], // 管路由、收参数、返回响应
providers: [CardsService], // 管业务逻辑与数据访问
imports: [PrismaModule], // 依赖别的模块导出的能力
exports: [CardsService], // 导出之后别的模块才能注入它
})
export class CardsModule {}
四个字段的语义:
- controllers:这个模块对外暴露哪些 HTTP 端点;
- providers:这个模块自己提供的能力(服务、仓储、工厂、第三方 SDK 的适配器);
- imports:依赖哪些别的模块;
- exports:把哪些能力开放给外部。
exports 是最常被漏掉的一环:模块 A 导入了模块 B,但 B 没有 exports 自己的 Provider,A 里就注入不到它,容器会直接报「找不到依赖」。
根模块把一切串起来
// server/src/app.module.ts
import { Module } from '@nestjs/common';
import { CardsModule } from './cards/cards.module';
import { CommentsModule } from './comments/comments.module';
@Module({
imports: [CardsModule, CommentsModule],
})
export class AppModule {}
模块内部的标准分层
cards/
├── cards.module.ts # 组装:声明 controller / provider / import / export
├── cards.controller.ts # HTTP 层:路由、参数、状态码
├── cards.service.ts # 业务层:业务规则、编排
├── cards.repository.ts # 数据层:SQL / ORM 调用
└── dto/
├── create-card.dto.ts # 入参形状与校验规则
└── card.dto.ts # 出参形状
职责边界要清楚:Controller 只做参数收发,Service 只做业务,Repository 只做数据访问。业务逻辑沉在 Service,Controller 就不必知道数据库的存在,Service 也不必知道 HTTP 的存在——这样才能换存储、换协议、单独测试。
还有一点必须想明白:模块是注册表,不是文件夹。文件放在哪个目录由你和构建工具决定,真正决定边界的是 imports 与 exports。两个目录毫不相干的文件可以属于同一个模块,同一个目录下的文件也可以分属不同模块。
三、动手:从零搭一个 cards 模块
前面讲的都是概念,接下来把一个完整的业务模块搭出来。这套代码可以直接跑起来:cards 模块负责卡片接口,database 模块负责连接池,auth 模块负责认证,common 目录放全局横切能力。跑通之后,你对「模块边界」「容器注册」「请求生命周期」三件事的理解会比看十篇文章都扎实。
3.1 目录与依赖
server/
├── src/
│ ├── main.ts # 入口:装配全局能力、监听端口、优雅退出
│ ├── app.module.ts # 根模块:只做组装
│ ├── common/
│ │ ├── response.interceptor.ts # 统一响应体
│ │ └── http-exception.filter.ts # 统一错误格式
│ ├── auth/
│ │ ├── auth.module.ts # 认证模块 + 全局守卫注册
│ │ ├── auth.service.ts # 令牌校验
│ │ ├── jwt-auth.guard.ts # 认证守卫
│ │ └── public.decorator.ts # 标记公开接口
│ ├── database/
│ │ ├── database.module.ts # 全局模块:配置 → 工厂 → 连接池
│ │ ├── database.tokens.ts # 自定义注入令牌
│ │ └── pool-cleanup.ts # 关停时释放连接池
│ └── cards/
│ ├── cards.module.ts # 业务模块
│ ├── cards.controller.ts
│ ├── cards.service.ts
│ ├── cards.repository.ts
│ └── dto/
│ ├── create-card.dto.ts
│ ├── list-cards.query.dto.ts
│ └── card.dto.ts
├── package.json
└── tsconfig.json
{
"name": "nest-di-demo",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.4.0"
}
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
experimentalDecorators 和 emitDecoratorMetadata 这两项是硬性前提:少了前者装饰器语法直接编译报错,少了后者容器读不到构造函数参数的类型元数据,依赖注入无从谈起。
3.2 入口文件 main.ts
入口文件是整个应用的装配台:横切能力在这里注册,端口在这里监听,关停钩子在这里开启。
// server/src/main.ts —— 应用入口:装配全局横切能力 + 监听端口 + 优雅退出
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/http-exception.filter';
import { ResponseInterceptor } from './common/response.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局管道:入参白名单 + 类型转换,多余字段直接拒绝
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// 全局拦截器:把控制器返回值统一包成 { ok: true, data }
app.useGlobalInterceptors(new ResponseInterceptor());
// 全局过滤器:把任何异常统一成 { ok: false, error }
app.useGlobalFilters(new HttpExceptionFilter());
// 端口来自环境变量,默认 3000;真实密钥只放 .env,仓库里只提交 .env.example
const port = Number(process.env.PORT ?? 3000);
// 开启关停钩子:收到 SIGTERM / SIGINT 时才触发 onModuleDestroy 等回调
app.enableShutdownHooks();
await app.listen(port);
Logger.log(`服务已启动,监听端口 ${port}`, 'Bootstrap');
}
bootstrap();
3.3 根模块 app.module.ts
// server/src/app.module.ts —— 根模块只做组装,不写业务
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { CardsModule } from './cards/cards.module';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [
DatabaseModule, // 基础设施:全局模块,装一次到处可用
AuthModule, // 认证:注册全局守卫
CardsModule, // 业务模块
],
})
export class AppModule {}
3.4 业务模块 cards.module.ts
// server/src/cards/cards.module.ts —— 声明本模块提供什么、用什么、对外给什么
import { Module } from '@nestjs/common';
import { CardsController } from './cards.controller';
import { CardsRepository } from './cards.repository';
import { CardsService } from './cards.service';
@Module({
controllers: [CardsController],
providers: [CardsService, CardsRepository],
// 注意:imports 里没有 DatabaseModule —— 它是 @Global(),全局模块不用重复导入
exports: [CardsService],
})
export class CardsModule {}
3.5 控制器 cards.controller.ts
// server/src/cards/cards.controller.ts —— HTTP 层:只管路由、参数与状态码
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { CardsService } from './cards.service';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';
@Controller('cards')
export class CardsController {
constructor(private readonly cardsService: CardsService) {}
@Get()
list(@Query() query: ListCardsQueryDto) {
return this.cardsService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.cardsService.findOne(id);
}
@Post()
create(@Body() dto: CreateCardDto) {
return this.cardsService.create(dto);
}
}
3.6 服务 cards.service.ts
// server/src/cards/cards.service.ts —— 业务层:规则与编排,不知道 HTTP 也不直接写 SQL
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CardsRepository } from './cards.repository';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';
@Injectable()
export class CardsService {
constructor(private readonly repository: CardsRepository) {}
findAll(query: ListCardsQueryDto) {
return this.repository.findAll(query);
}
async findOne(id: number) {
const card = await this.repository.findOne(id);
// 业务层负责把「查不到」翻译成对 HTTP 友好的异常
if (!card) throw new NotFoundException(`卡片 ${id} 不存在`);
return card;
}
async create(dto: CreateCardDto) {
const title = dto.title.trim();
if (!title) throw new BadRequestException('标题不能为空');
return this.repository.create({ ...dto, title });
}
}
3.7 仓储 cards.repository.ts
// server/src/cards/cards.repository.ts —— 数据层:只负责 SQL,不含业务判断
import { Inject, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
import { DATABASE_POOL } from '../database/database.tokens';
import { CardDto } from './dto/card.dto';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';
@Injectable()
export class CardsRepository {
// 用自定义令牌注入,构造函数的类型元数据只用来识别这个类
constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}
async findAll(query: ListCardsQueryDto): Promise<CardDto[]> {
const limit = query.limit ?? 20;
// 参数化查询:值走占位符,不拼字符串
const { rows } = await this.pool.query(
'SELECT id, title, content, created_at FROM cards ORDER BY id DESC LIMIT $1',
[limit],
);
// 数据库的蛇形列名转成对外的驼峰字段
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
createdAt: row.created_at,
}));
}
async findOne(id: number): Promise<CardDto | null> {
const { rows } = await this.pool.query(
'SELECT id, title, content, created_at FROM cards WHERE id = $1',
[id],
);
if (rows.length === 0) return null;
const row = rows[0];
return { id: row.id, title: row.title, content: row.content, createdAt: row.created_at };
}
async create(dto: CreateCardDto): Promise<CardDto> {
const { rows } = await this.pool.query(
'INSERT INTO cards (title, content) VALUES ($1, $2) RETURNING id, title, content, created_at',
[dto.title, dto.content ?? ''],
);
const row = rows[0];
return { id: row.id, title: row.title, content: row.content, createdAt: row.created_at };
}
}
3.8 DTO:入参校验与出参形状
// server/src/cards/dto/create-card.dto.ts —— 入参形状与校验规则
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateCardDto {
@IsString()
@MinLength(1)
@MaxLength(120)
title: string;
@IsOptional()
@IsString()
@MaxLength(2000)
content?: string;
}
// server/src/cards/dto/list-cards.query.dto.ts —— 查询参数也要过校验
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListCardsQueryDto {
@IsOptional()
@Type(() => Number) // 配合全局管道的 transform,字符串先转数字再校验
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
// server/src/cards/dto/card.dto.ts —— 出参形状:接口返回什么由这里定义
export class CardDto {
id: number;
title: string;
content: string;
createdAt: string;
}
DTO 的写法细节和前后端共享类型的那套思路可以对照着看,站内《前后端共享 DTO 类型》讲得更细。
3.9 跑起来看看
npm install # 安装依赖
npm run start:dev # 开发模式启动,文件改动自动重启
# 请求卡片列表:认证守卫默认拦截,这里先看路由与错误格式是否生效
try {
Invoke-WebRequest -Uri 'http://localhost:3000/cards' -UseBasicParsing |
Select-Object -ExpandProperty Content
} catch {
# 401 在 PowerShell 里以异常形式抛出,响应体要从异常里取
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$reader.ReadToEnd()
}
正常情况下你会拿到 401,而错误体已经是 { ok: false, error: { status: 401, ... } } 这个形状——说明全局过滤器生效了。带上令牌再试一次:
# 演示版 AuthService 只认 demo.开头的令牌
Invoke-WebRequest -Uri 'http://localhost:3000/cards' -Headers @{ Authorization = 'Bearer demo.1' } -UseBasicParsing |
Select-Object -ExpandProperty Content
这一步会打到数据库:上面的查询语句假定 cards 表已经存在,文章里只给了读写语句,没有给建表脚本。表没建好时仓储会抛数据库错误,全局过滤器会把它收成 500 的统一格式——这正好演示了异常兜底的作用。想跳过认证也可以给接口加 @Public()(见第八节)。
四、Provider:容器的供给方式
providers 数组里的每一项都是一个 Provider,不止是类。
@Module({
providers: [
// 1) 类:最常见,需要 @Injectable() 标记
CardsService,
// 2) useClass:同一个类换实现
{ provide: CardsService, useClass: MockCardsService },
// 3) useValue:直接给一个现成的值
{ provide: LOGGER, useValue: console },
// 4) useFactory:运行时根据配置创建
{
provide: DB_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => createClient(config.get('DATABASE_URL')),
},
// 5) useExisting:把已存在的另一个 Provider 别名过来
{ provide: CardStore, useExisting: CardsRepository },
],
})
export class CardsModule {}
五种供给方式的区别,一句话各说清楚:
| 写法 | 容器做什么 | 典型场景 |
|---|---|---|
| 类 | 用类型元数据反射出构造函数,new 出来并注入依赖 | 绝大多数业务服务 |
useClass |
换一个类的实现来满足同一个令牌 | 同一接口的两种实现,按环境切换 |
useValue |
直接给一个现成对象,不再创建 | 常量、测试替身、外部已建好的实例 |
useFactory |
调你的工厂函数,由你决定怎么造 | 需要配置、SDK、异步初始化 |
useExisting |
给已存在的 Provider 换个别名,不新建实例 | 同一能力换名字对外提供 |
useClass 与 useExisting 的差别经常被问:useClass 会新建一个实例,useExisting 复用原实例。需要「另一个令牌指向同一个单例」时用 useExisting;需要「同令牌换实现」时用 useClass。
自定义注入令牌
当注入的不是类(比如一个字符串、一个配置值、一个第三方 SDK 的实例)时,用令牌(token):
// 用 Symbol 更安全,不会和别的字符串撞车
export const DB_CLIENT = Symbol('DB_CLIENT');
@Injectable()
export class CardsService {
constructor(@Inject(DB_CLIENT) private readonly db: DatabaseClient) {}
}
令牌既可以是字符串,也可以是 Symbol。用字符串时全项目要保持完全一致(大小写敏感),否则容器会安静地找不到依赖;用 Symbol 则天然不会撞车。真实的项目里建议把令牌集中放在 tokens.ts 之类的文件里导出,避免同一个令牌在两处各写一遍。
构造函数注入的原理
@Injectable()
export class CardsService {
constructor(private readonly prisma: PrismaService) {}
}
看起来只是普通构造函数参数,但 emitDecoratorMetadata 会在编译期为参数类型发射元数据,容器据此知道要注入哪个 Provider。这就是为什么 NestJS 项目必须开 experimentalDecorators 和 emitDecoratorMetadata——少了前者装饰器语法直接报错,少了后者容器无法解析依赖类型。
第三方 SDK 怎么办
PrismaService 这类自己写的类加 @Injectable() 即可。但很多第三方 SDK 的类无法加装饰器(不是你写的),这时用工厂:
{
provide: PrismaClient,
inject: [ConfigService],
useFactory: (config: ConfigService) => new PrismaClient({ datasources: { url: config.get('DATABASE_URL') } }),
},
这样容器照样能管理它的创建时机。
五、Provider 工厂与配置注入:从环境变量到数据库客户端
这一节把第四节里的 useFactory 展开成一条完整的链路:环境变量 → ConfigModule → 工厂 → 全局模块导出 → Service 注入。
5.1 不要在业务代码里直接读 process.env
// 反例:每个用到配置的地方都自己读环境变量,测试时无法替换
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
问题不在于读环境变量,而在于读完之后这个值就固化在代码里了:测试里没法换、类型是 string | undefined、拼错 key 不会报错。正确做法是让配置成为容器里的一等公民。
5.2 配置模块
// server/src/database/database.module.ts —— 全局基础设施:配置 → 工厂 → 连接池
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
import { DATABASE_POOL } from './database.tokens';
import { PoolCleanup } from './pool-cleanup';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 配置在全局可见,业务模块不用重复导入
envFilePath: ['.env.local', '.env'],
cache: true, // 缓存读取结果,避免反复读文件
}),
],
providers: [
{
// 自定义令牌 + 工厂:容器负责创建连接池
provide: DATABASE_POOL,
inject: [ConfigService], // 声明工厂需要什么,容器负责喂进来
useFactory: (config: ConfigService) => {
// getOrThrow:拿不到就抛错,而不是把 undefined 传下去
const connectionString = config.getOrThrow<string>('DATABASE_URL');
return new Pool({ connectionString, max: 10 });
},
},
PoolCleanup, // 关停时释放连接池
],
exports: [DATABASE_POOL], // 导出令牌,业务模块才能注入
})
export class DatabaseModule {}
// server/src/database/database.tokens.ts —— 令牌集中管理
export const DATABASE_POOL = Symbol('DATABASE_POOL');
这段代码里有四个要点值得单独记住:
inject声明工厂的依赖。工厂不是凭空能拿到东西的,inject: [ConfigService]就是在告诉容器「调用它之前,先把ConfigService建好传进来」。useFactory可以是异步的。返回 Promise 时容器会等它完成再注入下游,异步初始化因此不必塞进构造函数。getOrThrow比get更安全。get拿不到时返回undefined,错误会推迟到第一次用连接池时才爆出来;getOrThrow在启动时就抛错。@Global()+exports让能力到处可用。业务模块不用写imports: [DatabaseModule],直接@Inject(DATABASE_POOL)就行。
5.3 Service 里注入
// server/src/cards/cards.repository.ts —— 工厂造好的连接池,在这里被注入
@Injectable()
export class CardsRepository {
constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}
}
到这里,整条链路闭合了:环境变量在 .env 里 → ConfigModule 读进来 → 工厂用配置造出连接池 → 全局模块导出令牌 → 仓储注入令牌。业务代码里没有一行 process.env,也没有一行 new Pool()。
5.4 工厂造出来的资源要负责关掉
连接池是工厂创建的,但释放它的责任得有人认领——通常是实现生命周期钩子的 Provider:
// server/src/database/pool-cleanup.ts —— 关停时释放连接池
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';
import { DATABASE_POOL } from './database.tokens';
@Injectable()
export class PoolCleanup implements OnModuleDestroy {
constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}
async onModuleDestroy() {
await this.pool.end(); // 关闭所有空闲连接
}
}
这条规则值得单独记住:容器负责创建,就该由容器负责销毁。自己 new 出来的东西没人会替你关。
六、作用域:一份实例还是每个一份
Provider 默认是单例(DEFAULT scope):容器创建一次,之后一直复用。大多数服务都该是单例。
@Injectable({
scope: Scope.TRANSIENT, // 每次注入都新建一个实例
})
export class RequestContextService {}
@Injectable({
scope: Scope.REQUEST, // 每个请求一份
})
export class UserContextService {}
三种作用域:
| 作用域 | 实例数量 | 适用 | 代价 |
|---|---|---|---|
| DEFAULT(单例) | 整个应用一份 | 无状态服务、数据库客户端 | 无 |
| TRANSIENT | 每次注入新建 | 需要独立状态的轻量工具 | 每次注入都创建,注意别在单例里注入它 |
| REQUEST | 每个请求一份 | 需要请求上下文(当前用户、租户) | 依赖链会整体降级为请求作用域,实例不再复用 |
REQUEST 作用域会向上传播:如果一个单例 Provider 注入了请求作用域的 Provider,容器会把这个单例也变成请求作用域(否则单例里存着上一个请求的状态就出事了)。这是性能敏感场景里的常见坑。
选型的判断顺序很简单:默认用单例;确实需要每次一份的独立状态,才用 TRANSIENT;只有真的要读当前请求的上下文时,才用 REQUEST,并且要意识到它会把整条依赖链降级。
七、全局模块:把基础设施提出来
数据库客户端、配置这类每个模块都要用的东西,做成全局模块一次注册:
// server/src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
业务模块就不用再 imports: [PrismaModule] 了,直接注入即可。
什么时候用全局模块:配置、数据库连接、消息队列这类真正的基础设施,用它;普通的业务模块不要图省事全做成全局——模块之间的依赖关系越清晰,越容易排查问题。
配置模块的标准写法
// server/src/config/config.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
cache: true,
}),
],
exports: [ConfigModule],
})
export class AppConfigModule {}
真实密钥只放 .env,仓库里只提交 .env.example。
全局模块是一种可见性豁免,不是一种「更好的模块」。用它的代价是:模块之间的依赖关系不再写在代码里,排查时得靠记忆。所以只给真正的基础设施开这个口子。
八、横切能力:同一个请求会依次经过哪些关卡
NestJS 把「每个请求都要做的事」做成了可插拔的关卡,它们的执行顺序是固定的:
Middleware → Guards → Interceptors(前置)→ Pipes → 控制器方法 → Service
↓
Interceptors(后置)→ Filters(异常兜底)
| 关卡 | 职责 | 典型用途 |
|---|---|---|
| Middleware | Express 层中间件 | 请求日志、原始请求体处理 |
| Guard | 能不能进 | JWT 校验、角色授权 |
| Interceptor | 包装执行 | 统一响应体、耗时统计、超时 |
| Pipe | 入参校验与转换 | ValidationPipe + DTO |
| Filter | 异常兜底 | 统一错误格式 |
| Controller / Service | 真正的业务 |
这四件套(认证 Guard、统一响应体、统一错误格式、全局管道注册)装在 main.ts 里,这就是横切能力的全部装配工作。
Guard:认证与授权
守卫依赖的认证服务自己实现,演示版只认 demo. 开头的令牌:
// server/src/auth/auth.service.ts —— 校验并解析令牌
import { Injectable, UnauthorizedException } from '@nestjs/common';
export interface User {
id: number;
name: string;
}
@Injectable()
export class AuthService {
// 演示实现:真实项目应校验 JWT 的签名、有效期与吊销状态
verify(token: string): User {
if (!token.startsWith('demo.')) throw new UnauthorizedException('令牌无效');
const id = Number(token.slice('demo.'.length));
if (!Number.isInteger(id)) throw new UnauthorizedException('令牌无效');
return { id, name: `用户 ${id}` };
}
}
// server/src/auth/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: User }>();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedException('缺少令牌');
request.user = this.authService.verify(token);
return true;
}
}
Guard 返回 false 或抛异常时,请求不会进入控制器。这是鉴权的正确位置——不要在 Service 里手写 if (!user) throw。
要让守卫默认拦住所有接口、个别接口放行,靠元数据标记:
// server/src/auth/public.decorator.ts —— 用元数据标记「这个接口不需要登录」
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// server/src/auth/jwt-auth.guard.ts —— 在原来的 canActivate 开头加一段放行逻辑
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
// 标记了 @Public() 的接口直接放行:先看方法,再看控制器
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// 下面是原来的令牌校验逻辑
const request = context.switchToHttp().getRequest<{ user?: User }>();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedException('缺少令牌');
request.user = this.authService.verify(token);
return true;
}
}
// server/src/auth/auth.module.ts —— 用 APP_GUARD 把守卫注册成全局守卫
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
providers: [
AuthService,
// APP_GUARD 让容器负责创建守卫,守卫内部才能注入 AuthService、Reflector
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
exports: [AuthService],
})
export class AuthModule {}
用 APP_GUARD 而不是 app.useGlobalGuards(new JwtAuthGuard()) 的原因很实际:前者由容器创建,守卫内部可以注入依赖;后者传进去的是你自己 new 出来的对象,它的构造函数只能吃字面量。
Interceptor:统一响应体
// server/src/common/response.interceptor.ts
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, { ok: true; data: T }> {
intercept(_context: ExecutionContext, next: CallHandler<T>) {
return next.handle().pipe(map((data) => ({ ok: true, data })));
}
}
Filter:统一错误格式
// server/src/common/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
response.status(status).json({
ok: false,
error: { status, message: status === 500 ? '服务器内部错误' : (exception as Error).message },
});
}
}
这三个关卡组合起来,前端拿到的响应格式就是稳定的:成功一律 { ok: true, data },失败一律 { ok: false, error: { status, message } }。
同一个请求里,它们各自做了什么
以一次 POST /cards 为例(下面是按代码推导出的预期结果,不是某次抓包的实测输出):
| 阶段 | 谁在处理 | 这一步做了什么 | 失败时的表现 |
|---|---|---|---|
| Middleware | Express | 记录原始请求 | 交给下一层 |
| Guard | JwtAuthGuard |
校验令牌,失败直接抛 401 | 401,且不会进入管道与控制器 |
| Interceptor 前置 | ResponseInterceptor |
记录开始时间,准备包装返回值 | 交给下一层 |
| Pipe | ValidationPipe |
校验并转换 CreateCardDto |
400,message 是校验文案数组 |
| Controller | CardsController |
取出 DTO,调用 Service | — |
| Service | CardsService |
执行业务规则,调用 Repository | 抛 NotFoundException 则转成 404 |
| Interceptor 后置 | ResponseInterceptor |
把返回值包成 { ok: true, data } |
— |
| Filter | HttpExceptionFilter |
兜住所有异常,统一成 { ok: false, error } |
500 时隐藏内部细节 |
注意 Guard 抛出的异常同样会经过 Filter,所以 401 也是 { ok: false, error: { status: 401, message: '缺少令牌' } } 这种形状。这正是把 Filter 注册成全局的意义:不管异常从哪个关卡冒出来,出口只有一个。
成功与失败的响应大致是这样(预期形状):
{
"ok": true,
"data": { "id": 12, "title": "周会纪要", "content": "", "createdAt": "2026-10-31T09:12:03.000Z" }
}
{
"ok": false,
"error": { "status": 404, "message": "卡片 999 不存在" }
}
九、生命周期钩子
Provider 可以实现接口,让容器在合适的时机调用它:
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect(); // 容器创建完实例后调用
}
async onModuleDestroy() {
await this.$disconnect(); // 应用关闭时调用,用于释放连接
}
}
常用的还有 OnApplicationBootstrap(所有模块初始化完成后)、OnApplicationShutdown。数据库连接、消息队列订阅、定时任务注册都放在这里,而不是塞进构造函数——构造函数里做异步初始化,容器是等不到的。
四个钩子的时机差别不大,但排查问题时很管用:
| 钩子 | 触发时机 | 适合放什么 |
|---|---|---|
OnModuleInit |
实例创建后、模块初始化阶段 | 建立连接、预热缓存 |
OnApplicationBootstrap |
所有模块 OnModuleInit 之后 |
依赖别的模块已就绪的启动逻辑 |
OnModuleDestroy |
收到关停信号、模块销毁阶段 | 释放连接、停止消费 |
OnApplicationShutdown |
全部销毁流程走完之后 | 最后的收尾与上报 |
钩子要写成 async 并 await:容器会等你的 Promise 结束,没 await 的话进程可能在清理完成前就退出了。而且要先在 main.ts 里调用 app.enableShutdownHooks(),否则收到 SIGTERM 时这些钩子根本不会被触发——这是「服务重启时连接没关干净」这类问题的第一嫌疑。
十、测试:依赖注入最大的回报
因为实例是容器给的,测试时可以直接把真实 Provider 换成 mock:
// server/src/cards/cards.service.spec.ts
import { Test } from '@nestjs/testing';
import { CardsService } from './cards.service';
import { CardsRepository } from './cards.repository';
describe('CardsService', () => {
let service: CardsService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CardsService, CardsRepository],
})
.overrideProvider(CardsRepository)
.useValue({ findAll: () => [{ id: 1, title: '测试卡片' }] })
.compile();
service = moduleRef.get(CardsService);
});
it('应当返回全部卡片', () => {
expect(service.findAll()).toEqual([{ id: 1, title: '测试卡片' }]);
});
});
业务代码里没有任何 new,也没有任何 if (process.env.NODE_ENV === 'test'),测试替身却能干净地插进去。这就是「不自己 new」换来的东西。
overrideProvider 有三个要点:
- 令牌必须对得上。注入用的是类就
overrideProvider(CardsService),用的是自定义令牌就overrideProvider(DATABASE_POOL)——令牌不一致时 mock 不会生效,你会看到「mock 好像没起作用」。 - 被替换的 Provider 得在当前测试模块里存在,否则会报「找不到这个 Provider 可以覆盖」。
useValue替掉的是整个实例,原类的构造函数不会再执行,它自己的依赖也不会被解析。这正是我们想要的:把仓储换成假实现之后,连接池根本不会被创建。
连模块一起测
单测测的是「一个类 + 假依赖」。如果想连控制器一起测,把整个模块装进来,只替换最底层的仓储:
// server/src/cards/cards.module.spec.ts —— 模块级测试:整条依赖链都在,只换最底层
import { Test } from '@nestjs/testing';
import { CardsController } from './cards.controller';
import { CardsModule } from './cards.module';
import { CardsRepository } from './cards.repository';
describe('CardsModule', () => {
let controller: CardsController;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [CardsModule],
})
.overrideProvider(CardsRepository)
.useValue({
findAll: jest.fn().mockResolvedValue([{ id: 1, title: '测试卡片' }]),
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 2, title: '新卡片' }),
})
.compile();
controller = moduleRef.get(CardsController);
});
it('列表接口应返回仓储给的数据', async () => {
await expect(controller.list({ limit: 10 })).resolves.toEqual([
{ id: 1, title: '测试卡片' },
]);
});
it('查不到卡片时应抛出 NotFoundException', async () => {
await expect(controller.findOne(999)).rejects.toThrow('卡片 999 不存在');
});
});
两种测试各有用处:单测快、定位准;模块级测试能验证「模块声明写对了没有」——比如 exports 漏写,模块级测试会直接报依赖找不到,而单测根本发现不了。
十一、最常见的报错怎么查
NestJS 报依赖找不到时,信息其实给得很全:
Nest can't resolve dependencies of the CardsService (?). Please make sure that the argument PrismaService at index [0] is available in the CardsModule context.
按这个顺序查三处:
CardsModule的imports里有没有PrismaModule—— 没有它,容器不知道 PrismaService 从哪来;PrismaModule的exports里有没有PrismaService—— 导入了但没导出,等于没导入;- Provider 有没有注册进
providers—— 写了@Injectable()但没列进数组,容器根本不知道这个类存在。
其它高频报错:
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
Nest can't resolve dependencies of the X (?). |
上面三处之一 | 按 imports → exports → providers 顺序查 |
| 注入的是第三方 SDK,提示找不到 | SDK 类没有类型元数据 | 改成 useFactory 提供 |
Circular dependency between X and Y |
两个 Provider 互相依赖 | 先审视边界;确实需要时用 forwardRef(() => Y) |
启动时报 design:paramtypes 相关错误 |
少了 emitDecoratorMetadata |
补上 tsconfig 选项 |
| 单例里读到了上一个请求的数据 | 请求作用域的依赖被单例持有 | 别在单例里注入请求作用域的 Provider |
| 单测里 mock 不生效 | Provider 没进 overrideProvider |
确认用的是同一个令牌(类或自定义 token) |
| 改完配置不生效 | 配置没走 ConfigModule | 检查 .env 加载路径与 cache 设置 |
报错说 argument at index [0],但类明明写了 |
类没加 @Injectable(),或没进 providers |
补装饰器并注册进模块 |
| 用自定义令牌注入报找不到 token | provide 与 @Inject 指向的不是同一个令牌 |
用 Symbol 并把令牌集中导出 |
overrideProvider 报「找不到可覆盖的 Provider」 |
该 Provider 不在当前测试模块里 | 先把它加进模块,或在测试里用 providers 直接声明 |
| 全局管道没生效 | 管道没在 main.ts 全局注册,或注册在了子模块 |
用 app.useGlobalPipes(...) 或 APP_PIPE |
| 认证没拦住某些接口 | 守卫只挂在部分路由上 | 用 APP_GUARD 全局注册,再用 @Public() 放行 |
响应没被包成 { ok, data } |
拦截器没全局注册 | 在 main.ts 里 app.useGlobalInterceptors(...) |
| 关停时连接没释放 | 没开 app.enableShutdownHooks() |
在 main.ts 里开启 |
| 关停钩子没执行完 | 钩子是同步返回,没 await |
钩子写成 async 并 await |
工厂拿到的配置是 undefined |
用了 config.get 而不是 getOrThrow |
换 getOrThrow,让错误在启动时暴露 |
循环依赖的正确处理方式
forwardRef 是应急手段,不是设计目标:
@Injectable()
export class CardsService {
constructor(@Inject(forwardRef(() => CommentsService)) private readonly comments: CommentsService) {}
}
看到循环依赖,先问一句:这两个东西是不是放错了模块? 绝大多数循环依赖是分层没划对的表现——把两个 Service 合并,或者把共用逻辑提到第三个模块,比加 forwardRef 更能治本。
十二、参考资料
- NestJS 官方文档
- NestJS:自定义 Provider —— useClass / useValue / useFactory / useExisting 与注入令牌
- NestJS:模块 —— imports / exports 的语义
- NestJS:模块参考 —— @Module() 各个字段的完整定义
- NestJS:作用域
- NestJS:请求生命周期与横切关卡
- NestJS:执行上下文 —— Guard / Interceptor / Filter 拿请求上下文的方式
- NestJS:测试
- NestJS:循环依赖
- NestJS:生命周期事件
- NestJS:动态模块 —— 按配置注册模块的写法
- NestJS:守卫 —— 全局守卫与 APP_GUARD
- NestJS:拦截器
- NestJS:管道与 ValidationPipe
- NestJS:异常过滤器
- NestJS:中间件
- NestJS:自定义装饰器 —— @Public() 这类元数据标记
- NestJS:配置模块 —— ConfigModule 与 ConfigService
- NestJS:Prisma 集成 —— PrismaService 的模块化写法
- NestJS:AsyncLocalStorage 配方 —— 请求上下文的另一种传递方式
- 前后端共享 DTO 类型 —— DTO 在共享层里的写法
- SQL 参数化查询 —— 仓储层里占位符与拼接的区别