动手 ②:让宿主学会拒绝
上一章写好了清单和校验器,这一章把它接到宿主上。做完之后,宿主第一次拥有了一种它之前完全没有的能力:在一行插件代码都没执行的情况下,认识一个插件、判断它、拒绝它。顺便,我们会种下懒激活的种子 —— 一个「只有壳没有肉」的命令。
把安装拆成两步
第 6 章那台宿主,「装上」和「跑起来」是同一个动作。现在把它们劈开:
install(manifest, source) | activate(id) | |
|---|---|---|
| 干什么 | 校验清单、检查版本、登记、把声明的命令挂成壳 | 编译源码、执行、调 activate(ctx) |
| 执行插件代码吗 | 完全不执行 | 执行 |
| 失败了怎么样 | 插件根本进不来,状态 rejected | 插件进来了但没跑起来,状态 error |
| 什么时候发生 | 启动时,一千个插件全都做一遍(很便宜) | 真正用到那一个的时候(很贵) |
最后一行是重点。因为 install 不执行任何插件代码,它便宜到可以在启动时对一千个插件全做一遍;而 activate 贵,所以要尽量推迟、尽量少做。这个成本差就是第 21 章那个「快 150 倍」的全部来源。
install 的完整实现
本书那台宿主的真实代码:
Host.prototype.install = function (manifest, source) {
// ── 第一关:清单本身合法吗
const v = validateManifest(manifest);
const rec = new PluginRec(this, manifest || {}, source);
if (!v.ok) {
rec.state = 'rejected';
rec.error = v.errors[0];
this.log('[宿主] ✗ 拒绝安装:' + (manifest?.id ?? '(无 id)') + ' —— ' + v.errors[0]);
this.plugins.push(rec); // ← 注意:还是登记进去了
return rec;
}
// ── 第二关:它要的宿主版本,我是吗
if (!satisfies(this.version, manifest.engine)) {
rec.state = 'rejected';
rec.error = '要求宿主 ' + manifest.engine + ',当前宿主是 ' + this.version;
this.log('[宿主] ✗ 拒绝安装:' + manifest.id + ' 要求 ' + manifest.engine
+ ',本宿主 ' + this.version);
this.plugins.push(rec);
return rec;
}
// ── 通过。登记,但代码一行都还没跑
this.plugins.push(rec);
this.log('[宿主] ✓ 已安装 ' + manifest.id + '@' + manifest.version);
// ── 关键一步:清单里声明的命令,先挂一个「壳」
const ctr = manifest.contributes || {};
(ctr.commands || []).forEach((c) => {
rec.track(this.registry.contribute('commands', {
id: c.id,
title: c.title,
by: manifest.id,
declared: true, // ← 我是从清单来的
run: null, // ← 但我还没有实现!
rec: rec, // ← 需要时去激活这个插件
}));
});
return rec;
};
三个地方值得停下来看。
细节一:被拒绝的插件也要登记
注意两处 rejected 分支里都有 this.plugins.push(rec)。为什么不直接扔掉?
因为用户需要知道发生了什么。如果被拒的插件从列表里凭空消失,用户的体验是「我明明装了那个插件,怎么没有了」。正确的做法是把它留在列表里,标成灰色,并写清楚为什么。
Mihon 就是这么做的:不受信任的扩展不会消失,它会以「不受信任」的状态显示,等你手动确认。IntelliJ 的插件管理器也会显示「与当前 IDE 版本不兼容」的插件,而不是隐藏它们。
细节二:只报第一个错,还是全报
上面的代码用了 v.errors[0] —— 只报第一个。这是给用户看的取舍(一次说一件事)。
但给插件作者看的时候应该反过来:把所有错误一次性列全,否则他修一个跑一次,五个错误要跑五轮。上一章那个校验器 demo 就是全列的。
同一份数据,对两种人要有两种呈现 —— 这在插件系统里是个反复出现的模式,因为你的系统同时服务着「用户」和「插件作者」两类完全不同的人。
细节三:那个「只有壳没有肉」的命令
这是这一章最重要的机制。清单里声明了一条命令,宿主立刻把它挂进命令面板 —— 但 run 是 null。
于是:命令面板里能看见它、能搜到它、能显示快捷键,而插件的代码一行都没跑。
等用户真的点下去:
Host.prototype.runCommand = function (cmdId) {
let hit = this.registry.get('commands').find(c => c.id === cmdId);
if (!hit) return { ok: false, msg: '没有这条命令:' + cmdId };
// ★ 懒激活就发生在这里
if (!hit.run && hit.rec && hit.rec.state !== 'active') {
this.log('[宿主] 命令 ' + cmdId + ' 被触发 → 懒激活 ' + hit.rec.manifest.id);
this.activate(hit.rec.manifest.id); // ← 现在才真的加载
// 插件 activate 时会注册一条「有肉」的同 id 命令,重新找一次
hit = this.registry.get('commands').find(c => c.id === cmdId && c.run);
}
if (typeof hit?.run !== 'function') return { ok: false, msg: '命令没有实现' };
try {
return { ok: true, value: hit.run() };
} catch (e) {
this.log('[宿主] ✗ 命令 ' + cmdId + ' 抛出 —— ' + e.message);
return { ok: false, msg: e.message, denied: e.name === 'PermissionDenied' };
}
};
这就是 VS Code 的 "activationEvents": ["onCommand:foo.bar"] 的完整原理,一共十几行。
它的一般形式是:把一个功能拆成「声明」和「实现」,声明进清单(便宜、静态、可提前读),实现留在代码里(贵、动态、按需加载)。
命令是最容易理解的例子,但同样的模式在真实系统里到处都是:
VS Code 的语言支持:清单声明「我支持 .py 文件」,用户打开 .py 时才加载语言服务器。
VS Code 的树视图:清单声明「侧边栏有这么一个视图」,用户展开它时才去要数据。
Chrome 的 content script:清单声明 matches: ["https://example.com/*"],只有访问到匹配的页面才注入。
Android 的 Activity/Service:其实也是同一个模式 —— 声明在 AndroidManifest.xml 里,系统按需实例化。
只要你发现自己在为「怎么让启动更快」发愁,就想想还有什么东西可以被拆成壳和肉。
看宿主拒绝人
下面是接好清单的宿主 v2。仓库里有四个候选插件,其中一个清单有问题,一个要求的宿主版本对不上。
把宿主版本在 1.0.0 / 1.5.0 / 2.0.0 之间切换,看放行名单怎么变:
切到 2.0.0 那一下值得盯着看:原来能装的两个插件全被拒了,而那个一直被拒的「未来插件」活了。
这就是所有插件系统都逃不掉的那件事 —— 宿主每升一次大版本,就要在「不许升」和「弄死一批插件」之间选一次。第 19 章会把这件事算清楚,包括一个相当扎心的结论。
拒绝的四个层次
本书的宿主目前只实现了前两层。完整的拒绝链条是这样的,而且顺序不能乱:
| 顺序 | 关卡 | 检查什么 | 本书在哪章 |
|---|---|---|---|
| 1 | 清单可解析 | JSON/XML 语法正确吗 | 07 |
| 2 | 清单合法 | 必填字段、id 格式、权限名、版本号格式 | 07 · 08 |
| 3 | 版本兼容 | 它要的宿主范围,包含当前宿主吗 | 08 · 19 |
| 4 | 依赖满足 | 它依赖的插件都在吗、有没有循环 | 20 |
| 5 | 来源可信 | 签名对吗、是不是被篡改过 | 22 |
顺序为什么重要:越靠前的检查越便宜、越不需要外部信息。如果你先做签名校验(要算哈希、可能要联网),再发现这个插件的清单根本就是坏的,那就白算了。把最便宜、最确定的检查放最前面 —— 这条原则在任何校验链里都成立。
顺带说一句,Mihon 的顺序基本就是这个:先看包有没有那面 tachiyomi.extension 旗子(最便宜的筛选),再读 metadata 里的扩展库版本比对白名单,再算签名 SHA-256 做信任检查,最后才去加载 DEX、反射实例化。最贵的动作留在最后。
拒绝之后,话该对谁说
这一节没有代码,但它决定了你的插件系统好不好用。
被拒绝时,有三种人需要知道,而他们需要的信息完全不同:
| 谁 | 他需要知道 | 他不需要知道 | 该怎么说 |
|---|---|---|---|
| 用户 | 这个插件现在不能用;要不要我帮你做点什么 | 字段名、版本范围语法、堆栈 | 「『字数统计』需要 2.0 以上版本的应用,请更新后再试」 |
| 插件作者 | 具体哪个字段、哪一行、合法值是什么 | —— 他要的就是细节 | 「未知权限 "network"(可用:notes.read / net / …)」 |
| 你(宿主维护者) | 有多少插件因为同一个原因被拒 | 单个插件的细节 | 日志聚合:「本周 340 个插件因 engine 不兼容被拒」 |
如果你发现「有 340 个插件因为同一个原因被拒」,那大概率不是这 340 个作者都错了,而是你的设计有问题 —— 也许那个字段的文档不清楚,也许你的版本范围规则反直觉,也许你在某个小版本里悄悄改了行为。
拒绝日志是你的插件 API 好不好用的最直接反馈。大多数团队从来不看它,然后一直纳闷为什么生态起不来。
装到自己身上
Kotlin 里最舒服的写法,是让「装不上」的每一种原因都是一个类型,然后用 when 穷尽处理 —— 编译器会强迫你把每种情况的提示文案都写出来:
sealed interface InstallResult {
data class Ok(val record: PluginRecord) : InstallResult
data class BadManifest(val errors: List<String>) : InstallResult
data class Incompatible(val required: String, val actual: String) : InstallResult
data class MissingDeps(val missing: List<String>) : InstallResult
data class Untrusted(val signature: String) : InstallResult
}
fun install(dir: File): InstallResult {
val manifest = when (val r = parseManifest(dir.resolve("manifest.json").readText())) {
is LoadResult.BadJson -> return InstallResult.BadManifest(listOf(r.reason))
is LoadResult.Invalid -> return InstallResult.BadManifest(r.errors)
is LoadResult.Ok -> r.manifest
}
if (!satisfies(hostVersion, manifest.engine))
return InstallResult.Incompatible(manifest.engine, hostVersion)
val missing = manifest.dependencies.filterNot { it in installedIds }
if (missing.isNotEmpty()) return InstallResult.MissingDeps(missing)
// …… 签名检查
return InstallResult.Ok(register(manifest, dir)) // ← 到这里,代码一行都还没跑
}
// 调用方被编译器强迫处理每一种情况
fun onInstall(dir: File) = when (val r = install(dir)) {
is InstallResult.Ok -> toast("已安装 ${r.record.manifest.name}")
is InstallResult.Incompatible -> toast("该插件需要 ${r.required} 版本的应用,当前是 ${r.actual}")
is InstallResult.MissingDeps -> toast("还需要先安装:${r.missing.joinToString()}")
is InstallResult.Untrusted -> showTrustDialog(r.signature) // ← Mihon 就是这么做的
is InstallResult.BadManifest -> { log.w("插件清单有误:${r.errors}"); toast("这个插件包损坏了") }
}
这个写法的价值不在优雅,在于它让「忘了处理某种拒绝情况」变成编译错误。插件系统里最烦人的 bug 就是「某种失败路径没人管,插件静默地不工作」—— sealed class + 穷尽 when 从根上堵住了这条路。
另外注意 Untrusted 那一支:它不是错误,是需要用户决策。这是插件系统特有的第三种结果 —— 不是成功也不是失败,而是「我不敢替你决定」。
这一章的一句话
把安装和激活劈成两步,宿主就获得了在「零执行」状态下拒绝插件的能力;而清单里声明的那个「只有壳没有肉」的命令,是懒激活、诚实商店页、执行前安全决策三件事共同的地基。
卷 II 到此结束 —— 你已经有了一台会开口、会读清单、会拒绝的宿主。下一卷进入最像魔法的部分:那段陌生的代码,究竟是怎么变成你进程里活着的对象的。