卷 II · 框CH 04深度 04/24

先有扩展点,才有插件

大多数人研究插件系统,是从「插件怎么写」开始的。那是从第二步开始的。第一步在宿主这边:你得先在自己身上开出一个口子 —— 一个有名字的、类型确定的、允许陌生人往里填东西的位置。这一章从一坨 if-else 出发,把扩展点亲手重构出来,并看清一个口子必须定义的四件事。

扩展点注册表控制反转顺序与冲突

从一个很小的需求开始

假设你在写一个笔记应用。有人提了个需求:加一个「统计字数」的功能,放在命令面板里。

你会怎么写?大概是这样:

// 命令面板
function runCommand(id) {
  if (id === 'wc.count') {
    const text = notes.read(current);
    ui.status(`共 ${text.length} 字`);
  }
}

function listCommands() {
  return [{ id: 'wc.count', title: '统计字数' }];
}

这段代码完全正确。如果你的应用只需要这一个功能,写成这样是最好的 —— 没有任何多余抽象。

然后来了第二个需求:盖时间戳。第三个:导出 Markdown。第四个:加密这篇笔记。半年后你的文件长这样:

function runCommand(id) {
  if (id === 'wc.count')       { /* … */ }
  else if (id === 'stamp.now') { /* … */ }
  else if (id === 'export.md') { /* … */ }
  else if (id === 'crypt.lock'){ /* … */ }
  // …… 再来 40 个
}

function listCommands() {
  return [
    { id: 'wc.count',  title: '统计字数' },
    { id: 'stamp.now', title: '盖个时间戳' },
    // …… 同样的 40 个,写第二遍
  ];
}

这段代码到底哪里不好

先说一个不是主要问题的问题:它不好看。这个真的无所谓。

真正的问题有三个,而且都很具体:

问题一:同一个事实写了两遍。「有一条叫 wc.count 的命令」这件事,在 runCommand 里写了一次,在 listCommands 里又写了一次。加第 41 个功能时忘了改第二处,就会出现「命令面板里没有,但能执行」或者反过来的诡异 bug。

问题二:所有功能被迫住在同一个文件里。四十个不相干的功能挤在一个 switch 里,谁改都要碰这个文件,天天冲突。

问题三 —— 这才是致命的:这个函数必须由你来写。

停在这里想一下。假如现在有个陌生人想给你的笔记应用加一个「翻译这篇笔记」的功能,在上面这个结构下,他能做到吗?

不能。他必须:拿到你的源码 → 在你的 runCommand 里加一个 else if → 在你的 listCommands 里加一项 → 重新编译整个应用 → 说服你合并他的改动。

◆ 问题的本质:谁在调用谁

在这段代码里,宿主调用功能runCommand 知道所有功能的名字,主动去调它们。这意味着宿主必须认识每一个功能 —— 而它不可能认识那些还没被写出来的功能。

插件系统要做的,是把这个箭头反过来:让功能主动来找宿主登记,宿主只在需要的时候问一句「现在都有谁」。

这个反转有个正式名字叫控制反转(IoC),但你不需要记这个词。你只需要记住那个动作:宿主从「我知道有哪些」变成「我提供一个位置,谁来填我不管」。

第一次重构:把 if-else 变成一个数组

重构其实小得可笑:

// 宿主:一个数组,和两个操作
const commands = [];

function addCommand(cmd) {          // ← 谁都可以来填
  commands.push(cmd);
}

function listCommands() {           // ← 宿主要用的时候问一句
  return commands;
}

function runCommand(id) {
  const cmd = commands.find(c => c.id === id);
  if (!cmd) return;
  cmd.run();                        // ← 宿主不知道这里面是什么,也不需要知道
}

而原来那四十个 else if,各自搬到自己的文件里去,主动登记:

// wordcount.js —— 现在它是独立的了
addCommand({
  id: 'wc.count',
  title: '统计字数',
  run() {
    const text = notes.read(current);
    ui.status(`共 ${text.length} 字`);
  }
});

就这么点改动,三个问题全解决了:一个事实只写一遍(id、标题、实现在一起);每个功能住自己的文件;而且最关键的 —— 那个陌生人现在可以写自己的功能了,只要他的代码能被执行到,他调一次 addCommand 就完事,你的源码一个字都不用改。

commands 这个数组,就是一个扩展点(extension point)。整个插件系统,就是从这个数组长出来的。

一个扩展点必须定义的四件事

那个数组当然还太朴素。要变成一个能给陌生人用的正式扩展点,它必须明确四件事。这四件事你少定义一件,就会在三个月后被咬一口。

① 身份:它叫什么

