NestJS 注入令牌:为何 useClass 会悄悄复制单例对象
了解 NestJS DI 中的类型、令牌和提供者,为何接口无法被注入,以及 how useExisting 如何避免出现同一个有状态服务的两个实例。
NestJS 的依赖注入乍看之下似乎毫不费力:只需声明一个构造函数参数,框架就会为你提供已准备好的实例。这种便捷性背后其实隐藏着 TypeScript 类型、运行时标记与提供者定义之间的映射关系,若理解不当就会引发难以发现的错误。比如出现缓存重复、数据库连接池过多,或是模拟对象始终无法被解析的情况。本文将帮助你建立关于 NestJS 如何解析依赖的精确认知模型,解释为何接口不能用作注入键,并说明单个提供者选项如何决定一个服务最终是只有一个实例还是两个。
从自动连接到显式标记
初次接触 NestJS 依赖注入时,通常会见到这样的构造函数:
constructor(
private readonly emailService: EmailService,
) {}
对于类依赖关系,NestJS会读取TypeScript生成的关于构造函数参数类型的元数据(通过emitDecoratorMetadata启用),并利用该类本身来查找对应的提供者。无需额外的注解。
规模较大的代码库通常情况有所不同:
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailService,
) {}
如果参数已经有了类型定义,那么@Inject()还能起到什么作用?为何将useClass改为useExisting会改变获取一个实例还是两个实例的结果?要回答这些问题,需要区分通常被统称为同一概念的三个不同概念。
类型、令牌和提供者是三件不同的东西
初学者常常将它们视为同一个概念,因为在简单情况下它们确实有相同的标识符:
- 类型:TypeScript编译器用来检查代码的依据。它仅存在于编译时。
在基于类的普通注入方式中,一个类同时承担 TypeScript 类型与 NestJS Token 的双重角色:
CustomLoggerService
↓
NestJS Token
+
TypeScript Type
自定义 Token 则将这两种角色分开。在下面的构造函数中,字符串与类型注解发挥着完全不同的作用:
constructor(
@Inject('logger')
private readonly logger: CustomLoggerService,
)
详细来看,各部分的职责如下:
@Inject('logger')
↓
NestJS Token (Finds the provider in memory)
: CustomLoggerService
↓
TypeScript Type (Gives you IDE autocomplete)
NestJS在运行时使用令牌来查找实例。类型注解仅让编译器和编辑器了解其结构,以便进行类型检查和自动补全。一个有用的简述是:令牌用于定位对象,而类型则描述其内容。需要注意的是,NestJS不会验证两者是否一致;如果'logger'背后的提供者返回了其他内容,TypeScript也无法检测到。
为何不能注入接口
对于刚接触NestJS的开发者来说,一个常见的需求是通过依赖接口来实现代码解耦,例如将参数定义为IMailService。但最初的尝试往往会失败:
// Won't work at runtime
constructor(
private readonly mailService: IMailService,
) {}
在启动时,NestJS会报出Nest无法解析UserService (?)的依赖项。问号表示它无法识别的参数。
原因是 JavaScript 中不存在接口。当 tsc 编译代码时,所有的 interface 以及纯类型注解都会被移除。NestJS 需要能够在运行时使用的元数据,而接口不会留下任何可供读取的内容;该参数生成的元数据会降级为通用的 Object,无法匹配任何提供者。
类则不同:它们会被编译为真正的 JavaScript 构造函数,因此在运行时仍然存在,并可作为键使用。
class CustomLoggerService
↓
exists at runtime
↓
can be used as a DI token
interface IMailer
↓
erased during compilation
↓
cannot be used as a runtime DI token
因此,当基于抽象进行编程时,必须明确提供运行时令牌,例如 @Inject('MAIL_SERVICE'),并在此令牌下注册提供者。抽象类也是一种值得了解的替代方案:因为它会被编译成实际的函数,所以无需 @Inject() 即可同时充当类型和令牌。
重复单例陷阱:useClass 与 useExisting
一旦使用了自定义令牌,每个模块都必须告知 NestJS 如何解析它们,而这正是常见细微错误容易出现的环节。请看这个模块:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useClass: CustomLoggerService, // The trap
},
],
})
export class CommonModule {}
乍一看,'APP_LOGGER' 似乎只是 CustomLoggerService 的另一个名称,但实际上并非如此。该模块现在包含了两个独立的提供者注册项,而在默认的单例作用域下,每个注册项都会生成自己的实例:
- 类令牌
CustomLoggerService是通过构造该类来解析的,从而生成实例 A。 - 字符串令牌
'APP_LOGGER'也是通过再次构造该类来解析的,从而生成实例 B。
这个问题仅出现在涉及状态的情况下。如果日志记录器维护内存缓冲区、队列,用于限制请求频率的计数机制,或持有连接,那么注入该类的使用者与注入字符串令牌的使用者实际上是在操作不同的对象,这些对象彼此之间无法看到对方的数据。
解决方案:使用 useExisting 创建别名
当需要为已注册的提供者指定另一个名称时,可使用 useExisting。它会告知 NestJS 不要创建新对象,而是将令牌解析为现有的实例:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useExisting: CustomLoggerService, // Points to the existing singleton
},
],
})
export class CommonModule {}
用盒子和姓名标签可以简单直观地理解两者的区别:
useClass会创建第二个盒子。类标记用于标识盒子A,字符串标记用于标识盒子B。useExisting则只创建一个盒子,并将两个名称标记都附加到该盒子上。
通过设置别名后,注入CustomLoggerService的消费者与注入'APP_LOGGER'的消费者会获得同一个对象,因此对它们进行严格相等性检查的结果为true。
当确实需要独立的实例,或者如下一节所述,该标记是类注册的唯一方式时,useClass仍然是最佳选择。
标记作为架构分隔手段
对于小型 CRUD 服务而言,自定义令牌似乎有些多余。但当实现可能发生变化时,其优势就会显现出来。假设有多个控制器通过基于 Winston 的服务进行日志记录,那么这些控制器无需在每个控制器中都导入 WinstonLoggerService,只需依赖一个令牌和一个接口即可:
constructor(
@Inject('LOGGER') private readonly logger: LoggerInterface
) {}
该模块负责决定该令牌背后对应的实现:
{
provide: 'LOGGER',
useClass: WinstonLoggerService,
}
若要切换到云日志后端,如 Google Cloud Logging 或 AWS CloudWatch,只需在模块中进行一行修改,无需对任何使用该服务的部分进行改动:
{
provide: 'LOGGER',
useClass: CloudLoggerService,
}
同样的设计也让测试变得十分简单,因为测试模块可以将 'LOGGER' 绑定到模拟对象上,而无需修改被测试的代码。
一个实用的改进:普通字符串容易输入错误,而且不同模块之间还可能发生冲突。将标记定义为导出的常量或Symbol值,既能保持一致性,还能让编译器及时发现拼写错误。
提供者模式概览
你最常遇到的四种提供者类型及其适用场景:
- 类提供者(
useClass):为某个标记实例化一个类。可用于将抽象标记绑定到具体实现,或根据不同环境切换实现方式。每次注册都会创建独立的实例。 - 别名提供者(
useExisting):让某个标记指向已存在的提供者。可用于用多个名称引用同一个实例,而无需重复创建状态。
useValue):返回固定值,例如配置对象、常量或测试中的模拟数据。useFactory):通过函数计算值,可选择性地使用在inject中列出的注入依赖项。当对象的创建依赖于配置或为异步操作时(如数据库连接)可使用此方式。别忘了导出
一个常见的难题是:CommonModule中已正确配置的令牌,UserModule仍然无法解析。providers数组控制模块为自身使用而注册的内容,exports数组则控制模块向导入它的其他模块提供什么内容。像'LOGGER'这样的自定义令牌必须出现在exports中(同时使用该令牌的模块也必须导入CommonModule),这样外部才能注入该令牌。当使用useExisting创建别名并导出时,要确保其他模块能够访问该别名所指向的提供者,或者同时导出这两个令牌。
关键要点
- 类型仅存在于编译时,令牌是运行时的键,而提供者则定义了如何满足该键的需求;在阅读依赖注入代码时需将这三者区分开来。
@Inject() 显式指定标记,或使用在运行时存在的抽象类。useClass 以两个标记注册同一类会创建两个单例;若只需别名,则应使用 useExisting。exports 和 imports。