所有插件系统都在答同样五个问题
上一章的五份答卷背后,是同一张问卷:发现、加载、契约、隔离、演化。任何一个插件系统 —— 包括你自己要造的那个 —— 都必须回答这五个问题,不回答就等于默认选了最差的那个答案。这一章把问卷立起来当全书地图,顺便让你先玩到最后一章的成品。
先玩,再讲
下面这台东西,是本书第 23 章造出来的成品:一个极小的笔记应用,有笔记、有命令面板、有状态栏。它是真的 —— 用 JavaScript 写成,此刻就跑在你的浏览器里。
三个插件还没装。点「安装」,再点「启用」,看它们各自往宿主里插了什么:
玩过之后,几个观察:
① 「安装」和「启用」是两件事。安装只是把插件登记下来、读了它的清单;启用才真的执行它的代码。这个分离不是我为了演示编出来的 —— 它是所有成熟插件系统的标准做法,第 21 章会告诉你这两步之间藏着多少性能。
② 有的插件往命令面板里加东西,有的不加。「首行加框」一条命令都没加,却改变了笔记的显示 —— 因为它插的是另一个口子。宿主开了几个口子,就决定了插件能从几个地方参与进来。
③ 停用之后,它加的东西干净地消失了。命令没了、文本处理也没了。这件事比它看起来难得多,第 13 章会让你亲眼看到「没做干净」是什么样子。
好,现在把问卷立起来。
① 发现:宿主怎么知道有哪些插件
在任何代码被执行之前,宿主要先回答:这台机器上、这个用户这里,有哪些插件?它们各自是什么?
五家的答案:
| 系统 | 去哪里找 | 靠什么认出来 |
|---|---|---|
| Obsidian | 扫描 .obsidian/plugins/ 目录 | 每个子目录里的 manifest.json |
| Mihon | 问 Android 的 PackageManager 要已安装的包 | 包声明了 tachiyomi.extension feature |
| IntelliJ | 扫描 plugins 目录 | jar 里的 plugin.xml |
| VS Code | 扫描扩展目录 | package.json 里的 contributes 段 |
| Chrome | 扩展目录 / 商店安装记录 | manifest.json |
看出共同点了吗:全都是「一个约定好位置的清单文件」。没有一家是靠扫描代码、靠反射、靠注解、靠约定俗成的函数名。
因为「发现」这一步必须发生在「执行」之前。
如果宿主要跑一遍插件的代码才知道它是什么,那么:一个坏插件在被识别出来之前就已经在你进程里跑过了(安全问题);一千个插件全部要加载一遍才能显示列表(性能问题);插件出错时你连它叫什么都不知道(可诊断性问题)。
清单的存在,让宿主能在「零信任、零执行」的状态下,完整地知道这个插件是谁、要什么、给什么。这是插件系统里最重要的一个设计决定,第 7 章整章都在讲它。
② 加载:那段代码怎么变成活的对象
这是最像魔法、实际上最简单的一步。本质就是一句话:把一段静态的数据(文本/字节码/DEX),变成一个可以被调用的对象。
各家用的原语:
- JS 侧:
new Function(...)、动态import()、Node 的require/vm模块; - JVM 侧:
ClassLoader+Class.forName().newInstance(),Android 上是DexClassLoader/PathClassLoader; - 原生侧:
dlopen/LoadLibrary加载动态库,再按符号名取函数指针; - 越来越常见的第四种:WASM 模块实例化 —— 天生带沙箱,第 16 章会提。
加载这一步真正的难点不在「怎么加载」,而在加载之后的三个后果:
后果一:作用域。加载进来的代码能看见什么?(eval 能看见你的局部变量,new Function 不能 —— 第 9 章会真跑给你看。)
后果二:身份。在 JVM 里,同一个类被两个类加载器加载,就是两个不同的类,互相不能转换。这条规则会制造出插件开发者最困惑的那个异常(第 10 章会让你复现它)。
后果三:卸载。加载容易,卸载极难。一个函数被注册进了某个数组,一个监听器挂在了某个事件上,一个定时器还在跑 —— 只要有一处没收干净,插件的整个模块就无法被回收(第 13 章)。
③ 契约:宿主和插件之间说什么话
这是设计工作量最大的一环,也是最能看出功力的一环。它包含两个方向:
方向一:插件怎么告诉宿主「我提供什么」。三种流派 —— 实现接口(Mihon)、注册回调(Obsidian)、声明式贡献(VS Code / IntelliJ)。第 5 章整章讲这三派的取舍。
方向二:宿主给插件什么能力。也就是那个 ctx / context / app 对象里有什么。这里的每一个方法都是一句你要守很多年的承诺。
错误一:把内部对象直接交出去。比如为了省事,把宿主的整个 Application 实例传给插件。这等于把你所有的内部结构变成了公开 API —— 从此你改任何一个字段都可能弄死插件。
错误二:暴露可变状态。把一个 MutableList 交给插件,它就能清空它。交出去的东西要么是不可变副本,要么是只提供受控操作的接口。
错误三:让插件的类型出现在你的签名里。如果宿主的某个函数签名里有一个「插件自带的库」的类型,你就把自己和那个库的版本绑死了(在 JVM 里还会直接引发第 10 章那个异常)。边界上只能出现宿主定义的类型。
④ 隔离:它出事的时候,能波及多大范围
这是那根轴上真正的刻度。问题可以精确地拆成四条,而且它们的难度是递增的:
| 你想防住什么 | 需要什么 | 难度 |
|---|---|---|
| 插件抛异常把宿主带崩 | 调用插件时 try/catch,仅此而已 | 容易 —— 所有系统都做了 |
| 插件拿到不该拿的 API | 受控的 ctx 对象 + 权限闸门 | 中等 —— 第 17 章 |
| 插件死循环 / 卡死宿主 | 必须有独立线程或进程,没有别的办法 | 难 —— 第 15 章 |
| 插件作恶(偷数据、装后门) | 运行时边界 + 权限 + 分发环节把关,三样都要 | 最难 —— 第 14、16、22 章 |
第一条几乎不要成本,第三条要付出整个架构。这就是为什么大多数系统停在第一、二条:try/catch 谁都会写,但为了防死循环去改成多进程架构,代价是整个 API 变成异步(VS Code 付了这笔钱,第 18 章讲它买到了什么)。
在同一个运行时里,你无法用语言特性把另一段代码关起来。
不管你把 ctx 对象设计得多小心、冻结得多彻底、用 Proxy 包得多严实 —— 只要插件的代码和你的代码跑在同一个 JS 引擎(或同一个 JVM)里,它总有办法出来。第 14 章会用三行真的能跑的代码,从一个「什么都没给它」的 ctx 里把整个 globalThis 拿回来。
这不是说 ctx 和权限没用 —— 它们非常有用,它们防的是「误用」和「不知情的越界」,而且是良好 API 设计的一部分。但要清楚:它们是权限工具,不是隔离工具。真正的隔离只能来自运行时之外:另一个 Worker、另一个进程、另一个世界。
⑤ 演化:你变了之后,它还算不算数
前四个问题在第一天就要回答,第五个问题会追着你三年。它包含:
- 版本兼容:宿主升级,哪些插件还能装?(第 19 章,你会看到一个反直觉的结论:换代时先死的,是那批老老实实写版本范围的人)
- 依赖关系:插件能不能依赖别的插件?(第 20 章 —— 一旦允许,你就多了个包管理器的职责)
- 启动性能:一千个插件,冷启动要多久?(第 21 章,答案会让你重新看待清单的价值)
- 分发与信任:用户装的到底是不是作者写的那个?(第 22 章)
这四件事都不是「技术难题」,是「时间难题」:它们只在你有了足够多的插件和足够长的历史之后才会出现,而那时候再改就已经很贵了。这也是为什么这本书要用一整卷讲它们 —— 大多数插件系统的痛苦,都来自第一天没想过第三年。
为什么是这五个,不是四个或六个
这张问卷不是我随便凑的。它有一个可以验证的性质:少一个,就有一整类真实事故没人管;多一个,就会发现它其实是这五个中某一个的子问题。
验证「少一个会怎样」:
| 如果不回答 | 会发生的真实事故 |
|---|---|
| 发现 | 宿主必须执行代码才知道插件是什么 → 启动慢、无法在执行前拒绝、商店信息只能靠作者自述 |
| 加载 | 能装不能卸 → 用户禁用了插件它还在后台跑;或者插件带的库和宿主的撞车,报出看不懂的异常 |
| 契约 | 插件依赖你的内部结构 → 你改任何东西都会弄死一批插件,最后被自己的实现细节永久绑架 |
| 隔离 | 一个插件的死循环让几万人的应用卡死,而他们去应用商店给你打一星 |
| 演化 | 三年后你不敢发布任何新版本,因为不知道会死多少插件 |
再验证「常见的第六个问题其实是子问题」:
- 「插件的配置怎么存」 → 是契约的一部分(清单声明 schema,宿主生成界面);
- 「插件之间怎么通信」 → 是契约加演化(第 20 章);
- 「插件怎么更新」 → 是发现加演化;
- 「插件商店怎么做」 → 是发现加演化(第 22 章);
- 「插件怎么调试」 → 是加载(能不能热重载)加隔离(能不能挂调试器)。
当你在插件系统上遇到一个想不清楚的问题时,先把它归到这五个里的某一个。
归类本身通常就解决了一半 —— 因为你会立刻知道该往哪个方向找答案,也会发现它和你已经做过的某个决定有关联。
比如「我的插件商店该怎么设计」这个问题太大没法回答;但拆成「发现:用户怎么找到插件、宿主怎么校验它」和「演化:版本怎么管、出事怎么撤回」,就变成两个能动手的问题了。
全书地图
把五个问题和二十四章对上:
| 问题 | 在哪几章 | 你会亲手做的事 |
|---|---|---|
| ③ 契约 (先讲,因为它决定其余一切) |
卷 II:04 – 08 | 从 if-else 重构出扩展点注册表;造出第一台能加载插件的宿主;写一个真的清单校验器 |
| ① 发现 | 07、08、21 | 让宿主在不执行任何插件代码的前提下,认识、校验、拒绝插件 |
| ② 加载 | 卷 III:09 – 13 | 对比三种加载法的作用域差别;复现 JVM 那个经典异常;跑一个 Mihon 风格的源插件;亲眼看到监听器泄漏 |
| ④ 隔离 | 卷 IV:14 – 18 | 真的逃逸一次沙箱;真的把这个页面卡死 0.7 秒;给宿主装上权限闸门;搭一座 Worker RPC 桥 |
| ⑤ 演化 | 卷 V:19 – 22 | 算出宿主升大版本时生态的存活率;做依赖拓扑排序;算懒激活省下的启动时间 |
| 全部合起来 | 卷 VI:23 – 24 | 完整宿主的代码走查;一张你能直接抄的决策表 |
直觉上应该按时间顺序讲:先发现、再加载、再调用。但那是宿主运行时的顺序,不是设计时的顺序。
设计一个插件系统时,你真正要先想清楚的是:我要在自己身上开哪几个口子?插件从这些口子能参与到什么程度?这个问题答完,清单该写什么、加载器该怎么写、权限该怎么切,全都自然而然地跟着出来了。
反过来,如果你先去研究「怎么用 ClassLoader 加载 jar」,你会造出一个技术上完全正确、但没人知道该怎么用的插件系统。加载是手段,契约才是产品。
装到自己身上
如果你手上真有一个想加插件的程序,现在就可以拿这五个问题去问它。先别写代码,先把这五行填出来(填不出来的地方,正是你该重点读的那几章):
① 发现:我的插件放在哪里?靠什么文件被认出来? ______________________________________________ ② 加载:我用什么把它变成活对象?加载之后能卸载吗? ______________________________________________ ③ 契约:我要开哪几个扩展点?给插件的 ctx 里有什么? ______________________________________________ ④ 隔离:插件死循环,我的程序会怎样?我能接受吗? ______________________________________________ ⑤ 演化:我下次改 API,怎么让老插件不集体死掉? ______________________________________________
特别注意第 ④ 行。它是唯一一个「事后几乎改不了」的决定 —— 从同进程改成多进程,等于把整套 API 重写一遍(同步全变异步)。其它四个问题都可以边做边调整,这一个必须第一天想清楚。
这一章的一句话
发现、加载、契约、隔离、演化 —— 五个问题,任何插件系统都必须回答;不显式回答,就等于默认选了最省事也最危险的那个答案。
卷 I 到此结束。从下一章开始动手:我们先回到最开头,看宿主怎么把自己拆开 —— 先有扩展点,才有插件。