22 · NestJs InjectionScopes 注入作用域:默认单例不是偷懒,是最优——以及何时才该打破

承接上篇 :上一篇《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 类没有生命周期钩子------每请求生死,不绑应用生命周期,错过了那套仪式。

七、常见坑

  1. 每请求可变状态塞进默认单例 → 经典串数据 bug;
  2. 单例里 @Inject(REQUEST) → 逻辑不自洽:共享单例哪来"当前请求";
  3. 给必须单例的东西标 REQUEST → Gateway/Passport/Cron 出问题;
  4. 没意识到冒泡 → "我只改了一个 Service,结果半个应用被拖成请求级";
  5. TRANSIENT 当共享缓存用 → 瞬态是每消费方一份,想共享用 DEFAULT;
  6. REQUEST + 循环依赖 → 官方警告 undefined(20 期警告二);
  7. REQUEST 类里写生命周期钩子 → 不触发(16 期边界一);
  8. 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 题(先自己答,再看答案):

  1. 三种 scope 各是"给谁几份、活多久"?哪个默认? → DEFAULT 全应用一份与进程同寿(默认);REQUEST 每请求一份、请求毕 GC;TRANSIENT 每消费方一份、每次注入 new。

  2. CatsService 标 REQUEST,依赖它的 Controller 和它依赖的 Repository 各怎样?换 TRANSIENT 呢? → Controller 被冒泡成请求级;Repository 是叶子,保持单例。TRANSIENT 不冒泡:注入方拿新鲜实例但自己 scope 不变。

  3. 深处 Service 打日志带 requestId,为什么不建议标 REQUEST + @Inject(REQUEST)?ALS 方案怎么做? → 要的只是关联标识不是请求隔离状态;REQUEST 冒泡导致整链每请求实例化。ALS:中间件播种、深处读,零冒泡零实例化。

九、本篇收束与下一篇

作用域拆完,provider 的三个维度齐了:怎么造 (17 期四形态)、什么时候造 (21 期同步/懒建)、造几份给谁 (本期)。判断的总纲也立住了:默认单例不是偷懒,是最优------ALS + 懒连接把 REQUEST 的常规诉求全部消化,只在刻意演示机制时才造请求级实例。

下一篇(23 期《ModuleRef 模块引用》)从"作用域"切到"取货方式":到此为止所有依赖都是构造期静态注入 的------但如果想运行期按条件从容器里取一个实例呢?ModuleRef 的 get/resolve/create 四 API,包括 20 期预告过的"环的替代方案"。

相关推荐
liangshanbo12151 小时前
Web Worker 在 AI 前端应用中有什么具体使用场景?
前端·人工智能
rannn_1111 小时前
【Java面试题】高频面试题1|Java 后端、MySQL、Redis 与 RAG 面试整理
java·jvm·数据库·后端·面试
ZzT1 小时前
Claude Haiku 5.5 发布:单价降到 1/10,10 万 token 是分界线
ai编程
拖孩1 小时前
一个全程 AI 写的小程序「厨菜记」,上线 20 天跑通流量主,收入几块钱,开心得不行
前端·后端·微信小程序
颜进强1 小时前
21 · NestJs AsyncProviders 异步提供者:useFactory 返回 Promise 之后,容器发生了什么
前端·后端·ai编程
Mikko71 小时前
JVM 线上排查实战(七):jps 看不到进程、jstack 报不允许的操作怎么办?attach 失败的六种情况实测
java·运维·jvm·后端
小宋10212 小时前
Agent 工具升级如何不破坏线上:Tool Schema 版本兼容与契约测试
java·人工智能·后端·spring
莪_幻尘2 小时前
Agent 体检:乱编、连锁、失忆,给 Agent 做一次五维体检
前端·人工智能·agent
geovindu2 小时前
rust: Flyweight Pattern
开发语言·后端·设计模式·rust·享元模式·结构型模式