3.3 断言、日志分级与配置系统 CVar
内核代码里到处是「这个指针不该为空」「这个索引不该越界」这类假设。把假设写成注释,出错时没人看得见;写成 if (!x) console.log('oops'),发布版里又白白拖慢热路径。这一节把内核的三种「运行时反馈」从零做出来:断言、日志、配置。
断言与日志:两档行为 + 五级过滤
assert 与 check 的分工是本节的核心设计:assert 只在 debug 构建生效,release 下直接返回不抛错——它是给开发者的护栏;check 在所有构建档都生效,用来兜住「真出了就是 bug」的硬约束。注意一个 TypeScript 特有的坑:C++ 的 assert 是宏,release 下连条件都不求值;TS 没有宏,condition 是普通实参,调用点一定会先把它求值一次,想让昂贵表达式在 release 下彻底不跑,得自己用 if 包起来。
日志侧实现五级 trace/debug/info/warn/error,并且是双重过滤:低于最低级别的丢弃,命中被静音分类的也丢弃。下面 src/core/diagnostics.ts 层级 L1,完整可编译。
// file: src/core/diagnostics.ts
// tier: L1
// verify: npx vitest run src/core/diagnostics.test.ts
//
// helio 内核:断言 + 日志分级。
// assert 只在 debug 生效,check 永远生效;日志五级,按最低级别与分类双重过滤。
export type BuildMode = 'debug' | 'release';
let buildMode: BuildMode = 'debug';
export function setBuildMode(mode: BuildMode): void {
buildMode = mode;
}
export class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = 'AssertionError';
}
}
/**
* debug 专属断言;release 下直接返回不抛错。
* 注意:TS 无宏,condition 是普通实参,调用点仍会求值它一次。
*/
export function assert(condition: boolean, message: string): void {
if (buildMode === 'debug' && !condition) throw new AssertionError(message);
}
/** 所有构建档都生效的致命检查。 */
export function check(condition: boolean, message: string): void {
if (!condition) throw new AssertionError(message);
}
/** 日志级别表,数值越大越严重。 */
export const LogLevel = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 } as const;
export type LogLevelName = keyof typeof LogLevel;
export interface LogRecord {
level: LogLevelName;
category: string;
message: string;
}
export type LogSink = (record: LogRecord) => void;
/** 五级 + 分类过滤的日志器。 */
export class Logger {
#min: LogLevelName = 'info';
readonly #muted = new Set<string>();
readonly #sink: LogSink;
constructor(sink: LogSink) {
this.#sink = sink;
}
setMinLevel(level: LogLevelName): void {
this.#min = level;
}
/** 关掉某个分类(如 'asset')的全部日志。 */
mute(category: string): void {
this.#muted.add(category);
}
unmute(category: string): void {
this.#muted.delete(category);
}
#emit(level: LogLevelName, category: string, message: string): void {
if (LogLevel[level] < LogLevel[this.#min] || this.#muted.has(category)) return;
this.#sink({ level, category, message });
}
trace(category: string, message: string): void { this.#emit('trace', category, message); }
debug(category: string, message: string): void { this.#emit('debug', category, message); }
info(category: string, message: string): void { this.#emit('info', category, message); }
warn(category: string, message: string): void { this.#emit('warn', category, message); }
error(category: string, message: string): void { this.#emit('error', category, message); }
}
日志器不直接写 console,而是把 LogSink 注入进来:测试里换成收集数组,线上换成写文件或转发远端——这也是上一节 PAL 的 PlatformLog 能接进来的地方。
配置系统 CVar:类型化读写与命令行解析
CVar(Console Variable)是「能被命令行改的引擎参数」。它的价值在于类型:r.shadowQuality 是整数,r.vsync 是布尔,game.timeScale 是浮点。注册时就绑定类型,之后 getNumber / getBool 各取所需;类型对不上直接抛错,绝不静默返回错类型——这类静默错误在引擎里往往表现为「渲染莫名变黑」,极难查。
命令行解析支持 name=value 与 +name=value 两种写法,返回值分 applied / rejected 两组,非法项不会被悄悄吞掉。下面 src/core/cvar.ts 层级 L1。
// file: src/core/cvar.ts
// tier: L1
// verify: npx vitest run src/core/cvar.test.ts
//
// helio 内核:配置系统 CVar。带类型定义、类型化 get/set、命令行字符串解析。
export const CVarType = { bool: 'bool', int: 'int', float: 'float', string: 'string' } as const;
export type CVarTypeName = (typeof CVarType)[keyof typeof CVarType];
/** 命令行字符串 → 强类型值;解析失败返回 undefined(由调用方拒绝)。 */
export function parseCVarValue(
type: CVarTypeName,
raw: string,
): string | number | boolean | undefined {
if (type === CVarType.bool) {
if (raw === 'true' || raw === '1') return true;
if (raw === 'false' || raw === '0') return false;
return undefined;
}
if (type === CVarType.int) return /^[+-]?\d+$/.test(raw) ? Number.parseInt(raw, 10) : undefined;
if (type === CVarType.float) {
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
return raw;
}
export class CVarSystem {
readonly #types = new Map<string, CVarTypeName>();
readonly #values = new Map<string, string | number | boolean>();
register(name: string, type: CVarTypeName, def: string | number | boolean): void {
if (this.#types.has(name)) throw new Error(`cvar already registered: ${name}`);
if (parseCVarValue(type, String(def)) === undefined) {
throw new Error(`cvar ${name} default ${String(def)} does not match type ${type}`);
}
this.#types.set(name, type);
this.#values.set(name, def);
}
#type(name: string): CVarTypeName {
const t = this.#types.get(name);
if (t === undefined) throw new Error(`unknown cvar: ${name}`);
return t;
}
/** 类型化读取:类型不匹配直接抛错,绝不静默返回错类型。 */
getNumber(name: string): number {
const t = this.#type(name);
if (t !== CVarType.int && t !== CVarType.float) {
throw new Error(`cvar ${name} is ${t}, not numeric`);
}
return this.#values.get(name) as number;
}
getBool(name: string): boolean {
const t = this.#type(name);
if (t !== CVarType.bool) throw new Error(`cvar ${name} is ${t}, not bool`);
return this.#values.get(name) as boolean;
}
getString(name: string): string {
this.#type(name);
return String(this.#values.get(name));
}
/** 字符串赋值:类型解析失败直接拒绝并返回 false,旧值不变。 */
setFromString(name: string, raw: string): boolean {
const t = this.#types.get(name);
if (t === undefined) return false;
const parsed = parseCVarValue(t, raw);
if (parsed === undefined) return false;
this.#values.set(name, parsed);
return true;
}
/** 解析一行命令行,返回 applied / rejected 两组 cvar 名。 */
applyCommandLine(line: string): { applied: string[]; rejected: string[] } {
const applied: string[] = [];
const rejected: string[] = [];
for (const token of line.split(/\s+/).filter((t) => t.length > 0)) {
const bare = token.startsWith('+') ? token.slice(1) : token;
const eq = bare.indexOf('=');
if (eq < 0) continue;
const name = bare.slice(0, eq);
if (this.setFromString(name, bare.slice(eq + 1))) applied.push(name);
else rejected.push(token);
}
return { applied, rejected };
}
}
parseCVarValue 是唯一的「字符串 → 值」入口:register 校验默认值、setFromString 校验命令行输入、applyCommandLine 校验每个 token,三条路径共用一套解析。
可运行产物:真跑断言与过滤
src/core/diagnostics.test.ts 与 src/core/cvar.test.ts 分别验日志级别过滤生效、非法 CVar 值被拒且旧值保留,层级均为 L1。
// file: src/core/diagnostics.test.ts
// tier: L1
// verify: npx vitest run src/core/diagnostics.test.ts
import { afterEach, describe, expect, it } from 'vitest';
import { AssertionError, Logger, assert, check, setBuildMode } from './diagnostics.ts';
import type { LogRecord } from './diagnostics.ts';
afterEach(() => setBuildMode('debug'));
describe('assert vs check', () => {
it('debug 下 assert 失败抛 AssertionError', () => {
setBuildMode('debug');
expect(() => assert(1 + 1 === 3, 'math is broken')).toThrow(AssertionError);
});
it('release 下 assert 直接返回不抛错', () => {
setBuildMode('release');
expect(() => assert(false, 'skipped')).not.toThrow();
});
it('check 在任何构建档都生效', () => {
setBuildMode('release');
expect(() => check(false, 'always fatal')).toThrow(/always fatal/);
});
});
describe('Logger', () => {
function collect(): { records: LogRecord[]; sink: (r: LogRecord) => void } {
const records: LogRecord[] = [];
return { records, sink: (r) => records.push(r) };
}
it('默认 info 级别过滤掉 trace/debug', () => {
const { records, sink } = collect();
const log = new Logger(sink);
log.trace('core', 't');
log.debug('core', 'd');
log.info('core', 'i');
log.warn('core', 'w');
log.error('core', 'e');
expect(records.map((r) => r.level)).toEqual(['info', 'warn', 'error']);
});
it('分类 mute 后该分类完全静默', () => {
const { records, sink } = collect();
const log = new Logger(sink);
log.mute('asset');
log.error('asset', 'hidden');
log.error('render', 'shown');
expect(records.map((r) => r.category)).toEqual(['render']);
log.unmute('asset');
log.error('asset', 'back');
expect(records.at(-1)?.category).toBe('asset');
});
});
// file: src/core/cvar.test.ts
// tier: L1
// verify: npx vitest run src/core/cvar.test.ts
import { describe, expect, it } from 'vitest';
import { CVarSystem } from './cvar.ts';
function makeSystem(): CVarSystem {
const c = new CVarSystem();
c.register('r.vsync', 'bool', false);
c.register('r.shadowQuality', 'int', 2);
c.register('game.timeScale', 'float', 1.0);
c.register('game.project', 'string', 'glimmer-isle');
return c;
}
describe('CVarSystem', () => {
it('注册后读到默认值', () => {
const c = makeSystem();
expect(c.getBool('r.vsync')).toBe(false);
expect(c.getNumber('r.shadowQuality')).toBe(2);
expect(c.getString('game.project')).toBe('glimmer-isle');
});
it('非法值被拒绝,旧值保留', () => {
const c = makeSystem();
expect(c.setFromString('r.shadowQuality', '3')).toBe(true);
expect(c.setFromString('r.shadowQuality', 'ultra')).toBe(false);
expect(c.setFromString('r.shadowQuality', '2.5')).toBe(false);
expect(c.getNumber('r.shadowQuality')).toBe(3);
});
it('类型不匹配的 get 抛错', () => {
const c = makeSystem();
expect(() => c.getBool('r.shadowQuality')).toThrow(/is int, not bool/);
expect(() => c.getNumber('game.project')).toThrow(/not numeric/);
});
it('未知 cvar 读取抛错、赋值返回 false', () => {
const c = makeSystem();
expect(() => c.getNumber('nope')).toThrow(/unknown cvar/);
expect(c.setFromString('nope', '1')).toBe(false);
});
it('解析命令行并区分 applied / rejected', () => {
const c = makeSystem();
const r = c.applyCommandLine('r.vsync=true r.shadowQuality=4 game.timeScale=0.5 r.nope=1');
expect(r.applied).toEqual(['r.vsync', 'r.shadowQuality', 'game.timeScale']);
expect(r.rejected).toEqual(['r.nope=1']);
expect(c.getBool('r.vsync')).toBe(true);
expect(c.getNumber('game.timeScale')).toBeCloseTo(0.5);
});
});
本机实跑:Test Files 4 passed、Tests 22 passed(本节贡献 10 条:断言 3 + 日志 2 + CVar 5),tsc --noEmit 零错误。「非法值被拒绝,旧值保留」尤其关键——r.shadowQuality 设为 3 后,再喂 ultra 和 2.5 都被拒,读回来仍是 3,证明拒绝是原子的。
小结
assert分 debug/release 两档,check永远生效;TS 无宏,release 下实参仍会被求值一次。- 日志五级 + 分类静音双重过滤,输出走注入的
LogSink,可对接 PAL 的平台日志。 - CVar 注册时绑定类型,
get类型不匹配即抛错,set解析失败即拒绝且旧值不变;三件设施合起来就是内核的「可观测面」。
下一章进入内存:为什么引擎宁可自己管内存,也不交给 JS 垃圾回收。
阅读导航:上一节:3.2 PAL 平台抽象层 · 下一节:4.1 为什么要自己管内存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。