《互联网产品的客户端架构·规模化与架构演进》3.1 插件协议与宿主边界

插件化不是把代码拆出去,而是先定义宿主与插件的契约边界。本节从三个宿主版本同时在线、插件清单失控的现场出发,给出插件 id、版本、宿主 API 区间与能力声明的校验模型,用可运行的 TypeScript 模块完成协议校验、兼容判定与责任划分,并说明哪些关注点必须留在宿主。

三个版本的插件同时在线

某效率产品把「扩展能力」做成插件:网页端装浏览器扩展,桌面端装本地插件,移动端装轻量脚本。上线一年后,插件市场里有 47 个插件、9 个宿主版本,问题开始集中出现。

  • 一个插件在宿主 4.0 上白屏,因为清单里只写了 hostApi: "4",没人知道它依赖的是 4.0 还是 4.2。
  • 同一个插件 id 被两个团队各发了一版,版本号都是 1.0.0,商店里出现两个「1.0.0」。
  • 插件声明了 camera 能力,但桌面端根本没有摄像头,宿主没有拒绝安装,运行时才报错。

这三个问题看起来分别是版本、发布、能力问题,根因是同一个:清单没有被当成协议。它被当成一份「填了就行」的配置文件,宿主也没有校验。

清单是协议,不是配置文件

插件清单至少要回答四个问题:我是谁、我依赖哪个宿主、我需要什么、我能做什么。少一个,宿主就只能靠约定和运气。

字段作用校验规则缺失后果
id全局唯一身份反向域名、小写两个插件互相覆盖
version插件自身版本严格 x.y.z升级无法比较
hostApi宿主 API 区间min/max 合法且 min <= max装到不兼容宿主
capabilities声明所需能力必须在白名单内运行时才发现缺能力
name展示名非空商店无法展示

id 用反向域名不是为了好看,而是为了把「归属」写进字符串。com.acme.notes 一眼能看出归属,也能防止两个团队都叫 notes。

宿主 API 区间而不是单点版本

插件写 hostApi: "4.2.0" 是最常见的错误。它表达的是「我只在 4.2.0 上测试过」,宿主却会理解成「只兼容 4.2.0」。正确写法是一个闭区间:

hostApi:
  min: "1.4.0"
  max: "1.9.9"

区间两端都算兼容。宿主版本低于 min 时,是宿主太旧;高于 max 时,是插件太旧。这两种情况的处理方式不同:

判定含义宿主动作
compatible落在闭区间内允许安装
host-too-old宿主低于 min提示升级宿主,不装
host-too-new宿主高于 max提示插件待适配,不装
invalid任一侧版本非法拒绝安装并上报

把「太旧」和「太新」分开,是为了让提示能指向正确的升级方向。统一报「不兼容」,用户和开发者都不知道该动哪一边。

版本比较也不能用字符串。"1.10.0" < "1.9.0" 在字符串里成立,因为字符 1 小于 9;在语义化版本里,10 大于 9。必须逐段解析成数字再比较。

能力声明:声明的是需求,不是权限

capabilities 容易被写成「我能提供什么」,这会导致宿主无法判断缺什么。它应该声明「我需要宿主提供什么」:

能力典型用途宿主可拒绝的端
camera扫码、拍照桌面端
location附近内容桌面端、部分 Web
network请求外部服务离线模式
storage本地持久化无
clipboard复制结果部分浏览器
analytics埋点上报隐私模式

能力声明与权限授予是两件事:前者是安装前的静态检查,后者是运行时的动态授权。3.2 会展开权限模型,这里只需要记住:安装时先算缺口,不要等运行时抛错。

declaredCapabilityGaps 返回「声明了但宿主没有」的能力。缺口非空时,宿主应该阻止安装或在商店里标注「当前端不可用」。

把协议写成代码

下面这个模块把清单校验、版本比较、兼容判定与能力审计都做成纯函数。它不依赖任何宿主框架,可以同时跑在安装器、构建脚本和测试里。

// file: src/plugin/plugin-protocol.ts
// tier: L1
// verify: node --test test/plugin/plugin-protocol.test.ts

export type Capability =
  | "camera"
  | "location"
  | "network"
  | "storage"
  | "clipboard"
  | "analytics";

export type ApiVersion = { major: number; minor: number; patch: number };

export type PluginManifest = {
  id: string;
  name: string;
  version: string;
  hostApi: { min: string; max: string };
  capabilities: Capability[];
};

export type ManifestResult =
  | { ok: true; manifest: PluginManifest; warnings: string[] }
  | { ok: false; errors: string[] };

export type Compatibility =
  | { status: "compatible"; hostVersion: string }
  | { status: "host-too-old"; required: string; hostVersion: string }
  | { status: "host-too-new"; supported: string; hostVersion: string }
  | { status: "invalid"; reason: string };

