注入范围 Injection scopes

对于来自不同编程语言背景的人来说,可能出乎意料的是,在 Nest 中,几乎所有内容都是在传入请求之间共享的。我们有一个到数据库的连接池、具有全局状态的单例服务等。请记住,Node.js 不遵循请求/响应多线程无状态模型,在该模型中,每个请求都由单独的线程处理。因此,对我们的应用程序来说,使用单例实例是完全**安全的**。

但是,在某些情况下,基于请求的生存期可能是所需的行为,例如,GraphQL 应用程序中的每个请求缓存、请求跟踪和多租户。注入范围提供了一种获得所需提供程序生存期行为的机制。

提供程序范围

提供程序可以具有以下任何范围:

DEFAULT提供程序的单个实例在整个应用程序中共享。实例生存期与应用程序生命周期直接相关。应用程序启动后,所有单例提供程序都已实例化。默认情况下使用单例范围。
REQUEST为每个传入的请求专门创建一个新的提供程序实例。请求处理完成后,实例将被垃圾收集。
TRANSIENT临时提供程序不在消费者之间共享。每个注入临时提供程序的消费者都将收到一个新的专用实例。
提示

对于大多数用例,建议使用单例范围。在消费者和请求之间共享提供程序意味着可以缓存实例,并且其初始化仅在应用程序启动期间发生一次。

用法

通过将scope属性传递给@Injectable()装饰器选项对象来指定注入范围:

ts
import { Injectable, Scope } from '@nestjs/common'

@Injectable({ scope: Scope.REQUEST })
export class CatsService {}

类似地,对于 自定义提供商,以提供商注册的长格式设置 scope 属性:

ts
{
  provide: 'CACHE_MANAGER',
  useClass: CacheManager,
  scope: Scope.TRANSIENT,
}
提示

@nestjs/common 导入 Scope 枚举

默认使用单例范围,无需声明。如果您确实想将提供程序声明为单例范围,请对 scope 属性使用 Scope.DEFAULT 值。

注意

Websocket 网关不应使用请求范围提供程序,因为它们必须充当单例。每个网关都封装了一个真实的套接字,不能多次实例化。该限制也适用于其他一些提供程序,如 Passport strategiesCron controllers

控制器范围

控制器也可以具有范围,该范围适用于该控制器中声明的所有请求方法处理程序。与提供程序范围一样,控制器的范围声明了其生命周期。对于请求范围的控制器,会为每个入站请求创建一个新实例,并在请求处理完成后进行垃圾收集。

使用ControllerOptions对象的scope属性声明控制器范围:

ts
@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范围。

ts
import { Inject, Injectable, Scope } 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

ts
import { Inject, Injectable, Scope } 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令牌。

ts
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}`)
  }
}

然后按如下方式使用它:

ts
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 个临时实例。

拥有大多数提供程序所依赖的通用提供程序(想想数据库连接或记录器服务),也会自动将所有这些提供程序转换为请求范围提供程序。这可能会给多租户应用程序带来挑战,尤其是对于那些具有中央请求范围数据源提供程序的应用程序,该提供程序从请求对象中获取标头/令牌并根据其值检索相应的数据库连接/架构(特定于该租户)。

例如,假设您有一个由 10 个不同客户轮流使用的应用程序。每个客户都有其自己的专用数据源,并且您希望确保客户 A 永远无法访问客户 B 的数据库。实现此目的的一种方法是声明一个请求范围的数据源提供程序,该提供程序基于请求对象确定当前客户是什么并检索其相应的数据库。使用这种方法,您可以在几分钟内将您的应用程序转变为多租户应用程序。但是,这种方法的一个主要缺点是,由于您的应用程序的大部分组件很可能依赖于数据源提供程序,因此它们将隐式变为请求范围,因此您无疑会看到应用程序性能受到影响。

但如果我们有更好的解决方案呢?由于我们只有 10 个客户,难道我们不能为每个客户创建 10 个单独的 DI 子树(而不是为每个请求重新创建每棵树)?如果您的提供程序不依赖于每个连续请求真正唯一的任何属性(例如,请求 UUID),而是有一些特定的属性让我们可以聚合(分类)它们,那么就没有理由在每个传入请求上_重新创建 DI 子树_。

这正是持久提供程序派上用场的时候。

在我们开始将提供程序标记为持久之前,我们必须首先注册一个策略,指示 Nest 那些常见请求属性是什么,提供对请求进行分组的逻辑 - 将它们与相应的 DI 子树关联起来。

ts
import {
  ContextId,
  ContextIdFactory,
  ContextIdStrategy,
  HostComponentInfo,
} 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)
    }

    // If tree is not durable, return the original "contextId" object
    return (info: HostComponentInfo) =>
      info.isTreeDurable ? tenantSubTreeId : contextId
  }
}
提示

与请求范围类似,持久性使注入链冒泡。这意味着如果 A 依赖于标记为持久的 B,则 A 也会隐式变为持久(除非 A 提供程序的持久明确设置为false)。

警告

请注意,此策略对于拥有大量租户的应用程序并不理想。

attach方法返回的值指示 Nest 应该为给定主机使用什么上下文标识符。在这种情况下,我们指定当主机组件(例如,请求范围的控制器)被标记为持久时,应该使用tenantSubTreeId而不是原始的自动生成的contextId对象(您可以在下面了解如何将提供程序标记为持久)。此外,在上面的例子中,没有有效负载会被注册(其中有效负载 = 代表REQUEST/CONTEXT 提供程序 - 子树的父级)。

如果您想为持久树注册有效负载,请使用以下构造:

ts
// The return of `AggregateByTenantContextIdStrategy#attach` method:
return {
  resolve: (info: HostComponentInfo) =>
    info.isTreeDurable ? tenantSubTreeId : contextId,
  payload: { tenantId },
}

现在,每当您使用 @Inject(REQUEST)/@Inject(CONTEXT) 注入 REQUEST 提供程序(或 GraphQL 应用程序的 CONTEXT)时,都会注入 payload 对象(由单个属性组成 - 在本例中为 tenantId)。

好的,有了这个策略,您可以在代码中的某个地方注册它(因为它无论如何都是全局适用的),例如,您可以将它放在 main.ts 文件中:

ts
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy())
提示

ContextIdFactory 类从 @nestjs/core 包导入。

只要注册发生在任何请求到达您的应用程序之前,一切都会按预期工作。

最后,要将常规提供程序转变为持久提供程序,只需将 durable 标志设置为 true 并将其范围更改为 Scope.REQUEST(如果 REQUEST 范围已经在注入链中,则不需要):

ts
import { Injectable, Scope } from '@nestjs/common'

@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}

类似地,对于 自定义提供商,以提供商注册的长写形式设置 durable 属性:

ts
{
  provide: 'foobar',
  useFactory: () => { ... },
  scope: Scope.REQUEST,
  durable: true,
}