卷 I · 口CH 03深度 03/24

所有插件系统都在答同样五个问题

上一章的五份答卷背后,是同一张问卷:发现、加载、契约、隔离、演化。任何一个插件系统 —— 包括你自己要造的那个 —— 都必须回答这五个问题,不回答就等于默认选了最差的那个答案。这一章把问卷立起来当全书地图,顺便让你先玩到最后一章的成品。

五个问题全书地图成品预览

先玩,再讲

下面这台东西,是本书第 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 到此结束。从下一章开始动手:我们先回到最开头,看宿主怎么把自己拆开 —— 先有扩展点,才有插件。