以 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 只隔离"数据",并不会隔离"对象实例",两者可结合使用。
相关推荐
Eric_见嘉1 天前
NestJS 🧑‍🍳 厨子必修课(九):API 文档 Swagger
前端·后端·nestjs
XiaoYu20029 天前
第3章 Nest.js拦截器
前端·ai编程·nestjs
XiaoYu200211 天前
第2章 Nest.js入门
前端·ai编程·nestjs
实习生小黄11 天前
NestJS 调试方案
后端·nestjs
当时只道寻常15 天前
NestJS 如何配置环境变量
nestjs
濮水大叔1 个月前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
ovensi1 个月前
告别笨重的 ELK,拥抱轻量级 PLG:NestJS 日志监控实战指南
nestjs
ovensi1 个月前
Docker+NestJS+ELK:从零搭建全链路日志监控系统
后端·nestjs
Gogo8161 个月前
nestjs 的项目启动
nestjs