以 NestJS 为原型看懂 Node.js 框架设计:Provider Scope

前言

NestJS 的依赖注入系统支持 三种 Provider Scope:Singleton、Request 和 Transient。理解这些 Scope 对于设计稳定、高性能的服务依赖链至关重要。

Provider Scope

概览

在 NestJS 中,每个 Provider 都有一个 作用域(Scope) ,它决定了实例化的时机和生命周期:

Scope 含义 实例化时机 典型应用场景
Singleton(默认) 全局单例 启动阶段 大部分服务、全局工具类
Request 每个请求创建一个实例,与请求上下文绑定,避免跨请求数据污染 请求阶段 保存请求上下文状态,如用户信息 req.user
Transient 每次调用生成新实例 由调用方决定 短生命周期、工具类、多次独立实例

为什么要区分启动阶段和请求阶段?(ContextId)

从上表可以看出,不同 Scope 的 Provider 实例化时机不同,分为启动阶段请求阶段 ,为了在框架内部统一管理这些实例,同时保证 Singleton ScopeRequest Scope 的隔离,NestJS 为每个实例化过程引入了一个上下文标识 ContextId

typescript 复制代码
interface ContextId {
  readonly id: number;
}

const STATIC_CONTEXT: ContextId = Object.freeze({ id: 1 });
  • 启动阶段 → 使用 STATIC_CONTEXT,表示全局单例
  • 请求阶段 → 使用 { id: 随机数 },表示每个请求独立上下文

InstanceWrapper 与实例存储结构(Singleton / Request / Transient)

每个 Provider 可能在 启动阶段请求阶段 被实例化多次。为了管理这些实例,同时保证不同 Scope 之间不会互相干扰,NestJS 在 InstanceWrapper 中引入了 ContextId 对应的实例存储机制。

ts 复制代码
export interface InstancePerContext<T> {
  instance: T;
  isResolved?: boolean;  // 是否已经实例化
  isPending?: boolean;   // 是否正在实例化(用于防止循环依赖)
}

export class InstanceWrapper<T = any> {
    private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();

    // 为特定 context 保存实例
    public setInstanceByContextId(
        contextId: ContextId,
        value: InstancePerContext<T>
    ) {
        this.values.set(contextId, value);
    }

    // 获取指定 context 下的实例
    public getInstanceByContextId(
        contextId: ContextId,
        inquirerId?: string
    ): InstancePerContext<T> {
        // Transient Provider 根据父 Provider 区分实例
        if (this.scope === Scope.TRANSIENT && inquirerId) {
          return this.getInstanceByInquirerId(contextId, inquirerId);
        }
        // Singleton、Request scope provider
        const instancePerContext = this.values.get(contextId);
        if (instancePerContext) return instancePerContext;

        // 请求阶段,如果依赖树不是完全静态,则创建新实例
        if (contextId !== STATIC_CONTEXT) {
          return this.cloneStaticInstance(contextId);
        }

        // 为防御性设计,实际使用中 STATIC_CONTEXT 初始化会保证 instancePerContext 永远存在
        return {
            instance: null as T,
            isResolved: true,
            isPending: false,
        };
    }

    // 创建请求上下文的新实例(浅拷贝)
    public cloneStaticInstance(contextId: ContextId): InstancePerContext<T> {
      const staticInstance = this.getInstanceByContextId(STATIC_CONTEXT);

      // 依赖树完全静态(Singleton) → 直接复用 static 实例
      if (this.isDependencyTreeStatic()) {
        return staticInstance;
      }

      const instancePerContext: InstancePerContext<T> = {
        ...staticInstance,
        instance: undefined!,
        isResolved: false,
        isPending: false,
      };

      if (this.isNewable()) {
        instancePerContext.instance = Object.create(this.metatype!.prototype);
      }

      this.setInstanceByContextId(contextId, instancePerContext);
      return instancePerContext;
    }
}

以上只是 singleton 和 request scope provider 的实例存储结构,而Transient Provider 较为特殊, 每个调用者(inquirer)都有自己的实例,因此单独存储:

ts 复制代码
export class InstanceWrapper<T = any> {
  private transientMap = new Map<string, WeakMap<ContextId, InstancePerContext<T>>>();

