注入的作用域
对于来自不同编程语言背景的人来说,可能会感到意外的是,在Nest中,几乎所有内容都是跨传入请求共享的。 我们有一个与数据库的连接池、具有全局状态的单例服务等等。 请记住,Node.js不遵循请求/响应的多线程无状态模型,其中每个请求都由一个单独的线程处理。 因此,在我们的应用程序中使用单例实例是完全安全的。
然而,在一些特殊情况下,可能需要基于请求的生命周期, 例如在GraphQL应用程序中进行每个请求的缓存、请求跟踪和多租户支持。 注入作用域提供了一种机制来获得所需的提供程序生命周期行为。
提供程序作用域
提供程序可以具有以下任何一种作用域:
-
DEFAULT
: 提供程序的单个实例在整个应用程序中共享。实例的生命周期直接与应用程序生命周期相关联。 一旦应用程序启动,所有单例提供程序都已实例化。默认情况下使用单例作用域。 -
REQUEST
: 为每个传入请求专门创建提供程序的新实例。在请求完成处理后,该实例将被垃圾回收。 -
TRANSIENT
: 瞬时提供程序不在各个消费者之间共享。 每个注入瞬时提供程序的消费者都将获得一个新的、专用的实例。
对于大多数用例,建议使用单例作用域。 在消费者和请求之间共享提供程序意味着可以缓存实例,并且其初始化仅在应用程序启动期间发生一次。
使用方式
通过将scope
属性传递给@Injectable()
装饰器选项对象来指定注入作用域:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {}
同样,对于自定义提供程序,可以在提供程序注册的长格式中设置scope
属性:
{
provide: 'CACHE_MANAGER',
useClass: CacheManager,
scope: Scope.TRANSIENT,
}
从 @nestjs/common
中导入 Scope
枚举。
默认情况下使用单例作用域,并且无需声明。如果确实要将提供程序声明为单例作用域,
请对scope
属性使用Scope.DEFAULT
值。
WebSocket Gateways 不应使用请求作用域的提供程序,因为它们必须充当单例。 每个网关封装了一个真实的套接字,不能多次实例化。 此限制还适用于其他一些提供程序,例如 Passport 策略或 Cron 控制器。
控制器作用域
控制器也可以具有作用域,该作用域适用于在该控制器中声明的所有请求方法处理程序。 与提供程序作用域一样,控制器的作用域声明其生命周期。 对于请求作用域的控制器,将为每个传入请求创建一个新实例,并在请求处理完成后进行垃圾回收。
使用ControllerOptions
对象的scope
属性声明控制器作用域:
@Controller({
path: 'cats',
scope: Scope.REQUEST,
})
export class CatsController {}
作用域层次结构
REQUEST
作用域在注入链上传播。依赖于请求作用域提供程序的控制器本身将成为请求作用域。
想象一下以下依赖图:CatsController <- CatsService <- CatsRepository
。
如果CatsService
是请求作用域(其他是默认的单例),
则CatsController
将成为请求作用域,因为它依赖于注入的服务。
而不依赖的CatsRepository
仍将保持单例作用域。
瞬时作用域的依赖关系不遵循该模式。
如果单例作用域的DogsService
注入一个瞬时作用域的LoggerService
提供程序,它将收 到一个新实例。
然而,DogsService
将保持单例作用域,因此在任何地方注入它都不会解析为DogsService
的新实例。
如果希望达到这种行为,必须将DogsService
明确标记为TRANSIENT
。
请求提供程序
在基于 HTTP 服务器的应用程序中(例如使用 @nestjs/platform-express
或 @nestjs/platform-fastify
),当使用请求作用域的提供程序时,
您可能希望访问对原始请求对象的引用。您可以通过注入REQUEST
对象来实现这一点。
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(REQUEST) private request: Request) {}
}
由于底层平台/协议的差异,您在微服务或GraphQL应用程序中略有不同地访问传入请求。
在GraphQL应用程序中,您注入CONTEXT
而不是REQUEST
:
import { Injectable, Scope, Inject } from '@nestjs/common';
import { CONTEXT } from '@nestjs/graphql';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(CONTEXT) private context) {}
}
然后,在GraphQLModule
中配置上下文值,使其包含request
作为其属性。
询问者提供程序
如果您想要获取构造提供程序的类,例如在日志记录或度量提供程序中,您可以注入INQUIRER
令牌。
import { Inject, Injectable, Scope } from '@nestjs/common';
import { INQUIRER } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class HelloService {
constructor(@Inject(INQUIRER) private parentClass: object) {}
sayHello(message: string) {
console.log(`${this.parentClass?.constructor?.name}: ${message}`);
}
}
然后像下面这样使用它:
import { Injectable } from '@nestjs/common';
import { HelloService } from './hello.service';
@Injectable()
export class AppService {
constructor(private helloService: HelloService) {}
getRoot(): string {
this.helloService.sayHello('My name is getRoot');
return 'Hello world!';
}
}
在上面的示例中,当调用 AppService#getRoot
时,将向控制台记录
"AppService: My name is getRoot"
。
性能
使用请求作用域的提供程序将影响应用程序性能。 尽管 Nest 尝试尽可能缓存元数据,但仍然需要在每个请求上创建您的类的实例。 因此,它将减缓您的平 均响应时间和整体基准测试结果。 除非提供程序必须是请求作用域的,否则强烈建议使用默认的单例作用域。
尽管这听起来令人生畏,但一个设计良好的应用程序,充分利用请求作用域的提供程序, 从延迟的角度来说,不应该减速超过约 5%。
持久化提供者
如上一节所述,请求范围的提供者可能会导致增加的延迟,因为至少有一个请求范围的提供者 (注入到控制器实例中,或更深入到其中的一个提供者中)会使控制器也成为请求范围的。 这意味着它必须在每个单独的请求中重新创建(实例化),并在请求完成处理后进行垃圾回收。 现在,这也意味着,例如,对于 30k 个并行请求,将会有 30k 个控制器实例(及其请求范围的提供者)。
如果有一个通用的提供者,大多数提供者依赖于它(考虑一个数据库连接或日志记录服务), 则自动将所有这些提供者也转换为请求范围的提供者。 在多租户应用程序中,尤其是对于具有抓取请求对象中的标头/令牌并基于其值检索相应 数据库连接/模式(特定于该租户)的中央请求范围的“数据源”提供者,这可能构成挑战。
例如,假设您有一个由 10 个不同客户交替使用的应用程序。 每个客户都有自己专用的数据源,您希望确保客户 A 永远无法访问客户 B 的数据库。 实现这一目标的一种方法是声明一个请求范围的“数据源”提供者, 该提供者 - 基于请求对象 - 确定“当前客户”是什么,并检索其对应的数据库。 通过这种方法,您可以在短短几分钟内将应用程序转变为多租户应用程序。 但是,这种方法的一个主要缺点是,由于您的应用程序的大部分组件很可能依赖于“数据源”提供者, 它们将会自动变为“请求范围”,因此您无疑会在应用程序的性能上看到影响。
但是,如果我们有更好的解决方案呢?由于我们只有 10 个客户, 难道我们不能为每个客户拥有 10 个单独的 DI 子树(而不是在每个请求中重新创建每个树)吗? 如果您的提供者不依赖于每个连续请求都是真正独特的属性(例如,请求 UUID), 而是存在一些特定属性,让我们能够对它们进行聚合(分类), 那么没有理由在每个传入请求中重新创建 DI 子树。
这正是持久化提供者派上用场的地方。
在将提供者标记为持久化提供者之前,我们必须首先注册一种策略, 指示 Nest 什么是那些“通用请求属性”,提供将请求分组的逻辑 - 将其与相应的 DI 子树关联。
import {
HostComponentInfo,
ContextId,
ContextIdFactory,
ContextIdStrategy,
} from '@nestjs/core';
import { Request } from 'express';
const tenants = new Map<string, ContextId>();
export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
const tenantId = request.headers['x-tenant-id'] as string;
let tenantSubTreeId: ContextId;
if (tenants.has(tenantId)) {
tenantSubTreeId = tenants.get(tenantId);
} else {
tenantSubTreeId = ContextIdFactory.create();
tenants.set(tenantId, tenantSubTreeId);
}
// 如果树不是持久的,返回原始的 "contextId" 对象
return (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId;
}
}
类似于请求范围,持久性会冒泡到注入链中。这意味着如果 A 依赖于标记为持久的 B,那么 A 也会变成持久的(除非对 A 的提供者显式设置为 false)。
请注意,该策略对于操作大量租户的应用程序不是理想的。
从attach
方法返回的值告诉 Nest 应该为给定的主机使用什么上下文标识符。
在这种情况下,我们指定了在主机组件(例如,请求范围的控制器)标记为持久时应使用
tenantSubTreeId
而不是原始的、自动生成的contextId
对象。
此外,在上面的示例中,不会注册有效载荷(其中有效载荷 = REQUEST/CONTEXT
提供者,表示子树的 "根" - 父级)。
如果要为持久化树注册有效载荷,请改用以下结构:
// `AggregateByTenantContextIdStrategy#attach` 方法的返回值:
return {
resolve: (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId,
payload: { tenantId },
}
现在,每当使用 @Inject(REQUEST)/@Inject(CONTEXT)
注入REQUEST
提供者时,
将注入有 效payload
对象(在本例中由tenantId
组成的单个属性)。
好的,有了这个策略,您可以在代码的某个地方(因为它全局适用)注册它,
因此例如,您可以将它放在main.ts
文件中:
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());
ContextIdFactory
类从@nestjs/core
包中导入。
只要注册在任何请求到达应用程序之前,一切都将按预期工作。
最后,要将常规提供者转变为持久化提供者,只需将durable
标志设置为true
,
并将其范围更改为Scope.REQUEST
(如果注入链中已经有REQUEST
范围,则不需要):
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}
同样,对于自定义提供者,以长格式注册时,设置durable
属性:
{
provide: 'foobar',
useFactory: () => { ... },
scope: Scope.REQUEST,
durable: true,
}