21 · NestJs AsyncProviders 异步提供者:useFactory 返回 Promise 之后,容器发生了什么

项目开源地址(本专栏实证代码的教程仓) https://gitee.com/yanjinqiang/corp-rag-tutorial (复制到浏览器打开)
承接上篇 :上一篇《循环依赖与 forwardRef》讲到工厂 provider 的一条死律(工厂之间不支持环);但 useFactory 还有一条活律 没讲------它的工厂可以是 async 的,返回 Promise,容器会等。 这篇拆它:异步 Provider 解决什么("异步初始化 + 只建一次"的资源交回 DI 管)、容器怎么等、失败为什么是特性 ------以及 rag-server 一个值得整篇细看的真实取舍:要复用 LanceDB 连接时,项目没选异步 Provider,选了"类 provider + 懒建缓存"。
定位:本篇讲 useFactory async 的三种用途(连接单例/启动期配置/按条件选实现)、"先等再建下游"的解析机制、fail-fast 语义,与 rag-server 的对照实证(VectorDbService 懒建缓存 vs 异步 provider 的决策账)。不讲 useFactory 基础(17 期)、不讲生命周期钩子(16 期)。读完你能回答"这个资源该不该用异步 Provider 管",并且能讲清 rag-server 为什么两次绕开它。

一、一句话回答

异步 Provider,就是让 provider 的"生产配方"(useFactory 工厂函数)是 async 的------Nest 启动时会 await 它,把 resolve 出来的值缓存成单例,再交给依赖它的组件。

它解决的问题是:有一些资源必须异步初始化(await connect()、await fetchSecrets()),又该是全应用共享一份的单例------而 useClass 只能同步 new、useValue 只能给现成值。 useFactory 的 Promise 把两种需求同时满足了。

dart 复制代码
其他 Provider / Controller
   │  要注入 "DB_CONNECTION"
   ▼
Nest 容器
   │  查表:这个 token 的配方是 async factory
   │  await factory()          ← 容器停下来等 Promise
   │        ▼  (如 await connect(url) 建好连接)
   │  resolve → 得到连接实例 → 缓存为单例
   ▼
所有注入方拿到同一个、已连接好的实例
维度 useClass useValue useFactory(同步) useFactory(async)
怎么生产 new 一个类 给现成值 调工厂函数 调工厂函数并 await
能异步初始化吗 ❌ ❌ ❌ ✅
典型场景 Service 类 mock/常量 轻量计算 DB/客户端/配置加载

前端移植锚点:前端常写 const db = await createClient(); export { db } 或 initApp() 里 await 完再 render------异步 Provider 就是后端把"异步建单例"标准化,顺带解决了替换/mock 的难题。

二、三种用途

2.1 连接单例:最主流

数据库/Redis/MQ/外部 SDK 客户端,三个特征:初始化异步、只建一次、到处要用。没有异步 Provider 时被迫写"模块级单例 + 懒加载"手工管理:

ts 复制代码
// ❌ 手工单例:模块级共享变量,难测试、难替换
let cachedConn: Connection | null = null;
export async function getConnection(): Promise<Connection> {
    if (!cachedConn) cachedConn = await connect(url);
    return cachedConn;
}

交给 DI:

ts 复制代码
// ✅ 连接作为单例 provider,谁要用谁注入
{
    provide: "DB_CONNECTION",
    useFactory: async () => {
        const conn = await connect(url);   // 启动时建一次
        return conn;
    },
    inject: [ConfigService],               // 工厂依赖在 inject 点名(17 期纪律)
}

收益:只建一次(容器单例缓存)、mock 用 overrideProvider().useValue(fake)、哪个模块要用 imports 进来注入即可。

2.2 启动期异步配置:就绪即依赖

读远端密钥(Vault/KMS)、拉 feature flag、解析加密配置------import 是同步的,等不了 Promise,这才是异步 Provider 的活:

ts 复制代码
{
    provide: "APP_CONFIG",
    useFactory: async () => {
        const secrets = await fetchSecrets("prod");
        return { ...localConfig, ...secrets };
    },
}

关键好处:依赖它的 provider 只会在配置 resolve 之后才被实例化 ------不存在"配置还没加载完就有人读"的竞态。@nestjs/config 的 ConfigModule.forRoot(内部 useFactory 读 .env)就是这套------你用第三方库时,早就在消费异步 Provider 的产物了。

2.3 按条件选实现(可配异步初始化)

ts 复制代码
{
    provide: "STORAGE",
    useFactory: async (cfg: ConfigService) => {
        if (cfg.get("STORAGE") === "s3") return await createS3Client(...);
        return createLocalFs();
    },
    inject: [ConfigService],
}

