项目开源地址(本专栏实证代码的教程仓)
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 还是懒建缓存。
五、常见坑
- 工厂依赖忘了写
inject→ 参数拿到 undefined(函数没有design:paramtypes,17 期第一坑); - reject 冒到启动流程 → 想降级启动就工厂内 try/catch 返回兜底值;
- 字符串 token 消费方不写
@Inject→ 查表失败; - 把请求级/临时连接包成全局单例 → 共享了不该共享的状态;先想清楚连接该活多久;
- 为异步而异步 → 同步能拿到的值(import 常量、useClass)包 async factory 是负资产;
- 测试忘了 override 真连接 → 单测会真的去连数据库;
- 把"外部资源"一律 fail-fast → 忘了可用性线:该拒起的才拒起(§4.2 的核心教训)。
六、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
await createClient() 后存模块级单例 |
useFactory async + 容器缓存 | 异步建单例标准化 |
initApp() await 完再 render |
启动期 await + 就绪即依赖 | 等待挪到启动期 |
| env 切换不同 client | 工厂按配置返回实现 | 策略模式 via DI |
| 非 key 资源加载失败要不要白屏 | fail-fast vs 懒建 + 降级 | 可用性判断先于技术选型 |
自测 4 题(先自己答,再看答案):
-
异步 Provider"异步"在哪?useClass/useValue 为什么做不到? → 生产配方是 async 工厂,容器启动期
await,resolve 后缓存单例再建下游。useClass 只能同步 new,useValue 只能给现成值------都"等不了异步动作"。 -
工厂 reject 会发生什么?rag-server 为什么刻意不让 LanceDB 连接走这条路? → provider 解析失败,Nest 中止启动(fail fast)。rag-server 选懒建缓存:首次调用才连、失败清空可重试、库没建好应用照常 boot 由
/ready上报------向量库是"可能稍后才绪的外部资源",不该焊死部署顺序。 -
core 的
openVectorStore每次现连,和 HTTP 层 VectorDbService 的懒建缓存,各管什么? → core 是平台无关纯库,每次现连(文件型库,连接便宜,无状态最简);HTTP 层 VectorDbService 为探针/业务管共享连接(懒建缓存单例)。两层策略不同是刻意的:core 不知道 Nest 存在(19 期红线),连接管理是适配层的事。 -
"这个资源要不要用异步 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 期的伏笔)。