项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇 :上一篇《LifecycleEvents》结尾留了根线:VectorDbService 特意选"类 provider 而非 useFactory 返回裸对象",就为了让生命周期钩子必然被调------"选哪种 provider 形态"原来是个有后果的决策 。16 期前,04 期也讲过 token(类/字符串/Symbol),但当时只回答了"钥匙长什么样"。 这篇补上"锁芯":当容器默认的"反射 + new + 单例"不满足你时,四个开关(useValue/useClass/useFactory/useExisting)各自在什么场景下掰开默认接线,判断红线是什么。
定位:本篇讲 provider 的四种自定义形态与选型判据,实证全部来自 rag-server 真实 providers 数组(APP_CONFIG 的 useValue、AUTH_TOKENS 的 useFactory、APP_GUARD 的 useClass、VectorDbService 的类速记、useExisting 的诚实未用)。不讲 token 反射机制(04/05 期)、不讲动态模块 forRoot(18 期)。读完你看到任何 provider 定义,能秒答"为什么是这个形态、换别的会怎样"。
一、先立真相:providers: [X] 只是速记
providers: [CatsService] 是下面这行的语法糖:
ts
providers: [{ provide: CatsService, useClass: CatsService }]
语义拆开是两件事:
provide(token):给依赖起个"名字/钥匙",别人用它来注入(04 期讲过的那把钥匙);useXxx(recipe/配方):告诉容器"满足这个 token 时,该怎么造、给什么"。
默认(useClass)= 照常反射 design:paramtypes、递归 new、单例缓存(05 期那条链)。而你迟早遇到"不想照常 new"的场景------给配置类对象、按环境换实现、要 async 建连接、老 token 想统一------这时就换 recipe:
| 形态 | 一句话 | 典型场景 |
|---|---|---|
useValue |
容器啥也不做,直接把你给的值发出去 | 配置对象、常量、mock、第三方 SDK 实例 |
useClass |
同 token 换"用哪个类来 new",仍走容器反射 | 按环境切实现、测试替身 |
useFactory |
给个函数现场组装,支持 async + 声明自身依赖 | 连接类对象:要参数、要异步、要注入别的 provider |
useExisting |
给已有 provider 起别名,指向同一个实例 | 后向兼容、统一两个入口 token |
判断信号:当你想"给一个不是类的东西做依赖",或"同一个 token 在不同时候给不同实现",你就在自定义 provider 的门口了。
前端移植锚点:这就是依赖注入容器的"Provider 配置"------和组件库里"注册一个全局 Provider,底下组件按 key 取"同构;useValue/useFactory 的差别,像 React Context 里塞"现成的对象"还是"useMemo 现算的派生值"。
二、rag-server 全量 provider 形态盘点
先看事实,再逐个判:
| Provider | 形态 | 为什么是这个形态 |
|---|---|---|
RagController/RagService/HealthController/VectorDbService/DebugGuard 等 |
类速记(=useClass) | 标准服务:有构造依赖、要反射、16 期讲过类 provider 才保生命周期钩子 |
{ provide: APP_GUARD, useClass: AuthGuard } |
useClass(token ≠ 类) | APP_* 系统 token 是框架约定的"注入槽位",把你的类装进去 |
{ provide: APP_CONFIG, useValue: options.config } |
useValue | 配置是"数据不是服务",已在 core 生成,容器无需再造 |
{ provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] } |
useFactory | 值要依赖另一个 provider 现算(从宽 config 挑出两枚 token) |
{ provide: VECTOR_DB_OPTIONS, useValue: options } |
useValue | forRoot 传进来的参数对象,原样注入 |
useExisting |
未用 | 项目无"老 token 指向新实现"的迁移期诉求 |
四形态里项目占了三,唯独 useExisting 没用 ------不是不会,是没有后向兼容包袱(单人项目没有"老代码还在注入旧 token"的历史债)。这个"未用"本身就是判断:useExisting 的唯一入口是迁移期别名,新项目从零写,没有这个入口。
三、逐形态拆(配项目源码)
3.1 useValue:注入"现成的东西",不经过 new
ts
// config.module.ts(真实代码,forRoot 内部)
providers: [
{ provide: APP_CONFIG, useValue: options.config },
],
容器不做反射、不 new、不进依赖图 ------@Inject(APP_CONFIG) 拿到的就是 options.config 那个对象本身。两个甜点:
- "数据而不是服务"的注入:配置、feature flag 这类,天然没有"构造过程";
- 单测零成本 mock :DI 让所有依赖从容器经过,测试里
overrideProvider(X).useValue(fake)即可整体替换,业务类一行不改。
注意 16 期的反面提醒:useValue 塞的对象没有生命周期钩子------ConfigValidator 必须是类(钩子拒起),APP_CONFIG 必须是值(数据无生灭),两种形态在同一个模块里各就各位,这是"形态即语义"的活样本。
3.2 useClass:同一个 token,换"造哪个类"
ts
// app.module.ts(真实代码)
providers: [
{ provide: APP_GUARD, useClass: AuthGuard }, // token 是框架的系统槽位
{ provide: APP_GUARD, useClass: DebugGuard }, // 同一 token 第二次 = 两个都注册(10 期顺序探针)
{ provide: APP_PIPE, useClass: TrimBodyPipe },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
useClass 有两种用法,项目用的是第二种:
- 抽象换实现 (教科书用法):
provide: LoggerService, useClass: env === "prod" ? JsonLogger : ConsoleLogger------消费方只认 token,换实现是容器一处改动,下游全无感; - 系统槽位 (项目用法):
APP_GUARD/APP_PIPE这些是框架约定的 token ,本身不是类,你把自己的类"装进槽位",框架在管线对应位置消费它们(08 期绑定表里APP_*那几行的来历)。
仍是容器 new + 反射,所以 AuthGuard 自己的构造依赖(AUTH_TOKENS、Reflector)照常被注入------recipe 换的是"new 谁",不换"怎么 new"。
3.3 useFactory:函数现场组装(最强大,三条纪律)
ts
// config.module.ts(真实代码)
{
provide: AUTH_TOKENS,
useFactory: (cfg: AppConfig): AuthTokens => ({
authToken: cfg.authToken,
adminToken: cfg.adminToken,
}),
inject: [APP_CONFIG], // ← 关键:工厂自己的依赖在这里声明
},
为什么 AUTH_TOKENS 不用 useValue?因为它的值要依赖另一个 provider(APP_CONFIG)现算 ------"从宽 config 里挑出守卫真正需要的两枚 token"。这是窄 token 模式:让 AuthGuard 不依赖整个 config 对象,只拿它要的两枚(接口最小化,04 期讲过的 token 消费现场)。
三条纪律(漏一条就是运行时坑):
- 工厂的依赖必须写进
inject数组 ------函数不是类,没有design:paramtypes可反射,不写就拿到undefined; inject里的 token 按位置传给 useFactory 的参数(第一个 token → 第一个参数);- 可选依赖用对象形式
{ token: "X", optional: true }。
另外两个能力:useFactory 支持 async (返回 Promise,容器 await 后再注入------21 期 AsyncProviders 展开);但工厂 provider 之间不支持循环依赖(20 期:类可以靠 forwardRef 二次实例化兜底,函数不能调两次)。
3.4 useExisting:别名,且是"同一个实例"
ts
// 项目未用,示意
const alias = {
provide: "LEGACY_LOGGER", // 老代码习惯注入这个名字
useExisting: LoggerService, // 指向新实现 token
};
关键区别 :useExisting 拿到的是和原 token 同一个单例 ;用 useClass 再造一遍则是另一个实例。典型场景只有一个------重构迁移期:老 token 与新 token 并存,老入口自动指向新实现,业务逐步迁移,迁完删别名。验证方法:
ts
constructor(
private readonly logger: LoggerService,
@Inject("LEGACY_LOGGER") private readonly legacy: LoggerService,
) {
logger === legacy; // true ------ 同一个对象
}
四、跨模块:自定义 provider 按 token 导出
自定义 provider 要被别的模块消费,必须 exports 出来,且按 token 导出:
ts
// config.module.ts(真实代码)
exports: [APP_CONFIG, AUTH_TOKENS],
ConfigValidator 在 providers 里但不进 exports------它是模块内部自检,不供别的模块注入(16 期讲过它被 AppModule import 即实例化、钩子必然触发)。
判断信号:看到
@Inject(APP_CONFIG)报 "not available in the ... module",先查提供方模块有没有把那个 token 加进 exports------光放 providers 不够,exports 才对外可见。
五、判断红线(背这张表就够)
| 你想表达的语义 | 选它 | 别选的理由 |
|---|---|---|
| 注入的是数据/常量/现成对象 | useValue |
useClass 会画蛇添足去 new |
| 同 token,按环境/测试换实现类 | useClass |
useValue 得手拼完整实例,失去反射注入 |
| 要 async / 要喂依赖 / 现算派生值 | useFactory |
前两者做不到真异步与依赖注入 |
| 只想给老 token 指到新实现,且要同实例 | useExisting |
useClass 会变成两个实例,状态不共享 |
| 要被生命周期钩子管理 | 类(useClass/速记) | useValue 裸对象没有钩子(16 期) |
| token 会跨包/跨模块共享 | Symbol(项目 APP_CONFIG 即是) |
字符串易撞易拼错 |
一句话:useValue 给"值",useClass 换"类",useFactory 管"过程",useExisting 造"别名"。 从默认 useClass 开始,遇到具体痛点再升级------rag-server 的 providers 数组就是按这张表长出来的:能一行判对的形态选型,不需要更多理由。
六、常见坑
- useFactory 漏写
inject→ 工厂参数拿到undefined,不是编译错,是运行时炸; - useClass 当 useExisting 用 → 同 token 两个实例,状态不共享,"换实现"变成"另起炉灶";
- 给接口/配置对象裸写构造注入 →
design:paramtypes反射不出接口,必须@Inject(token)显式点名(04 期); - 字符串 token 到处手写字面量 → 两边拼写不一致 = 查表查不到;抽进
xxx.constants.ts(项目的config.constants.ts就是这么干的,且全部用Symbol); - 自定义 provider 不进 exports → 消费方模块报 "not available";exports 按 token,不按类;
- useValue 塞对象又期待生命周期钩子 → 没有(16 期);要钩子就当类 provider;
- 为了炫技提前上 useFactory → 单纯给值能解决的,工厂是负资产(失去类型直给、多一层间接)。
七、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
| Context 里塞现成对象 | useValue |
值直给,无构造过程 |
| Context 里放 useMemo 派生值 | useFactory + inject |
依赖别的值现算 |
| Provider 换实现,消费方无感 | useClass 换类 |
只认 token 不认实现 |
| 老代码 import 旧路径 + re-export 指向新模块 | useExisting |
迁移期别名,同一实例 |
| 注册 key 两边拼错取不到值 | token 不一致 | 契约常量纪律 |
自测 4 题(先自己答,再看答案):
-
providers: [RagService]展开成什么?provide 和 useXxx 各管什么? →{ provide: RagService, useClass: RagService }。provide = token(注入的钥匙),useXxx = recipe(容器满足这个 token 时的造法)。 -
AUTH_TOKENS 为什么是 useFactory 而不是 useValue? → 它的值要依赖 APP_CONFIG 现算 (从宽 config 挑两枚 token 成窄对象);useValue 只能给"已存在的值",给不了"派生过程"。同时
inject: [APP_CONFIG]声明工厂依赖------函数没有design:paramtypes,漏写拿到 undefined。 -
useExisting 和 useClass(同 token 二次注册)的实例语义差别? → useExisting = 别名指向同一单例 ;useClass 再注册 = 容器 new 另一个实例 。所以迁移期要"老入口指新实现且状态共享"只能 useExisting。项目里 APP_GUARD 注册两次是另一个语义(两个独立守卫都装进槽位、按序执行,10 期),不是别名。
-
ConfigValidator 和 APP_CONFIG 同在一个模块,为什么一个必须是类、一个必须是值? → ConfigValidator 要
onModuleInit钩子做 fail-fast 自检------钩子只有类 provider(容器实例化)才有;APP_CONFIG 是纯数据,无构造过程无生灭,useValue 直给。形态跟着语义走,同一模块里两种形态各就各位。
八、本篇收束与下一篇
四形态拆完,provider 这层的"选型自由度"就闭环了:04 期讲 token(钥匙),06 期讲全景(哪些东西进了容器),16 期讲钩子资格(类 provider 的特权),本期把 recipe(锁芯)补齐------token 决定"叫什么",recipe 决定"怎么造",两者拼起来才是完整的 provider 定义。rag-server 的 providers 数组里,每一个形态选择都能用 §五 的红线一行判对。
但你可能已经注意到:ConfigModule 和 VectorDbModule 都不是"静态 @Module"------它们的 providers 是 forRoot(options) 按参数现算返回 的。为什么配置模块要长成函数?isGlobal 参数和 07 期退役的 @Global() 装饰器什么关系?这就是下一篇。
下一篇(18 期《动态模块与 forRoot 模式》)拆 DynamicModule:模块从"静态收纳盒"升级为"带参数的工厂",ConfigModule.forRoot + VectorDbModule.forRoot 两份真实实现逐行对读,以及"组合根"这个角色为什么重要。