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

0 阅读8分钟

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

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

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


结论速览

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

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


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

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

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

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

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


NestJS 的答案:在装配期绑定

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

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

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

@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,拿到的是被绑定的实例或实例数组:

constructor(@Inject(PAY_PROVIDER) private readonly pay: IPayProvider) {}

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

{
  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 场景,以及一个与之对应的装饰器。以支付为例:

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

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

领域场景实现(各自独立模块)
认证authProviderauth-simple、auth-oauth
验证码captchaProvidercaptcha-simple
支付payProviderpay-stripe、pay-paypal、pay-mock
图片imageProviderimage-native、image-cloudflare
文件fileProviderfile-native、file-cloudflare

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

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

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

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

// 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);
}

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

// onionName = 'pay-stripe:stripe'
// → beanFullName = 'pay-stripe.payProvider.stripe'
onionName.replace(':', '.payProvider.');

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

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

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

1. 配置层 —— VonaConfig.onions

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

2. 请求层 —— ctx.onionsDynamic

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

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