注入作用域
对于来自不同编程语言背景的开发者来说,可能会意外地发现在 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.DEFAULT 值作为 scope 属性。
注意
Websocket 网关不应使用请求作用域的提供者,因为它们必须作为单例运行。每个网关封装了一个真实的 socket,不能被多次实例化。此限制也适用于某些其他提供者,如 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 对象来实现。
REQUEST 提供者本质上是请求作用域的,这意味着在使用它时不需要显式指定 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) {}
}然后你配置 context 值(在 GraphQLModule 中)使其包含 request 作为其属性。
Inquirer 提供者
如果你想获取构造提供者的类,例如在日志或指标提供者中,你可以注入 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%。
持久提供者
如上一节所述,请求作用域的提供者可能会导致延迟增加,因为至少有 1 个请求作用域的提供者(注入到控制器实例中,或更深层——注入到其某个提供者中)会使控制器也变为请求作用域的。这意味着它必须在每个单独的请求中被重新创建(实例化)(并在之后被垃圾回收)。这也意味着,假设有 30k 个并行请求,将会有 30k 个控制器(及其请求作用域的提供者)的临时实例。
如果有一个大多数提供者都依赖的公共提供者(想想数据库连接或日志服务),它会自动将所有这些提供者也转换为请求作用域的提供者。这在多租户应用程序中可能会带来挑战,特别是对于那些拥有一个中心化的请求作用域"数据源"提供者的应用程序,该提供者从请求对象中获取 headers/token,并根据其值检索相应的数据库连接/schema(特定于该租户)。
例如,假设你有一个被 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 依赖于被标记为 durable 的 B,A 也会隐式地变为持久的(除非 A 提供者的 durable 被显式设置为 false)。
警告
请注意,此策略不适用于拥有大量租户的应用程序。
attach 方法返回的值指示 Nest 应该为给定的宿主使用哪个上下文标识符。在本例中,我们指定当宿主组件(例如,请求作用域的控制器)被标记为持久时,应使用 tenantSubTreeId 而不是原始的自动生成的 contextId 对象(你可以在下面了解如何将提供者标记为持久的)。此外,在上面的示例中,不会注册任何 payload(其中 payload = REQUEST/CONTEXT 提供者,代表子树的"根"——父节点)。
如果你想为持久树注册 payload,请使用以下构造:
// AggregateByTenantContextIdStrategy#attach 方法的返回值:
return {
resolve: (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId,
payload: { tenantId },
};现在,每当你使用 @Inject(REQUEST)/@Inject(CONTEXT) 注入 REQUEST 提供者(或 GraphQL 应用程序的 CONTEXT)时,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,
}