Skip to content

自定义 Provider Token:字符串 vs Symbol vs 类型

在 NestJS 的依赖注入系统中,Provider Token 是用来标识和查找 Provider 的关键。虽然我们经常直接使用类作为 Token,但实际上 NestJS 支持多种类型的 Token,包括字符串、Symbol 和类型。每种类型都有其特点和适用场景。本文将深入探讨这些不同的 Token 类型及其最佳实践。

1. Provider Token 基础概念

1.1 什么是 Provider Token?

Provider Token 是依赖注入容器中用来标识 Provider 的唯一标识符。当我们需要注入一个 Provider 时,NestJS 会使用 Token 来查找对应的 Provider 实例。

typescript
// 使用类作为 Token
@Injectable()
export class UserService {
  findAll() {
    return ['user1', 'user2'];
  }
}

// 在控制器中注入
@Controller()
export class UserController {
  constructor(private userService: UserService) {} // UserService 作为 Token
}

1.2 Token 的类型

NestJS 支持以下几种 Token 类型:

typescript
type InjectionToken = string | symbol | Type<any> | Abstract<any> | Function;

2. 字符串 Token

2.1 基本用法

字符串是最简单的 Token 类型:

typescript
// 定义 Provider
@Module({
  providers: [
    {
      provide: 'CONFIG_SERVICE', // 字符串 Token
      useValue: {
        apiUrl: 'https://api.example.com',
        timeout: 5000,
      },
    },
  ],
})
export class ConfigModule {}

// 注入 Provider
@Injectable()
export class ApiService {
  constructor(
    @Inject('CONFIG_SERVICE') private config: any, // 使用 @Inject 指定 Token
  ) {}
}

2.2 内部实现机制

typescript
// 字符串 Token 的处理过程
class TokenResolver {
  resolveToken(token: string | symbol | Type<any>): string {
    if (typeof token === 'string' || typeof token === 'symbol') {
      // 字符串和 Symbol 直接转换为字符串
      return token.toString();
    }
    
    if (isClass(token)) {
      // 类型 Token 使用类名
      return token.name;
    }
    
    return token.toString();
  }
}

2.3 优缺点分析

优点:

  1. 简单直观,易于理解
  2. 可读性强
  3. 支持任意命名

缺点:

  1. 容易发生命名冲突
  2. 缺乏类型安全
  3. 难以重构

2.4 命名冲突问题

typescript
// 模块 A
@Module({
  providers: [
    {
      provide: 'DatabaseService', // 字符串 Token
      useClass: MongoDatabaseService,
    },
  ],
})
export class ModuleA {}

// 模块 B
@Module({
  providers: [
    {
      provide: 'DatabaseService', // 相同的字符串 Token
      useClass: SqlDatabaseService,
    },
  ],
})
export class ModuleB {}

// 当两个模块同时导入时,会发生冲突

3. Symbol Token

3.1 基本用法

Symbol 是 ES6 引入的一种原始数据类型,每个 Symbol 都是唯一的:

typescript
// 创建 Symbol Token
export const DATABASE_SERVICE = Symbol('DATABASE_SERVICE');
export const CONFIG_SERVICE = Symbol('CONFIG_SERVICE');

// 定义 Provider
@Module({
  providers: [
    {
      provide: DATABASE_SERVICE, // Symbol Token
      useClass: DatabaseService,
    },
    {
      provide: CONFIG_SERVICE, // Symbol Token
      useValue: {
        apiUrl: 'https://api.example.com',
      },
    },
  ],
})
export class AppModule {}

// 注入 Provider
@Injectable()
export class UserService {
  constructor(
    @Inject(DATABASE_SERVICE) private databaseService: DatabaseService,
    @Inject(CONFIG_SERVICE) private config: any,
  ) {}
}

3.2 内部实现机制

typescript
// Symbol Token 的处理过程
class TokenResolver {
  private symbolMap = new Map<symbol, string>();
  
  resolveSymbolToken(symbol: symbol): string {
    if (!this.symbolMap.has(symbol)) {
      // 为每个 Symbol 生成唯一的字符串标识
      const id = `Symbol(${symbol.description || ''})_${Date.now()}_${Math.random()}`;
      this.symbolMap.set(symbol, id);
    }
    
    return this.symbolMap.get(symbol);
  }
}

3.3 优缺点分析

优点:

  1. 绝对唯一,不会发生命名冲突
  2. 性能较好
  3. 适合私有 Provider

缺点:

  1. 跨模块共享困难
  2. 调试时不够直观
  3. 无法序列化

3.4 跨模块共享问题

typescript
// shared/tokens.ts
export const DATABASE_SERVICE = Symbol('DATABASE_SERVICE');

