Fundamentals

注入作用域

了解 NestJS 中的提供者作用域,包括 DEFAULT、REQUEST 和 TRANSIENT 作用域的使用方法和性能考虑。

对于来自不同编程语言背景的人来说,了解到在 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 网关不应使用请求作用域的提供者,因为它们必须充当单例。每个网关封装一个真实的套接字,不能多次实例化。此限制也适用于其他一些提供者,如 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 令牌。

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 个控制器(及其请求作用域提供者)的临时实例。

拥有大多数提供者依赖的通用提供者(想想数据库连接或记录器服务),会自动将所有这些提供者转换为请求作用域提供者。这在多租户应用程序中可能构成挑战,特别是对于那些具有中央请求作用域"数据源"提供者的应用程序,该提供者从请求对象中获取标头/令牌,并基于其值检索相应的数据库连接/模式(特定于该租户)。

例如,假设您有一个由 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 提供者显式设置 durablefalse)。

警告 请注意,此策略对于操作大量租户的应用程序来说并不理想。

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

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

// `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,
}