依赖注入与 IoC 容器
一、IoC 概念
控制反转(Inversion of Control,IoC):把"我主动创建依赖"变成"容器替我创建依赖,我只声明需要什么"。
没有 IoC:
class UserService {
// 硬编码:UserService 需要知道如何创建 UserRepository 和 DatabaseConnection
private repo = new UserRepository(
new DatabaseConnection('postgres://localhost:5432/mydb')
);
}问题:
- 耦合:更换数据库驱动需要修改
UserService - 不可测试:无法注入 Mock Repository
- 资源浪费:每个
UserService实例都创建独立的数据库连接
有 IoC:
@Injectable()
class UserService {
constructor(private repo: UserRepository) {}
// UserService 不知道也不关心 UserRepository 如何创建
// 由 IoC 容器负责创建并注入
}二、NestJS DI 容器工作原理
工作流程
1. 启动时扫描模块树
AppModule → UsersModule → [UserService, UserRepository]
2. 读取依赖元数据
Reflect.getMetadata('design:paramtypes', UserService)
→ [UserRepository]
3. 递归解析依赖
UserRepository 依赖 DataSource
DataSource 依赖 ConfigService
ConfigService 无依赖(叶子节点)
4. 按依赖顺序实例化(叶子先)
ConfigService → DataSource → UserRepository → UserService
5. 注入
new UserService(userRepositoryInstance)容器存储结构
每个 Module 都有自己的容器上下文:
// 简化的容器结构(概念图)
{
"UsersModule": {
providers: Map {
UserService → { instance: UserService{...}, scope: 'SINGLETON' },
UserRepository → { instance: UserRepository{...}, scope: 'SINGLETON' },
}
},
"AppModule": {
providers: Map {
ConfigService → { instance: ConfigService{...}, scope: 'SINGLETON' },
}
}
}三、Provider 的四种形式
1. 类 Provider(最常用)
providers: [UserService]
// 完整形式等价于:
providers: [{
provide: UserService, // Token(用于查找)
useClass: UserService, // 实现(用于实例化)
}]Token 和 Class 可以不同,实现接口替换:
// 生产环境用真实服务
providers: [{
provide: UserRepository,
useClass: PostgresUserRepository,
}]
// 测试环境用内存实现
providers: [{
provide: UserRepository,
useClass: InMemoryUserRepository,
}]2. 值 Provider(注入常量/配置)
providers: [
{
provide: 'APP_CONFIG',
useValue: {
apiUrl: 'https://api.example.com',
timeout: 5000,
},
},
{
provide: 'NODE_ENV',
useValue: process.env.NODE_ENV,
},
]
// 注入时用 @Inject()
@Injectable()
class SomeService {
constructor(
@Inject('APP_CONFIG') private config: { apiUrl: string; timeout: number },
) {}
}3. 工厂 Provider(需要异步或条件创建)
providers: [
{
provide: 'DB_CONNECTION',
useFactory: async (config: ConfigService): Promise<DataSource> => {
const dataSource = new DataSource({
type: 'postgres',
url: config.get('DATABASE_URL'),
entities: [User, Post],
synchronize: false,
});
await dataSource.initialize();
return dataSource;
},
inject: [ConfigService], // 声明工厂函数的依赖
},
]工厂 Provider 是实现异步初始化的关键——useFactory 可以是 async 函数,NestJS 会等待它 resolve 后再继续。
4. 别名 Provider(一个 Token 指向另一个)
providers: [
UserService,
{
provide: 'USER_SERVICE', // 字符串 Token
useExisting: UserService, // 指向已有的 Provider,复用同一实例
},
]
// 适用于:需要兼容旧代码中使用字符串 Token 注入的场景四、作用域(Scope)
| 作用域 | 生命周期 | 使用场景 |
|---|---|---|
DEFAULT(单例) | 整个应用共享一个实例,从启动到关闭 | 无状态 Service(默认,推荐) |
REQUEST | 每个 HTTP 请求创建新实例 | 需要每请求独立状态(租户 ID、用户上下文) |
TRANSIENT | 每次注入到消费者时创建新实例 | 需要独立内部状态、不可共享 |
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
// 每个请求有自己的实例
private requestData: Record<string, any> = {};
}
@Injectable({ scope: Scope.TRANSIENT })
export class TransientLogger {
private prefix: string;
setPrefix(prefix: string) { this.prefix = prefix; }
log(msg: string) { console.log(`[${this.prefix}] ${msg}`); }
}REQUEST 作用域的传染性
⚠️ 重要: REQUEST 作用域会向上传染。
如果一个 REQUEST 作用域的 Provider 被注入到另一个 Provider,那个 Provider 也会变成 REQUEST 作用域:
Controller(默认 DEFAULT)
└── UserService(DEFAULT)
└── TenantService(REQUEST) ← 传染
↑
UserService 被强制变为 REQUEST 作用域
↑
Controller 也被强制变为 REQUEST 作用域影响: 每次请求都会创建新的 Controller 和 UserService 实例,带来一定的性能开销(GC 压力增大)。
建议: 若只需要读取请求上下文(如 userId),优先使用 AsyncLocalStorage 或 cls-hooked,而非直接使用 REQUEST 作用域。
五、注入 Token 的选择
NestJS 支持三种 Token 类型:
// 1. 类(最推荐,有类型推断)
providers: [UserService]
constructor(private userService: UserService) {}
// 2. 字符串(需要 @Inject,易于拼写错误)
providers: [{ provide: 'DB', useValue: db }]
constructor(@Inject('DB') private db: Database) {}
// 3. Symbol(避免字符串冲突,推荐用于库开发)
export const DB_TOKEN = Symbol('DB');
providers: [{ provide: DB_TOKEN, useValue: db }]
constructor(@Inject(DB_TOKEN) private db: Database) {}
// 4. InjectionToken(官方推荐方式,有类型安全)
import { InjectionToken } from '@nestjs/common';
export const DB_TOKEN = new InjectionToken<Database>('DB');最佳实践: 在同一文件中导出 Token 常量(字符串/Symbol/InjectionToken),避免到处重复字符串字面量。
六、循环依赖的处理
当 A 依赖 B,B 又依赖 A 时,NestJS 无法正常解析依赖顺序。
解决方案:使用 forwardRef()
// users.service.ts
@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => AuthService))
private authService: AuthService,
) {}
}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(
@Inject(forwardRef(() => UsersService))
private usersService: UsersService,
) {}
}Module 之间的循环依赖:
@Module({
imports: [forwardRef(() => AuthModule)],
exports: [UsersService],
})
export class UsersModule {}
@Module({
imports: [forwardRef(() => UsersModule)],
exports: [AuthService],
})
export class AuthModule {}⚠️
forwardRef()是代码坏味道。 循环依赖通常意味着模块边界划分不合理,应考虑提取公共依赖到第三个模块。
七、异步 Provider 与应用初始化
当应用启动前需要完成异步操作(如连接数据库、读取配置),使用异步 Provider:
// 方式一:工厂 Provider
{
provide: 'DATABASE',
useFactory: async (config: ConfigService) => {
const conn = await createConnection(config.get('DB_URL'));
return conn;
},
inject: [ConfigService],
}
// 方式二:onModuleInit 钩子(更显式)
@Injectable()
export class DatabaseService implements OnModuleInit {
private connection: DataSource;
async onModuleInit() {
this.connection = new DataSource({ ... });
await this.connection.initialize();
console.log('数据库连接成功');
}
}NestJS 会等待所有 onModuleInit() 完成后再开始监听 HTTP 请求,确保应用完全就绪。
八、常见错误与排查
错误 1:Nest can't resolve dependencies
Nest can't resolve dependencies of the UserService (?).
Please make sure that the argument UserRepository at index [0] is available in the UsersModule context.原因: UserRepository 没有在 UsersModule 的 providers 中注册,或所在模块没有 exports。
排查步骤:
- 检查
UserRepository是否在providers: []中 - 如果在另一个 Module 中,检查那个 Module 是否
exports: [UserRepository] - 检查当前 Module 是否
imports: [ThatModule]
错误 2:A circular dependency detected
A circular dependency between modules. Please, make sure that each side of a bidirectional relationships are using forwardRef()解决: 先用 forwardRef(),再重构模块边界消除循环。
错误 3:注入时得到 undefined
// ❌ 忘记 @Inject() 注解,字符串/Symbol Token 无法自动识别
constructor(private db: Database) {} // design:paramtypes 是 Database 类,找不到 'DB' Token
// ✅ 必须用 @Inject()
constructor(@Inject('DB') private db: Database) {}可运行 Demo:
practice/01-ioc-demo— 手动实现 IoC 容器,与 NestJS 内置 IoC 对比