NestJS Dynamic Providers 与 Cabloy/Vona Provider Scene:同接口多实现的两种装配哲学

同样面对"一个接口、多个实现、运行时选一个",两种框架的分歧不在实现方式,而在绑定发生的时机。

"同一接口、多种实现、按配置或上下文选择其中一个",是所有后端框架都要回答的问题。支付要能在 Stripe、PayPal、Mock 之间切换;文件存储要能在本地磁盘与对象存储之间切换;验证码要能在图形验证码与短信验证码之间切换。

NestJS 用 Dynamic Providers 回答它,Cabloy/Vona 用 Provider Scene 回答它。两者都能用,但装配模型完全不同------而这个差异会一路传导到运行时切换能力、模块解耦程度,以及"消费方到底持有什么引用"这个更根本的问题上。


结论速览

维度 NestJS Dynamic Providers Vona Provider Scene
绑定时机 模块初始化装配期,一次性绑定 每次解析时按名字查容器
表达形式 { provide: TOKEN, useClass: X } @PayProvider() 装饰器注册到场景
同一接口多实现 multi: true 聚合,或每 token 一个 场景下可注册 N 个,天然平级共存
实现放哪 通常在同一个模块的 providers 数组 每个实现是独立模块
消费方引用什么 构造注入的 token 一个名字
运行时切换 初始化后基本固定(overrideProvider 仅在编译测试模块时可用) VonaConfig.onions + ctx.onionsDynamic 请求级覆盖
对依赖结构的贡献 参与装配图 不产生任何静态依赖边

一句话概括:NestJS 在唯一的装配阶段 把 token 绑到实现;Vona 把"绑哪个实现"推迟到每次解析,因此它从不需要一个会被打破的装配顺序。


问题:一个接口,多种实现

先明确两者要解决的同一个问题。业务代码期望这样写:

ts 复制代码
// 消费方只依赖抽象
const result = await payService.startPayment({ provider: 'stripe', orderId, amount });

它不应该关心 Stripe 的 SDK、PayPal 的 OAuth 流程、Mock 的桩数据分别怎么实现,也不应该在新增一种支付方式时被改动。

差别出现在"payService 从哪拿到 stripe 那个实现对象"这一步。


NestJS 的答案:在装配期绑定

NestJS 把这种关系表达为 provider 定义,最常见的是 useClass:

ts 复制代码
// pay.module.ts
@Module({
  providers: [
    { provide: PAY_PROVIDER, useClass: StripePayProvider },
  ],
  exports: [PAY_PROVIDER],
})
export class PayModule {}

需要多个实现共存时,用 multi: true 聚合成一个数组:

ts 复制代码
@Module({
  providers: [
    { provide: PAY_PROVIDER, useClass: StripePayProvider, multi: true },
    { provide: PAY_PROVIDER, useClass: PaypalPayProvider, multi: true },
    { provide: PAY_PROVIDER, useClass: MockPayProvider, multi: true },
  ],
})
export class PayModule {}

消费方注入的是 token,拿到的是被绑定的实例或实例数组:

ts 复制代码
constructor(@Inject(PAY_PROVIDER) private readonly pay: IPayProvider) {}

useFactory 也可以表达"按配置决定用哪个":

ts 复制代码
{
  provide: PAY_PROVIDER,
  useFactory: (config: ConfigService) =>
    config.get('PAY_DRIVER') === 'paypal'
      ? new PaypalPayProvider()
      : new StripePayProvider(),
  inject: [ConfigService],
}

关键约束:绑定只发生一次

无论用 useClass、useValue 还是 useFactory,这段绑定逻辑都在模块装配期 执行一次:NestFactory.create() 引导应用时,容器把所有模块的 provider 解析成实例并连线,之后整个运行期复用同一批实例。

由此带来三个实际后果:

  1. 实现集合在启动时就固定了。 运行时无法再"注册"一个新的支付实现------模块装配不是运行期行为,multi: true 的数组长度同样在装配期确定。
  2. 运行时改选实现要绕路。 useFactory 读配置没问题,但它在装配期就把结果算完了;要在运行期改变,得自己注入 ModuleRef、包一层可变的工厂对象,或者干脆把整个 provider 做成 request-scoped。
  3. 实现与模块的边界容易糊在一起。 三个支付实现常常写在同一个模块的 providers 数组里,于是"新增一种支付方式"变成"编辑既有支付模块"。用独立模块也能做对,但这需要开发者主动为之,而不是框架的默认结构。

overrideProvider(TOKEN).useClass(...) 只在 Test.createTestingModule() 编译测试模块时可用,属于装配期行为,不是运行期能力。


Vona 的答案:在解析期选择

Vona 把这种关系表达为场景(scene):每个业务领域拥有一个具名的 provider 场景,以及一个与之对应的装饰器。以支付为例:

ts 复制代码
// a-pay/src/lib/payProvider.ts
export const PayProvider = createBeanDecorator('payProvider', ...);

于是各实现分布在与领域同级的独立模块中:

领域 场景 实现(各自独立模块)
认证 authProvider auth-simple、auth-oauth
验证码 captchaProvider captcha-simple
支付 payProvider pay-stripe、pay-paypal、pay-mock
图片 imageProvider image-native、image-cloudflare
文件 fileProvider file-native、file-cloudflare

每个实现只是"在某个场景下注册的一个 bean":

ts 复制代码
// pay-stripe/src/bean/payProvider.stripe.ts
@PayProvider()
export class PayProviderStripe extends BeanBase implements IPayProviderExecute {
  async startPayment(options) { /* Stripe SDK */ }
}

