夜雨聆风学习资料网

ARTICLE · 1226380

生命周期与热插拔:插件装得上,更要卸得干净

生命周期与热插拔:插件装得上,更要卸得干净

10 讲"钥匙怎么发",这一篇讲"钥匙怎么收"——装上、启用、禁用、更新、卸载,每一步都得能干净地撤销。

核心命题:前面几篇我们把插件系统拆成了契约、激活、隔离、权限——一件事一件事地"关进笼子"。但还有一个问题没解决:插件不是装上去就不动了的。它会启用、禁用、更新、卸载,每一次状态跳转都在修改宿主运行时的内部状态。如果跳转不干净,残留的监听器、定时器、中间件、服务注册会在后台默默作祟——这就是"插件越用越卡、越用越崩"的老毛病。生命周期管理的最高追求,不是不让插件出错,而是让每一次出错(和每一次卸载)都能被干净地撤销。本篇的主线就一句话:副作用可逆。

一、引子:钥匙发了,还得收得回

10 篇我们解决了"插件能拿哪些钥匙、按什么规矩拿"——权限的授予。但钥匙一旦发出,能不能干净地收回?装上的插件能不能无痛禁用、更新、卸载?残留配置和副作用会不会在卸载之后继续作祟?

这正是 03 篇"核心闭环"里提到的"生命周期与治理"那一环,也是 10 篇末我埋下的钩子。09 讲物理墙(隔离),10 讲钥匙政策(权限),11 要补上"可逆副作用"这一块:真正优雅的插件系统,不是不犯错,而是犯错(和卸载)都能被干净地撤销。

把它放回 03 的八环节闭环看:契约定义 → 插件声明 → 发现装配 → 按需激活 → 隔离执行 → 生命周期回收 → 治理反馈 → 契约演进。生命周期就是闭环里那个"回收"闸门——前面再严密,回收没做好,整个系统会随着时间腐烂。

二、生命周期状态机:把"活着"拆成离散事件

最经典、也最值得抄的作业,是 OSGi 的 bundle 生命周期。OSGi 是 Java 世界的模块化规范(源于 1998–1999 年),它的 Bundle 接口把"一个插件活着"这件事,拆成了六个离散状态:

  • •

    UNINSTALLED(0x1):已卸载,僵尸终态。对象可能还在某个引用里,但框架已不允许它再进入任何状态——只能用来做内省(introspection),不能执行代码。

  • •

    INSTALLED(0x2):已安装但依赖还没解析。

  • •

    RESOLVED(0x4):依赖全部解析成功,能启动但还没跑。注意它是个枢纽——几乎所有其他状态都围着它转。

  • •

    STARTING(0x8):正在启动,BundleActivator.start() 正在执行;若用 lazy 激活策略,可能停在这里等触发。

  • •

    ACTIVE(0x20):运行中。

  • •

    STOPPING(0x10):正在停止,BundleActivator.stop() 正在执行。

六个状态值被设计成位标志(bit values),可以 OR 在一起判断。关键的转移路径是:

install → INSTALLEDresolve → RESOLVED          (依赖就绪,框架随时可尝试解析)start   → STARTING → ACTIVEstop    → STOPPING → RESOLVED (停了回到 RESOLVED,可以再 start)uninstall → UNINSTALLED     (从 INSTALLED 或 RESOLVED 都能卸)

这里有两个设计哲理性的点,值得单独拎出来:

第一,RESOLVED 是枢纽,不是死路。 停掉的 bundle 回到 RESOLVED 而不是 INSTALLED,意味着它的依赖解析结果被保留,下次 start 不必重新解析。这让"禁用再启用"成为轻量操作。

第二,UNINSTALLED 是僵尸终态。 卸载后 bundle 还能被引用,但框架明确禁止它再变回任何状态——这堵死了"半卸载"那种最容易出现内存泄漏和状态错乱的中间态。

(OSGi 这套六态来自官方 org.osgi.framework.Bundle 接口的 javadoc)