扩展点要有一个稳定的、字符串形式的 id,比如 commandstransforms。为什么是字符串而不是直接暴露那个数组变量?因为插件是在另一个文件、另一个包、甚至另一个 APK 里的,它没法直接引用你的变量,只能通过名字来找。

② 契约:往里面填的东西长什么样

这是最重要的一件。「一条命令」到底是什么?

// 契约:往 commands 里填的东西必须长这样
{
  id: string,           // 唯一标识,用来触发
  title: string,        // 显示给用户看的名字
  run: () => void,      // 真正要执行的东西
  priority?: number     // 可选:排序用
}

在有类型的语言里,这就是一个接口:

interface Command {
    val id: String
    val title: String
    fun run()
}

契约一旦发布就很难改。如果你后来想给 Command 加一个必填的 icon 字段,所有已有的插件全部编译不过 / 运行时报错。所以真实系统里,扩展点的契约几乎总是只增不减,而且新增的一定是可选的

③ 多重性:能填几个

大部分扩展点是「多个」(命令面板可以有很多命令),但有些天然是「一个」:

  • 多个:命令、菜单项、文本处理器、语法高亮规则 —— 大家共存;
  • 一个:默认存储后端、主题、认证提供者 —— 只能有一个生效

「只能有一个」的扩展点必须回答一个尴尬问题:两个插件都想填,谁赢?常见做法是按优先级排序取第一个,或者干脆报错让用户去选。不回答这个问题,用户就会遇到「装了两个主题插件,界面随机变成其中一个」这种鬼故事。

④ 顺序:谁先谁后

这一条最容易被忘,也最容易出玄学 bug。

假设有两个插件都往 transforms(渲染前的文本处理)里填了东西:一个把 Markdown 转成 HTML,一个把 :smile: 转成 emoji。顺序反了,结果就不一样 —— 甚至可能一个把另一个的输出弄坏了。

如果你不定义顺序,实际顺序就取决于「插件加载的先后」,而那取决于文件系统返回目录项的顺序。换台机器就变。

所以扩展点要么给一个显式的 priority,要么给一个明确的规则(按 id 字典序、按声明依赖排序)。随便选一个都行,但必须选一个,而且写进文档。

看它真的跑起来

下面这个注册表是真的(它就是本书那台宿主里在用的那一个)。点按钮往三个扩展点里填东西,看它们各自的状态和 get() 的返回值:

注意几件事:

宿主的代码一行都没变。它只做了两件事:开三个口(define),和在需要时问一句「这个口上现在有谁」(get)。

「拼写检查」的优先级是 10,它永远排在「首行加框」前面,不管你按什么顺序点。这就是第 ④ 条在起作用。

每次 contribute 都返回一个「可弃置对象」(再点一次就撤销)。这个小设计是第 13 章整章的地基 —— 能装进来的东西,必须能原样拿出去。

注册表的完整实现

这就是全部代码,不到 30 行:

function Registry() {
  this.points = {};
}

// 宿主开一个口
Registry.prototype.define = function (id, desc) {
  if (!this.points[id]) this.points[id] = { id, desc, items: [] };
  return this.points[id];
};

// 插件往口里填一个东西,拿回一个「撤销凭证」
Registry.prototype.contribute = function (pointId, item) {
  const p = this.points[pointId];
  if (!p) throw new Error('没有这个扩展点:"' + pointId + '"');   // ← 别沉默失败
  p.items.push(item);
  p.items.sort((a, b) => (b.priority || 0) - (a.priority || 0)); // ← 顺序
  return {
    dispose() {                                                   // ← 撤销
      const i = p.items.indexOf(item);
      if (i >= 0) p.items.splice(i, 1);
    }
  };
};

// 宿主要用的时候问一句
Registry.prototype.get = function (pointId) {
  const p = this.points[pointId];
  return p ? p.items.slice() : [];    // ← 给副本,别把内部数组交出去
};
⚠ 这 30 行里有三个刻意为之的细节

① 往不存在的扩展点填东西要报错,不能沉默。插件作者把 commands 拼成了 command,如果你默默创建一个新扩展点,他会盯着「我的命令为什么不出现」查一下午。沉默失败是插件系统里最贵的 bug 类型,因为出错的人和能看到错误的人不是同一个人。

get() 返回的是副本(.slice())。如果直接返回内部数组,任何一个插件都能 host.registry.get('commands').length = 0 把所有人的命令清空。交出去的东西要么不可变,要么是副本。

