前言
NestJS 的依赖注入系统支持 三种 Provider Scope:Singleton、Request 和 Transient。理解这些 Scope 对于设计稳定、高性能的服务依赖链至关重要。
Provider Scope
概览
在 NestJS 中,每个 Provider 都有一个 作用域(Scope) ,它决定了实例化的时机和生命周期:
Scope | 含义 | 实例化时机 | 典型应用场景 |
---|---|---|---|
Singleton(默认) | 全局单例 | 启动阶段 | 大部分服务、全局工具类 |
Request | 每个请求创建一个实例,与请求上下文绑定,避免跨请求数据污染 | 请求阶段 | 保存请求上下文状态,如用户信息 req.user |
Transient | 每次调用生成新实例 | 由调用方决定 | 短生命周期、工具类、多次独立实例 |
为什么要区分启动阶段和请求阶段?(ContextId)
从上表可以看出,不同 Scope 的 Provider 实例化时机不同,分为启动阶段 和请求阶段 ,为了在框架内部统一管理这些实例,同时保证 Singleton Scope 和 Request 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
对象,它的values
(WeakMap<ContextId, InstancePerContext<T>>
)属性用来存放具体的实例。每次请求进来时更新values
就能拿到最新的Request
对象。
小结:
- 若一个 singleton provider 注入了REQUEST,等同于依赖了一个 request scope provider,遵循request 冒泡原则。
- 根据第一点延伸可知:任何依赖 Request Provider 的 Provider,只会在请求阶段实例化,因此构造函数里访问 req 对象是安全的。启动阶段不会执行它们的构造函数。
- Req 对象和 contextId(非 STATIC_CONTEXT)强绑定,因此只有那些在请求阶段会实例化的provider 才可以拿到正确的req对象。
实践建议与思考
-
优先使用 Singleton
- 对大多数服务和工具类,默认 Singleton 就足够,启动阶段就会实例化,无需担心请求上下文。
- 仅当需要保存每个请求独立状态或临时实例时才考虑 Request/Transient。
-
谨慎使用 Request Scope
- 每个请求都会生成新实例,频繁创建可能影响性能。
- 避免在启动阶段访问 Request Scope Provider,否则会拿不到 req 对象。
-
Transient Scope 要明确调用链
- Transient Provider 会根据调用者生成独立实例,理解 inquirer 与依赖树关系是关键。
- 设计短生命周期工具类时,考虑是否真的需要每次生成新实例。
-
考虑更轻量的上下文隔离方案 AsyncLocalStorage(ALS)
- 如果只是想在任意位置访问当前请求的上下文信息(如 userId、traceId),可以采用 AsyncLocalStorage(ALS) 来管理请求级数据,它不依赖依赖注入系统,性能更高,也能在全局单例中安全访问请求上下文,但要注意,ALS 只隔离"数据",并不会隔离"对象实例",两者可结合使用。