3.2 PAL 平台抽象层
上一节我们把子系统编好了序,但子系统里干的活(读文件、取时间、开线程、写日志)最终都要落到某个具体宿主上。这一节处理的就是「宿主」这层:内核绝不直接 import 任何平台 API,而是只依赖一个接口 IPlatform。这个接口就是 PAL——Platform Abstraction Layer,平台抽象层。
为什么内核不能直接 import node:fs
先看反例。假设资源加载器里直接写:
// snippet: 反面写法,本节不采用
import { readFileSync } from 'node:fs';
const data = readFileSync('assets/isle.mesh', 'utf8');
这在 Node 里跑得好好的,一到浏览器就整条 import 链炸掉——node:fs 不存在。反过来,如果写成 await fetch('assets/isle.mesh'),浏览器没问题,Node 命令行工具(离线烘焙、CI 校验)又要另写一套。
PAL 的解法是:把「宿主能做什么」抽成接口,内核只依赖接口。内核代码里出现的是 p.file.readText(path),至于这个 p 在 Node 上是 node:fs、在浏览器上是 fetch,内核完全不知道。换平台 = 换一个 IPlatform 实现,内核一行不改。
对《微光岛》来说,这条边界直接决定了两件事能不能共用一套逻辑:浏览器里跑的游戏本体,和 Node 里跑的离线资源烘焙工具。
IPlatform:四个能力面
PAL 只暴露内核真正需要的最小面:时间、文件、线程、日志。面越窄,实现一个新平台就越便宜。下面的文件还带了两个与平台无关的辅助函数——注意 measureMillis 的参数类型是 IPlatform,这就是「同一段代码在所有平台通用」的写法。
下面 src/core/platform.ts 层级 L1,完整可编译。
// file: src/core/platform.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
//
// helio 内核:平台抽象层 PAL。内核其余部分只依赖 IPlatform,
// 不直接 import node:fs / window / performance。
// 换平台 = 换一个 IPlatform 实现,调用方代码一行不动。
export interface PlatformTime {
/** 单调时钟,单位秒。用于帧计时、超时,不用于日历。 */
nowSeconds(): number;
/** 单调时钟,单位毫秒。 */
nowMillis(): number;
}
export interface PlatformFile {
readText(path: string): string;
writeText(path: string, data: string): void;
exists(path: string): boolean;
}
export interface PlatformThread {
readonly hardwareConcurrency: number;
/** 在另一条线程上跑 task,返回其完成 Promise。 */
spawn(task: () => void): Promise<void>;
}
export interface PlatformLog {
write(level: string, category: string, message: string): void;
}
/** 内核与宿主环境的唯一契约面。 */
export interface IPlatform {
readonly name: string;
readonly time: PlatformTime;
readonly file: PlatformFile;
readonly thread: PlatformThread;
readonly log: PlatformLog;
}
/**
* 与平台无关的代码:只吃 IPlatform。
* 这段函数在 Node 与浏览器上是同一份源码。
*/
export function measureMillis(p: IPlatform, fn: () => void): number {
const t0 = p.time.nowMillis();
fn();
const t1 = p.time.nowMillis();
return t1 - t0;
}
/** 内核启动横幅:展示平台名与可用并发度。 */
export function describePlatform(p: IPlatform): string {
return `${p.name}: ${p.thread.hardwareConcurrency} hw threads`;
}
时间用单调时钟(nowMillis),不是 Date.now()——单调时钟不受系统时间被改影响,帧计时才可信。文件接口只给同步文本读写,够内核启动期加载配置用;异步流式加载留给渲染卷。
NodePlatform:Node 上的实现
Node 实现就是把四个面分别接到 node:perf_hooks、node:fs、node:os 与 process.stdout。下面 src/core/node-platform.ts 层级 L1。
// file: src/core/node-platform.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
//
// NodePlatform:IPlatform 的 Node 实现,用于命令行工具、离线烘焙与单测。
// 时间走 node:perf_hooks,文件走 node:fs,并发度走 node:os。
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { availableParallelism } from 'node:os';
import type {
IPlatform,
PlatformFile,
PlatformLog,
PlatformThread,
PlatformTime,
} from './platform.ts';
const nodeTime: PlatformTime = {
nowSeconds: () => performance.now() / 1000,
nowMillis: () => performance.now(),
};
const nodeFile: PlatformFile = {
readText: (path) => readFileSync(path, 'utf8'),
writeText: (path, data) => writeFileSync(path, data, 'utf8'),
exists: (path) => existsSync(path),
};
const nodeThread: PlatformThread = {
hardwareConcurrency: availableParallelism(),
// 真正的并行要 worker_threads;这里用微任务队列模拟「交给调度器」。
spawn: (task) => Promise.resolve().then(task),
};
const nodeLog: PlatformLog = {
write: (level, category, message) => {
process.stdout.write(`[${level}][${category}] ${message}\n`);
},
};
export const NodePlatform: IPlatform = {
name: 'node',
time: nodeTime,
file: nodeFile,
thread: nodeThread,
log: nodeLog,
};
本工程没装 @types/node(tsconfig 的 types 只列了 @webgpu/types),所以还得补一个只声明用到的宿主 API 的类型垫片,让 tsc 能过。真实工程直接 npm i -D @types/node 即可,不需要这个文件。src/core/node-shims.d.ts 层级 L3(仅类型)。
// file: src/core/node-shims.d.ts
// tier: L3
//
// 本工程未安装 @types/node,这里只声明 NodePlatform 用到的宿主 API。
// 真实工程 `npm i -D @types/node` 后本文件可删。
declare module 'node:fs' {
export function readFileSync(path: string, encoding: 'utf8'): string;
export function writeFileSync(path: string, data: string, encoding?: 'utf8'): void;
export function existsSync(path: string): boolean;
export function mkdtempSync(prefix: string): string;
}
declare module 'node:os' {
export function availableParallelism(): number;
export function tmpdir(): string;
}
declare module 'node:path' {
export function join(...parts: string[]): string;
}
declare module 'node:perf_hooks' {
export const performance: { now(): number };
}
declare const process: {
readonly stdout: { write(chunk: string): boolean };
};
WebPlatform 的差异
浏览器上同样实现 IPlatform,但每个面的底层 API 都不同,差异不是「换个函数名」那么小:
| 能力面 | Node 实现 | 浏览器 WebPlatform |
|---|---|---|
| 时间 | performance.now()(node:perf_hooks) | performance.now()(全局,语义一致) |
| 文件 | node:fs 同步读写 | 无同步文件系统;改走 fetch + Cache Storage / IndexedDB |
| 线程 | worker_threads | Web Worker;主线程上 spawn 常退化成 queueMicrotask |
| 日志 | process.stdout | console.*,或转发到远端日志端点 |
关键差异是文件:浏览器没有同步文件读取,所以 PlatformFile.readText 的同步签名在浏览器上无法诚实实现。工程上的两种做法:(1) 把 PlatformFile 改成全异步(Promise<string>),代价是内核启动流程也要异步化;(2) 保持同步签名,但 WebPlatform 在启动前把所有必需资源预取进内存,readText 只查内存表。helio 选 (2):内核启动路径保持同步、可预测,异步加载被挡在 PAL 之外。
WebPlatform 是 L4(命令行不可复现)——它依赖 fetch、Web Worker 等浏览器全局,本节的 Node 环境跑不了,需在浏览器里手动验证,这里只给差异说明,不给可运行代码。
可运行产物:同一段代码在 PAL 上读时间戳
下面这个测试文件做了两件事:用 measureMillis(它只认 IPlatform)在 NodePlatform 上量一段计算耗时;以及验证文件接口能读回刚写入的内容。层级 L1,命令 npx vitest run src/core/platform.test.ts。
// file: src/core/platform.test.ts
// tier: L1
// verify: npx vitest run src/core/platform.test.ts
import { describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describePlatform, measureMillis } from './platform.ts';
import { NodePlatform } from './node-platform.ts';
describe('PAL on NodePlatform', () => {
it('同一段 measureMillis 代码在 PAL 上读时间戳', () => {
const elapsed = measureMillis(NodePlatform, () => {
let acc = 0;
for (let i = 0; i < 1000; i++) acc += i;
expect(acc).toBe(499500);
});
expect(elapsed).toBeGreaterThanOrEqual(0);
});
it('nowMillis 单调不减', () => {
const a = NodePlatform.time.nowMillis();
const b = NodePlatform.time.nowMillis();
expect(b).toBeGreaterThanOrEqual(a);
});
it('文件接口读回写入的内容', () => {
const dir = mkdtempSync(join(tmpdir(), 'helio-'));
const path = join(dir, 'cvar.cfg');
NodePlatform.file.writeText(path, 'r.vsync=true\n');
expect(NodePlatform.file.exists(path)).toBe(true);
expect(NodePlatform.file.readText(path)).toBe('r.vsync=true\n');
});
it('describePlatform 汇总平台信息', () => {
expect(describePlatform(NodePlatform)).toMatch(/^node: \d+ hw threads$/);
});
it('spawn 异步跑完 task', async () => {
let ran = false;
await NodePlatform.thread.spawn(() => {
ran = true;
});
expect(ran).toBe(true);
});
});
本机实跑:Test Files 4 passed、Tests 26 passed(本节贡献其中 5 条),tsc --noEmit 零错误。注意第一条用例里 measureMillis 完全不知道自己在 Node 上——它只吃 IPlatform,这正是 PAL 存在的意义。
小结
- PAL 把宿主能力收敛成
IPlatform一个接口,内核只依赖它,不碰任何平台 API。 - NodePlatform 是真实实现(
node:fs/node:perf_hooks/node:os),浏览器 WebPlatform 的最大差异在文件——没有同步读,helio 用「启动前预取进内存」保持同步签名。 - 平台无关的代码(如
measureMillis)在所有平台上只有一份源码。
下一节补齐内核的三件「基础设施」:断言、日志分级与配置系统 CVar。
阅读导航:上一节:3.1 子系统注册、启动顺序与逆序关闭 · 下一节:3.3 断言、日志分级与配置系统 CVar 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。