消费方只注入 token,不关心底层是 S3 还是本地------策略模式 via DI。

三、机制层:容器怎么等,失败会怎样

解析是"先等再建下游" :容器沿依赖图实例化,遇到 async factory 就 await(挂起),resolve 后缓存单例,才继续 new 下游。不是它自己慢,而是它让依赖它的那串 provider 都"等它就绪"。

工厂 reject → 整个应用启动失败 :provider 解析失败,Nest 中止启动抛错(配合 main.ts 的 catch → process.exit(1))。这是特性不是缺陷:连接这种"建不起来后面全废"的资源,就该启动即失败(fail fast),而不是应用看似起来了、等第一个请求才 500 雪崩。若想"失败也降级启动",在工厂里自己 try/catch 返回兜底值,别让 reject 冒出去。

机制一句话:异步 Provider = "启动期的一次性 await"。它把异步初始化的等待从请求期(每次现连、现等)挪到启动期(建一次、等一次)。

四、rag-server 的真实取舍:两次绕开异步 Provider

4.1 第一次:core 每次现连(连接便宜,不需要单例这层)

core 的向量库操作是函数式 + 每次现连 (openVectorStore 每次调用 await connect(...),不缓存):

ts 复制代码
// core/src/infrastructure/vector-store.ts(节选)
export async function openVectorStore(system, docType) {
    const conn = await connect(config.vectorDbPath);   // 每次现连
    const table = await conn.openTable(tableName);
    ...
}

为什么能这么写?LanceDB 是文件型向量库,连接 = 打开本地目录,开销很低 ------不像 MySQL/Redis 那种 TCP + 鉴权握手昂贵到必须复用。连接便宜 → 不需要连接单例 → 不需要异步 Provider 管单例。 技术选型决定架构复杂度的活例子。这一层到今天没变(core 保持纯库,06/19 期讲过)。

4.2 第二次:要复用连接了,却选了"懒建缓存"而不是异步 Provider

后来 HTTP 层出现真实诉求------/ready 探针每次都现连太浪费------于是落地了 VectorDbService(16/17 期讲过它的钩子与形态)。注意它的连接策略:

ts 复制代码
// vector-db.service.ts(真实代码,节选)
private getConnection(): Promise<Connection> {
    if (this.conn) return Promise.resolve(this.conn);      // 已有:直给
    if (!this.pending) {
        this.pending = connect(this.options.dbPath)        // 首次:懒建
            .then((c) => { this.conn = c; return c; })
            .catch((err) => { this.pending = null; throw err; });  // 失败:清空可重试
    }
    return this.pending;
}

这不是异步 Provider------它是"类 provider + 方法级懒建缓存" 。为什么不用 useFactory: async () => await connect(...) 一步到位?把两种方案摆在 16 期那条"可用性线"上就清楚了:

异步 Provider(启动期建) VectorDbService(懒建缓存)
连接时机 启动期,await 阻塞 bootstrap 首次调用
库挂了/没建库 应用起不来(fail fast) 应用照常 boot,/ready 报 not_ready
失败后 启动失败,进程退出 清空 pending,下次调用重试
哲学 "这资源挂了,应用不该活着" "这资源挂了,应用还活着,探针上报"

rag-server 的答案(16 期 6.2 原文):配置坏了不算活着(ConfigValidator 拒起),向量库暂时没有还活着(探针上报) 。向量库是外部资源、可能"稍后才就绪"(比如刚部署、索引还没建),把它焊进启动期等于"部署顺序锁死"------所以选了懒建。异步 Provider 的 fail-fast 是好特性,但"什么该 fail-fast"是业务判断,不是技术默认。

4.3 那 rag-server 什么时候会用上?

触发信号清单(出现其一再引入):① 现连开销肉眼可见(每次查询慢一拍);② 引入真正的长连接客户端(Redis/MySQL/连接池);③ 配置要从异步来源加载(远端/加密);④ 想给连接写单测换 mock。当前四个都没有:core 现连(便宜)、VectorDbService 管共享连接(懒建)、config 同步单例(18 期)、AUTH_TOKENS 工厂是同步的。

诚实结论:"零异步 Provider"两次都是正确简化,不是技术债。 学它不是为了现在就用,是为了识别它何时该出现------以及该出现时,先过一遍"这个资源挂了,应用该不该活着"这一问,再决定是异步 Provider 还是懒建缓存。

