Skip to content

自定义提供者 (Custom Providers)

NestJS 的“私人订制”服务:深入理解自定义提供者

在之前的学习中,我们接触到的“提供者”(Provider) 大多是这样的:用 @Injectable() 装饰一个服务类,然后在模块的 providers 数组中直接放入这个类名。

typescript
// src/cats/cats.service.ts
@Injectable()
export class CatsService {
  findAll() { /* ... */ }
}

// src/cats/cats.module.ts
@Module({
  controllers: [CatsController],
  providers: [CatsService], // <-- 标准的提供者注册方式
})
export class CatsModule {}

这是一种简写形式。它的完整写法其实是这样的:

typescript
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

typescript
export const APP_CONFIG = 'APP_CONFIG';

第二步:在模块中使用 useValue 提供它

src/app.module.ts

typescript
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

typescript
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

typescript
// 这是一个抽象类,定义了接口规范
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

typescript
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

typescript
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 实例。

typescript
// 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

typescript
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 实现。

typescript
@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 注入一个简单的配置对象开始,逐步体会自定义提供者带来的强大能力吧!