5.1 模块边界与依赖方向
前三卷把 helio 从零搭到了能跑渲染、动画、物理的程度,但那些代码都长在一棵没人看管的 src/ 里。卷四要做的第一件工程化的事,不是加功能,而是给这棵树立规矩:谁可以 import 谁,必须能被机器检查,而不是靠 review 时的自觉。
这一节只解决一个问题:把 src/ 里真实的 import 关系抽成一张有向图,然后自动回答两件事——有没有「向上依赖」,有没有「循环依赖」。两张答案都是 0,才有资格谈后面几章的裁剪与插件。
本章与站内既有内容的分工
「模块边界」这个题目,站内已经有人从不同角度写过。先把分工写清楚,避免重复:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
helio-kernel-01-1 引擎的分层与模块边界 | 用类型系统把「内核不得依赖渲染」钉在编译期 | 把约束从「手写清单」升级成「扫描真实 import 图」,并额外抓循环依赖 |
helio-render-01-2 渲染器的分层 | 渲染内部 RHI / 渲染器 / 场景三层的依赖方向 | 把同一套检查器推广到整棵 src/,是跨层检查而非渲染内部 |
/game-engine-architecture-ecs/ | 一篇文章讲清 ECS 引擎的骨架长什么样 | 不展开引擎架构,只做「源码级 import 图」这一个工程问题 |
一句话:别人讲「边界应该长什么样」,本节讲「怎么让一条命令在 CI 里替你守住它」。
依赖方向:层序决定合法边
helio 的层序在第一卷就定死了:Platform < Kernel < Render < Subsystem < Game,依赖只准从高处流向低处。落在目录上就是一张很短的映射表:
| 目录 | 层 | 允许 import |
|---|---|---|
src/core、src/math、src/ecs、src/generated | Kernel | Platform(本卷尚未落地,实际只有同层) |
src/render | Render | Platform、Kernel |
src/game、src/apps | Game | 下面所有层 |
检查的判据极其简单:一条边 A -> B 非法,当且仅当 layer(A) 的序号小于 layer(B) 的序号。同层互相 import 合法,向下合法,只有「低层 import 高层」会被点名。这和第一卷那篇文章里 AllowedImports 映射类型是同一个规则,区别是那里靠编译器挡单条声明,这里靠扫描挡住整棵树——包括从配置或生成代码里冒出来的边。
边界不会在某一天轰然倒塌,它是被一次次「就这一处、下次改」磨穿的。工程上最常见的三种腐烂方式:
- 抄近路:内核里的工具函数需要知道「当前相机」,于是顺手
import了渲染层的相机类型。一次没事,两次之后内核就再也没法在无窗口环境跑。 - 循环依赖:
A里为了少传一个参数 import 了B,B又为了复用某个枚举 import 回A。单测还全绿,因为打包器把两段代码塞进了同一个 chunk;直到某天拆包时才炸。 - 隐性边:
import('../render/x.ts')这种动态 import 不会出现在文件头的静态 import 列表里,只查「第一屏」的脚本必然漏掉。
所以检查器必须同时干两件事:扫全部相对 import(含动态),并且把结果做成一条能返回退出码的命令。这也是本节不满足于「写一份文档约定」的原因——文档不会拦住 git push,退出码会。
可运行产物:两件纯函数 + 一个扫描器
先把图算法做成纯函数,它不碰文件系统,因此可以单测。这个文件定义了层序表、按路径判层、找向上依赖、找循环依赖。
// file: src/core/module-graph.ts
// tier: L1
// verify: npx vitest run src/core/module-graph.test.ts
//
// helio 内核:模块依赖图。把「谁 import 谁」抽象成有向图,
// 提供两件纯函数:找向上依赖、找循环依赖。文件系统遍历交给 CLI 工具。
export const LAYER_INDEX = {
Platform: 0,
Kernel: 1,
Render: 2,
Subsystem: 3,
Game: 4,
} as const;
export type LayerName = keyof typeof LAYER_INDEX;
/** 目录名 -> 层。未列出的目录一律当 Kernel(内核是最底、最安全的默认)。 */
export const DIR_LAYER: Readonly<Record<string, LayerName>> = {
core: 'Kernel',
math: 'Kernel',
ecs: 'Kernel',
generated: 'Kernel',
render: 'Render',
game: 'Game',
apps: 'Game',
};
export interface Edge {
readonly from: string;
readonly to: string;
}
export interface UpwardViolation {
readonly from: string;
readonly fromLayer: LayerName;
readonly to: string;
readonly toLayer: LayerName;
}
/** 取路径的第一段目录名查层表;查不到时返回 fallback(默认 Kernel)。 */
export function layerOfPath(path: string, fallback: LayerName = 'Kernel'): LayerName {
const top = path.split('/').filter(Boolean)[0] ?? '';
return DIR_LAYER[top] ?? fallback;
}
/** 找所有「向上依赖」:起点层序小于终点层序的边。同层与向下都不算。 */
export function findUpwardDeps(
edges: readonly Edge[],
layerOf: (path: string) => LayerName = (p) => layerOfPath(p),
): UpwardViolation[] {
const out: UpwardViolation[] = [];
for (const e of edges) {
const fl = layerOf(e.from);
const tl = layerOf(e.to);
if (LAYER_INDEX[fl] < LAYER_INDEX[tl]) {
out.push({ from: e.from, fromLayer: fl, to: e.to, toLayer: tl });
}
}
return out;
}
/** 用递归 DFS + 三色标记找有向环,每个环按发现顺序返回节点路径。 */
export function findCycles(nodes: readonly string[], edges: readonly Edge[]): string[][] {
const adj = new Map<string, string[]>(nodes.map((n) => [n, []]));
for (const e of edges) adj.get(e.from)?.push(e.to);
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>(nodes.map((n) => [n, WHITE]));
const path: string[] = [];
const cycles: string[][] = [];
const visit = (node: string): void => {
color.set(node, GRAY);
path.push(node);
for (const nb of adj.get(node) ?? []) {
const c = color.get(nb);
if (c === WHITE) visit(nb);
else if (c === GRAY) cycles.push(path.slice(path.indexOf(nb)));
}
path.pop();
color.set(node, BLACK);
};
for (const n of nodes) if (color.get(n) === WHITE) visit(n);
return cycles;
}
循环检测用的是「三色 DFS」:白色未访问、灰色在栈上、黑色已完成。当遍历遇到一条指向灰色节点的边,说明回到了当前路径上,从栈里截出这一段就是一个环。这个判据比「检查 A 依赖 B 且 B 依赖 A」的朴素写法正确得多——后者会漏掉三元及以上的环。配套的单测里专门造了二元环和三元环各一条,断言都能被抓到。
// snippet: src/core/module-graph.test.ts 的两条关键断言
it('内核 import 渲染被点名', () => {
const v = findUpwardDeps([{ from: 'core/clock.ts', to: 'render/renderer.ts' }]);
expect(v[0].fromLayer).toBe('Kernel');
expect(v[0].toLayer).toBe('Render');
});
it('二元环与三元环都能找到', () => {
const cycles = findCycles(['a.ts', 'b.ts', 'c.ts'], [
{ from: 'a.ts', to: 'b.ts' }, { from: 'b.ts', to: 'a.ts' },
{ from: 'b.ts', to: 'c.ts' }, { from: 'c.ts', to: 'b.ts' },
]);
expect(cycles.length).toBe(2);
});
CLI 只负责一件事:把 src/ 下所有 .ts 走一遍,从源码里抠出相对 import,解析成真实文件路径,然后调用上面两件纯函数。注意正则里同时匹配 from '...' 和 import('...'),动态 import 也不能放过;node: 内置模块与 vitest 这类裸包说明符会被过滤掉,它们不参与层间依赖。
// file: tools/module-boundary-check.mjs
// tier: L1
// verify: node tools/module-boundary-check.mjs
//
// helio 工程化:扫描 src/ 的 import 图,检查两件事:
// 1) 依赖方向:只准上层 import 下层(Platform < Kernel < Render < Game);
// 2) 循环依赖:任何 import 环都要被点名。
// 退出码 0 = 干净,1 = 有违规。CI 直接拿它当门禁。
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { findCycles, findUpwardDeps, layerOfPath } from '../src/core/module-graph.ts';
const ROOT = resolve(process.argv[2] ?? 'src');
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (name.endsWith('.ts')) out.push(p);
}
return out;
}
/** 抠出所有相对说明符:`from './x.ts'` 与 `import('./x.ts')`。 */
function importsOf(src) {
const specs = [];
const re = /(?:from|import)\s*\(?\s*'([^']+)'/g;
let m;
while ((m = re.exec(src)) !== null) specs.push(m[1]);
return specs.filter((s) => s.startsWith('.'));
}
const files = walk(ROOT).sort();
const nodes = new Set(files);
const edges = [];
for (const file of files) {
for (const spec of importsOf(readFileSync(file, 'utf8'))) {
const target = resolve(dirname(file), spec);
if (nodes.has(target)) edges.push({ from: file, to: target });
}
}
const upward = findUpwardDeps(edges, (p) => layerOfPath(relative(ROOT, p)));
const cycles = findCycles(files, edges);
const byLayer = {};
for (const f of files) {
const l = layerOfPath(relative(ROOT, f));
byLayer[l] = (byLayer[l] ?? 0) + 1;
}
console.log(`modules: ${files.length}`);
console.log(`edges: ${edges.length}`);
console.log(`layers: ${JSON.stringify(byLayer)}`);
console.log(`upward: ${upward.length}`);
for (const v of upward) {
console.log(` ${v.fromLayer} -> ${v.toLayer}: ${relative(ROOT, v.from)} -> ${relative(ROOT, v.to)}`);
}
console.log(`cycles: ${cycles.length}`);
for (const c of cycles) console.log(` ${c.map((f) => relative(ROOT, f)).join(' -> ')}`);
process.exit(upward.length === 0 && cycles.length === 0 ? 0 : 1);
本机对当前 src/ 实跑(node tools/module-boundary-check.mjs):
modules: 296
edges: 258
layers: {"Game":11,"Kernel":135,"Render":150}
upward: 0
cycles: 0
296 个 .ts 文件、258 条相对 import 边,三层里 Kernel 135、Render 150、Game 11,向上依赖 0、循环依赖 0。这个数字包含本节新加的检查器自身,所以会随后续小节往 src/ 里加文件而变大——真正要盯的是最后两行恒为 0。
出问题时它长什么样
一个只在干净树上打印 0 的检查器没有说服力。往 src/core/ 里塞一个故意越界的文件,再跑一次:
modules: 297
edges: 259
layers: {"Game":11,"Kernel":136,"Render":150}
upward: 1
Kernel -> Render: core/__tmp_violation.ts -> render/renderer.ts
cycles: 0
upward 变成 1,退出码变成 1,并且点名了具体的两端。删除该文件后重新跑,upward 回到 0。这就是它能进 CI 的底气:node tools/module-boundary-check.mjs 的退出码非 0 时,流水线直接红掉,越界的那次 import 根本合不进来。
整个检查链在本机跑过三道,结果如下:
$ npx tsc --noEmit
TypeScript: No errors found
$ npx vitest run src/core/module-graph.test.ts
Test Files 1 passed (1)
Tests 6 passed (6)
$ node tools/module-boundary-check.mjs ; echo $?
0
三道各自守一段:tsc 守「单条声明里的类型级边界」,vitest 守「图算法的正确性」(二元环、三元环、同层合法、向上非法四个用例),CLI 守「整棵树的实际依赖」。缺任何一道都会留缝——只跑 tsc 挡不住运行期拼出来的边,只跑 CLI 又发现不了算法本身写错。
下一节我们处理另一个和「整棵树」有关的问题:既然边界已经清楚了,哪些模块根本不该进包——用构建期特性开关把它们整个摇掉。
阅读导航:上一节:回到目录 · 下一节:5.2 特性开关与死代码消除 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。