项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇 :上一篇《什么是依赖注入与控制反转》把概念地基立好了------IoC 反转创建权、DI 递依赖、DIP 倒方向,但留了个尾巴:"容器凭什么知道要递 RagService?靠类型 + providers 注册"只答了一半,token 这条解析链路没拆。 这篇就进容器内部:注册(登记)→ 解析(查表)→ 注入(塞进构造)三件事,本质上只押在一个词上------token 对不对得上。
定位 :本篇讲 Nest DI 的机制层 (token/注册/解析),不讲概念(IoC/DI/DIP,03 期已拆),也不论证"为什么值得不用 new"(05 期动机篇),useValue/useFactory/useClass四形态的完整展开在 17 期。官方页里"可选依赖@Optional/ 属性注入"两块是项目没用到的,会单独讲------见过它们,读别人的代码才不懵。读完你能对任何一行注入报错定位到"哪个 token 没对上"。
一、一句话回答
Nest 的依赖注入 = 三处靠同一个"token(身份)"对上号的接线:
- 注册 :你在模块
providers数组里写RagService(或{ provide: token, useClass/useValue/useFactory }),等于往容器的"登记表"里塞一条token → 怎么造的条目;- 声明 :某个类(Controller/Service/Guard...)的构造参数写类型(或
@Inject(token)),等于说"我要 token = 这个类 / 这个常量 的 provider";- 注入 :Nest 启动时扫整张依赖图,按每个构造参数要的 token 去登记表查条目 → 构造好(或取现成值)后替你 new 并塞进构造函数 ------你从不在业务代码里写
new RagService()。
"对得上"靠的是 token 一致:providers: [RagService] 的 token 就是 RagService 这个类(和构造参数类型一致 → 免写 @Inject);@Inject(AUTH_TOKENS) 的 token 是那个常量(和 provide: AUTH_TOKENS 一致 → 必须显式标注)。
typescript
你想让 RagController 拿到一个 RagService:
rag.module.ts rag.controller.ts
──────────────────── ─────────────────────────
@Module({ @Controller('api')
controllers: [RagController], export class RagController {
providers: [RagService], ①登记 constructor(
exports: [RagService], private readonly ragService: RagService, ②声明
}) ) {} ③容器:查 token=RagService → new → 塞进来
}
记忆锚点:providers 是"登记表",构造签名是"领料单",token 是"货号" ------领料单写的货号和仓库登记对不上,启动就报
Nest can't resolve dependencies of ...。
二、先走一遍最小闭环:登记 → 构造签名 → 注入(rag-server 真实代码)
拿项目里最"标准"的一对(packages/rag-server/src/rag/):
ts
// rag.controller.ts ------ 只写业务,不关心 RagService 从哪来
@Controller("api")
export class RagController {
constructor(private readonly ragService: RagService) {} // ← "我要一个 RagService"
@Get("systems")
listSystems(): { systems: readonly string[] } {
return this.ragService.listSystems(); // ← 直接能用,没人 new 过它
}
}
ts
// rag.module.ts ------ 把这份依赖"报户口"
@Module({
controllers: [RagController],
providers: [RagService], // ← 简写,完整写 = { provide: RagService, useClass: RagService }
exports: [RagService], // ← 需要被别人注入时,要"开出口"
})
export class RagModule {}
拆开这四行,DI 的"语法"就全在这了:
providers: [RagService]是简写 :等价于{ provide: RagService, useClass: RagService }。provide定 token (用什么当货号),useClass定 怎么造(new 哪个类);- 构造签名不用写
@Inject:因为 token 就是RagService这个类,TS 参数类型已经把它说了。只有"token 不是类型"时(字符串/Symbol 常量)才需要显式@Inject(见下节); - 控制器也是被容器实例化的对象 :
controllers: [RagController]让 Nest 负责 new 它,new 的时候按构造参数注入ragService。连"入口层的控制器"自己都是被注入的一方,只是它不再被别人依赖; exports: [RagService]决定别人借不借得到 :模块里的 provider 默认"私有",别的模块想注入得先imports这个模块,而该模块要exports它(07 期模块篇展开)。
前端直觉:
providers像一份"全局可用的服务清单",构造参数像你在函数签名里声明依赖却不用写 import ------容器把"找依赖 + 组装"替你做了。真正的前端对照:React 里你写useContext(Token)时不会自己创建值,值由上层<Token.Provider value=...>提供;Nest 里你写参数类型,值由容器按登记表提供。
一个值得点破的细节 :provider 类只要自己也依赖别人,就得标 @Injectable() ------因为 TS 只在"被装饰过的类"上发射 design:paramtypes 反射元数据,容器才读得到"它要什么"(这条反射链路 05 期专拆)。没依赖的类裸写也能被 new 出来,但约定是:providers 里的类一律标 @Injectable(),这样哪天它开始依赖别人,反射才不会静默失效。
三、三种"身份(token)":类 token / 自定义 token / 框架注入 token
"对上号"是 DI 的全部,而 token 不止"类"一种。rag-server 四种全占了,一张表收全:
| # | token 类型 | 注册处(怎么 provide) | 注入处(怎么要) | rag-server 真实代码 |
|---|---|---|---|---|
| ① | 类 token | providers: [RagService](= useClass 简写) |
构造参数写类型 | rag/rag.module.ts → rag/rag.controller.ts |
| ② | 自定义常量 token(useValue) | { provide: APP_CONFIG, useValue: options.config } |
@Inject(APP_CONFIG) appConfig: AppConfig |
config/config.module.ts → health/health.controller.ts |
| ③ | 自定义常量 token(useFactory) | { provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] } |
@Inject(AUTH_TOKENS) authTokens: AuthTokens |
config/config.module.ts → common/auth.guard.ts |
| ④ | 框架注入 token + useClass | { provide: APP_GUARD, useClass: AuthGuard } |
框架自己按 token 取用,不写构造 | app.module.ts(守卫/拦截器/过滤器/管道各一) |
其中最有教学价值的一行,是 health.controller.ts 里同一个构造签名混用两种 token:
ts
constructor(
@Inject(APP_CONFIG) private readonly appConfig: AppConfig, // ② token 注入:要"那份配置值"(useValue)
private readonly vectorDb: VectorDbService, // ① 类注入:要"那个服务"(useClass 简写)
) {}
Nest 逐参数解析:第一个参数按 APP_CONFIG 这个常量 token 查表(找到 config.module.ts 里 useValue 那条,值是调用方传进来的 core config 单例);第二个参数按 VectorDbService 类 token 查表。两种"货号"可以混在同一张领料单上,容器各查各的。
三条值得记的推论:
- 类 token 免
@Inject只是"恰好类型就是 token" :凡是 token ≠ 类型的地方(配置值、options、useFactory 现算的窄配置),一律@Inject(常量)显式点货号; useValue / useFactory / useClass只回答"怎么造",token 由provide定 :换 provider 形态不换 token------所以 AuthGuard 从@Inject(AUTH_TOKENS)拿到的值,可以随时从"直接读 config"改成"useFactory 现算",注入方零改动(四形态完整展开见 17 期);- 绝大多数
Nest can't resolve dependencies报错,本质是 token 没对上:provide 端和注入端不是同一个身份(漏登记、漏 exports、字符串字面量打错、用了两套常量),而不是"类不存在"。
前端直觉:类 token ≈ 组件类型本身;
@Inject(AUTH_TOKENS)≈ import 具名导出 ------两边 import 的必须是同一个东西,字符串手写(@Inject('AUTH_TOKENS'))就像在俩文件里各写一个同名常量,不是同一个引用,早晚踩坑。自定义 token 一律导出共享常量,别手写字符串。
四、@Optional:允许依赖缺席(官方页独有 · 项目未用)
默认情况下,构造参数要的依赖必须能找到,否则启动直接抛错(fail fast------这是 DI 的优点)。但偶尔你希望"有就用,没有拉倒":
ts
@Injectable()
export class HttpService<T> {
constructor(
@Optional() // ← 找不到 HTTP_OPTIONS 时注入 undefined,而不是报错
@Inject("HTTP_OPTIONS")
private readonly httpClient: T,
) {}
}
什么时候值得用:
| 场景 | 例子 |
|---|---|
| 依赖"可增强可不增强" | 传了 HTTP_OPTIONS 就走定制客户端,没传用内置默认 |
| 配置项可能没配 | 环境变量没设,注入 undefined 让模块退化为默认行为 |
| 可选插件/适配器 | 注册了某 provider 才启用某功能,否则静默跳过 |
rag-server 为什么没用 :它的依赖要么必须有(Service、VectorDb),要么有明确默认值且由 useFactory 兜底(配置),不存在"可缺省的接线"。@Optional 是"逃生门",不是标配------能用"必有 + 默认值"表达的需求,别用"可缺省"掩盖。
前端直觉:近似 TS 的可选参数
foo?: Bar/ React 的props.xxx ?? default------把"依赖可能不存在"写进签名。区别是它作用于注入阶段而不是函数调用阶段。
五、Property-based injection:属性注入(官方页独有 · 项目未用)
另一种打破"构造注入"的写法:不在构造参数里声明,而是给类字段直接贴 @Inject:
ts
@Injectable()
export class HttpService<T> {
@Inject("HTTP_OPTIONS") // ← 依赖不走构造,直接往字段上塞
private readonly httpClient: T;
}
官方为什么默认推荐构造注入?
| 维度 | 构造注入(默认推荐) | 属性注入(逃生门) |
|---|---|---|
| 依赖在哪能看见 | 构造签名里,一目了然 | 藏在字段上,扫一眼看不到 |
| 缺依赖什么时候暴露 | 启动即报错(fail fast) | 字段为 undefined,用到才炸(运行时) |
| 测试/覆写 | mock 传进构造即可 | 字段构造后才赋值,难在 new 前注入 mock |
| 何时才值得 | 一切默认场景 | 类要向父类转发构造参数、不想在每个子类重写 super(...) |
一句话收编本篇两个"逃生门":
@Optional回答"依赖可以没有 ",属性注入回答"依赖可以不走构造签名 "。它们都是官方正经写着的用法,项目虽未用,但你要见过------同时记住 Nest 的默认姿势永远是"构造注入 + 必有依赖",两条逃生门是有明确代价的例外。
六、本篇在专栏 DI 主线的位置
遇到问题对号入座:
| 你看到/想解决的问题 | 去读哪篇 |
|---|---|
| token 已对上,想知道容器怎么 new 出来的(反射元数据、查表递归、单例缓存) | 05 期《依赖注入:为什么不用 new》 |
| useValue/useFactory/useClass/useExisting 完整写法与互斥 | 17 期《自定义 Provider 四形态》 |
| A 要 B、B 要 A 的环 | 20 期《循环依赖与 forwardRef》 |
ConfigModule.forRoot(...) 这种"带参模块" |
18 期《动态模块与 forRoot 模式》 |
| provider 报户口/开出口、"该不该把 X 放进容器" | 06 期《Providers 提供者》 + 07 期《Modules 模块》 |
| IoC/DI/DIP 概念辨析 | 03 期(上一篇) |
七、前端心智一眼记 + 自测
与前端心智一一对应
| 前端概念 | 对应 Nest DI | 本质 |
|---|---|---|
useContext(Token) 和 <Token.Provider value> 必须配对 |
注入端 token 和 provide 端 token 必须一致 |
一切按"身份(token)"匹配 |
| import 具名导出,两边引用必须同一份 | @Inject(AUTH_TOKENS) 用共享常量,别手写字符串 |
字符串/Symbol token 是"引用一致",不是"值一样" |
| 函数签名声明参数 → 调用方负责传 | 构造参数声明依赖 → 容器负责注入 | 声明式依赖,控制权交给容器 |
组件从 props 拿依赖,不在内部 new |
类从构造拿依赖,不写 new |
依赖从外面来(IoC) |
| mock 一个模块改一处,测试就换 | 测试时 overrideProvider(token) |
可测性是 DI 的红利 |
4 个自测题(先自己答,再看答案)
-
providers: [RagService]的完整写法是什么?provide和useClass各在回答什么问题? →{ provide: RagService, useClass: RagService }。provide定 token(货号),useClass定怎么造(new 哪个类)。简写是"token 和类同名"时的缩写。 -
RagController构造参数写类型就能注入,为什么HealthController里appConfig要写@Inject(APP_CONFIG)? → 前者 token 就是类(类型已说清,免@Inject);后者 token 是常量,不是类型,必须显式点货号。"要不要 @Inject"由"token 是不是类"决定。 -
HealthController一个构造签名同时出现@Inject(APP_CONFIG)和vectorDb: VectorDbService,Nest 怎么解析? → 逐参数独立解析:一个按APP_CONFIG常量 token 查(全局 ConfigModule 的 useValue 条目),一个按VectorDbService类 token 查。token 可以混用,容器各查各的。 -
@Optional()和属性注入分别在"打破默认"的哪一点?项目为什么没用? →@Optional打破"依赖必有";属性注入打破"依赖走构造签名"。项目依赖要么必有、要么有 useFactory 默认值,两条逃生门都没触发。
八、常见坑与边界
- providers 漏登记 → 启动报
can't resolve dependencies:写了构造注入,却忘了在模块providers登记(或登记在没被imports的模块里)。定位看报错最后括号里那个(?)是哪个参数; - 自定义 token 手写字符串两处不一致 :
@Inject('AUTH_TOKENS')在 A 文件、provide: 'auth_tokens'在 B 文件------值长得像但不是同一个身份。token 用共享常量,别手写; - 漏
exports→ 别的模块@Inject报查不到 :provider 默认模块私有。想让外部借到,登记模块exports、消费模块imports(全局模块例外,19 期展开); - 该标
@Injectable()的类没标 :provider 类一旦"自己也要注入别人",必须有装饰器才发射构造参数元数据;不标可能让依赖静默变成undefined(不是报错,更阴); - 类 token 场景画蛇添足加
@Inject(RagService):无害但多余------类型已经等于 token。看到@Inject(某类)反而是个信号:可能该用类型注入; - 把"模块顶层 import 的单例"当 provider 用 :那是走私全局状态。要"可替换、可测",得走
forRoot(值)+ useValue 注入------值从组合根进容器,不靠文件顶部的 import(18 期专讲这次 ConfigModule 重构的动机); - 属性注入当默认姿势用:依赖不可见、缺失不 fail fast、难测试------只有"向父类转发构造参数"这类特定场景才值得;
- 把
@Optional当"掩盖未登记"的遮羞布 :@Optional是给"设计上就可缺省"的依赖用的;该必有却缺,是登记 bug,应该让启动报错暴露它。
九、本篇收束与下一篇
机制层拆到这:DI 的全部语法 = 登记表 + 领料单 + 货号,四形态 token 在 rag-server 里各有真实落点。但有个更深的问题一直悬着------**TS 类型编译后就消失了,容器在运行时凭什么还能读到构造参数的类型?**第二节那句"靠 emitDecoratorMetadata 发射 design:paramtypes"只是报了个名词,没拆这条链路。
下一篇(05 期《依赖注入:为什么不用 new》)就是动机 + 原理的合体:从"手动 new 的三宗罪"出发,把反射元数据 → 容器查表 → 递归构造 → 单例缓存这条完整链路拆开,并给出 can't resolve dependencies 报错的系统定位法。
项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)