一条漏过评审的 import
案例设定:潮汐消消的一次活动需求里,战绩面板为了复用格式化函数,从 system 的统计模块 import 了 presentation 的格式化器。评审时这行 import 看起来只是「工具函数」,单测也全绿,因为测试为了隔离,恰好 mock 掉了格式化器。两周后统计模块要在后台任务里运行,团队才发现它依赖了一个只在 UI 进程存在的模块。
这类问题靠评审很难稳定拦住:import 只有一行,路径命名又很普通。靠单测也不一定:隔离测试经常恰好 mock 掉那条违规边。更可靠的办法是把依赖规则写成可执行断言,让合并前就失败,而不是等运行时才暴露。
两种检测:声明边与真实导入
分层规则有两种事实来源:设计上声明的层间边,以及代码里真实存在的 import。两者都要检测,但能发现的问题不同。
| 检测 | 输入 | 能发现 | 不能发现 |
|---|---|---|---|
| 声明边检测 | 产品声明的 LayerEdge[] | 设计文档里的向上依赖、跨层跳过 | 代码里偷偷新增的 import |
| 真实导入检测 | 模块节点与 imports 列表 | 代码里的向上导入、未知模块、重复模块 | 运行时才出现的动态导入或反射 |
| 同层导入计数 | 同层模块边 | 耦合热点与潜在循环依赖 | 循环本身还需要图算法确认 |
「跨层导入检测」是中性的:它先把所有跨层边找出来,再看方向是否允许。向上导入是其中必须阻断的子集;同层导入不阻断,但单独计数,因为同层循环依赖会让模块无法单独拆出。把这三类混成一个布尔值,报告就只剩「通过/失败」,评审时看不出问题在哪。
两种检测也不能互相替代。声明边检测能发现「设计图里本来就不该有的箭头」,但发现不了代码里的偷渡;真实导入检测能发现偷渡,但依赖模块图本身是完整的。最稳妥的做法是两者都跑,并且把结果放在同一份报告里。
模块图报告长什么样
模块图报告记录产品名、模块数、跨层边、同层边、向上边和未知模块。下面是两个产品的报告。
| 产品 | 模块数 | 跨层导入 | 同层导入 | 向上导入 | 未知模块 | 结果 |
|---|---|---|---|---|---|---|
| 潮汐消消 | 8 | 5 | 3 | 0 | 0 | 通过 |
| 潮汐方舟 | 10 | 6 | 3 | 0 | 0 | 通过 |
消消的模块图有 8 个节点:board-view、activity-banner、ui-kit、match3-core、levels、save-service、activity-config、host-fs。5 条跨层边全部向下,3 条同层边被单独计数;活动配置走 system,文件差异留在 platform。
方舟的模块图有 10 个节点,多出 battle-hud、battle-net、account-client 等战斗相关模块;6 条跨层边同样全部向下,3 条同层边集中在战斗表现与战斗规则内部。
两张图都通过,不代表它们「没有同层耦合」,而是说明没有向上依赖和未知模块。同层耦合需要后续用循环检测或模块拆分继续处理,不能因为报告是绿色就忽略。
把规则写成构建期断言
下面的 boundaries.ts 把上述检测写成纯函数和断言。它复用 2.1 的 LayerEdge、classifyDependency 与 findUpwardEdges,不重复定义层顺序。
// file: src/core/boundaries.ts
// tier: L1
// verify: node --test test/core/boundaries.test.ts
import {
classifyDependency,
findUpwardEdges,
type DependencyKind,
type Layer,
type LayerEdge,
} from "./layers.ts";
export interface ModuleRef {
readonly module: string;
readonly layer: Layer;
}
export interface ModuleNode extends ModuleRef {
readonly imports: readonly ModuleRef[];
}
export interface ImportEdge {
readonly from: ModuleRef;
readonly to: ModuleRef;
readonly direction: DependencyKind;
readonly allowed: boolean;
}
export function classifyImport(from: ModuleRef, to: ModuleRef): ImportEdge {
const direction = classifyDependency(from.layer, to.layer);
return {
from,
to,
direction,
allowed: direction !== "upward",
};
}
function edgesOf(nodes: readonly ModuleNode[]): readonly ImportEdge[] {
const edges: ImportEdge[] = [];
for (const node of nodes) {
const from: ModuleRef = { module: node.module, layer: node.layer };
for (const target of node.imports) {
edges.push(classifyImport(from, target));
}
}
return edges;
}
export function findCrossLayerImports(nodes: readonly ModuleNode[]): readonly ImportEdge[] {
return edgesOf(nodes).filter((edge) => edge.from.layer !== edge.to.layer);
}
export function findUpwardImports(nodes: readonly ModuleNode[]): readonly ImportEdge[] {
return edgesOf(nodes).filter((edge) => edge.direction === "upward");
}
export interface UnknownImport {
readonly from: ModuleRef;
readonly to: ModuleRef;
}
export function findUnknownImports(nodes: readonly ModuleNode[]): readonly UnknownImport[] {
const known = new Set(nodes.map((node) => node.module));
return edgesOf(nodes)
.filter((edge) => !known.has(edge.to.module))
.map((edge) => ({ from: edge.from, to: edge.to }));
}
export function assertLayerEdges(product: string, edges: readonly LayerEdge[]): void {
const upward = findUpwardEdges(edges);
if (upward.length > 0) {
const paths = upward.map((edge) => `${edge.from}->${edge.to}`).join(", ");
throw new Error(`${product}: upward layer edges: ${paths}`);
}
}
export interface ModuleGraphReport {
readonly product: string;
readonly modules: number;
readonly crossLayerImports: readonly ImportEdge[];
readonly upwardImports: readonly ImportEdge[];
readonly unknownImports: readonly UnknownImport[];
readonly sameLayerImports: number;
readonly passed: boolean;
}
export function moduleGraphReport(
product: string,
nodes: readonly ModuleNode[],
): ModuleGraphReport {
const ids = new Set(nodes.map((node) => node.module));
if (ids.size !== nodes.length) {
throw new Error(`${product}: duplicate module ids in graph`);
}
const all = edgesOf(nodes);
const upwardImports = all.filter((edge) => edge.direction === "upward");
const unknownImports = findUnknownImports(nodes);
return {
product,
modules: nodes.length,
crossLayerImports: all.filter((edge) => edge.from.layer !== edge.to.layer),
upwardImports,
unknownImports,
sameLayerImports: all.filter((edge) => edge.direction === "same-layer").length,
passed: upwardImports.length === 0 && unknownImports.length === 0,
};
}
export function assertModuleGraph(report: ModuleGraphReport): void {
if (report.passed) {
return;
}
const upward = report.upwardImports
.map((edge) => `${edge.from.module}(${edge.from.layer})->${edge.to.module}(${edge.to.layer})`)
.join(", ");
const unknown = report.unknownImports
.map((edge) => `${edge.from.module}->${edge.to.module}`)
.join(", ");
throw new Error(
`${report.product}: boundary violations; upward=[${upward}] unknown=[${unknown}]`,
);
}
export interface DualGraphReport {
readonly products: readonly ModuleGraphReport[];
readonly modules: number;
readonly upwardImports: number;
readonly unknownImports: number;
readonly passed: boolean;
}
export function dualGraphReport(
first: ModuleGraphReport,
second: ModuleGraphReport,
): DualGraphReport {
return {
products: [first, second],
modules: first.modules + second.modules,
upwardImports: first.upwardImports.length + second.upwardImports.length,
unknownImports: first.unknownImports.length + second.unknownImports.length,
passed: first.passed && second.passed,
};
}
export const DECLARED_LAYER_EDGES: readonly LayerEdge[] = [
{ from: "presentation", to: "gameplay" },
{ from: "presentation", to: "system" },
{ from: "gameplay", to: "system" },
{ from: "system", to: "platform" },
];
export const MATCH3_MODULES: readonly ModuleNode[] = [
{
module: "board-view",
layer: "presentation",
imports: [
{ module: "match3-core", layer: "gameplay" },
{ module: "activity-banner", layer: "presentation" },
{ module: "ui-kit", layer: "presentation" },
],
},
{
module: "activity-banner",
layer: "presentation",
imports: [{ module: "activity-config", layer: "system" }],
},
{ module: "ui-kit", layer: "presentation", imports: [] },
{
module: "match3-core",
layer: "gameplay",
imports: [
{ module: "levels", layer: "gameplay" },
{ module: "save-service", layer: "system" },
],
},
{ module: "levels", layer: "gameplay", imports: [] },
{
module: "save-service",
layer: "system",
imports: [{ module: "host-fs", layer: "platform" }],
},
{
module: "activity-config",
layer: "system",
imports: [{ module: "host-fs", layer: "platform" }],
},
{ module: "host-fs", layer: "platform", imports: [] },
];
export const ARK_MODULES: readonly ModuleNode[] = [
{
module: "battle-hud",
layer: "presentation",
imports: [
{ module: "battle-core", layer: "gameplay" },
{ module: "battle-net", layer: "system" },
{ module: "battle-log", layer: "presentation" },
],
},
{ module: "battle-log", layer: "presentation", imports: [] },
{
module: "battle-core",
layer: "gameplay",
imports: [
{ module: "battle-rules", layer: "gameplay" },
{ module: "card-config", layer: "system" },
],
},
{ module: "battle-rules", layer: "gameplay", imports: [] },
{
module: "battle-net",
layer: "system",
imports: [
{ module: "socket-host", layer: "platform" },
{ module: "account-client", layer: "system" },
],
},
{
module: "account-client",
layer: "system",
imports: [{ module: "session-host", layer: "platform" }],
},
{
module: "card-config",
layer: "system",
imports: [{ module: "asset-host", layer: "platform" }],
},
{ module: "socket-host", layer: "platform", imports: [] },
{ module: "session-host", layer: "platform", imports: [] },
{ module: "asset-host", layer: "platform", imports: [] },
];
代码里有四个关键决策。
第一,classifyImport 把每条 import 标成 downward、upward 或 same-layer,并给出 allowed。allowed 只对向上为假;同层允许但单独计数。这样报告既能阻断,也能暴露耦合。
第二,moduleGraphReport 先检查模块 id 是否重复。重复 id 会让两条不同的导入被合并成一条,报告数字看起来正常,实际漏掉一条边;这类错误必须在生成报告前抛出。
第三,findUnknownImports 检查 import 目标是否在模块图里。真实项目里常见「模块改名但 import 没改」或「import 了一个不在图里的工具包」;未知目标必须视为失败,否则图会慢慢失真。
第四,assertModuleGraph 把失败信息写成 模块(layer)->模块(layer) 的路径。断言失败时,评审者不需要再打开代码找边,直接看到「谁在向上依赖谁」。
断言清单与失败信息
| 断言 | 触发条件 | 处理 |
|---|---|---|
assertLayerEdges | 声明边里出现向上依赖 | 抛错,列出 from->to |
assertModuleGraph | 真实导入里出现向上依赖 | 抛错,列出 module(layer)->module(layer) |
assertModuleGraph | import 目标不在模块图 | 抛错,列出 from->to |
moduleGraphReport | 模块 id 重复 | 抛错,阻止生成报告 |
| 同层导入 | 同层模块互相 import | 通过,但计数 |
| 跨层向下 | 高层 import 低层 | 通过,记录边 |
这些断言放在 test/core/boundaries.test.ts,运行命令是 node --test test/core/boundaries.test.ts,6 条断言全部通过。测试里故意构造了一个 battle-core(gameplay)->stats-view(presentation) 的向上边,确认报告会把它标成 upward 并让 assertModuleGraph 抛错;还构造了未知模块、重复模块和声明边向上,确认每类失败都有明确的错误信息。
如果要把它接到发布流程,只需要在打包前增加一个测试步骤。它不依赖设备、网络或引擎,运行时间在毫秒级,适合作为每次合并的必过门禁。报告本身也可以归档,用来回答「这个版本的模块边界和上个版本相比变了什么」。
与相邻章节的关系
2.1 定义了四层职责与允许矩阵,本节把矩阵变成模块图断言。2.2 的纯逻辑内核是模块图里的一个节点;本节检查它有没有反向 import 表现层。
跨书的分层测试思路可以参考分层测试 ,但本书额外检查「未知模块」和「同层计数」,因为移动游戏的模块图经常被渠道包和活动资源打散。动态 import、原生桥接与条件编译不在本节的静态图范围内,它们放到第 3 章讨论。
小结
- 依赖规则要同时检查声明边和真实 import;只画图不查 import,偷渡边会留在代码里。
- 跨层检测是中性的,向上导入必须阻断,同层导入单独计数;三者混成一个布尔值会丢失信息。
- 模块图报告要包含产品名、模块数、跨层边、同层边、向上边和未知模块,才能归档和对比。
- 重复模块 id、未知 import 目标和向上依赖都必须在生成报告前失败;失败信息要直接给出依赖路径。
- 断言在毫秒级完成,适合作为每次合并的构建期门禁,而不是发布前才跑一次的检查。
阅读导航:上一节:2.2 纯逻辑内核:把玩法从引擎里拆出来 · 下一节:3.1 引擎负责什么,原生壳负责什么 。
常见错误与边界
- 只检查声明边,不扫描真实 import。设计图可以很干净,代码里的偷渡边只有扫描模块图才能发现。
- 把同层导入当成合法或非法两个极端。同层导入不必然违规,但会形成耦合热点;正确做法是计数并持续观察。
- 忽略未知模块。模块改名后 import 没改,图会悄悄失真;未知目标必须让报告失败。
- 允许重复模块 id。重复 id 会把两条边合并成一条,报告数字正常但漏检;必须在生成报告前抛错。
- 失败信息只写「边界违规」。没有
module(layer)->module(layer)路径,评审者还得自己找;断言的价值一半在路径。 - 边界:静态图不覆盖动态 import、反射和条件编译。这些需要运行时检测或第 3 章的 Host 边界测试,不能指望一张静态模块图解决所有问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。