消费方持有的不是引用,而是一个名字

支付门面获取实现的方式是按名查容器:

ts 复制代码
// a-pay/src/bean/bean.payProvider.ts
get<N extends keyof IPayProviderRecord>(providerName: N) {
  const beanFullName = beanFullNameFromOnionName(providerName, 'payProvider');
  return this.app.bean._getBean(beanFullName);
}

而这里的名字转换就是一次字符串替换:

ts 复制代码
// onionName = 'pay-stripe:stripe'
// → beanFullName = 'pay-stripe.payProvider.stripe'
onionName.replace(':', '.payProvider.');

因此 pay-stripe:stripe 直到调用那一刻 才被翻译成容器键,再由容器按需解析出实例。消费模块从不 import 任何具体实现------它只认识一个名字。

选择发生在解析期,而不是装配期

"当前哪个实现生效"由三层机制决定,且都在解析时读取:

1. 配置层 ------ VonaConfig.onions

每个场景在配置里持有自己的启用项与选项,框架据此判定某个实现是否可用于当前上下文。

2. 请求层 ------ ctx.onionsDynamic

支持在单次请求内覆盖选择结果,例如测试中临时关掉某个切面:

ts 复制代码
await app.bean.executor.performAction('post', '/api/test', {
  onions: { interceptor: { 'a-ratelimit:rateLimit': { enable: false } } },
});

3. 场景层 ------ resolver

以验证码场景为例,一个场景可以:选择一个 provider、在多个 provider 之间轮换、或依据用户状态与业务上下文改变选择。

此外,每个实现自带 enable / match / ignore 等元数据,解析时由 checkOnionOptionsEnabled() 过滤,决定它在当前运行时是否生效。

扩展的单位是模块

新增一种支付方式,等于新增一个模块(并为其生成脚手架):注册一个新实现进 payProvider 场景,既有实现与消费方都不需要改动。


对照:绑定时机如何决定一切

观察角度 NestJS 装配期绑定 Vona 解析期选择
token 何时变成实现 模块初始化时 每次 get() 时
新增第 N 个实现要动谁 通常要改既有模块的 providers 新增一个独立模块
运行期切换实现 需要 ModuleRef / request-scoped / 可变工厂 配置项或请求级覆盖
消费方持有的对象 已解析的实现实例 一个名字
实现能否与消费方构成循环依赖 可能(装配期递归解析) 不能(运行时按名查)

第二种差异尤其值得注意。因为在 Vona 里,消费方到实现之间只有"名字 → 容器查找"这一条运行时路径 :注册第 N 个实现,给依赖结构增加的是 0 条边。

这直接解释了 Vona 为什么无需任何"打破循环"的机制:既没有装配期的递归解析,也没有任何静态边会因为实现数量而变化。


什么时候用哪种

  • 需要 N 个实现同时共存、并在同一请求链路里轮流使用 (例如风控策略链、消息通道轮询):Vona 的 multi 式聚合思路在 NestJS 里同样成立;Vona 侧则由场景 resolver 承接"轮换/按用户状态选择"这类逻辑。
  • 实现是长期可替换的基础设施(支付、存储、验证码、认证方式):Vona 的 Provider Scene 更贴合,因为每个实现天然是独立模块,切换是一次配置变更,而不是编辑既有模块。
  • 只是为了在测试里替换一个依赖 :NestJS 的 overrideProvider 更直接;Vona 则可用请求级 onionsDynamic 或配置覆盖达到同样效果。

两种模型没有绝对优劣:NestJS 的装配期绑定让依赖关系显式、可静态检查;Vona 的解析期选择让扩展与切换更松散。真正需要区分的是你的扩展点是"启动时确定"还是"运行期可变"。


小结

NestJS Dynamic Providers 与 Vona Provider Scene 都回答了"同接口多实现",但答案的落点不同:

  1. NestJS 在装配期把 token 绑定到实现------依赖关系明确,但实现集合与绑定结果在启动后固定;
  2. Vona 在解析期按名字选择实现------消费方只持有一个名字,实现在独立模块中平级注册;
  3. 绑定时机之差,决定了运行期切换能力、模块解耦程度,以及"实现能否与消费方构成循环依赖"这个更根本的结论。

换句话说,Vona 并不是把 Dynamic Providers 做得更聪明,而是把绑定的时机推后了:只要绑定发生在解析时,多实现对依赖结构就是零成本的。


参考与源码

NestJS

CabloyJS / Vona

相关推荐
const_qiu5 小时前
第一章 1.1-1.2:基础模型调用与 TypeScript 入门 —— Java 开发者的视角
java·javascript·typescript
ZoeLandia8 小时前
TypeScript 03:泛型与工具类型
前端·typescript
silvamux_8 小时前
Node.js 调用大模型 API 排错实录
node.js
ZoeLandia9 小时前
TypeScript 05:交叉类型、索引签名、元组与 enum
前端·typescript
Amumu1213812 小时前
NodeJs介绍
前端·node.js
孟陬12 小时前
利用 TypeScript `asserts` function 语法定制一个类型缩窄的 `assertIncludes`
typescript·node.js
damoluomu13 小时前
静态站点生成器怎么选:Node.js 生态里常用的五个(番外一)
javascript·css·typescript·node.js·html
ZoeLandia13 小时前
TypeScript 04:类型断言与类型守卫
开发语言·前端·typescript
濮水大叔13 小时前
Cabloy/Vona 如何避开 NestJS 的循环依赖问题:懒属性注入机制源码解析
typescript·node.js·nestjs