Cordis 插件热插拔能力深度解析
基于
@deepseek-ai/cordis@4.x。本文所有示例与结论均经过实际运行验证。
一、什么是热插拔
热插拔(Hot-plugging)指应用运行期间动态安装、卸载、替换插件,而无需重启进程的能力。Cordis 将其做到了三个层面:
| 操作 | API | 语义 |
|---|---|---|
| 插入 | const fiber = ctx.plugin(plugin) |
安装插件,返回 Fiber(运行时句柄) |
| 拔出 | await fiber.dispose() |
卸载插件,自动执行全部清理 |
| 热重载 | await fiber.update(config) |
卸载 + 以新配置重装,fiber 身份不变 |
| 重装 | 再次 ctx.plugin(plugin) |
DISPOSED 的插件可重新安装(全新 fiber) |
支撑这一切的两大基石:
- Fiber 状态机------每个插件实例有完整生命周期,一切资源(事件监听、服务注册、定时器)由 fiber 托管,卸载时自动回收;
- 服务作为依赖锚点 ------插件不直接依赖插件,而是依赖服务名。框架持续监视服务可用性,依赖变化时自动联动。
scss
PENDING(0) → LOADING(1) → ACTIVE(2) → UNLOADING(5) → DISPOSED(4)
↑ ↓
└──── 依赖消失自动回退 ────┘ (FAILED=3:启动或校验失败)
二、基础热插拔:插入与拔出
2.1 插入:ctx.plugin()
ts
import * as Cordis from '@deepseek-ai/cordis'
const ctx = new Cordis.Context()
const greeterPlugin = {
name: 'greeter',
apply(ctx: Cordis.Context) {
console.log('[greeter] 加载')
// 注册的一切资源都由 fiber 托管
ctx.effect(() => {
const timer = setInterval(() => console.log('[greeter] 心跳'), 1000)
return () => clearInterval(timer) // 清理函数
}, '心跳')
return () => console.log('[greeter] 卸载清理') // 插件级清理
}
}
const fiber = ctx.plugin(greeterPlugin)
await fiber // 等待加载完成(state → 2 ACTIVE)
2.2 拔出:fiber.dispose()
ts
await fiber.dispose() // state → 4 DISPOSED
拔出时框架自动完成:
- 逆序执行全部清理 (插件级清理函数、
ctx.effect的清理、事件监听注销、服务注销); - 通知依赖方------依赖本插件所提供服务的插件会被联动卸载(见第四节);
- 清理按逆收集序 执行:插件级清理先跑,
ctx.effect注册的资源后回收。
实测输出(摘自验证脚本):
csharp
[greeter] 卸载清理 ← 插件级清理函数(最后收集,最先执行)
[greeter] 定时器已清理 ← effect 清理
2.3 拔出后重装:状态归零
DISPOSED 的插件可以重新安装,得到全新的 fiber 与全新的一次性状态:
ts
const fiber2 = ctx.plugin(greeterPlugin) // 重装成功,state = 2
重要:重装 ≠ 恢复。插件内的局部变量、计数器等一次性状态不会延续(下文 5.1 详述应对方法)。
三、热插拔的六种情况分析
情况1:插入时依赖已就绪(最简单)
apply 执行时依赖的服务保证可用,直接使用,无需判空:
ts
const enhanced = {
name: 'enhanced',
inject: ['calculator'],
apply(ctx: Cordis.Context) {
// ctx.calculator 必然存在------框架保证
const orig = ctx.calculator.add.bind(ctx.calculator)
ctx.calculator.add = (a, b) => {
console.log(`[enhanced] 拦截 add(${a}, ${b})`)
return orig(a, b)
}
return () => { ctx.calculator.add = orig } // 拔出时恢复原方法
}
}
await ctx.plugin(enhanced)
情况2:插入时依赖缺失(PENDING 挂起)
插件不会失败,停在 PENDING 状态静默等待:
ts
const consumer = {
name: 'consumer',
inject: ['power'],
apply(ctx: Cordis.Context) {
console.log('[consumer] 加载,power.id =', ctx.power.id)
}
}
const cf = ctx.plugin(consumer)
await new Promise(r => setTimeout(r, 20))
console.log(cf.state) // 0 (PENDING)------apply 尚未执行
实测输出:
ini
[main] 无提供者时 consumer.state = 0 (0=PENDING)
设计意义:安装顺序无关紧要。可以先装消费者、后装提供者,框架自动衔接。
情况3:拔出 PENDING 中的插件(从未加载过)
安全。fiber 直接从 PENDING → DISPOSED,无任何报错,apply 从未执行:
ts
const cf = ctx.plugin(consumerPlugin) // 依赖缺失,PENDING
await cf.dispose()
// 实测:state = 4 (DISPOSED),无报错
情况4:拔出时清理函数抛错(故障隔离)
单个清理抛错不会 中断其余清理,也不会卡死 dispose():
ts
const bad = {
name: 'bad',
apply(ctx: Cordis.Context) {
ctx.effect(() => () => { throw new Error('清理爆炸') }, '会抛错的清理')
ctx.effect(() => () => console.log('[bad] 后一个清理仍执行 ✓'), '正常清理')
return () => console.log('[bad] 插件级清理仍执行 ✓')
}
}
const bf = ctx.plugin(bad)
await bf
await bf.dispose() // 正常完成(错误被框架捕获并记入日志)
实测输出:
csharp
[bad] 插件级清理仍执行 ✓
[bad] 后一个清理仍执行 ✓
[main] 清理抛错不影响其余清理与 dispose 完成
情况5:热重载(fiber.update)
保持 fiber 身份不变,用新配置"拔了再插"------清理函数与 apply 各重跑一遍:
ts
const fiber = ctx.plugin(greetPlugin, { greeting: '你好' })
await fiber.update({ greeting: '早上好' }) // 清理 → 重新 apply
情况6:重复插入(幂等性由调用方保证)
同一插件对象可以多次 ctx.plugin()(产生多个 fiber 实例)。若需要"单实例"语义,在管理层做幂等检查:
ts
if (!manager.getActivePlugins().includes(name)) {
await manager.installPlugin(plugin, name)
}
注意:框架层面拦截的是同名服务重复注册(抛错),而非插件重复加载。
四、核心:热插拔导致的依赖变化(重点)
这是热插拔最值得深入的部分。依赖关系的两端------提供者 与消费者------任何一方的插拔都会引发连锁反应,全部由框架自动驱动。
4.1 场景全景
以 consumer 依赖 power、relay 依赖 power、tail 依赖 relay 为例(实测脚本):
ts
const providerPlugin = {
name: 'provider',
provide: 'power',
apply(ctx: Cordis.Context) {
ctx.provide('power', { id: ++powerSeq })
console.log(`[provider] 上线, power.id = ${powerSeq}`)
return () => console.log('[provider] 下线清理')
}
}
const consumerPlugin = {
name: 'consumer',
inject: ['power'],
apply(ctx: Cordis.Context) {
console.log(`[consumer] 加载, 看到 power.id = ${ctx.power.id}`)
return () => console.log('[consumer] 因依赖消失而自动卸载')
}
}
完整时序实测输出:
ini
[main] 1. 无提供者时 consumer.state = 0 (PENDING)
[provider] 上线, power.id = 1
[consumer] 加载, 看到 power.id = 1
[main] 2. 提供者上线后 consumer.state = 2 (ACTIVE)
[provider] 下线清理
[consumer] 因依赖消失而自动卸载
[main] 3. 提供者下线后 consumer.state = 0 (PENDING)
[provider] 上线, power.id = 2
[consumer] 加载, 看到 power.id = 2
[main] 4. 提供者回归后 consumer.state = 2, 加载次数 = 2
4.2 依赖变化的五种联动逐一分析
① 提供者插入 → 消费者自动加载(迟到的依赖)
消费者先装、提供者后到:PENDING → ACTIVE 自动流转,无需任何手工触发。见上面时序第 1→2 步。
适用场景:插件市场按需启用、模块懒加载、可选增强(提供者在才启用功能)。
② 提供者拔出 → 消费者自动卸载 → 回到 PENDING(不是 DISPOSED!)
关键细节:消费者因依赖消失卸载后,状态回到 PENDING(0) 而非 DISPOSED(4)------它仍处于安装状态,只是等待依赖。见时序第 3 步。
ts
await providerFiber.dispose()
// 实测链:provider 下线清理 → consumer 自动卸载(清理执行)→ consumer.state = 0
这意味着:拔掉提供者不等于拔掉消费者。消费者持续监听服务可用性,随时准备恢复。
③ 提供者回归 → 消费者自动重载(状态刷新)
提供者重新插入后,PENDING 中的消费者自动重新加载 ,拿到的是新服务实例 (id = 2),且内部一次性状态归零(加载次数计数器证明重新执行了 apply)。见时序第 4 步。
推论:依赖变化驱动的重载等价于一次完整的拔插。插件作者必须假设"apply 可能被执行任意多次"。
④ 级联依赖变化(传递性卸载)
依赖链 tail → relay → power,拔掉根部的 power,整条链自动级联卸载:
ini
[relay] 加载, 转发 power.id = 2
[tail] 加载, relay 来自 power.id = 2
[main] 5. 级联就绪 relay=2 tail=2
[provider] 下线清理
[consumer] 因依赖消失而自动卸载
[relay] 卸载 ← 中间层自动卸载
[tail] 卸载 ← 末梢自动卸载
[main] 6. 拔 power 后 relay=0 tail=0
框架逐层广播 internal/service 事件,每一层消费fiber收到依赖消失信号后卸载,进而触发下一层。任意深度的依赖树都正确联动,无需人工编排。
提供者回归时,级联按依赖序自动重建:先 relay(拿到新 power.id)再 tail。
⑤ 一对多/多对一的扇出扇入
- 扇出:一个提供者被 N 个消费者依赖------拔出时全部消费者联动卸载(上面输出中 consumer 与 relay 同时卸载即为扇出证据);
- 扇入 :一个消费者依赖 N 个服务(
inject: ['a', 'b'])------全部就绪才加载,任一消失即卸载(all-or-nothing 语义)。
ts
const scientific = {
inject: ['calculator', 'history'], // 两个都就绪才 ACTIVE
apply(ctx) { /* ... */ }
}
4.3 依赖变化中的清理保证
依赖驱动的自动卸载与手动 dispose() 走同一条卸载路径,清理保证完全一致:
| 保证 | 说明 |
|---|---|
| 清理必执行 | apply 返回的清理函数、ctx.effect 的清理,卸载时全部逆序执行 |
| 事件监听自动注销 | fiber 内 ctx.on 注册的监听随卸载消失(不泄漏) |
| 服务自动注销 | fiber 提供的服务随卸载注销(进而通知下一层依赖方) |
| 异步清理被等待 | 清理函数返回 Promise 时,dispose() 等它完成 |
| 清理错误被隔离 | 单个清理抛错不影响其余清理(情况4 实测) |
五、热插拔的工程实践
5.1 状态丢失问题与对策
热插拔(尤其是依赖驱动的自动重载)会重置插件一次性状态。三种对策:
对策1:状态外置到服务(推荐)
把需要延续的状态放进生命周期更长的服务(如示例中的 HistoryService),插件只做无状态的逻辑编排:
ts
class HistoryService extends Cordis.Service {
private records: string[] = []
constructor(ctx: Cordis.Context) {
super(ctx, 'history')
// 服务订阅事件采集数据------插件怎么插拔,历史都在
ctx.on('calculation/performed', (op, result) => {
this.records.push(`${op} = ${result}`)
})
}
}
对策2:持久化到外部 (数据库/文件/ctx.effect 内管理的资源),apply 时读回。
对策3:接受重置------把 apply 写成幂等的纯初始化,重载即全新开始。
5.2 插件作者的三条纪律
- apply 必须可重入:假设它会被执行任意多次(重装、热重载、依赖回归都会重跑);
- 一切资源走 fiber 托管 :定时器/监听/句柄用
ctx.effect(() => {...; return 清理函数})注册,不要裸用setInterval; - 修改他人服务必须可逆:包装方法前保存原引用,清理函数中恢复(AOP 式增强的标准范式):
ts
const original = ctx.calculator.add.bind(ctx.calculator)
ctx.calculator.add = enhancedAdd
return () => { ctx.calculator.add = original } // 可逆
5.3 应用层的插件管理器
产品级应用通常在框架之上加一层管理器,统一处理幂等、审计与状态汇报(本项目 src/index.ts 的 PluginManager 即完整实现):
ts
class PluginManager {
private fibers = new Map<string, Cordis.Fiber>()
private active = new Set<string>()
async install(plugin: any, name: string) {
if (this.active.has(name)) return // 幂等
const fiber = this.ctx.plugin(plugin)
try { await fiber } // 加载失败如实上报
catch (e) { console.error(`[管理器] ${name} 加载失败:`, e); return }
this.fibers.set(name, fiber)
this.active.add(name)
}
async uninstall(name: string) {
const fiber = this.fibers.get(name)
if (!fiber) return
await fiber.dispose() // 清理由框架保证
this.fibers.delete(name)
this.active.delete(name)
}
}
六、总结:热插拔的能力边界
| 能力 | 支持情况 |
|---|---|
| 运行时安装/卸载插件 | ✅ ctx.plugin() / fiber.dispose() |
| 卸载后重装 | ✅ 全新 fiber,一次性状态归零 |
| 新配置热重载 | ✅ fiber.update(),fiber 身份不变 |
| 依赖迟到自动加载 | ✅ PENDING → ACTIVE,安装顺序无关 |
| 依赖消失自动卸载 | ✅ 回到 PENDING(保持安装态),清理保证完整 |
| 依赖回归自动重载 | ✅ 拿到新实例,状态刷新 |
| 任意深度级联联动 | ✅ 扇出/扇入/传递链全部自动 |
| 清理故障隔离 | ✅ 单点抛错不中断整体卸载 |
| PENDING 中拔出 | ✅ 安全,从未加载的 apply 不执行 |
| 跨插件状态延续 | ⚠️ 框架不提供------状态外置到服务或持久化 |
Cordis 热插拔的本质:服务是依赖的锚点,fiber 是资源的边界,状态机是联动的引擎。插件作者只需遵守"apply 可重入、资源走托管、修改可恢复"三条纪律,插拔的正确性就完全交给框架------这正是插件化架构在生产环境站得住脚的关键。