同一套"状态机"语言,在别的系统里换了个说法:

  • •

    Koishi / Cordis:没有显式的六态枚举,而是用 ctx.dispose() 把整棵 ctx 子树(包含嵌套插件)一键"已回收"。它的生命周期是作用域式的——作用域在,插件活;作用域没了,插件连同它注册的所有能力一起消失。

  • •

    VS Code:activate(context) 领到一个 ExtensionContext,插件在激活时往 context.subscriptions 里 push 可释放对象;deactivate() 在扩展被停用或编辑器关闭时被调用做清理;卸载还有专门的 vscode:uninstall 脚本钩子(自 VS Code 1.21,2018-02 起)。

三套话术,同一套内核:状态是离散的、可枚举的、每一次跳转都应该是可拦截的事件。 这正是 03 篇说的"闭环"在生命周期维度的具体形态。

三、可逆副作用:作用域即边界,卸载即清扫

状态机解决了"插件处于哪个阶段",但没解决一个更隐蔽的问题:插件在运行期到底往宿主运行时里塞了多少东西?

一个插件一旦激活,它通常会:

  • •

    注册事件监听器(on('message', handler))

  • •

    启动定时器(setInterval / setTimeout)

  • •

    挂载中间件(ctx.middleware())

  • •

    注册服务(ctx.service())

  • •

    打开文件句柄、建立网络连接

  • •

    往全局对象上挂属性

这些就是副作用(side effects)。它们不像"代码"那样随插件卸载而自然消失——它们活在宿主的内存和事件循环里。如果插件卸载时不清掉它们,结果就是:监听器越积越多、定时器偷偷跑、中间件链越来越长。用户感受到的就是"插件越用越卡、消息被重复处理、行为诡异"。这是插件系统最经典的"慢性病"。

优雅的插件系统怎么治这个病?答案是作用域登记 + 反向清扫,三步走:

① 注册即登记。 每一个副作用在创建的那一刻,就被 push 进当前作用域的"登记册"(Koishi 的 ctx、VS Code 的 context.subscriptions、各类 Disposable 容器)。没登记在册的副作用,等于埋下一颗未来的泄漏雷。

② 作用域即边界。 副作用被圈在作用域里。Koishi 的嵌套 ctx 让子模块的副作用只活在子作用域内,子模块卸载不影响兄弟模块——这就是"局部热插拔"的物理基础。

③ 卸载即清扫。 当作用域被 dispose(),系统反向遍历登记册,逐个释放。Koishi 的 ctx.dispose() 会递归回收整棵子树;VS Code 在 deactivate() 里释放所有 subscriptions;OSGi 在 BundleActivator.stop() 里显式释放。

一句话:一处登记,统一回收。 副作用可逆的关键,不是"记得手动清理",而是"从机制上强制每个副作用都挂在可释放的作用域上"。

四、重启代价光谱:越能局部,越优雅

生命周期管理的另一个维度,是代价——"更新一个插件"这件事,最差要重启整个宿主,最好只热替换一个模块。我们把代价排成一条光谱:

① 全量重启(代价最高)。 改一行代码,整个宿主重启、用户掉线、状态全丢。这是单体架构和无模块隔离的老式插件宿主的常态——早期的某些 CMS、以及 Eclipse 时代让人又爱又恨的"热部署难"(classpath 泄漏、旧类卸不干净)都在此列。

② 进程级重载(代价中等)。 宿主不死,但插件所在的进程或作用域要重启。VS Code 禁用一个扩展后再启用,往往需要重载窗口(reload window);扩展主机(Extension Host)进程重启。用户有感,但影响范围被限制在插件内。

③ 局部热重载(代价最低)。 只替换变动的那个模块,其余照常运行。Koishi 的 ctx.plugin() 局部热重载、前端领域的 React Fast Refresh、Webpack 的 HMR(Hot Module Replacement),都属于这一类——秒级、无感、不丢状态。

(Eclipse/OSGi Equinox 的热部署问题、VS Code 重载窗口、Koishi 局部热重载、React Fast Refresh/HMR 这些案例,具体实现细节不同年份口径不一,本文只引用其"代价层级"定位;如需精确年份建议直连各项目文档核对。)

光谱的意义在于:热插拔不是炫技,而是把"更新成本"从"全宿主"压到"单模块"的工程纪律。 能做到局部热重载的系统,往往也是副作用管理做得最干净的系统——因为它们必须先把作用域边界划清楚,才敢只换一部分。

五、更新与回滚:热更新不是魔法,是作用域替换

