自定义提供者 (Custom Providers)
NestJS 的“私人订制”服务:深入理解自定义提供者
在之前的学习中,我们接触到的“提供者”(Provider) 大多是这样的:用 @Injectable()
装饰一个服务类,然后在模块的 providers
数组中直接放入这个类名。
// src/cats/cats.service.ts
@Injectable()
export class CatsService {
findAll() { /* ... */ }
}
// src/cats/cats.module.ts
@Module({
controllers: [CatsController],
providers: [CatsService], // <-- 标准的提供者注册方式
})
export class CatsModule {}
这是一种简写形式。它的完整写法其实是这样的:
providers: [
{
provide: CatsService, // “令牌” (Token)
useClass: CatsService, // 使用这个类来创建实例
},
],
NestJS 的依赖注入系统像一个聪明的管家。当你请求 CatsService
时,它会根据这个“令牌” (provide: CatsService
) 去寻找对应的“配方” (useClass: CatsService
),然后实例化一个对象给你。
自定义提供者,就是允许我们完全自定义这个“配方”,不再局限于“用哪个类来实例化”。这给予了我们极大的灵活性来处理各种复杂场景。
1. 为什么需要“私人订制”?—— 标准服务的局限性
标准的服务提供者很棒,但有时会遇到以下问题:
- 提供一个常量或配置对象:如果我想在整个应用中注入一个配置字符串(比如 API 密钥)或者一个配置对象,这个值不是一个类,怎么注入?
- 条件化提供:我能否在开发环境提供一个“模拟日志服务”,而在生产环境提供一个“真实日志服务”?
- 依赖其他服务的复杂创建过程:如果一个服务的实例化过程很复杂,需要依赖其他服务或配置信息才能完成(比如,创建一个数据库连接,需要先从配置服务中读取连接字符串),该怎么办?
- 复用现有实例:我能否为一个已经存在的服务实例创建另一个别名,让它们指向同一个对象?
这些问题,都可以通过自定义提供者优雅地解决。NestJS 提供了几种不同的“配方”来满足我们的“私人订制”需求。
2. 四种“私人订制”配方
NestJS 提供了四种主要的自定义提供者语法:useValue
, useClass
, useFactory
, 和 useExisting
。
配方一:useValue
- 提供现成的值
这是最简单的一种。它告诉 NestJS:“别费劲去实例化什么类了,直接用我提供好的这个值就行”。这个值可以是任何东西:字符串、数组、对象,甚至是一个已经 new
好的实例。
应用场景:
- 注入常量,如 API 密钥、配置对象。
- 在测试中注入一个模拟 (mock) 对象来替代真实服务。
- 注入一个外部库的实例。
代码示例:注入一个配置对象
假设我们有一个 APP_CONFIG
对象,我们想在服务中使用它。
第一步:定义一个唯一的“令牌” (Token)
因为我们的配置对象不是一个类,所以不能用类名作为令牌。我们可以用一个普通的字符串。最好是创建一个常量来避免拼写错误。
src/config/config.constants.ts
export const APP_CONFIG = 'APP_CONFIG';
第二步:在模块中使用 useValue
提供它
src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_CONFIG } from './config/config.constants';
const config = {
apiKey: 'YOUR_SUPER_SECRET_API_KEY',
version: '1.0.0',
};
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_CONFIG, // 使用字符串令牌
useValue: config, // 提供这个现成的对象
},
],
})
export class AppModule {}
第三步:在服务中注入并使用它
使用 @Inject()
装饰器并传入我们的字符串令牌来注入这个值。
src/app.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { APP_CONFIG } from './config/config.constants';
// 定义配置对象的类型,为了更好的代码提示和类型安全
interface AppConfig {
apiKey: string;
version: string;
}
@Injectable()
export class AppService {
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
getHello(): string {
return `Hello World! App version: ${this.config.version}`;
}
}
现在,AppService
就成功获得了我们的配置对象,而无需关心它是如何被创建的。
配方二:useClass
- 用 A 类满足对 B 类的请求
useClass
允许你指定一个不同于令牌的类来实例化。当一个地方请求 A
时,NestJS 实际上会提供一个 B
的实例。
应用场景:
- 根据环境提供不同的实现。
- 使用一个更具体或更复杂的类来覆盖一个抽象类或基类。
代码示例:根据环境提供不同的 Logger
假设我们有一个抽象的 LoggerService
和两个实现:一个简单的 ConsoleLogger
用于开发,一个更复杂的 FileLogger
用于生产。
src/logger/logger.service.ts
// 这是一个抽象类,定义了接口规范
export abstract class LoggerService {
abstract log(message: string): void;
}
// 开发环境用的 Logger
@Injectable()
export class ConsoleLogger extends LoggerService {
log(message: string) {
console.log(`[Console] ${message}`);
}
}
// 生产环境用的 Logger
@Injectable()
export class FileLogger extends LoggerService {
log(message: string) {
// 想象这里是写入文件的逻辑...
console.log(`[FileLogger] Saving to file: ${message}`);
}
}
现在,在模块中,我们可以根据环境变量来决定到底使用哪个实现来满足对 LoggerService
的请求。
src/app.module.ts
import { Module } from '@nestjs/common';
import { LoggerService, ConsoleLogger, FileLogger } from './logger/logger.service';
// 根据环境变量动态选择 Logger 类
const loggerProvider = {
provide: LoggerService, // 当有地方请求 LoggerService 时...
useClass: process.env.NODE_ENV === 'production'
? FileLogger // 生产环境用 FileLogger
: ConsoleLogger, // 其他环境用 ConsoleLogger
};
@Module({
providers: [loggerProvider],
exports: [loggerProvider], // 如果想在其他模块也使用,需要导出
})
export class AppModule {}
这样,任何注入 LoggerService
的地方,都会在不同环境下自动获得正确的实例,而注入方完全不需要关心这个决策过程。
配方三:useFactory
- 最强大的“工厂”模式
这是最灵活也是最强大的方式。useFactory
允许你提供一个工厂函数,这个函数的返回值将被用作注入的值。
更重要的是,这个工厂函数可以依赖注入其他的提供者!
应用场景:
- 创建提供者实例的过程依赖于其他服务。
- 需要异步创建提供者(例如,等待数据库连接成功)。
代码示例:创建一个依赖配置的数据库连接
假设我们需要创建一个数据库连接服务 Connection
,它的创建需要一个 options
对象(包含连接字符串等)。
第一步:创建一个配置选项的提供者 (使用 useValue
)
src/database/database.module.ts
import { Module, DynamicModule } from '@nestjs/common';
// 用来注入配置项的令牌
export const DATABASE_OPTIONS = 'DATABASE_OPTIONS';
// ... 稍后我们会填充这个模块
@Module({})
export class DatabaseModule {
// 我们使用动态模块来接收配置
static forRoot(options: any): DynamicModule {
// ...
}
}
第二步:使用 useFactory
创建 Connection
提供者
这个工厂函数会注入 DATABASE_OPTIONS
,然后利用它来创建一个 Connection
实例。
// Connection.ts - 假设有这样一个类
class Connection {
constructor(private options: any) {
console.log(`Connecting to database with options:`, options);
}
// ... other methods
}
// database.module.ts (完整版)
import { Module, DynamicModule } from '@nestjs/common';
export const DATABASE_OPTIONS = 'DATABASE_OPTIONS';
const connectionFactory = {
provide: 'CONNECTION', // 提供一个名为 'CONNECTION' 的服务
useFactory: (options) => { // 这是工厂函数
// 函数的返回值就是最终注入的值
return new Connection(options);
},
inject: [DATABASE_OPTIONS], // 关键!告诉 NestJS 这个工厂函数需要注入 DATABASE_OPTIONS
};
@Module({})
export class DatabaseModule {
static forRoot(options: any): DynamicModule {
return {
module: DatabaseModule,
providers: [
{
provide: DATABASE_OPTIONS, // 提供配置项
useValue: options,
},
connectionFactory, // 提供 Connection 服务
],
exports: ['CONNECTION'], // 导出连接,让其他模块能用
};
}
}
第三步:在应用根模块中使用它
src/app.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [
DatabaseModule.forRoot({ url: 'mongodb://localhost/mydb', user: 'admin' }),
],
})
export class AppModule {}
现在,任何需要数据库连接的地方,都可以通过 @Inject('CONNECTION')
注入这个由工厂函数动态创建的、包含了正确配置的连接实例。
配方四:useExisting
- 为服务创建“别名”
useExisting
允许你为一个已经存在的提供者创建别名。当请求别名时,NestJS 会返回已存在的那个提供者实例,而不是创建一个新的。
应用场景:
- 有一个通用的服务接口,和几个具体的实现,你想让通用接口指向其中一个具体的实现。
代码示例:为通用存储服务指定具体实现
假设我们有一个通用的 StorageService
,还有一个具体的 DiskStorageService
实现。
@Injectable()
class DiskStorageService {
store(file: any) { console.log('Storing file on disk...'); }
}
// 我们想让应用中所有请求抽象的 StorageService 的地方,
// 实际上都拿到 DiskStorageService 的实例。
const storageProvider = {
provide: 'StorageService', // 这是一个抽象的令牌(别名)
useExisting: DiskStorageService, // 指向已经存在的 DiskStorageService
};
@Module({
providers: [DiskStorageService, storageProvider],
})
export class AppModule {}
现在,如果在某个服务中注入 @Inject('StorageService')
,它得到的其实是 DiskStorageService
的单例对象。
总结
自定义提供者是 NestJS IoC 容器的瑞士军刀,它让你能够以极大的灵活性管理依赖关系。
配方 | 何时使用 | 核心思想 |
---|---|---|
useValue | 提供一个常量、配置对象或已有的实例。 | “直接用这个值。” |
useClass | 根据条件使用不同的类来实现同一个接口。 | “需要A?给你B的实例。” |
useFactory | 创建过程复杂,需要依赖其他服务。 | “按这个配方(函数)去制作。” |
useExisting | 为已有的服务创建一个别名或第二个入口。 | “需要X?它就是Y,去拿吧。” |
一开始可能会觉得这些概念有些复杂,但当你遇到实际问题时,回过头来再看这些“配方”,你会发现它们正是解决问题的利器。尝试在你的项目中用 useValue
注入一个简单的配置对象开始,逐步体会自定义提供者带来的强大能力吧!