// module-a/database.service.ts
@Injectable()
export class DatabaseService {
  // 实现
}

// module-a/module-a.module.ts
@Module({
  providers: [
    {
      provide: DATABASE_SERVICE,
      useClass: DatabaseService,
    },
  ],
  exports: [DATABASE_SERVICE],
})
export class ModuleA {}

// module-b/module-b.module.ts
import { DATABASE_SERVICE } from '../shared/tokens';

@Module({
  imports: [ModuleA],
  providers: [
    {
      provide: DATABASE_SERVICE, // 注意:这是不同的 Symbol 实例!
      useClass: MockDatabaseService,
    },
  ],
})
export class ModuleB {}

4. 类型 Token

4.1 基本用法

类型 Token 是 NestJS 中最常用的方式,直接使用类作为 Token:

typescript
// 定义服务
@Injectable()
export class UserService {
  findAll() {
    return ['user1', 'user2'];
  }
}

// 定义接口(可选)
export abstract class IUserService {
  abstract findAll(): any[];
}

// 使用类作为 Token
@Module({
  providers: [
    UserService, // 简写形式
    // 或者完整形式
    // {
    //   provide: UserService,
    //   useClass: UserService,
    // }
  ],
})
export class UserModule {}

// 注入服务
@Controller()
export class UserController {
  constructor(private userService: UserService) {} // 直接注入,无需 @Inject
}

4.2 内部实现机制

typescript
// 类型 Token 的处理过程
class TokenResolver {
  resolveClassToken(token: Type<any>): string {
    // 使用类的构造函数作为唯一标识
    return token.name + '_' + token.toString();
  }
  
  // 类型检查
  isClassToken(token: any): boolean {
    return typeof token === 'function' && token.prototype;
  }
}

4.3 优缺点分析

优点:

  1. 类型安全,支持 TypeScript 检查
  2. 自动注入,无需 @Inject 装饰器
  3. 易于理解和维护
  4. 支持重构工具

缺点:

  1. 需要创建具体的类
  2. 对于简单值不够灵活
  3. 接口不能作为 Token 使用

4.4 接口与实现分离

typescript
// 定义接口
export abstract class IUserService {
  abstract findAll(): any[];
}

// 实现类
@Injectable()
export class UserService implements IUserService {
  findAll() {
    return ['user1', 'user2'];
  }
}

// 使用实现类作为 Token
@Module({
  providers: [
    {
      provide: UserService, // 使用实现类作为 Token
      useClass: UserService,
    },
  ],
})
export class UserModule {}

// 注入时需要指定具体实现
@Controller()
export class UserController {
  constructor(private userService: UserService) {} // 注入实现类
}

5. InjectionToken 解决方案

5.1 为什么需要 InjectionToken?

为了解决字符串 Token 的命名冲突问题和 Symbol Token 的共享问题,NestJS 提供了 InjectionToken:

typescript
// common/interfaces/injection-token.interface.ts
export type InjectionToken<T = any> = string | symbol | Type<T> | Abstract<T> | Function;

// common/interfaces/abstract.interface.ts
export interface Abstract<T> extends Function {
  prototype: T;
}

5.2 创建 InjectionToken

typescript
// shared/tokens.ts
import { InjectionToken } from '@nestjs/common';

// 创建 InjectionToken
export const DATABASE_SERVICE = new InjectionToken<DatabaseService>('DATABASE_SERVICE');
export const CONFIG_SERVICE = new InjectionToken<any>('CONFIG_SERVICE');

// 或者使用简单的字符串常量
export const DATABASE_SERVICE = 'DATABASE_SERVICE' as const;
export const CONFIG_SERVICE = 'CONFIG_SERVICE' as const;

5.3 使用 InjectionToken

typescript
// 定义 Provider
@Module({
  providers: [
    {
      provide: DATABASE_SERVICE,
      useClass: DatabaseService,
    },
    {
      provide: CONFIG_SERVICE,
      useValue: {
        apiUrl: 'https://api.example.com',
      },
    },
  ],
  exports: [DATABASE_SERVICE, CONFIG_SERVICE],
})
export class SharedModule {}

// 注入 Provider
@Injectable()
export class UserService {
  constructor(
    @Inject(DATABASE_SERVICE) private databaseService: DatabaseService,
    @Inject(CONFIG_SERVICE) private config: any,
  ) {}
}

6. 最佳实践

6.1 Token 命名规范

typescript
// 推荐的命名方式
// 1. 使用 UPPER_SNAKE_CASE 命名常量
export const DATABASE_SERVICE = 'DATABASE_SERVICE';
export const CONFIG_SERVICE = 'CONFIG_SERVICE';

