同样面对"一个接口、多个实现、运行时选一个",两种框架的分歧不在实现方式,而在绑定发生的时机。
"同一接口、多种实现、按配置或上下文选择其中一个",是所有后端框架都要回答的问题。支付要能在 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 解析成实例并连线,之后整个运行期复用同一批实例。
由此带来三个实际后果:
- 实现集合在启动时就固定了。 运行时无法再"注册"一个新的支付实现------模块装配不是运行期行为,
multi: true的数组长度同样在装配期确定。 - 运行时改选实现要绕路。
useFactory读配置没问题,但它在装配期就把结果算完了;要在运行期改变,得自己注入ModuleRef、包一层可变的工厂对象,或者干脆把整个 provider 做成 request-scoped。 - 实现与模块的边界容易糊在一起。 三个支付实现常常写在同一个模块的
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 都回答了"同接口多实现",但答案的落点不同:
- NestJS 在装配期把 token 绑定到实现------依赖关系明确,但实现集合与绑定结果在启动后固定;
- Vona 在解析期按名字选择实现------消费方只持有一个名字,实现在独立模块中平级注册;
- 绑定时机之差,决定了运行期切换能力、模块解耦程度,以及"实现能否与消费方构成循环依赖"这个更根本的结论。
换句话说,Vona 并不是把 Dynamic Providers 做得更聪明,而是把绑定的时机推后了:只要绑定发生在解析时,多实现对依赖结构就是零成本的。