你写不完所有代码
插件系统不是为了「架构优雅」发明的,它解决的是一个非常物理的问题:你的程序,和它要应付的那个世界,变化速率不一样。这一章不写代码,只做两件事:把这个问题摆清楚,然后立起整本书的主线 —— 你即将做的,是一笔关于信任的交易。
先看一个具体到有点荒唐的例子
Mihon(就是原来的 Tachiyomi)是一个漫画阅读 App。它能从上千个漫画网站抓内容 —— 中文的、日文的、英文的、西班牙文的,大站小站,有的一年换三次域名。
现在请你认真想一下:如果 Mihon 没有插件系统,这件事要怎么做?
答案是:把一千个网站的解析逻辑,全部写进 App 里。于是你得到这样一个函数:
// 如果没有插件系统,事情会长成这样
fun fetchPopular(site: String): List<Manga> {
return when (site) {
"manga-a.com" -> { /* 200 行,它家 HTML 的解析逻辑 */ }
"manga-b.net" -> { /* 180 行,它家的又不一样 */ }
"manga-c.org" -> { /* 还有登录、还有反爬 */ }
// …… 再来 997 个分支
else -> emptyList()
}
}
先别急着说「这代码好丑」。丑不是主要问题。真正致命的是下面这四件事:
第一,你得懂一千个网站。包括那个只有巴西人在用的葡萄牙语站,包括那个 HTML 里混着三种编码的老站。你不可能懂,你也不该懂。
第二,其中某一个网站今晚会改版。不是「可能」,是「一定」—— 一千个网站,平均每个每年小改两次,那就是每天有五六个站在变。而你的 App 发一次版要打包、要过商店审核、要等用户更新,最快也要几天。你永远追不上。
第三,改一行要重新编译全部。为了修好那个巴西站的选择器,你要重新编译整个 App、重新测试、让一亿个不关心巴西站的用户一起更新一次。
第四,也是最要命的:那个站的解析代码写错了,整个 App 崩。一个你根本没听说过的小站,一次空指针,全体用户闪退。
把上面四条压缩成一句话:你的程序变化得慢(发版周期以周计),你的程序要应付的那个世界变化得快(以小时计)。
这个「速率差」,就是插件系统存在的全部理由。它不是审美问题,是物理问题。只要两边的变化速率差得足够远,你迟早会被逼到同一个答案上:把变得快的那部分,从变得慢的那部分里切出去,让它能被单独修改、单独发布、单独出错。
把「速率差」变成一个数字
「变化速率不一样」听起来抽象,但它可以被估算出来。拿一个很简单的比值:
速率差 = (需要变的那部分,每年变化次数)÷(你的发版周期内能响应的次数)
套到几个真实场景上:
| 场景 | 那部分每年变多少次 | 你多久能发一版 | 速率差 | 结论 |
|---|---|---|---|---|
| Mihon 的一千个漫画站 | 约 2000 次 | 两周(还要过安装) | ≈ 80 | 必须插件 |
| 编辑器要支持的编程语言 | 几百次(新语言、新版本) | 一个月 | ≈ 30 | 必须插件 |
| App 的主题配色 | 3 次 | 两周 | ≈ 0.1 | 配置项就够 |
| 后台管理系统的报表类型 | 10 次 | 一周 | ≈ 0.2 | 改源码就行 |
速率差小于 1,别做插件系统。意味着你完全追得上,插件系统的复杂度是白付的。
大于 10,你迟早会被逼着做。不做的结果是你永远在赶工、永远有一堆修不完的适配 issue、而且每一个都要走完整的发版流程。
1 到 10 之间,看第三个条件 —— 有没有人愿意替你写。有人愿意,就做;只有你一个人,那么插件系统只是把「改源码」换成了「改插件」,你并没有省下任何工作,反而多了一套加载器要维护。
先分清插件和它的四个邻居
「让别人的代码进到我的程序里」有好几种形态,它们经常被混着叫。分清楚能帮你省下一次错误的技术选型:
| 谁决定它在不在 | 什么时候确定 | 出错时 | 典型 | |
|---|---|---|---|---|
| 库 | 你(写在依赖清单里) | 编译时 | 编译不过 | okhttp、React |
| 插件 | 用户(装或不装) | 运行时 | 宿主要自己扛住 | 本书的主题 |
| 脚本 / 宏 | 用户(现写现跑) | 运行时 | 通常只影响这一次执行 | Excel 宏、编辑器的用户脚本 |
| 微服务 / 外部进程 | 运维 | 部署时 | 网络调用失败,天然隔离 | 语言服务器、外部工具 |
| 配置 / 规则 | 用户 | 运行时 | 数据不会执行,最多是逻辑不对 | 广告拦截规则、lint 配置 |
看第二列和第三列 —— 这是插件真正特别的地方:它的存在与否由用户在运行时决定,而它是会被执行的代码。
这两条合起来,就解释了为什么插件系统的复杂度这么高:
- 因为用户决定,所以你没法在编译期检查任何东西 —— 类型系统帮不了你;
- 因为运行时确定,所以你要处理装、卸、升级、冲突;
- 因为是代码不是数据,所以你要面对安全、崩溃、性能问题。
如果你能把需求降级到「配置」那一行,上面三类问题会一次性全部消失。这就是下一节要说的 —— 认真考虑一下你是不是真的需要「代码」。
「加功能」其实有三种方式,插件是最后一种
在跳到插件之前,值得先看清另外两种 —— 因为大多数时候,它们才是对的。
方式一:改源码
最直接:需要什么功能,就在代码里加。
- 优点:没有任何抽象成本,改动最直接,编译器全程给你兜底。
- 缺点:改的人必须是你(或你团队),必须重新编译发布。
- 适用:功能的数量有限、变化不快,而且只有你需要改。
90% 的软件,永远停在这一档就够了,而且是对的。
方式二:配置项
把「可变的部分」提取成数据:开关、字符串、一段 JSON、一份规则表。
{
"site": "manga-a.com",
"listSelector": "div.item",
"titleSelector": "h3.t",
"coverAttr": "data-src"
}
这一招被严重低估了。上面这四行配置,就能覆盖相当一部分结构规整的网站 —— 不需要任何插件系统,不需要加载任何代码,一个 JSON 文件就够了,甚至可以从服务端热更新。
- 优点:完全没有安全问题(数据不会执行)、可以热更新、非程序员也能写。
- 缺点:只能表达你预先想到的那些变化。那个需要先登录、再解密、再拼三次请求才能拿到章节列表的站,任何配置格式都表达不了。
团队从配置项起步,遇到表达不了的情况就往配置格式里加一点能力:加个条件判断、加个循环、加个变量替换、加个表达式求值……
三年之后,你的配置格式变成了一门难用、没有文档、没有调试器、没有类型检查的编程语言。而且是你自己一个人维护的。
这个坑有个名字,叫「格林斯潘第十定律」的变体。识别信号很简单:当你开始往配置里加 if 的时候,停下来,认真考虑直接上插件。让插件用一门真正的语言去写,你就白得了那门语言的全部工具链。
方式三:插件
让别人写的代码,在不改你的源码、不重新编译、甚至不重启的前提下,进入你的程序、成为它的一部分。
这是唯一能同时满足下面三条的方案:
- 扩展的人不是你(你不必懂那一千个网站);
- 扩展的发布不经过你(那个站改版,作者自己修自己发,不用等你发版);
- 扩展能用完整的编程能力(登录、解密、拼三次请求,随便)。
代价也在这三条里面 —— 写代码的不是你,发布不经过你,而它拥有完整的编程能力。
你到底交出去了什么
这是这本书真正的主题。请慢慢读下面这段。
当你在程序里加载一个插件,你交出去的不是一个函数调用的机会,而是:
| 你交出去的 | 意味着什么 | 最坏情况 |
|---|---|---|
| 进程 | 它的代码和你的代码跑在同一个地方,共享内存、共享线程 | 它死循环,你整个应用卡死 |
| 权限 | 操作系统眼里它就是你 —— 你能读的文件它能读,你能发的请求它能发 | 它读走用户的密钥,用你的名义发出去 |
| 信誉 | 用户装的是「你的 App」,出了事骂的是你 | 某个插件偷数据上了新闻,标题写的是你的产品名 |
| 演化自由 | 一旦有一万个插件依赖某个 API,你就再也不能随便改它 | 你被自己三年前随手写的一个函数签名永久绑架 |
最后那一条最容易被低估,所以再说一遍:
插件 API 一旦发布,就不再属于你了。它属于所有依赖它的人。
你可以随时重构自己程序里的任何一个私有函数 —— 但你不能重构一个已经有一万个插件在调的公开 API,除非你准备好弄死那一万个插件、承受一年的骂声,并且专门为此做一套迁移工具。(Chrome 干过这件事,叫 MV2 → MV3,走了五年多。第 19 章会细讲。)
所以插件系统真正的设计难点,从来不是「怎么把代码加载进来」—— 那是最容易的部分,第 9 章一章就讲完了。难的是决定「什么东西可以被插件看见」,因为你写进 API 的每一样东西,都是一句你要守很多年的承诺。
那什么时候不该做插件系统
一本讲插件系统的书,第一章就劝你别做,听起来很怪。但这是全书最省钱的一节。
下面这些情况,请不要做插件系统:
① 扩展的人只有你自己。如果所有插件都是你团队写的,你根本不需要「插件系统」这套东西 —— 你需要的是好的模块划分。插件系统的所有复杂度(清单、加载器、沙箱、版本兼容)全部来自「作者是陌生人」这个前提。作者不是陌生人,这些复杂度就是纯亏。
② 你还不知道扩展点该开在哪。这是最常见的错误:产品还没定型,就先设计一套「灵活的插件架构」。结果是你猜的那几个扩展点没人用,真正需要扩展的地方没开口。正确顺序是反的:先把三四个功能用最笨的方式硬编码进去,等你看清它们「长得像」的部分在哪,那里才是扩展点。
③ 配置项能解决。见上面。JSON 打不过的时候再上插件,不要反过来。
④ 你承受不起「插件搞坏了东西」的后果。如果你的程序是记账系统、是医疗设备、是支付网关,那么「让陌生代码进来」这件事本身可能就不该发生,无论你隔离得多好。
当这三条同时成立时,插件系统的收益会大到无法拒绝:
① 需求在「长尾」上。不是一个功能一万个人要,而是一万个功能各有几个人要。一千个漫画站、几百种编程语言的支持、每个人自己那套笔记工作流 —— 这种形状你自己永远做不完。
② 变化速率你追不上。网站改版、语言出新版本、平台改接口,这些事不等你发版。
③ 有人愿意替你写。这条最容易被忘。插件系统是一套社区基础设施,没有社区,你造出来的只是一套给自己添麻烦的加载器。先确认有人想写,再去造。
这本书的主线
现在可以把话说完整了。
插件系统是一笔关于信任的交易:你把程序的一部分权力,交给一段你没写过、没审过、随时会变的代码。
而这本书接下来要讲的每一个机制,都是在给这笔交易定价:
| 机制 | 在这笔交易里管什么 | 在哪一卷 |
|---|---|---|
| 扩展点 | 它能从哪些地方参与进来 —— 你在自己身上开的口子 | 卷 II |
| 契约 / API | 它能对你说什么话、你能对它说什么话 | 卷 II |
| 清单 manifest | 它在跑起来之前必须先申报什么 | 卷 II |
| 加载器 | 它的代码怎么变成你进程里活着的对象 | 卷 III |
| 生命周期 | 它什么时候开始、什么时候必须彻底消失 | 卷 III |
| 隔离 / 沙箱 | 它出事的时候,能波及多大范围 | 卷 IV |
| 权限 | 它能拿到的能力上限,以及谁来批准 | 卷 IV |
| 版本约定 | 你变了之后,它还算不算数 | 卷 V |
| 分发与签名 | 用户装的到底是不是作者写的那个东西 | 卷 V |
五个真实系统(Mihon、Chrome、VS Code、IntelliJ、Obsidian)之所以长得天差地别,不是因为技术水平有高下,而是因为它们把这九个格子填了不同的答案。下一章我们就把这五张答卷摆在一起看。
装到自己身上
① 先问「速率差」,再谈架构。下次有人提议「我们做个插件系统吧」,先问一句:需要变的那部分,变化速率是多少?我们的发版周期是多少?如果两个数量级相同,那大概率不需要插件系统,需要的是更好的模块划分。
② 用「三种方式」当分诊表。改源码 → 配置项 → 插件,成本和风险依次上升一个数量级。永远从最左边开始,被逼到不行了才往右走一格。特别是:在你往配置格式里加第一个 if 之前,停一下。
③ 把「交出去的东西」写进设计文档。在你为自己的程序设计插件系统时,第一页不要画架构图,先老实写下这四行:我交出去的是哪个进程、哪些权限、谁的信誉、以及哪些 API 从此不能再改。这四行会替你砍掉一半的坏设计。
这一章的一句话
插件系统解决的是「你的程序和世界的变化速率不一样」这个物理问题;解决的方式,是把一部分权力交给陌生代码 —— 而这笔交易一旦成交,就再也退不回去了。
下一章,我们把五个真实系统摆在同一根轴上,看它们各自把这笔交易定在了什么价钱。