// 2. 添加模块前缀避免冲突
export const USER_DATABASE_SERVICE = 'USER_DATABASE_SERVICE';
export const ORDER_DATABASE_SERVICE = 'ORDER_DATABASE_SERVICE';

// 3. 使用 InjectionToken 类型
export const DATABASE_SERVICE = new InjectionToken<DatabaseService>('DATABASE_SERVICE');

6.2 模块化 Token 管理

typescript
// shared/tokens/database.tokens.ts
import { InjectionToken } from '@nestjs/common';
import { DatabaseService } from '../services/database.service';

export const DATABASE_SERVICE = new InjectionToken<DatabaseService>('DATABASE_SERVICE');
export const DATABASE_CONFIG = new InjectionToken<any>('DATABASE_CONFIG');

// shared/tokens/config.tokens.ts
export const APP_CONFIG = new InjectionToken<any>('APP_CONFIG');
export const LOGGER_CONFIG = new InjectionToken<any>('LOGGER_CONFIG');

// shared/shared.module.ts
import { DATABASE_SERVICE, DATABASE_CONFIG } from './tokens/database.tokens';
import { APP_CONFIG, LOGGER_CONFIG } from './tokens/config.tokens';

@Module({
  providers: [
    {
      provide: DATABASE_SERVICE,
      useClass: DatabaseService,
    },
    {
      provide: DATABASE_CONFIG,
      useValue: {
        host: 'localhost',
        port: 5432,
      },
    },
    {
      provide: APP_CONFIG,
      useValue: {
        appName: 'MyApp',
      },
    },
    {
      provide: LOGGER_CONFIG,
      useValue: {
        level: 'info',
      },
    },
  ],
  exports: [
    DATABASE_SERVICE,
    DATABASE_CONFIG,
    APP_CONFIG,
    LOGGER_CONFIG,
  ],
})
export class SharedModule {}

6.3 类型安全的 Token

typescript
// 定义带类型的 Token
export const DATABASE_SERVICE = new InjectionToken<DatabaseService>('DATABASE_SERVICE');

// 定义 Provider
@Module({
  providers: [
    {
      provide: DATABASE_SERVICE,
      useClass: DatabaseService,
    },
  ],
})
export class DatabaseModule {}

// 注入时获得完整的类型支持
@Injectable()
export class UserService {
  constructor(
    @Inject(DATABASE_SERVICE) private databaseService: DatabaseService, // 完整的类型检查
  ) {}
  
  findAll() {
    // TypeScript 可以正确推断 databaseService 的方法
    return this.databaseService.findUsers();
  }
}

7. 高级用法

7.1 条件性 Token

typescript
// 根据环境选择不同的 Token
const getDatabaseServiceToken = () => {
  if (process.env.DB_TYPE === 'mongo') {
    return 'MONGO_DATABASE_SERVICE';
  }
  return 'SQL_DATABASE_SERVICE';
};

@Module({
  providers: [
    {
      provide: getDatabaseServiceToken(),
      useClass: process.env.DB_TYPE === 'mongo' 
        ? MongoDatabaseService 
        : SqlDatabaseService,
    },
  ],
})
export class DatabaseModule {}

7.2 动态 Token

typescript
// 创建动态 Token 工厂
function createServiceToken(serviceName: string) {
  return `${serviceName.toUpperCase()}_SERVICE`;
}

@Module({
  providers: [
    {
      provide: createServiceToken('user'),
      useClass: UserService,
    },
    {
      provide: createServiceToken('order'),
      useClass: OrderService,
    },
  ],
})
export class AppModule {}

8. 总结

NestJS 支持多种 Provider Token 类型,每种都有其适用场景:

  1. 字符串 Token:简单直接,但容易冲突
  2. Symbol Token:绝对唯一,但难以共享
  3. 类型 Token:类型安全,最常用的方式
  4. InjectionToken:最佳实践,结合了类型安全和唯一性

在实际开发中,推荐使用以下策略:

  1. 对于服务类,优先使用类型 Token
  2. 对于配置值和简单对象,使用字符串 Token 并配合常量
  3. 对于需要跨模块共享的 Token,使用 InjectionToken
  4. 建立统一的 Token 管理机制

理解不同 Token 类型的特点和使用场景,有助于我们:

  1. 避免命名冲突
  2. 提高代码的类型安全性
  3. 设计更清晰的依赖注入结构
  4. 提高代码的可维护性

在下一篇文章中,我们将探讨循环依赖的三种解法:forwardRef、模块拆分、延迟注入,了解 Nest 如何通过临时占位解决 A 依赖 B、B 依赖 A 的问题。