承接上篇 :上一篇《AsyncProviders》讲 provider 的"时间维度"(启动期造 vs 首次调用造);还有第三个维度没讲------份数 。16 期埋过"request-scoped 类没有生命周期钩子"、20 期埋过"REQUEST 作用域 + 环 = undefined",21 期结尾也预告了它。 这篇收口:DI 回答"依赖给谁",作用域回答"依赖给几份、活多久"。 默认全应用一份单例;REQUEST 每请求一份、TRANSIENT 每消费方一份------以及深处拿 requestId 为什么走 ALS 而不是 REQUEST scope。
定位 :本篇讲三种 scope 的份数与寿命、Node 单例为什么默认安全、REQUEST 的"向上冒泡"与 TRANSIENT 的不冒泡、@Inject(REQUEST)/INQUIRER特殊 token、与 ALS 方案的取舍对照。不讲 durable 机制细节(25 期整篇)。读完你能判断"这个 provider 该不该单例",并讲清单例 + ALS 与 REQUEST scope 的分界线。
一、一句话回答
Nest 的 provider 默认是"全应用共享一份"的单例:同一个 token,不管被谁注入、来了多少请求,拿到的都是同一个实例------启动时 new 好,与进程同寿。 作用域就是让你精确地改掉"几份、给谁、活多久":
Scope.DEFAULT(默认):全应用一份,启动时创建,与进程同寿;Scope.REQUEST:每个请求新建一份,请求结束就 GC;Scope.TRANSIENT:每次注入新建一份,不跨消费方共享(同一个服务注两次,拿两份)。关键规则:REQUEST 会沿注入链向上冒泡(依赖它的上游也被迫变请求级),TRANSIENT 不会。绝大多数 provider 该保持单例。
scss
谁拿到几份? 活多久?
DEFAULT(单例) app 所有地方共享同一份 ──────→ 启动建一次,和进程同寿(默认)
REQUEST(请求级) 每个请求独享一份 ──────→ 请求进来 new,请求结束 GC
TRANSIENT(瞬态) 每个"注入它的消费方"各一份 ────→ 每次注入都 new,不缓存不共享
记忆锚点:04 期的 providers 是登记表 (token → 怎么造);作用域是给每张条目标注 "造几次、给谁"。静态接线的正确性看 token 对不对得上;生命周期的正确性看作用域选得对不对。
二、为什么"默认全是单例"在 Node 里是安全的
前端/Java 背景的人会惊讶:Nest 里几乎所有东西都跨请求共享------连接池、带状态的单例服务。这在 Node 里默认安全,因为:
Node 不是"每请求一条线程"的多线程模型;它单线程跑事件循环,请求之间靠异步交错。没有"两个线程同时改同一个对象"的并发写,单例就没有 Java 那种线程安全问题。
所以无状态/只读/连接池类 provider 默认单例,不仅省内存还快 。什么时候不安全?单例里存了"本该每请求隔离的可变状态":
ts
// 反例:把"当前请求的用户"塞进单例 → 请求 B 读到请求 A 的残留
@Injectable() // 默认单例:全应用一份!
export class BadService {
currentUser?: string; // ← 每请求都该不同,但它在"共享的一份"上
}
请求 A 写入、请求 B 在覆写前读------时序性串数据。正确姿势:状态不入单例(参数传下去)/ 标 REQUEST / 用隐形上下文(§五)。
前端直觉:这和 SSR/中间件里"module-scope 变量被并发请求污染"是同一个坑------单例 ≈ 模块顶层
const x共享;REQUEST scope ≈ 每次请求一个全新的上下文实例。
三、三种作用域的写法与份数
ts
// ① provider(最常用):@Injectable 的 options
@Injectable({ scope: Scope.REQUEST })
export class CatsService {}
// ② controller:整个控制器所有 handler 按这个 scope
@Controller({ path: "cats", scope: Scope.REQUEST })
export class CatsController {}
// ③ 自定义 provider 长写法:scope 与 useClass 平级
{ provide: "CACHE_MANAGER", useClass: CacheManager, scope: Scope.TRANSIENT }
单例是默认,不用写 ;@Injectable() 没带 scope、providers: [Xxx] 简写,全是单例。单例 vs TRANSIENT 的份数最直观:
ts
@Injectable() // 单例:DogsService 注入 N 处,同一份
@Injectable({ scope: Scope.TRANSIENT }) // 瞬态:Logger 注入 N 处,N 份
@Injectable() export class A { constructor(private readonly log: Logger) {} } // log 是 #1
@Injectable() export class B { constructor(private readonly log: Logger) {} } // log 是 #2
触发器:需求是"给谁一份"就决定选哪个------全世界一份 → DEFAULT;每请求一份 → REQUEST;每个消费方各一份 → TRANSIENT。 没有"我懒得想"这种理由选后两个。
四、Scope hierarchy:REQUEST 冒泡,TRANSIENT 不冒泡
规则一:REQUEST 沿依赖链向上冒泡。
scss
CatsController ──依赖──▶ CatsService(Scope.REQUEST) ──依赖──▶ CatsRepository(单例)
CatsService 请求级 → 依赖它的 CatsController 也变请求级 (它每请求都要拿到新的 Service,自己不可能还是共享单例);CatsRepository 是被依赖的叶子,不依赖请求级东西,保持单例。为什么必须冒泡?共享的壳包不住每请求的核------Nest 宁可把整条链拖成请求级,也不让你写出"单例里藏每请求状态"的 bug。
规则二:TRANSIENT 不冒泡。 单例 DogsService 注入瞬态 Logger,每次拿到新鲜的 Logger,但 DogsService 自己仍是单例------瞬态是"往下给新鲜的",不反向传染上游。
前端直觉:REQUEST 冒泡 ≈ React 里某个依赖一旦按渲染隔离,消费它的父级也没法全局共享,只能跟着隔离;TRANSIENT ≈ 工厂函数每次返回新对象,但不改变调用方自己的缓存策略。
五、深处拿当前请求:官方路 vs ALS 路
问题 :深处的 provider 想拿"当前这次请求"(method/header/requestId),构造里却没有 req------req 只活在 Express 层。官方解法:标 REQUEST scope + 注入 REQUEST token:
ts
@Injectable({ scope: Scope.REQUEST }) // 前提:得在请求级链上
export class CatsService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
// this.request.method / this.request.headers
}
(传输层不同 token 不同:HTTP 注 REQUEST、GraphQL 注 CONTEXT------和 14 期 switchToHttp 换挡是同一思想。另一个特殊 token INQUIRER 能让 provider 知道"我正被哪个类构造",日志/指标用。)
另一条路是 ALS(AsyncLocalStorage) :深处 Service 打日志要 requestId,可以不 标 REQUEST scope------中间件用 AsyncLocalStorage.run() 播种隐形上下文,深处直接读。两条路对比:
REQUEST scope + @Inject(REQUEST) |
单例 + ALS | |
|---|---|---|
| 每请求新实例 | 是,provider 每请求 new | 否,全是单例,每请求只播种 ALS |
| 上游冒泡 | 会,一个 REQUEST 拖一串 | 不会,无冒泡 |
| 深处拿到什么 | 整个 req | 只拿播种进 ALS 的那几个显式字段 |
| 代价 | 每请求实例化 + GC,冒泡面越大越慢 | ALS 开销极小 |
| 适合 | 真需要"每请求一份独立状态"(多租户/每请求缓存) | 只要"每请求关联标识"做日志/trace |
先问自己要的是"每请求隔离的状态"还是"每请求共享上下文里的几个值";后者用 ALS,前者才上 scope。 只要 requestId 关联的话,标 REQUEST 是杀鸡用牛刀。
顺带一个判断口径:业务代码 grep 不到任何 scope 标注(Auth/Logging/Filter/业务 Service/连接服务全单例)不是"没学会",而是设计结果------无状态服务 + 连接懒建(21 期)+ ALS 上下文(09 期),把 REQUEST 的两大经典诉求(请求隔离状态、深处拿请求)都消化掉了。什么时候你会在真实项目里见到 REQUEST?典型是学习/机制演示模块(比如 25 期要讲的 durable,不造 REQUEST 实例就没有演示素材)------机制演示非生产,业务路径零 scope。
六、性能与判断红线
官方明确:请求级 provider 有性能代价------每个请求 new 一次 + GC(Nest 缓存元数据也省不掉实例化),设计得当的应用通常延迟增幅 ≤ ~5%,但冒泡面越大拖累越大。
| 你想解决的问题 | 默认答案 | 例外(才考虑 REQUEST/TRANSIENT) |
|---|---|---|
| 日志/耗时/requestId 关联 | 单例 + ALS | ------ |
| 无状态业务 Service | 单例(别动) | ------ |
| 每次都要全新小对象 | 直接 new 更简单 |
TRANSIENT(确有注入诉求时) |
| 每请求缓存 / 请求跟踪 / 多租户隔离 | ------ | REQUEST(主场) |
| 数据源按租户取连接 | ------ | REQUEST;租户数少想复用子树 → durable(25 期) |
反向红线 :WebSocket Gateway、Passport 策略、Cron 控制器必须单例 (Gateway 封装真实 socket,不能每请求多实例)。16 期埋的那条线在这收口:REQUEST 类没有生命周期钩子------每请求生死,不绑应用生命周期,错过了那套仪式。
七、常见坑
- 每请求可变状态塞进默认单例 → 经典串数据 bug;
- 单例里
@Inject(REQUEST)→ 逻辑不自洽:共享单例哪来"当前请求"; - 给必须单例的东西标 REQUEST → Gateway/Passport/Cron 出问题;
- 没意识到冒泡 → "我只改了一个 Service,结果半个应用被拖成请求级";
- TRANSIENT 当共享缓存用 → 瞬态是每消费方一份,想共享用 DEFAULT;
- REQUEST + 循环依赖 → 官方警告 undefined(20 期警告二);
- REQUEST 类里写生命周期钩子 → 不触发(16 期边界一);
- DB 连接标 REQUEST → 每请求重连是灾难;连接池是典型单例,多租户想按租户取连接去想 durable。
八、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
模块顶层 const x(module-scope 共享) |
DEFAULT 单例 | 全应用一份 |
| SSR"别把请求态放模块顶层" | REQUEST 每请求一份 | 请求隔离可变状态 |
每次调用 new 新对象 |
TRANSIENT | 不共享不缓存 |
| 请求 A 数据串进请求 B(SSR race) | 单例塞请求态反例 | 冒泡的根因 |
| per-request context / AsyncLocalStorage | ALS vs REQUEST scope | 深处拿请求的两条路 |
自测 3 题(先自己答,再看答案):
-
三种 scope 各是"给谁几份、活多久"?哪个默认? → DEFAULT 全应用一份与进程同寿(默认);REQUEST 每请求一份、请求毕 GC;TRANSIENT 每消费方一份、每次注入 new。
-
CatsService 标 REQUEST,依赖它的 Controller 和它依赖的 Repository 各怎样?换 TRANSIENT 呢? → Controller 被冒泡成请求级;Repository 是叶子,保持单例。TRANSIENT 不冒泡:注入方拿新鲜实例但自己 scope 不变。
-
深处 Service 打日志带 requestId,为什么不建议标 REQUEST +
@Inject(REQUEST)?ALS 方案怎么做? → 要的只是关联标识不是请求隔离状态;REQUEST 冒泡导致整链每请求实例化。ALS:中间件播种、深处读,零冒泡零实例化。
九、本篇收束与下一篇
作用域拆完,provider 的三个维度齐了:怎么造 (17 期四形态)、什么时候造 (21 期同步/懒建)、造几份给谁 (本期)。判断的总纲也立住了:默认单例不是偷懒,是最优------ALS + 懒连接把 REQUEST 的常规诉求全部消化,只在刻意演示机制时才造请求级实例。
下一篇(23 期《ModuleRef 模块引用》)从"作用域"切到"取货方式":到此为止所有依赖都是构造期静态注入 的------但如果想运行期按条件从容器里取一个实例呢?ModuleRef 的 get/resolve/create 四 API,包括 20 期预告过的"环的替代方案"。