Cordis 插件热插拔能力深度解析

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)

支撑这一切的两大基石:

  1. Fiber 状态机------每个插件实例有完整生命周期,一切资源(事件监听、服务注册、定时器)由 fiber 托管,卸载时自动回收;
  2. 服务作为依赖锚点 ------插件不直接依赖插件,而是依赖服务名。框架持续监视服务可用性,依赖变化时自动联动。
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 依赖 powerrelay 依赖 powertail 依赖 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 插件作者的三条纪律

  1. apply 必须可重入:假设它会被执行任意多次(重装、热重载、依赖回归都会重跑);
  2. 一切资源走 fiber 托管 :定时器/监听/句柄用 ctx.effect(() => {...; return 清理函数}) 注册,不要裸用 setInterval
  3. 修改他人服务必须可逆:包装方法前保存原引用,清理函数中恢复(AOP 式增强的标准范式):
ts 复制代码
const original = ctx.calculator.add.bind(ctx.calculator)
ctx.calculator.add = enhancedAdd
return () => { ctx.calculator.add = original }   // 可逆

5.3 应用层的插件管理器

产品级应用通常在框架之上加一层管理器,统一处理幂等、审计与状态汇报(本项目 src/index.tsPluginManager 即完整实现):

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 可重入、资源走托管、修改可恢复"三条纪律,插拔的正确性就完全交给框架------这正是插件化架构在生产环境站得住脚的关键。

相关推荐
不爱编程的小九九1 小时前
小九源码-springboot004-springboot智能阅读推荐系统
java·spring boot·后端
码事漫谈1 小时前
为什么调试模式不崩溃,打包后却崩溃了?
后端
智驭未来掌门人2 小时前
利用FFmpeg快速实现一个RTSP推流服务器
后端
垚垚学技术_聚焦云原生2 小时前
K8s Pod 完整生命周期详解
后端
JavaGuide2 小时前
Github 史诗级故障,与此同时,Cursor 版「GitHub」正式上线!
前端·后端
不一样的少年_2 小时前
修了 Bug、做了重构,为什么老板还是觉得你没产出?
前端·后端·程序员
水深火乐2 小时前
安全地生成验证码、密码和 Token
后端
小强19883 小时前
SQL Server 慢查询怎么定位?一套从“卡”到“快”的完整排查流程
后端
水深火乐3 小时前
interface和any
后端