《移动游戏产品的架构·客户端与移动运行时》2.3 边界测试:依赖规则写成可执行断言

依赖规则如果只写在评审清单里,就会在下一个紧急需求里被绕开。本节把向上依赖、跨层导入、未知模块与重复模块写成可执行断言,并用消消与方舟两张模块图报告验证:每条跨层边都必须向下,同层边单独计数,向上边直接阻断合并,报告可归档,也可作为发布前门禁。

一条漏过评审的 import

案例设定:潮汐消消的一次活动需求里,战绩面板为了复用格式化函数,从 system 的统计模块 import 了 presentation 的格式化器。评审时这行 import 看起来只是「工具函数」,单测也全绿,因为测试为了隔离,恰好 mock 掉了格式化器。两周后统计模块要在后台任务里运行,团队才发现它依赖了一个只在 UI 进程存在的模块。

这类问题靠评审很难稳定拦住:import 只有一行,路径命名又很普通。靠单测也不一定:隔离测试经常恰好 mock 掉那条违规边。更可靠的办法是把依赖规则写成可执行断言,让合并前就失败,而不是等运行时才暴露。

两种检测:声明边与真实导入

分层规则有两种事实来源:设计上声明的层间边,以及代码里真实存在的 import。两者都要检测,但能发现的问题不同。

检测输入能发现不能发现
声明边检测产品声明的 LayerEdge[]设计文档里的向上依赖、跨层跳过代码里偷偷新增的 import
真实导入检测模块节点与 imports 列表代码里的向上导入、未知模块、重复模块运行时才出现的动态导入或反射
同层导入计数同层模块边耦合热点与潜在循环依赖循环本身还需要图算法确认

「跨层导入检测」是中性的:它先把所有跨层边找出来,再看方向是否允许。向上导入是其中必须阻断的子集;同层导入不阻断,但单独计数,因为同层循环依赖会让模块无法单独拆出。把这三类混成一个布尔值,报告就只剩「通过/失败」,评审时看不出问题在哪。

两种检测也不能互相替代。声明边检测能发现「设计图里本来就不该有的箭头」,但发现不了代码里的偷渡;真实导入检测能发现偷渡,但依赖模块图本身是完整的。最稳妥的做法是两者都跑,并且把结果放在同一份报告里。

模块图报告长什么样

模块图报告记录产品名、模块数、跨层边、同层边、向上边和未知模块。下面是两个产品的报告。

产品模块数跨层导入同层导入向上导入未知模块结果
潮汐消消85300通过
潮汐方舟106300通过

消消的模块图有 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)
assertModuleGraphimport 目标不在模块图抛错,列出 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 边界测试,不能指望一张静态模块图解决所有问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查