export const KNOWN_CAPABILITIES: Capability[] = [
  "camera",
  "location",
  "network",
  "storage",
  "clipboard",
  "analytics",
];

const ID_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;

export function parseApiVersion(input: string): ApiVersion | undefined {
  const match = SEMVER_PATTERN.exec(input);
  if (!match) return undefined;
  return {
    major: Number(match[1]),
    minor: Number(match[2]),
    patch: Number(match[3]),
  };
}

export function compareApiVersion(a: ApiVersion, b: ApiVersion): number {
  if (a.major !== b.major) return a.major < b.major ? -1 : 1;
  if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
  if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
  return 0;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function validateManifest(raw: unknown): ManifestResult {
  const errors: string[] = [];
  const warnings: string[] = [];
  if (!isRecord(raw)) return { ok: false, errors: ["清单必须是对象"] };

  const id = raw.id;
  if (typeof id !== "string" || !ID_PATTERN.test(id)) {
    errors.push("id 必须是反向域名格式,例如 com.acme.notes");
  }
  const name = raw.name;
  if (typeof name !== "string" || name.trim() === "") {
    errors.push("name 不能为空");
  }
  const version = raw.version;
  if (typeof version !== "string" || !SEMVER_PATTERN.test(version)) {
    errors.push("version 必须是 x.y.z 形式的语义化版本");
  }

  const hostApi = raw.hostApi;
  let min: ApiVersion | undefined;
  let max: ApiVersion | undefined;
  if (!isRecord(hostApi)) {
    errors.push("hostApi 必须包含 min 与 max");
  } else {
    if (typeof hostApi.min !== "string") {
      errors.push("hostApi.min 必须是字符串");
    } else {
      min = parseApiVersion(hostApi.min);
      if (!min) errors.push("hostApi.min 不是合法版本");
    }
    if (typeof hostApi.max !== "string") {
      errors.push("hostApi.max 必须是字符串");
    } else {
      max = parseApiVersion(hostApi.max);
      if (!max) errors.push("hostApi.max 不是合法版本");
    }
  }
  if (min && max && compareApiVersion(min, max) > 0) {
    errors.push("hostApi.min 不能大于 hostApi.max");
  }
  if (min && max && min.major !== max.major) {
    warnings.push("宿主 API 区间跨主版本,插件需要分别验证");
  }

  const capabilities: Capability[] = [];
  const rawCapabilities = raw.capabilities;
  if (!Array.isArray(rawCapabilities)) {
    errors.push("capabilities 必须是数组");
  } else {
    for (const item of rawCapabilities) {
      if (typeof item !== "string" || !KNOWN_CAPABILITIES.includes(item as Capability)) {
        errors.push(`未知能力: ${String(item)}`);
        continue;
      }
      const capability = item as Capability;
      if (capabilities.includes(capability)) {
        warnings.push(`能力重复声明: ${capability}`);
        continue;
      }
      capabilities.push(capability);
    }
  }

  if (errors.length > 0) return { ok: false, errors };
  const hostRange = hostApi as { min: string; max: string };
  return {
    ok: true,
    manifest: {
      id: id as string,
      name: (name as string).trim(),
      version: version as string,
      hostApi: { min: hostRange.min, max: hostRange.max },
      capabilities,
    },
    warnings,
  };
}

export function checkCompatibility(
  manifest: PluginManifest,
  hostVersion: string,
): Compatibility {
  const host = parseApiVersion(hostVersion);
  if (!host) return { status: "invalid", reason: "宿主版本不是合法语义化版本" };
  const min = parseApiVersion(manifest.hostApi.min);
  const max = parseApiVersion(manifest.hostApi.max);
  if (!min || !max) {
    return { status: "invalid", reason: "插件声明的宿主版本区间非法" };
  }
  if (compareApiVersion(host, min) < 0) {
    return {
      status: "host-too-old",
      required: manifest.hostApi.min,
      hostVersion,
    };
  }
  if (compareApiVersion(host, max) > 0) {
    return {
      status: "host-too-new",
      supported: manifest.hostApi.max,
      hostVersion,
    };
  }
  return { status: "compatible", hostVersion };
}

export function declaredCapabilityGaps(
  manifest: PluginManifest,
  hostCapabilities: Capability[],
): Capability[] {
  return manifest.capabilities.filter(
    (capability) => !hostCapabilities.includes(capability),
  );
}

export type CapabilityAudit = { undeclared: Capability[]; unused: Capability[] };

export function auditCapabilities(
  manifest: PluginManifest,
  used: Capability[],
): CapabilityAudit {
  const undeclared = used.filter(
    (capability) => !manifest.capabilities.includes(capability),
  );
  const unused = manifest.capabilities.filter(
    (capability) => !used.includes(capability),
  );
  return { undeclared, unused };
}

export type ConcernOwner = "host" | "plugin" | "shared" | "unknown";

const CONCERN_OWNERS: Record<string, ConcernOwner> = {
  "lifecycle": "host",
  "route-registration": "host",
  "permission-prompt": "host",
  "crash-report": "host",
  "business-ui": "plugin",
  "data-transform": "plugin",
  "api-client": "shared",
  "storage-schema": "shared",
};

export function ownerOf(concern: string): ConcernOwner {
  return CONCERN_OWNERS[concern] ?? "unknown";
}

代码里有三个刻意设计:

  1. validateManifest 接收 unknown,因为它处理的是外部输入,不能假设调用方已经校验过。
  2. 重复能力只是警告,不阻断安装;未知能力是错误,因为它意味着宿主无法兑现。
  3. auditCapabilities 同时返回未声明与未使用,前者是越权,后者是最小授权可以裁剪的空间。

判据与反例

判断一份清单是否合格,不看字段是否填满,而看四个判据:

  • 唯一性:id 全局唯一,且与发布者归属一致。
  • 可比较:version 与 hostApi 都能被解析和排序,不依赖字符串比较。
  • 可拒绝:宿主能在安装前算出 host-too-old、host-too-new 或能力缺口。
  • 可收敛:区间有明确上下界,不会出现「永远兼容」的清单。

两个反例:

第一个反例是把 max 写成 99.0.0,表示「永远兼容」。这不是兼容性声明,而是放弃声明。宿主升级后如果出现破坏性变更,插件会在最坏的时候失败。

第二个反例是用插件版本号代替宿主 API 区间。插件从 1.0 升到 2.0,不代表宿主也要升到 2.0;两个版本轴必须分开。

责任边界:宿主必须自己拿住的东西

插件化的常见误解是「把功能都交给插件,宿主只做容器」。容器也要负责容器该负责的事:

关注点归属理由
生命周期宿主插件不能决定自己何时被卸载
路由注册宿主路由冲突需要全局裁决
权限弹窗宿主授权界面不能被插件伪造
崩溃上报宿主插件崩溃时可能已无法上报
业务 UI插件这是插件存在的价值
数据转换插件属于业务逻辑
API 客户端共享协议由宿主定,调用由插件发起
存储 schema共享写入方是插件,迁移责任在宿主

ownerOf 把这些归属写成可查询的表。它不是教条,而是评审时的默认答案:如果某个关注点被放到了错误的一侧,评审至少要给出理由。

一次清单事故的复盘

回到开头的白屏。复盘时发现,插件作者在本地用的是宿主 4.2,清单里写了 hostApi: "4",宿主解析器把 "4" 当成非法版本,走了「宽松模式」直接放行。于是三个端的行为不一致:Web 拒绝安装,桌面放行后崩溃,移动端根本没读这个字段。

修复分三步:

  1. 清单校验失败即拒绝,不再有「宽松模式」。
  2. 宿主版本与 API 版本分离,hostApi 只描述 API 区间。
  3. 商店把 host-too-old 与 host-too-new 展示成不同的提示。

事故的直接原因是解析器,但根因是协议允许了模糊表达。只要协议允许模糊,实现就一定会分歧。

小结

插件协议的本质是把「谁能做什么」变成可校验的数据。

  • 清单的四个核心字段是身份、插件版本、宿主 API 区间与能力声明,缺一不可。
  • 宿主 API 用闭区间表达,两端都兼容;「太旧」和「太新」要分开处理。
  • 能力声明描述需求,安装前算缺口,不要等运行时抛错。
  • 生命周期、路由、权限弹窗、崩溃上报必须留在宿主,业务 UI 留给插件。

协议先于插件。没有协议的插件市场,只是一个更大的单体。

阅读导航:上一节:2.3 多品牌与多业务复用 · 下一节:3.2 沙箱、权限与失败隔离 。

常见错误与边界

  1. 把 hostApi 写成单点版本。 单点版本无法表达兼容范围,宿主升级一次就要改一次清单。
  2. 用字符串比较版本号。 "1.10.0" < "1.9.0" 在字符串里成立,在语义化版本里不成立。
  3. 把能力声明当权限。 声明只解决安装前检查,运行时仍需 3.2 的授权模型。
  4. 重复能力当错误。 重复通常来自合并清单,警告并去重即可,阻断安装收益很低。
  5. 让插件注册路由。 两个插件抢同一路径时,宿主必须有裁决权,否则行为取决于加载顺序。
  6. 跨主版本的宿主区间不给警告。 跨主版本意味着插件要在两套 API 上验证,不能静默通过。
  7. 忽略未使用的能力声明。 未使用能力是最小授权的裁剪信号,长期不清理会扩大攻击面。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查