5.3 插件系统与引擎版本 / API 稳定性
前两节把引擎内部的边界和裁剪管住了,这一节要引入一个「外部变量」:插件。一旦引擎允许别人写代码挂进来,兼容性就不再是你自己的事——你每一次改内部结构,都可能让某个第三方插件在用户机器上崩掉。
所以插件系统的核心不是「怎么动态加载」,而是一套版本契约:插件声明它需要哪个引擎版本、依赖哪个接口版本;引擎在加载时逐条核对,不满足就明确拒绝,绝不允许「半加载」——那种「插件的 setup 跑了一半抛异常」的状态最难排查。
这也是它和上一节「特性开关」的内在联系:开关解决的是编译期「哪些代码进包」,插件解决的是运行期「哪些外部代码被允许挂进来」。两者都在回答同一个工程问题——什么东西可以进入这个引擎,以及进入的条件是什么。区别只是审查发生的时间点。
先把这一节的范围说清楚。站内 helio-subsys-08-1 编辑器与运行时的关系
讲过扩展在编辑器侧的组织方式;本节不讲编辑器,只做「插件宿主 + 版本协商」这一件事,并且把兼容性规则做成可单测的纯函数。
插件接口:能力注入,不是代码共享
helio 的插件拿到的不是引擎内部的模块引用,而是一个受限的上下文对象。插件只能通过 ctx.register() 声明自己提供了什么扩展点,拿不到内核的任何可变状态。
这个设计有三条硬理由:
- 隔离:插件碰不到 ECS 的 archetype 存储、渲染器的命令队列,就不可能破坏引擎的内部不变量。它能做的只有「注册一个扩展」,剩下的接入由引擎自己完成。
- 可裁剪:上一节的构建期开关能摇掉整个插件系统——如果插件直接
import内核模块,这条依赖边会让内核被迫保留为插件服务的代码。 - 可测:上下文是个纯数据对象,单测里传一个假的进去就能验证插件行为,不需要真的起一个引擎。
和它对应的是站内 helio-subsys-07-3 模块替换与状态迁移
那节讲的「模块替换」——那里关注的是替换前后状态怎么搬,本节关注的是替换之前资格怎么审。两件事互补:先过版本闸门,才轮到状态迁移。
能力边界的划法可以概括成一张很短的清单:
- 插件可以:注册一个扩展点名字、声明它要监听的事件、提供一份配置默认值。
- 插件不可以:直接读写 ECS 世界、直接调用渲染命令、持有内核对象的裸引用。
- 引擎负责:把注册进来的扩展点接到真实的渲染/更新流程上,并决定它们在什么时机被调用。
这条边界的判据是:插件描述的是「我想要什么」,引擎决定的是「怎么做到」。一旦插件开始描述「怎么做到」,它就和引擎的内部实现绑死了,版本升级必然把它弄坏。
版本协商:engineRange 与 apiVersion 双闸门
一个插件在 load 时要通过两道独立的检查,任何一道不过就整体拒绝:
| 闸门 | 字段 | 语义 | 不通过的典型场景 |
|---|---|---|---|
| 引擎版本 | engineRange(如 ^1.4.0) | 宿主引擎版本必须落在这个语义化版本范围内 | 插件为 2.0 写的,装在 1.4 的引擎上 |
| 接口版本 | apiVersion(整数) | 必须与宿主的接口版本完全相等 | 插件编译时接口是 v2,宿主已经升到 v3 |
为什么接口版本用「完全相等」而不是范围?因为它描述的是函数签名,不是行为。engineRange 允许范围,是因为引擎的次版本升级通常保持行为兼容,插件跑在新引擎上大概率没事;但 PluginContext 的方法签名一旦变(比如 register 多了一个参数),旧插件就是编译不过的,没有「大概率」可言。把接口版本做成整数而不是语义化版本,就是为了逼你「要变就一起变,别偷偷兼容」。
两道闸门分别挡住三类最常见的插件事故:
- 装错引擎:插件文档写着「需要 1.4+」,玩家却在一个 1.2 的旧引擎上加载它。
engineRange在加载瞬间就挡住,不会等到某个 API 返回undefined才崩。 - 接口漂移:引擎内部把
register从「注册扩展点」改成「注册扩展点 + 优先级」,签名变了。如果只看引擎版本,旧插件可能刚好落在范围内蒙混过关;apiVersion精确匹配会把它们全部拦下。 - 重复注册:同一个插件被两条路径各加载一次(比如手动 + 配置各一份)。这类错误如果不在宿主层拦截,就会变成「扩展点被执行两遍」的诡异 bug,且极难定位。
三类的共同点是:越早失败越好。插件系统最怕的不是加载失败,而是「看起来加载成功了,但在某个特定场景下才崩」——那种 bug 往往要等到玩家报告才被发现。
拒绝时的可用性
明确拒绝之后,还有一件常被忽略的事:拒绝信息要能指导修复。LoadResult.reason 不是给日志看的,是给插件作者看的。引擎 1.4.2 不满足 ^2.0.0 这一句,作者一眼就知道该把自己的 engineRange 改回 ^1.4.0 或者干脆为 2.0 重新编译;而如果只返回一个 false,作者就得反过来读引擎源码才知道发生了什么。
所以宿主的返回值设计成三段:id(是谁)、status(哪类失败)、reason(为什么)。status 是给程序分支用的枚举值,reason 是给人读的句子,两者都要有。
一个插件的声明长这样,两个字段就是它的「准入申请」:
// snippet: 插件清单里的两个兼容性字段
{
"id": "shadow-quality",
"engineRange": "^1.4.0",
"apiVersion": 3
}
版本范围的匹配规则和 npm 一致,本节实现其中最常用的四种:
| 写法 | 含义 | 1.4.2 满足? |
|---|---|---|
^1.4.0 | >=1.4.0 且同主版本 | 是 |
~1.4.0 | >=1.4.0 且同主次版本 | 是 |
>=1.0.0 | 不小于该版本即可 | 是 |
1.4.0 / ==1.4.0 | 精确匹配 | 否 |
可运行产物:插件宿主的加载判定
宿主的全部逻辑都在这个文件里:解析语义化版本、匹配范围、按顺序核对四类结果(加载成功、引擎不匹配、接口不匹配、重复 id)。注意 load 的返回值永远是一个可读的 LoadResult,而不是抛异常——插件加载失败是预期内的事,调用方需要能逐个处理。
三处判定的顺序不是随便排的。duplicate 放最前面,因为一个已经加载过的插件再进来时,报「重复」比报「版本不匹配」更贴近事实——哪怕它这次真的版本不匹配,调用方首先该知道的是「这个 id 已经在里面了」。接着是 engine-mismatch 再是 api-mismatch:先核对更粗粒度的引擎版本,再核对更细的接口版本,这样拒绝信息从「大范围不匹配」到「小范围不匹配」逐级收敛,作者读起来也是一层层排除。
// file: src/core/plugin-host.ts
// tier: L1
// verify: npx vitest run src/core/plugin-host.test.ts
//
// helio 工程化:插件宿主。插件在加载时声明两条兼容性约束:
// - engineRange:宿主引擎版本必须落在的语义化版本范围;
// - apiVersion:插件接口的整数版本,必须与宿主完全相等。
// 两条都过才允许 setup;否则给出可读的拒绝原因,绝不半加载。
export interface Semver {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
export function parseSemver(text: string): Semver | null {
const m = SEMVER_RE.exec(text);
if (m === null) return null;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
}
/** 逐段比较:a<b 返回 -1,a>b 返回 1,相等返回 0。 */
export function compareSemver(a: Semver, b: Semver): number {
for (const k of ['major', 'minor', 'patch'] as const) {
if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
}
return 0;
}
/** 支持 `^1.4.0`、`~1.4.0`、`>=1.4.0`、`==1.4.0` 与裸 `1.4.0` 五种写法。 */
export function satisfiesRange(range: string, version: string): boolean {
const v = parseSemver(version);
if (v === null) return false;
const op = range.startsWith('^') || range.startsWith('~') ? range[0]
: range.startsWith('>=') ? '>='
: range.startsWith('==') ? '=='
: '==';
const base = parseSemver(range.replace(/^(\^|~|>=|==)/, ''));
if (base === null) return false;
if (op === '>=') return compareSemver(v, base) >= 0;
if (op === '==') return compareSemver(v, base) === 0;
if (op === '~') {
return compareSemver(v, base) >= 0 && v.major === base.major && v.minor === base.minor;
}
// '^':>= base 且同主版本
return compareSemver(v, base) >= 0 && v.major === base.major;
}
export interface PluginContext {
readonly engineVersion: string;
readonly apiVersion: number;
register(extension: string): void;
}
export interface Plugin {
readonly id: string;
readonly engineRange: string;
readonly apiVersion: number;
readonly setup: (ctx: PluginContext) => void;
}
export type PluginStatus = 'loaded' | 'engine-mismatch' | 'api-mismatch' | 'duplicate';
export interface LoadResult {
readonly id: string;
readonly status: PluginStatus;
readonly reason: string;
}
export interface HostOptions {
readonly engineVersion: string;
readonly apiVersion: number;
}
export interface Host {
load(plugin: Plugin): LoadResult;
readonly loaded: readonly string[];
readonly extensions: readonly string[];
}
export function createPluginHost(opts: HostOptions): Host {
const loaded: string[] = [];
const extensions: string[] = [];
return {
load(plugin: Plugin): LoadResult {
if (loaded.includes(plugin.id)) {
return { id: plugin.id, status: 'duplicate', reason: `插件 ${plugin.id} 已加载` };
}
if (!satisfiesRange(plugin.engineRange, opts.engineVersion)) {
return {
id: plugin.id,
status: 'engine-mismatch',
reason: `引擎 ${opts.engineVersion} 不满足 ${plugin.engineRange}`,
};
}
if (plugin.apiVersion !== opts.apiVersion) {
return {
id: plugin.id,
status: 'api-mismatch',
reason: `插件接口版本 ${plugin.apiVersion} != 宿主 ${opts.apiVersion}`,
};
}
plugin.setup({
engineVersion: opts.engineVersion,
apiVersion: opts.apiVersion,
register: (extension) => { extensions.push(extension); },
});
loaded.push(plugin.id);
return { id: plugin.id, status: 'loaded', reason: 'ok' };
},
get loaded() { return loaded; },
get extensions() { return extensions; },
};
}
把四个插件依次丢给一个 1.4.2 / 接口 v3 的宿主,本机实跑结果:
loaded shadow-quality ok
engine-mismatch future-water 引擎 1.4.2 不满足 ^2.0.0
api-mismatch legacy-tools 插件接口版本 2 != 宿主 3
duplicate shadow-quality 插件 shadow-quality 已加载
loaded: shadow-quality
extensions: shadow-quality:hook
四种结果各出现一次:正常的加载成功并注册了扩展;为 2.0 写的插件被引擎版本挡住;接口版本对不上的旧插件被挡住;同名插件第二次加载被判重复。关键是后三个都发生在 setup 之前——future-water 和 legacy-tools 的 setup 一行都没执行,所以不存在「加载到一半的脏状态」。
配套的单测覆盖了版本解析、逐段比较、四种范围的匹配,以及四种加载结果,共 8 条断言,本机 npx vitest run src/core/plugin-host.test.ts 全过。其中两条最能说明「闸门在 setup 之前生效」:
// snippet: src/core/plugin-host.test.ts 的加载判定用例
it('兼容插件被加载并注册扩展', () => {
const host = createPluginHost({ engineVersion: ENGINE, apiVersion: API });
const r = host.load(makePlugin('shadow-quality', '^1.4.0', API));
expect(r.status).toBe('loaded');
expect(host.extensions).toEqual(['shadow-quality:hook']);
});
it('引擎版本不满足范围时拒绝', () => {
const host = createPluginHost({ engineVersion: ENGINE, apiVersion: API });
expect(host.load(makePlugin('future', '^2.0.0', API)).status).toBe('engine-mismatch');
expect(host.loaded).toEqual([]);
});
第二条断言里的 host.loaded 为空是关键:被拒绝的插件没有留下任何痕迹。工程上这一点比「能加载」更重要——一个加载失败的插件如果污染了宿主状态,排查成本会成倍上升。
语义化版本与兼容策略
有了闸门,还得有「什么时候该动哪个版本位」的规矩,否则闸门只会变成随机拦截。helio 跟随语义化版本的三段含义,并把它翻译成引擎场景里的具体动作:
| 改动 | 版本位 | 例子 | 插件需要做什么 |
|---|---|---|---|
| 修 bug、内部重构 | patch +0.0.1 | 修正某个剔除边界判断 | 无需改动,直接兼容 |
| 新增可选能力 | minor +0.1.0 | 给 PluginContext 加一个新方法 | 老插件照常跑;想用新能力再升 |
| 删除或改签名 | major +1.0.0 | 改 register 的参数 | 必须重新编译,否则被 apiVersion 挡住 |
再加一条实操规则:接口版本 apiVersion 只在 major 破坏性变更时才 +1,patch 和 minor 一律不动它。这样插件作者只需要盯一个整数,而不是去猜「这次 minor 升级会不会动到我的签名」。把「行为兼容」交给 engineRange 的范围匹配,把「签名兼容」交给 apiVersion 的精确匹配,两件事各管一段,就不会互相污染。
破坏性变更也不是说删就删,工程上要留一个弃用窗口:先在新版本里把旧接口标成 deprecated 但仍然可用,同时把 apiVersion 保持不动;等到下一个 major 再真正删掉、apiVersion 才 +1。这样第三方插件有一整个 minor 周期去适配,而不是某天醒来发现引擎拒绝加载它。窗口的长度取决于生态规模——helio 这种教学引擎取「一个小版本」,商业引擎通常取「一到两年」。
最后提醒一点:本节是 L1 纯逻辑,可以命令行复现;但真实插件加载还涉及跨进程或动态 import 的宿主环境(沙箱权限、模块缓存、加载失败回滚),那部分需要在真实运行时里手动验证,命令行不可复现。本节能保证的是「版本判定」这一层的正确性——它是插件系统里唯一能被完全形式化、也因此最值得先做扎实的部分。
本章小结
三节合起来是一条完整的「准入链」:5.1 用依赖方向检查器管住内部模块能不能互相引用,5.2 用构建期开关管住可选模块能不能进包,5.3 用版本闸门管住外部插件能不能挂进来。三者都是「把一条本该由人记住的规矩,变成一条会自己执行的检查」,这也是整卷「工程与实战」反复回到的主线——前三卷把引擎做出来,卷四让它能被别人安全地使用和扩展。
下一章我们离开「代码结构」,转向运行时的可见性:怎么在画面上把调试信息直接画出来。
阅读导航:上一节:5.2 特性开关与死代码消除 · 下一节:6.1 调试绘制与运行时可视化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。