  public getInstanceByInquirerId(
    contextId: ContextId,
    inquirerId: string,
  ): InstancePerContext<T> {
    let collectionPerContext = this.transientMap.get(inquirerId);
    if (!collectionPerContext) {
      collectionPerContext = new WeakMap();
      this.transientMap.set(inquirerId, collectionPerContext);
    }
    const instancePerContext = collectionPerContext.get(contextId);
    return instancePerContext ?? this.cloneTransientInstance(contextId, inquirerId);
  }
}
  • 每个调用者(inquirerId)拥有独立的 WeakMap
  • WeakMap 再根据 ContextId 区分不同请求上下文
  • 确保 Transient Provider 不同调用者之间互不干扰

实例化流程:loadProvider 如何决定实例生成?

在 NestJS 中,同一个 Provider 可以在不同的上下文(Context)下拥有多个实例 。这种上下文区分由 ContextId 实现,它标识了当前实例属于 全局启动阶段(Static Context) 还是 请求阶段(Request Context)

Nest 的依赖注入机制遵循"自底向上"的递归解析原则。每个待解析的 Provider 在实例化前都会依次经过 isStatic()、isInRequestScope() 等条件判断。

这种机制体现了 Request Scope 冒泡原则

  • 在启动阶段(Static Context):

    仅实例化依赖树 完全静态(不包含任何 Request Scope Provider)的 Provider。

  • 在请求阶段(Request Context):

    当请求到来时,Nest 会根据当前 ContextId,为所有与请求相关的 Provider 创建独立实例。

    若某个 Singleton 依赖了 Request Scope Provider,它的类实例不会重新创建,但会在当前请求上下文中生成一个新的实例包装(InstancePerContext),从而正确关联请求作用域的依赖。

Tip: 依赖树由当前provider和其依赖组成,与上游provider无关。

ts 复制代码
export class Container {
  public async loadProvider(
    wrapper: InstanceWrapper,
    moduleRef: Module,
    contextId: ContextId = STATIC_CONTEXT, // 增加contextId参数
    inquirer?: InstanceWrapper
  ): Promise<void> {
    // ...
    await this.loadInstance(wrapper, collection, moduleRef, contextId, inquirer);
  }

  private async loadInstance<T>(
    wrapper: InstanceWrapper<T>,
    collection: Map<InjectionToken, InstanceWrapper>,
    moduleRef: Module,
    contextId: ContextId = STATIC_CONTEXT,
    inquirer?: InstanceWrapper
  ) {
    // 重点1:配合instanceWrapper,获取当前上下文下的实例,这里包含了所有的 scope
    const instanceHost = wrapper.getInstanceByContextId(contextId, inquirer?.id);

    // 如果当前上下文中该 provider 已经被实例化,则直接返回
    if (instanceHost.isResolved) {
      return;
    }

    await this.resolveConstructorParams(
      wrapper,
      moduleRef,
      wrapper.inject as InjectionToken[],
      async (instances: unknown[]) => {
        // 这是解析完构造函数参数后的回调;instances为构造函数参数的实例列表
        // 率先实例化的是最底层的provider, 其instances为空
        
        // 重点2:只有在isInContext 为 true 的时候才会实例化,最后给当前provider标记isResolved 并返回
        const isInContext =
          wrapper.isStatic(contextId, inquirer) ||
          wrapper.isInRequestScope(contextId, inquirer)
        if (isInContext) {
            // 源码中要区分一般provider 和 factory provider,也就是判断 inject 是否为空
            const instance = new (wrapper.metatype as Type<any>)(...instances);
            instanceHost.instance = instance;
        }
        instanceHost.isResolved = true;
        return instanceHost.instance;
      },
      contextId,
      inquirer
    );
  }
  public async resolveConstructorParams(wrapper, moduleRef, inject, callback) {
      // 伪代码:递归调用 loadProvider
      const params = this.getClassDependencies(wrapper)
      const instances = await Promise.all(params.map(async (param) => {
          const instanceHost = getInstanceByContextId(...)
          await loadProvider(...)
          return instanceHost.instance;
      }))
      callback(instances)
  }
}