"热插拔"听起来像魔法,拆开看不过是作用域的原子替换:把旧作用域 dispose 掉,装一个新作用域顶上。OSGi 的 update(InputStream) 就是从一段输入流更新 bundle——典型做法是先 stop、再 update、再 start,框架负责把旧状态切换到新版本。

更新里最容易被忽略、也最容易出事的是数据迁移:插件版本变了,它持久化在宿主里的数据结构往往也要变。优雅的做法是把迁移逻辑挂在生命周期钩子上——卸载旧版本前、安装新版本后,分别执行向下/向上迁移。如果迁移失败,还要能回滚到上一版本。

回滚本身也是个生命周期事件:保留上一版制品、更新失败时不切换、或在切换后检测到异常再切回去。它和"副作用可逆"是同源思想——任何一步前进,都要有一套对称的撤退预案。

六、副作用回收模式:三原则闭环

把前面几节收一收,生命周期管理(尤其是"卸得干净"这件事)本质就是管好三件事,而且它们构成一个闭环:

  1. 1.

    注册即登记 —— 副作用创建时就进登记册;没登记的 = 未来泄漏。

  2. 2.

    作用域即边界 —— 嵌套作用域把副作用圈住;边界模糊 = 牵连崩溃。

  3. 3.

    卸载即清扫 —— dispose() 反向遍历登记册逐个释放;清扫不全 = 残留作祟。

这三原则不是某一家独创,而是被反复独立发现的工程共识:OSGi 用 BundleActivator.start/stop 显式登记与释放;Koishi 用 ctx.dispose 自动清扫;VS Code 用 subscriptions + deactivate 组合。三套话术,同一套回收哲学。

给设计者的可执行清单:

  • •

    任何在运行时"挂"到宿主上的东西,都要有对应的"摘"下来的代码,且两者在同一作用域里成对出现。

  • •

    禁止插件往全局对象随意挂属性——全局即无界,无界即不可回收。

  • •

    嵌套作用域优先:能做成子模块的,不要做成平铺的全局插件。

  • •

    卸载/更新前,先验证登记册是否清空;不清空就告警,而不是静默放过。

七、衔接 12:生命周期之后是开发者体验

本篇我们解决了"插件能干净地来、也能干净地走"。但还有一个更上游的问题:插件好不好写? 生命周期再优雅,如果插件作者要手写一堆样板才能注册一个监听器、要理解六态状态机才能上手,这个生态也长不大。

这就是下一站——12《开发者体验:SDK、脚手架与文档》。从 OSGi 的 BundleActivator 样板,到 Koishi 的一个 apply(ctx) 函数,到 VS Code 的 contributes 声明式贡献,插件 API 的易用性直接决定了生态的繁荣度。12 篇我们从"系统怎么管插件"切到"人怎么写插件"。


附:史实与口径核对(诚实标注)

  • •

    OSGi 六态生命周期:来自 OSGi 官方 org.osgi.framework.Bundle 接口 javadoc(UNINSTALLED/INSTALLED/RESOLVED/STARTING/STOPPING/ACTIVE,位值 0x1/0x2/0x4/0x8/0x10/0x20)。OSGi 起源约 1998–1999 年(具体成立时间不同资料口径不一,标注待核)。

  • •

    VS Code 生命周期:activate(context) / context.subscriptions.push(disposable) / deactivate() / vscode:uninstall 脚本钩子,来自 code.visualstudio.com 官方文档(Extension Anatomy、Activation Events、February 2018 (1.21) 更新说明)。VS Code 首个公开版本 2015 年。

  • •

    Koishi / Cordis:ctx.dispose() 整棵 ctx 子树回收、嵌套 ctx.plugin() 局部热重载,来自 Koishi 官方《认识插件》章节与素材 B02_Koishi-Cordis-Plugin-Context.md。

  • •

    热部署 / HMR:Eclipse/OSGi Equinox 热部署难(classpath 泄漏)、React Fast Refresh、Webpack HMR——本文只引用其"重启代价层级"定位,具体实现细节与年份建议直连各项目文档核对,未引精确数字。

  • •

    动态插件架构可预测性:素材 03_Chatley-Predictable-Dynamic-Plugin-Architectures-PhD2005.pdf 与大纲 11 直接对口(动态加载/卸载的可预测性),作为形式化背书。

#生命周期 #热插拔 #副作用回收

相关学习资料