04 · NestJS 依赖注入:你在 `@Module` 写的 providers,和构造参数里那个类型,是怎么"对上"的?

项目开源地址(本专栏实证代码的教程仓) 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(身份)"对上号的接线:

  1. 注册 :你在模块 providers 数组里写 RagService(或 { provide: token, useClass/useValue/useFactory }),等于往容器的"登记表"里塞一条 token → 怎么造 的条目;
  2. 声明 :某个类(Controller/Service/Guard...)的构造参数写类型(或 @Inject(token)),等于说"我要 token = 这个类 / 这个常量 的 provider";
  3. 注入 :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 的"语法"就全在这了:

  1. providers: [RagService] 是简写 :等价于 { provide: RagService, useClass: RagService }providetoken (用什么当货号),useClass怎么造(new 哪个类);
  2. 构造签名不用写 @Inject :因为 token 就是 RagService 这个类,TS 参数类型已经把它说了。只有"token 不是类型"时(字符串/Symbol 常量)才需要显式 @Inject(见下节);
  3. 控制器也是被容器实例化的对象 :controllers: [RagController] 让 Nest 负责 new 它,new 的时候按构造参数注入 ragService连"入口层的控制器"自己都是被注入的一方,只是它不再被别人依赖;
  4. 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.tsrag/rag.controller.ts
自定义常量 token(useValue) { provide: APP_CONFIG, useValue: options.config } @Inject(APP_CONFIG) appConfig: AppConfig config/config.module.tshealth/health.controller.ts
自定义常量 token(useFactory) { provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] } @Inject(AUTH_TOKENS) authTokens: AuthTokens config/config.module.tscommon/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 查表。两种"货号"可以混在同一张领料单上,容器各查各的。

三条值得记的推论:

  1. 类 token 免 @Inject 只是"恰好类型就是 token" :凡是 token ≠ 类型的地方(配置值、options、useFactory 现算的窄配置),一律 @Inject(常量) 显式点货号;
  2. useValue / useFactory / useClass 只回答"怎么造",token 由 provide :换 provider 形态不换 token------所以 AuthGuard 从 @Inject(AUTH_TOKENS) 拿到的值,可以随时从"直接读 config"改成"useFactory 现算",注入方零改动(四形态完整展开见 17 期);
  3. 绝大多数 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 个自测题(先自己答,再看答案)

  1. providers: [RagService] 的完整写法是什么?provideuseClass 各在回答什么问题?{ provide: RagService, useClass: RagService }provide 定 token(货号),useClass 定怎么造(new 哪个类)。简写是"token 和类同名"时的缩写。

  2. RagController 构造参数写类型就能注入,为什么 HealthControllerappConfig 要写 @Inject(APP_CONFIG)? → 前者 token 就是类(类型已说清,免 @Inject);后者 token 是常量,不是类型,必须显式点货号。"要不要 @Inject"由"token 是不是类"决定。

  3. HealthController 一个构造签名同时出现 @Inject(APP_CONFIG)vectorDb: VectorDbService,Nest 怎么解析? → 逐参数独立解析:一个按 APP_CONFIG 常量 token 查(全局 ConfigModule 的 useValue 条目),一个按 VectorDbService 类 token 查。token 可以混用,容器各查各的。

  4. @Optional() 和属性注入分别在"打破默认"的哪一点?项目为什么没用?@Optional 打破"依赖必有";属性注入打破"依赖走构造签名"。项目依赖要么必有、要么有 useFactory 默认值,两条逃生门都没触发。

八、常见坑与边界

  1. providers 漏登记 → 启动报 can't resolve dependencies :写了构造注入,却忘了在模块 providers 登记(或登记在没被 imports 的模块里)。定位看报错最后括号里那个 (?) 是哪个参数;
  2. 自定义 token 手写字符串两处不一致 :@Inject('AUTH_TOKENS') 在 A 文件、provide: 'auth_tokens' 在 B 文件------值长得像但不是同一个身份。token 用共享常量,别手写;
  3. exports → 别的模块 @Inject 报查不到 :provider 默认模块私有。想让外部借到,登记模块 exports、消费模块 imports(全局模块例外,19 期展开);
  4. 该标 @Injectable() 的类没标 :provider 类一旦"自己也要注入别人",必须有装饰器才发射构造参数元数据;不标可能让依赖静默变成 undefined(不是报错,更阴);
  5. 类 token 场景画蛇添足加 @Inject(RagService) :无害但多余------类型已经等于 token。看到 @Inject(某类) 反而是个信号:可能该用类型注入;
  6. 把"模块顶层 import 的单例"当 provider 用 :那是走私全局状态。要"可替换、可测",得走 forRoot(值) + useValue 注入------值从组合根进容器,不靠文件顶部的 import(18 期专讲这次 ConfigModule 重构的动机);
  7. 属性注入当默认姿势用:依赖不可见、缺失不 fail fast、难测试------只有"向父类转发构造参数"这类特定场景才值得;
  8. @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 (复制到浏览器打开)

相关推荐
谁在黄金彼岸1 小时前
Ollama-使用速查
后端
ServBay1 小时前
Jev是什么?哑巴模型居然全网爆火
后端·aigc·ai编程
肆仲冬1 小时前
不用框架,用 TypeScript 从零搭一个 Agent 框架
前端
林语琛1 小时前
我写的 switch…break 被 Babel 偷偷吞了
前端·javascript·babel
烈风逍遥1 小时前
第六篇:RAG 知识库构建与检索全链路
前端·人工智能·后端
花椒技术1 小时前
Agent 沙箱怎么接入生产?花椒的选型、持久化与执行协议实践
人工智能·后端·agent
爱丶不疚1 小时前
什么是 Jev 决策模型?它适合干什么?
前端·agent
殷紫川1 小时前
从 Java 开发者视角看Jev这个不生成文字的决策模型,为什么能颠覆 Agent 架构
ai编程
独泪了无痕1 小时前
Hutool之ArrayUtil:解锁数组操作的新境界
后端