instantiateClass 中的 isInContext 判断

在实际源码中,resolveConstructorParams的回调参数中调用了 instantiateClass(),其内部定义的isInContext决定了当前 Provider 是否要在当前上下文中被实例化:

ts 复制代码
const isInContext =
  wrapper.isStatic(contextId, inquirer) ||
  wrapper.isInRequestScope(contextId, inquirer) ||
  wrapper.isLazyTransient(contextId, inquirer) ||
  wrapper.isExplicitlyRequested(contextId, inquirer);

下面逐个解释这四个条件的语义:

函数 参数说明 返回 true 的典型场景 场景说明
isStatic 启动阶段(STATIC_CONTEXT),依赖树完全静态(无 Request) *? <- singleton[target] <- singleton/transient? *? <- singleton - transient[target] <- singleton/transient? 硬性条件:依赖树不能有 request scope provider; 满足以下条件之一即可: 1. 自身是 singleton scope 2. 自身是 transient scope 但是被 singleton scope provider 调用。
isInRequestScope 请求阶段(contextId ≠ STATIC_CONTEXT,依赖树存在 Request scope provider 或自身是 Request scope provider request[target] <- *? *? <- singleton[target] <- request * <- transient[target] <- request 硬性条件:依赖树存在request scope provider; 满足以下条件之一即可: 1. 自身是 request scope 2. 自身是 singleton scope 3. 自身是 transient scope 且 被任意provider调用
isLazyTransient 请求阶段,依赖树静态,当前 provider 是 transient 且被 request scope provider 调用 *? <- request - transient[target] - transient/singleton? 硬性条件:依赖树不能有 request scope provider; 满足以下条件之一即可: 1. 自身是transient scope, 被 request scope provider调用
isExplicitlyRequested 请求阶段,当前 provider 显式被调用,或其调用者是 transient *? <- transient - transient/singleton[target] - transient/singleton? @Injectable({ scope: Scope.TRANSIENT }) class MyService {}; const instance = moduleRef.get(MyService);`` // 这里 inquirer === MyService 硬性条件:依赖树不能有 request scope provider 满足以下条件之一即可: 1. 被transient provider 调用 2. 调用者是自身

小结:

1. Singleton scope provider 实例化规则:
  • 启动阶段:
    • 依赖树完全静态(无 Request Scope Provider)的 Singleton 会被立即实例化
  • 请求阶段:
    • 依赖树中包含 Request Scope Provider,则会生成与上下文(contextId)绑定的实例;
2. Request scope provider 实例化规则:
  • 启动阶段:
    • 不会被实例化(isStatic === false),因为此时请求上下文尚未存在。
  • 请求阶段:
    • 每个请求都会创建新的实例;
3. Transient scope provider 实例化规则:
  • 启动阶段:
    • 依赖树完全静态,且被 Singleton Provider 调用,会立即实例化;
  • 请求阶段:
    • transient 被 request scope provider 调用,生成新实例(绑定当前请求);
    • transient 被 transient provider 调用,生成新实例(与调用者隔离);
    • transient 被 singleton provider 调用,且依赖链下游有 request scope provider, 生成新实例;

注入 Request 对象

在 NestJS 中,请求对象(Request)不是自动全局可用的,而是通过依赖注入机制按请求上下文注入到 Provider 中。

@Inject(REQUEST) 的用法

Nest 提供了一个全局 token REQUEST,用来注入当前请求对象。用法示例:

ts 复制代码
import { Injectable, Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class UserService {
  constructor(@Inject(REQUEST) private readonly request: any) {}

  getUserId(): string {
    return this.request.user?.id;
  }
}

实现原理

1. Request Provider 的注册

NestJS 在内部核心模块(InternalCoreModule)里,将 REQUEST 作为 Request Scope Provider 注册在全局 provider 列表里,这个 provider 被全局导出 (exports),因此所有模块都可以注入 @Inject(REQUEST):

ts 复制代码
const noop = () => {}; // 默认是个空对象
export const requestProvider: Provider = {
  provide: REQUEST,
  scope: Scope.REQUEST,
  useFactory: noop, // 会在请求阶段被真正赋值
};
  • scope: Scope.REQUEST 表示它只能在请求阶段实例化。
  • 启动阶段只创建 provider metadata,但不会有 request 对象,此时request provider 是个空函数。

2. 请求阶段实例化与 request 注入

ts 复制代码
export class RouterExplorer {
  private getContextId<T extends Record<any, unknown> = any>(
    request: T,
    isTreeDurable: boolean,
  ): ContextId {
    // 这个request是 express返回的 req 对象
    const contextId = ContextIdFactory.getByRequest(request);
    if (!request[REQUEST_CONTEXT_ID as any]) {
      // 给request 增加 contextId 属性
      Object.defineProperty(request, REQUEST_CONTEXT_ID, {
        value: contextId,
        enumerable: false,
        writable: false,
        configurable: false,
      });

      const requestProviderValue = isTreeDurable
        ? contextId.payload
        : Object.assign(request, contextId.payload);
       // 最终得到 request 对象和 contextId,赋值给request provider
       this.container.registerRequestProvider(requestProviderValue, contextId);
    }
    return contextId;
  }
}
ts 复制代码
export class NestContainer {
  public registerRequestProvider<T = any>(request: T, contextId: ContextId) {
    const wrapper = this.internalCoreModule.getProviderByKey(REQUEST);
    wrapper.setInstanceByContextId(contextId, {
      instance: request,
      isResolved: true,
    });
  }
}

这是个很巧妙的实现,将request 对象作为全局 provider,和普通的provider一样,经过Nestjs 解析后生成对应 instanceWrapper 对象,它的valuesWeakMap<ContextId, InstancePerContext<T>>)属性用来存放具体的实例。每次请求进来时更新values就能拿到最新的Request对象。

小结:

  1. 若一个 singleton provider 注入了REQUEST,等同于依赖了一个 request scope provider,遵循request 冒泡原则。
  2. 根据第一点延伸可知:任何依赖 Request Provider 的 Provider,只会在请求阶段实例化,因此构造函数里访问 req 对象是安全的。启动阶段不会执行它们的构造函数。
  3. Req 对象和 contextId(非 STATIC_CONTEXT)强绑定,因此只有那些在请求阶段会实例化的provider 才可以拿到正确的req对象。

实践建议与思考

  1. 优先使用 Singleton

    • 对大多数服务和工具类,默认 Singleton 就足够,启动阶段就会实例化,无需担心请求上下文。
    • 仅当需要保存每个请求独立状态或临时实例时才考虑 Request/Transient。
  2. 谨慎使用 Request Scope

    • 每个请求都会生成新实例,频繁创建可能影响性能。
    • 避免在启动阶段访问 Request Scope Provider,否则会拿不到 req 对象。
  3. Transient Scope 要明确调用链

    • Transient Provider 会根据调用者生成独立实例,理解 inquirer 与依赖树关系是关键。
    • 设计短生命周期工具类时,考虑是否真的需要每次生成新实例。
  4. 考虑更轻量的上下文隔离方案 AsyncLocalStorage(ALS)

    • 如果只是想在任意位置访问当前请求的上下文信息(如 userId、traceId),可以采用 AsyncLocalStorage(ALS) 来管理请求级数据,它不依赖依赖注入系统,性能更高,也能在全局单例中安全访问请求上下文,但要注意,ALS 只隔离"数据",并不会隔离"对象实例",两者可结合使用。
相关推荐
妖孽白YoonA2 天前
NestJS - 循环依赖地狱及其避免方法
架构·nestjs
用户17592342150283 天前
原来 Nest.js 如此简单
nestjs
Whbbit19995 天前
在 Nestjs 中使用 Drizzle ORM
前端·javascript·nestjs
濮水大叔6 天前
VonaJS AOP编程:全局中间件全攻略
typescript·nodejs·nestjs
濮水大叔7 天前
AOP编程有三大场景:控制器切面,内部切面,外部切面,你get到了吗?
typescript·node.js·nestjs
急急王子小啊皓7 天前
nestjs中passport-jwt非对称签名算法的使用
nestjs
一碗饭特稀8 天前
NestJS入门(1)——TODO项目创建及概念初步了解
node.js·nestjs