contribute 返回撤销凭证,而不是 void这个决定在第 13 章会救你的命:它让「卸载插件」这件事从「遍历所有扩展点找出属于它的项」(易漏、O(n))变成「把它给的凭证挨个 dispose」(不可能漏)。这个 API 形状是从 VS Code 的 context.subscriptions 和 Obsidian 的 register* 抄来的,它们都是这么干的。

扩展点该开在哪里

技术上会开扩展点很容易,难的是决定开在哪。这里有三条实战经验:

经验一:先硬编码三个,再抽扩展点

不要凭空设计扩展点。正确的顺序是:用最笨的方式把三个具体功能写死进去,然后盯着它们看 —— 它们长得像的那部分,就是扩展点的形状。

如果你只写了一个功能就抽象,你抽出来的是那一个功能的形状;如果你写了三个,你抽出来的才是「这一类功能」的形状。这个「三」是个经验值,但它出奇地可靠。

经验二:扩展点越少越好,但要在对的地方

每开一个扩展点,你就多了一份永久的维护义务。所以宁可少开,但要开在「变化真正发生的地方」

Mihon 是个极好的例子:它的扩展点本质上只有一个 —— 「一个 Source」。不是「一个 HTTP 客户端扩展点」加「一个解析器扩展点」加「一个缓存策略扩展点」,就是一个大接口。因为它想清楚了:变化的单位是一个网站,不是网站的某个环节。

经验三:能声明的别用回调

比较这两种 API:

// 写法 A:插件调用函数来注册
ctx.ui.addCommand('wc.count', '统计字数', () => { /* … */ });

// 写法 B:插件在清单里声明,代码只提供实现
// manifest.json: "commands": [{ "id": "wc.count", "title": "统计字数" }]
ctx.ui.onCommand('wc.count', () => { /* … */ });

A 更直观,B 更有价值:用 B 的话,宿主不执行插件的任何代码就知道有这条命令 —— 命令面板可以立刻显示它,等用户真的点了再去加载插件。这就是 VS Code 的做法,也是第 21 章那个「快 150 倍」的来源。

本书造的宿主两种都支持:清单里声明的命令先登记成一个「壳」,代码跑起来后再填上真正的实现。第 8 章你会看到这个机制。

装到自己身上

◆ Kotlin / JVM:你其实早就有一个注册表了

JVM 平台自带一个扩展点机制,叫 ServiceLoader(Java SPI)。它就是上面那个注册表的标准库版本:

// ① 宿主定义契约
interface NoteCommand {
    val id: String
    val title: String
    fun run(ctx: HostContext)
}

// ② 插件实现它,并在自己的 jar 里放一个文件:
//    META-INF/services/com.you.notes.NoteCommand
//    文件内容就是实现类的全限定名,一行一个
class WordCountCommand : NoteCommand { /* … */ }

// ③ 宿主问一句「现在都有谁」
val commands = ServiceLoader.load(NoteCommand::class.java).toList()

优点:标准、零依赖、jar 丢进 classpath 就自动被发现。
缺点:它把「发现」和「实例化」绑在一起了 —— ServiceLoader 一遍历就把每个类都 new 出来,没有清单,也就没法懒加载(第 21 章的问题它解决不了)。而且它没有优先级、没有卸载、没有版本检查。

什么时候够用:插件数量不多、都在同一个 classpath 里、不需要动态装卸 —— 比如一个内部工具的可插拔后端。什么时候不够:一旦你需要「用户能装卸」「启动要快」「插件来自不可信来源」,你就得自己写注册表 + 清单了。

另外注意 IntelliJ 文档里的一个坑:在 IDE 插件里用 ServiceLoader 时,要临时把线程的 context classloader 换成插件自己的加载器,否则它找不到你的 META-INF/services(原因见第 10 章)。

◆ 一个立刻能做的动作

打开你手上那个想加插件的项目,找到最大的那个 when / switch / if-else 链。问自己三个问题:

① 这些分支长得像吗?(都接收同样的输入、返回同样的东西?)—— 像,说明这里有一个扩展点。

② 它们会一直变多吗?—— 会,那这个扩展点值得开。

③ 未来加分支的人会是别人吗?—— 会,那你需要的不只是注册表,还需要这本书后面讲的全套(清单、加载、隔离)。如果永远只有你自己加,那就停在注册表,别往下做了,做了就是纯亏。

这一章的一句话

扩展点是宿主在自己身上开的一个有名字的口子;开一个口子要同时定义四件事 —— 身份、契约、多重性、顺序 —— 少定义一件,三个月后就会变成一个查不出来的玄学 bug。

下一章,我们看这个「口子」的三种不同形状:让插件实现接口、让插件注册回调、还是让插件声明数据。这三派的选择,会决定你的插件系统之后能长成什么样。