卷 II · 框CH 08深度 08/24

动手 ②:让宿主学会拒绝

上一章写好了清单和校验器,这一章把它接到宿主上。做完之后,宿主第一次拥有了一种它之前完全没有的能力:在一行插件代码都没执行的情况下,认识一个插件、判断它、拒绝它。顺便,我们会种下懒激活的种子 —— 一个「只有壳没有肉」的命令。

动手零执行拒绝命令壳

把安装拆成两步

第 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 就是全列的。

同一份数据,对两种人要有两种呈现 —— 这在插件系统里是个反复出现的模式,因为你的系统同时服务着「用户」和「插件作者」两类完全不同的人。

细节三:那个「只有壳没有肉」的命令

这是这一章最重要的机制。清单里声明了一条命令,宿主立刻把它挂进命令面板 —— 但 runnull

于是:命令面板里能看见它、能搜到它、能显示快捷键,而插件的代码一行都没跑。

等用户真的点下去:

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:用 sealed class 把「拒绝」变成类型

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 到此结束 —— 你已经有了一台会开口、会读清单、会拒绝的宿主。下一卷进入最像魔法的部分:那段陌生的代码,究竟是怎么变成你进程里活着的对象的。