五、常见坑

  1. 工厂依赖忘了写 inject → 参数拿到 undefined(函数没有 design:paramtypes,17 期第一坑);
  2. reject 冒到启动流程 → 想降级启动就工厂内 try/catch 返回兜底值;
  3. 字符串 token 消费方不写 @Inject → 查表失败;
  4. 把请求级/临时连接包成全局单例 → 共享了不该共享的状态;先想清楚连接该活多久;
  5. 为异步而异步 → 同步能拿到的值(import 常量、useClass)包 async factory 是负资产;
  6. 测试忘了 override 真连接 → 单测会真的去连数据库;
  7. 把"外部资源"一律 fail-fast → 忘了可用性线:该拒起的才拒起(§4.2 的核心教训)。

六、前端心智对照 + 自测

前端概念 对应 本质
await createClient() 后存模块级单例 useFactory async + 容器缓存 异步建单例标准化
initApp() await 完再 render 启动期 await + 就绪即依赖 等待挪到启动期
env 切换不同 client 工厂按配置返回实现 策略模式 via DI
非 key 资源加载失败要不要白屏 fail-fast vs 懒建 + 降级 可用性判断先于技术选型

自测 4 题(先自己答,再看答案):

  1. 异步 Provider"异步"在哪?useClass/useValue 为什么做不到? → 生产配方是 async 工厂,容器启动期 await,resolve 后缓存单例再建下游。useClass 只能同步 new,useValue 只能给现成值------都"等不了异步动作"。

  2. 工厂 reject 会发生什么?rag-server 为什么刻意不让 LanceDB 连接走这条路? → provider 解析失败,Nest 中止启动(fail fast)。rag-server 选懒建缓存:首次调用才连、失败清空可重试、库没建好应用照常 boot 由 /ready 上报------向量库是"可能稍后才绪的外部资源",不该焊死部署顺序。

  3. core 的 openVectorStore 每次现连,和 HTTP 层 VectorDbService 的懒建缓存,各管什么? → core 是平台无关纯库,每次现连(文件型库,连接便宜,无状态最简);HTTP 层 VectorDbService 为探针/业务管共享连接(懒建缓存单例)。两层策略不同是刻意的:core 不知道 Nest 存在(19 期红线),连接管理是适配层的事。

  4. "这个资源要不要用异步 Provider 管"的判定流程? → 先问"拿它要不要 await?"------不要,直接 import/useValue/useClass;要,再问"失败了应用该不该活着?"------该拒起,异步 Provider(fail fast);该活着,类 provider + 懒建 + 探针。最后看复用度:多处共享才值得进 DI。

七、本篇收束与下一篇

异步 Provider 拆完,provider 的"时间维度"补齐:17 期四形态讲"怎么造",本期讲"什么时候造"------启动期 await(异步 Provider)与首次调用时造(懒建)是两个时机,选哪个是可用性判断而非技术偏好。rag-server 两次绕开它的完整决策链(连接便宜 → 懒建足够 → fail-fast 不适用),比任何 API 示例都值钱。

下一篇进入 DI 进阶线最后一个大主题:作用域。到此为止所有 provider 都是单例------但 Nest 还有 REQUEST(每请求一个)和 TRANSIENT(每消费方一个)两种 scope。谁该单例、谁该请求级?16 期埋过的"request-scoped 没有生命周期钩子"、20 期埋的"REQUEST 作用域 + 环 = undefined",都在 22 期收口。

下一篇(22 期《InjectionScopes 注入作用域》)讲三种 scope 的实例化差异、性能代价(per-request DI 子树),以及 rag-server 为什么全库单例却专门为多租户 demo 建了 REQUEST scope(25 期的伏笔)。

相关推荐
Mikko742 分钟前
JVM 线上排查实战(七):jps 看不到进程、jstack 报不允许的操作怎么办?attach 失败的六种情况实测
java·运维·jvm·后端
小宋10211 小时前
Agent 工具升级如何不破坏线上:Tool Schema 版本兼容与契约测试
java·人工智能·后端·spring
莪_幻尘1 小时前
Agent 体检:乱编、连锁、失忆,给 Agent 做一次五维体检
前端·人工智能·agent
geovindu1 小时前
rust: Flyweight Pattern
开发语言·后端·设计模式·rust·享元模式·结构型模式
我的xiaodoujiao1 小时前
Django 基础知识详细图文教程 13-Django 模型定义与使用 3
数据库·后端·python·测试工具·oracle·django
狂师1 小时前
Agent 天天挂在嘴边的沙箱,到底是个啥?
人工智能·agent·ai编程
SimonKing1 小时前
QClaw关停之后:一个工具的退场,一段关系的告别
java·后端·程序员
秋天的一阵风1 小时前
🚀 nacos-web-config:运营半夜改条配置,网页秒更新 —— 不用发版、不用轮询,我把它开源了
java·前端·开源
大飞记Python1 小时前
MiMo-V2.6-Distill-Qwen-9B 本地部署实测:8GB内存可跑,但推理能力让人失望
人工智能·ai·ai编程