项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇 :上一篇《动态模块与 forRoot》按住了一个字眼没展开:global: options.isGlobal ?? false------rag-server 的 ConfigModule 曾用@Global()装饰器,后来换成了 forRoot 的参数 。 这篇算这笔账:@Global 买什么、卖什么、装饰器与参数两种姿势各适配什么场景、rag-server 为什么选了参数版、以及这次改造守住的红线(双源、core 零改动)。 与 18 期是"原理篇/落地篇"分工:18 讲 DynamicModule 机制,19 讲 @Global 的取舍与落地现场。
定位 :本篇讲 @Global 的语义(exports 对全容器可见)、触发信号("被 2+ feature 共享的基础 provider")、装饰器版与参数版的三条实质差别与各自的适用场景、以及 rag-server ConfigModule 改造的完整前后对照(旧版走私 config 的三宗罪 → 新版三件套)。不讲 forRoot 骨架(18 期)、不讲 ALS 机制细节(09 期)。读完你能判断"我的模块该不该全局、该用哪种姿势声明全局"。
一、@Global 到底买了什么
@Global()(或动态模块的 global: true)让模块 exports 的 provider 对整个容器可见:
- 不加:别的模块想注入
APP_CONFIG,必须先imports: [ConfigModule]------每个消费方都要写一遍; - 加了:只要 ConfigModule 在根模块被 import 一次 ,任意模块直接
@Inject(APP_CONFIG),零 import。
注意边界:@Global 只放行 exports------没进 exports 的 providers(ConfigValidator 这种模块内部自检)照样对外不可见;imports 也不随之传播(它 import 的模块不因此全局)。
触发信号:别为了显高级硬上
| 判断 | 信号 |
|---|---|
| 该全局 | 存在"被 2+ feature 模块共享、跨切面消费"的基础 provider(配置、连接、日志、请求上下文) |
| 不该全局 | 只有 1 个消费方;或它是 APP_* token 类跨切面------那本来就全局注册,不构成 @Global 触发 |
rag-server 的对照现场:APP_GUARD/APP_PIPE 这些走 APP_* token 注册(08 期绑定表),它们本来就不需要别人 import ,加 @Global 是画蛇添足;而 APP_CONFIG 会被 HealthController、AuthGuard(经 AUTH_TOKENS)等多个 feature 消费------这才是 @Global 的真实触发。
前端移植锚点:@Global ≈ 把依赖放进全局 Context 而非逐层 props 传递------省了 N 层透传,代价是"谁在消费"从显式(props 链)变隐式(哪里 useContext 都行)。React 里"context 滥用让数据流向难追踪"的告诫,原封不动适用于 @Global。
二、rag-server 的改造前后:旧版的三宗罪
2.1 旧版:@Global() 装饰器 + 模块内走私 config
ts
// 旧版 config.module.ts(已退役,示意)
@Global()
@Module({
providers: [{ provide: APP_CONFIG, useValue: config }], // 模块顶部 import { config }
exports: [APP_CONFIG],
})
export class ConfigModule {}
三宗罪(都写在现在的源码头注释里,是"改造理由"的原文):
- 值走私 :
import { config } from "@corprag/core"写死在模块内------值无法替换、不可单测(mock 不了 APP_CONFIG); - 全局与否写死 :
@Global()是编译期装饰器,消费方(组合根)没有发言权------模块自己宣布"我是全局的",不管应用想不想。注意:问题出在**"写死"**而非装饰器本身,§三 会讲装饰器在什么场景下写死恰恰是对的; - AuthGuard 同样走私 :守卫顶层
import { config }拿 token(10 期讲过的旧态,后来改@Inject(AUTH_TOKENS))。
2.2 新版:forRoot 参数化,isGlobal 由组合根拍板
ts
// 现版 config.module.ts(真实代码,节选)
export interface ConfigModuleOptions {
config: AppConfig; // 值由调用方传入,不再走私
isGlobal?: boolean; // 全局与否由调用方拍板
}
@Module({})
export class ConfigModule {
static forRoot(options: ConfigModuleOptions): DynamicModule {
return {
module: ConfigModule,
global: options.isGlobal ?? false, // ← 装饰器让位,参数上岗(本模块的场景选择)
providers: [
{ provide: APP_CONFIG, useValue: options.config },
{ provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] },
ConfigValidator,
],
exports: [APP_CONFIG, AUTH_TOKENS],
};
}
}
三、装饰器版 vs 参数版:三条实质差别
@Global() 装饰器 |
global: options.isGlobal 参数 |
|
|---|---|---|
| 决策权 | 模块自己宣布,库作者拍板 | 组合根传入,应用作者拍板 |
| 值可替换性 | 与模块内静态值绑死(走私不可测) | options.config 是参数,单测可传任意值 |
| 一致性 | 与"配置由组合根注入"的新哲学矛盾(值全局了,决策却不在组合根) | 值和全局性都在组合根一处拍板 |
第三条最值得点破:这次改造把"值从哪来"和"是否全局"两个决策一起交给了组合根 ------AppModule 那一行 ConfigModule.forRoot({ config, isGlobal: true }),一眼看全这个模块"用什么配置、以什么可见度"被装进应用。装饰器版做不到这一点:全局性散落在库代码里,组合根管不着。
但"决策权在组合根"不是免费的,别把它当无条件真理。 两种姿势各有主场:
@Global() 装饰器更合适的场景 |
global: isGlobal 参数更合适的场景 |
|
|---|---|---|
| 模块形态 | 静态模块(没有 forRoot,没处传参数) | 动态模块 (DynamicModule 对象挂不了装饰器,global 字段是官方给的标准入口) |
| 谁写的 | 应用内自有模块------作者=使用者,"给宿主留选择权"是伪需求 | 要跨宿主复用的库模块(HTTP 服务 / CLI / 单测宿主对可见度诉求不同) |
| 全局性是不是模块本质 | 是------如基础设施模块(日志/请求上下文),"到哪都全局"没有争议空间 | 否------同一模块在不同宿主可能要不同的可见度(CLI 想局部化控制实例边界) |
| 代价 | 宿主无法局部化(想测试隔离时不好拆) | 多一个 options 字段 + 默认值语义要设计好(?? false 默认不全局) |
一句话决策:模块和宿主是不是同一个作者、全局性有没有争议空间------都是,装饰器省事;不是,参数给选择权。 @nestjs/event-emitter 的 EventEmitterModule.forRoot({ global: true }) 走参数,而应用内自己写的 CommonModule(纯静态、天生共享)直接 @Global() 一行完事,两个都是对的。
官方动态模块文档本身就是这个姿势:
global是 DynamicModule 的可选字段。@Global()装饰器没有废弃、也不该废弃------rag-server 两个 forRoot 都用options.isGlobal ?? false(默认不全局,显式传才全局),是因为这两个模块恰好都落在"动态模块 + 跨宿主"的参数主场,而不是装饰器错了。
四、落地现场的两条红线
4.1 双源红线:配置没有第二个来源
@corprag/core 是平台无关纯库,被 mcp-server / cli(都非 Nest)一起 import { config } 消费。所以 rag-server 的容器只注入 core 已解析的 config 单例------绝不能在 HTTP 层再造一份"读同一批 env 的 ConfigService",否则出现两个配置来源,将来必然漂移。配套细节:
- 类型写
type AppConfig = typeof config(值派生类型),core 加字段这里自动跟随,零手改; main.ts(bootstrap 装配点)保留import { config }是恰当的------容器外不属于 DI,不该强行注入;只把"容器内组件对配置的读取"注入化。
4.2 core 零改动红线
core 的纯函数/常量库地位不动------改造只在 rag-server 侧加"可注入通道",core 不知道 Nest 存在。这正是 monorepo 里"平台无关内核 + 各端适配层"的分层纪律(26 期会再回到这个主题)。
五、全局化的另一半:ALS 请求上下文为什么没走 @Global
这次改造同期还落了 ALS request-context(09 期讲过),值得对照的是:它没有做成 @Global() 的 RequestContextModule + 可注入 Service,而是落成了 common/request-context.ts 的模块级函数:
ts
// common/request-context.ts(真实代码,节选)
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();
export function getRequestContext(): RequestContext | undefined {
return requestContextStorage.getStore();
}
export function getRequestId(): string | undefined {
return requestContextStorage.getStore()?.requestId;
}
为什么?ALS 的本质是"进程级隐形通道",不是"可注入的资源" ------store 沿异步链自动传播,和 DI 容器没关系;包装成 provider 再注入,只是给全局单例多绕一层 DI 仪式,没买到任何可替换性(AsyncLocalStorage 没有第二实现)。所以它以"纯函数 + 模块级 storage"落地,@Global 在这里没有触发信号。
这是 @Global 决策的反向样本 :ConfigModule(值要可替换、消费方多)配全局注入;request-context(隐形通道、无替换需求)用模块级导出------"全局可见"有两种姿势,选哪种看"它是不是一个可替换的依赖"。
六、将来出现什么信号才继续叠
DynamicModule 的另一半语义(同模块多实例)rag-server 还没用到,触发信号留着:
- 同模块多实例 :一份代码连两套知识库/两个 ollama → 包成
forRoot({ dbPath, ... })在 AppModule 注册两次不同 options(token 要错开,18 期的坑表); - 启动期异步组配:接 .env 文件解析、远程 secret manager → forRootAsync + useFactory(18 期 §五);
- 反向警戒 :没有任何共享/多实例需求时硬拆 @Global,让依赖图变隐式、更难测更难读。@Global 是"共享地基"的优化,不是模块标配。
七、常见坑
- 以为 @Global 让 providers/imports 也全局可见 → 只放行 exports;没 export 的 provider 照样模块私有;
- 给 APP_ token 类跨切面再加 @Global* → 重复;APP_* 本来就全局注册;
- 装饰器 @Global 和 global:true 同时存在 → 语义重复,以动态字段为准即可,别两处都写;
- 忘传
isGlobal: true→ 每个 feature 模块注入报 "not available",要逐个 import(症状是好的------它提醒你依赖显式化); - 在容器外(main.ts)强行注入配置 → bootstrap 装配点保留静态 import 是恰当的(§4.1);
- 把 ALS 包装成全局 Service 又注入 → 没买到可替换性,白付一层 DI 仪式(§五反向样本)。
八、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
| 全局 Context vs 逐层 props | @Global vs 逐模块 import | 省透传,换隐式 |
| "context 滥用难追踪"告诫 | @Global 反向警戒 | 隐式化的代价 |
| 库组件的 defaultProps vs 使用方传 props | 装饰器版 vs isGlobal 参数 | 决策权归谁 |
| 模块级单例直接 import | request-context.ts 纯函数 | 不是所有全局都要 DI |
自测 4 题(先自己答,再看答案):
-
@Global 让什么对全容器可见?什么照旧不可见? → 只放行 exports 的 provider;providers 里未 export 的(如 ConfigValidator)照旧模块私有,imports 也不传播。
-
rag-server 为什么把
@Global()换成参数?什么场景装饰器反而更合适? → rag-server 的 ConfigModule 是动态模块 + 要跨宿主复用,恰好落在参数的主场:装饰器把"是否全局"焊死在库代码里,参数版让组合根拍板,和"值由组合根传入"哲学一致(forRoot({ config, isGlobal: true })一行看全"用什么值、什么可见度")。但应用内的静态自有模块 (没有 forRoot、全局性无争议,如日志/CommonModule)用@Global()装饰器一行完事,反而更简洁------装饰器没有废弃,只是不适合"库 + 跨宿主"的场景。 -
双源红线是什么?为什么 main.ts 还在
import { config }? → core 是 mcp-server/cli 共享的平台无关库,rag-server 只注入 core 已解析的单例,绝不重读 env 造第二份(漂移风险)。main.ts 是容器外 bootstrap 装配点,不属于 DI,静态 import 恰当。 -
ALS request-context 为什么没做成 @Global 的可注入 Service? → ALS 是进程级隐形通道,store 沿异步链自动传播,与 DI 无关;包装成 provider 没买到可替换性(无第二实现),故以模块级纯函数落地。@Global 的触发信号是"可替换的共享依赖",不是"所有全局的东西"。
九、本篇收束与下一篇
@Global 的账算完,rag-server 的模块层补完最后一块拼图:18 期 forRoot(参数化接线)→ 本期 isGlobal(参数化可见度)------对"库模块 + 跨宿主"这个场景,模块怎么装、装成多大可见度,全部收口到组合根一处拍板 ,库模块(含未来的复用)保持自包含、可测、默认不全局;应用内静态模块该用 @Global() 的照用,两种姿势各有主场。顺带立的反向样本(ALS 不走 DI)也把"全局化的姿势边界"划清了。
DI 进阶线还剩最后几块硬骨头,下一块是所有依赖图问题的起点:环 。A 依赖 B、B 又依赖 A,容器递归 new 的时候到底发生了什么?forwardRef 那个"先给个空盒子"的招数,源码层怎么实现?
下一篇(20 期《循环依赖与 forwardRef》)拆循环依赖:类级环与模块级环的两种救法、为什么 useFactory 之间救不了、以及 @nestjs/core 源码里那个"二次实例化兜底"的真相。