《互联网产品的客户端架构·端形态与体验基础》13.2 模块划分与依赖图

收口卷一的模块依赖:把各章实验整理成带层级的模块清单,构建依赖图、Kahn 拓扑排序、回路检测与方向校验,输出可直接写入评审记录的文本。附卷一真实依赖图的结论,说明 summary 组合根为什么允许向下穿透多层,以及依赖图与架构图之间仍缺的那部分证据。

收口为什么要再看一遍依赖图

单章实验里,模块关系是“这一章 import 上一章”,看起来很清楚。到了卷一收口,九个目录同时存在,引用关系变成一张网:组合根依赖四个模块,视图依赖状态,数据层彼此独立。人脑能记住两三个文件的关系,记不住整张图。

依赖图的第一价值不是画得好看,而是把“允许的边”变成可执行判断。循环依赖、反向依赖、悬空引用这三类问题,单看某个文件都不明显,放进全图就一目了然。第二价值是给改动定位:一个模块被多少模块依赖,决定了改它的接口要通知多少人。

4.2 已经给过一套通用算法。这一节不重复讲原理,而是把它落到卷一的真实模块清单上,并补上“汇总输出”这一层:模块数、边数、构建顺序、被依赖热点、阻塞模块。它仍然独立可运行,不 import 4.2 的实现。

图从构建清单来

图中的节点不是文件路径的字符串匹配,而是扫描器产出的模块记录:模块 id、所属层、静态 import 列表。扫描器可以是构建脚本,也可以是一次性的仓库检查;本节的函数只吃清单,不读文件系统。

这样做有两个好处。第一,测试不需要造真实目录,三五个模块就能覆盖循环与反向。第二,同一个函数可以接不同来源:TypeScript 的 import 图、打包器的模块图、甚至手工维护的子系统清单。输入统一,判断就统一。

清单里的层沿用前文的划分,并新增一个 summary 层:

层等级说明
summary4卷一组合根,只允许出现在最外层
view3页面与组件契约
state2共享状态、状态机、缓存视图
domain1业务规则与用例
data0缓存、队列、调度器等基础设施
shared-1纯工具,不依赖任何业务层

规则仍是“目标等级不高于来源等级”。同层允许,shared 只能依赖自己。summary 之所以单独设一层,是因为组合根本来就要同时认识页面栈、状态与数据层;如果把它塞进 view,summary -> data 会被误报成跨层,团队就会开始加例外,最后例外比规则还多。

构建、校验与汇总

下面的文件一次产出五类结果:规范化后的边、方向违规、悬空引用、构建顺序、循环回路。任何一个非空,结论就是 fail。

// file: src/summary/module-dependency.ts
// tier: L1
// verify: node --test test/summary/module-dependency.test.ts

export type GraphLayer = "summary" | "view" | "state" | "domain" | "data" | "shared";

export interface ModuleRecord {
  id: string;
  layer: GraphLayer;
  /** 本模块静态依赖的模块 id */
  imports: string[];
}

export interface GraphEdge {
  from: string;
  to: string;
}

export interface DirectionViolation {
  from: string;
  to: string;
  message: string;
}

export interface DependencyReport {
  modules: string[];
  edges: GraphEdge[];
  cycles: string[][];
  directionViolations: DirectionViolation[];
  missing: string[];
  fanIn: Record<string, number>;
  fanOut: Record<string, number>;
  order: string[];
  blocked: string[];
  status: "pass" | "fail";
}

/** 数字越大越靠外;依赖只允许从大到小,summary 是卷一的组合根。 */
export const LAYER_RANK: Record<GraphLayer, number> = {
  summary: 4,
  view: 3,
  state: 2,
  domain: 1,
  data: 0,
  shared: -1,
};

interface Normalized {
  names: string[];
  layers: Map<string, GraphLayer>;
  adj: Map<string, string[]>;
}

function normalize(modules: ModuleRecord[]): Normalized {
  const names: string[] = [];
  const layers = new Map<string, GraphLayer>();
  for (const module of modules) {
    if (layers.has(module.id)) throw new Error(`重复模块: ${module.id}`);
    layers.set(module.id, module.layer);
    names.push(module.id);
  }
  names.sort();

  const adj = new Map<string, string[]>();
  for (const name of names) adj.set(name, []);
  for (const module of modules) {
    const targets = [...new Set(module.imports)].sort();
    adj.set(module.id, targets);
  }
  return { names, layers, adj };
}

/** 在 blocked 集合内找一条回到 start 的具体回路,找不到返回 undefined。 */
function cycleFrom(
  start: string,
  adj: Map<string, string[]>,
  allowed: Set<string>,
): string[] | undefined {
  const path: string[] = [];
  const onPath = new Set<string>();

  const walk = (node: string): string[] | undefined => {
    path.push(node);
    onPath.add(node);
    for (const next of adj.get(node) ?? []) {
      if (next === start) return [...path, start];
      if (!allowed.has(next) || onPath.has(next)) continue;
      const found = walk(next);
      if (found) return found;
    }
    path.pop();
    onPath.delete(node);
    return undefined;
  };

  return walk(start);
}

/**
 * 从一张模块清单构建依赖图,并一次产出循环、方向、悬空引用与构建顺序。
 * 不读文件系统:清单由构建脚本扫描后传入,纯函数可在测试里完整覆盖。
 */
export function buildDependencyReport(modules: ModuleRecord[]): DependencyReport {
  const { names, layers, adj } = normalize(modules);
  const known = new Set(names);
  const edges: GraphEdge[] = [];
  const missing = new Set<string>();

  for (const from of names) {
    for (const to of adj.get(from) ?? []) {
      edges.push({ from, to });
      if (!known.has(to)) missing.add(to);
    }
  }

  const directionViolations: DirectionViolation[] = [];
  for (const edge of edges) {
    const fromLayer = layers.get(edge.from) as GraphLayer;
    const toLayer = layers.get(edge.to);
    if (!toLayer) continue;
    if (LAYER_RANK[toLayer] > LAYER_RANK[fromLayer]) {
      directionViolations.push({
        from: edge.from,
        to: edge.to,
        message: `${edge.from}(${fromLayer}) 依赖了更外层的 ${edge.to}(${toLayer})`,
      });
    }
  }
  directionViolations.sort(
    (a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to),
  );

  const indegree = new Map<string, number>();
  const dependents = new Map<string, string[]>();
  for (const name of names) {
    indegree.set(name, 0);
    dependents.set(name, []);
  }
  for (const edge of edges) {
    if (!known.has(edge.to)) continue;
    indegree.set(edge.from, (indegree.get(edge.from) ?? 0) + 1);
    dependents.get(edge.to)?.push(edge.from);
  }

  const ready = names.filter((name) => indegree.get(name) === 0).sort();
  const order: string[] = [];
  while (ready.length > 0) {
    const node = ready.shift() as string;
    order.push(node);
    for (const dependent of dependents.get(node) ?? []) {
      const left = (indegree.get(dependent) ?? 0) - 1;
      indegree.set(dependent, left);
      if (left === 0) ready.push(dependent);
    }
    ready.sort();
  }

  const emitted = new Set(order);
  const blocked = names.filter((name) => !emitted.has(name));
  const blockedSet = new Set(blocked);
  const cycles: string[][] = [];
  const covered = new Set<string>();
  for (const start of blocked) {
    if (covered.has(start)) continue;
    const path = cycleFrom(start, adj, blockedSet);
    if (!path) continue;
    cycles.push(path);
    for (const node of path) covered.add(node);
  }
  cycles.sort((a, b) => a.join(">").localeCompare(b.join(">")));

  const fanIn: Record<string, number> = {};
  const fanOut: Record<string, number> = {};
  for (const name of names) {
    fanIn[name] = 0;
    fanOut[name] = (adj.get(name) ?? []).length;
  }
  for (const edge of edges) {
    if (known.has(edge.to)) fanIn[edge.to] = (fanIn[edge.to] ?? 0) + 1;
  }

  const status =
    cycles.length === 0 &&
    directionViolations.length === 0 &&
    missing.size === 0
      ? "pass"
      : "fail";

  return {
    modules: names,
    edges,
    cycles,
    directionViolations,
    missing: [...missing].sort(),
    fanIn,
    fanOut,
    order,
    blocked,
    status,
  };
}

/** 输出稳定文本,便于贴进 CI 日志或基线评审记录。 */
export function formatDependencyReport(report: DependencyReport): string[] {
  const lines = [
    `已检查 ${report.modules.length} 个模块,${report.edges.length} 条依赖`,
    `构建顺序:${report.order.join(" -> ") || "无"}`,
  ];
  const topFanIn = Object.entries(report.fanIn)
    .filter(([, count]) => count > 0)
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .slice(0, 3);
  lines.push(
    `被依赖最多:${
      topFanIn.map(([id, count]) => `${id}(${count})`).join(", ") || "无"
    }`,
  );
  for (const cycle of report.cycles) lines.push(`循环依赖:${cycle.join(" -> ")}`);
  for (const violation of report.directionViolations) {
    lines.push(`方向违规:${violation.message}`);
  }
  for (const id of report.missing) lines.push(`悬空引用:${id}`);
  if (report.status === "pass") lines.push("结果:pass");
  else lines.push(`结果:fail(阻塞 ${report.blocked.length} 个模块)`);
  return lines;
}

实现里有四个取舍。

第一,import 先去重再排序。同一模块重复写两次 import 不会产生两条边,否则 fanIn 会被重复计数,评审时看不出真实热度。

第二,拓扑排序用 Kahn 而不是递归 DFS。Kahn 的好处是“排不完的节点”天然就是阻塞集合,不需要额外的访问标记;blocked 直接告诉评审者哪些模块因为环而无法确定构建顺序。

第三,回路只在阻塞集合里找。一个节点依赖了环,自己也会排不出来,但它不在环上;从它出发找不到回到自身的路径,函数会跳过它,继续找真正的环。这样输出的 cycles 是具体回路,不是一坨阻塞节点。

第四,方向检查跳过悬空目标。目标模块不存在时,报“悬空引用”已经足够;再报一条方向违规会让同一个问题出现两次,修复者反而不知道先改哪个。

formatDependencyReport() 把结果压成固定文本:第一行是规模,第二行是构建顺序,第三行是被依赖最多的三个模块,之后是循环、方向、悬空,最后一行是结论。固定格式的意义是可比较:两次评审的记录可以直接 diff,而不是靠人读两段自由文本。

测试与真实输出

测试用一张卷一的真实依赖图,加三张故意坏掉的图:同层三角环、数据层反向依赖视图层、引用不存在的模块。完整测试在 test/summary/module-dependency.test.ts。

单文件命令:

node --test test/summary/module-dependency.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 卷一真实依赖图无循环、无反向、无悬空引用 (0.996458ms)
✔ 同层三角依赖被 Kahn 拓扑识别为循环并给出一条回路 (0.1925ms)
✔ 数据层反向依赖视图层会被判为方向违规 (0.153542ms)
✔ 引用清单里不存在的模块会进入 missing 并让结果失败 (0.097333ms)
✔ 文本输出包含顺序、热点与结论,可直接贴进评审记录 (9.100459ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 158.524333

循环测试特别值得保留。三个同层模块互相 import,方向检查完全通过,只有拓扑排序能发现它们无法排出先后。这正是“方向对但结构坏”的典型:每条边都合法,整体却不可构建。只做方向检查的团队会漏掉这类问题。

卷一依赖图的实际结论

把前十二章的模块按真实 import 汇总,得到的核心结论是:

模块层fanInfanOut
data/cache-storedata10
data/sync-queuedata10
router/page-stackview10
state/storestate20
view/contractview01
summary/baselinesummary04

整张图 6 个模块、5 条边,没有循环、没有反向、没有悬空引用。数据层的两个模块 fanOut 为 0,说明它们可以独立测试;state/store 的 fanIn 为 2,说明改它的接口至少要通知视图契约与组合根两个地方;summary/baseline 的 fanOut 为 4,是整张图里唯一的宽依赖点。

宽依赖点不是坏事,但必须唯一。组合根如果分裂成三个各自 import 一半模块的入口,失败语义就会被复制三份,改一处漏两处。卷一把它收敛到一个 EchoBaseline,就是为了让“先缓存后网络、失败标离线、写入先落盘”只存在一份。

同样要看到图里没有的东西:data/cache-store 与 data/sync-queue 之间没有边,它们通过组合根协作,而不是互相调用。这是有意的。缓存不需要知道队列存在,队列也不需要知道缓存怎么失效;一旦它们直接互相 import,数据层内部就会长出一个新的隐式组合根。

依赖图不是架构图

依赖图只回答“谁 import 谁”,不回答“运行时谁调用谁”。一次动态导入可以没有静态边,一次反射式注册可以制造图上看不见的耦合;反过来,只 import 类型的边不产生运行时代码,但它仍然影响编译与变更成本。

它也不回答“这个依赖是否合理”。summary -> data 在等级上合法,在职责上是否应该存在,要看组合根有没有把业务规则偷偷搬进来。13.1 的基线把顺序与失败语义放在组合根,把算法留在数据层,这个分工无法由依赖图证明,只能由代码评审判断。

所以依赖图是门禁,不是判决书。它能拦住循环、反向与悬空,不能替代 4.1 的调用审计、4.3 的架构规则和人的职责判断。三层证据合在一起,才足以说“模块划分成立”。

小结

卷一收口时,依赖图把散落在各章的 import 关系变成一张可校验的图:节点带层,边带方向,拓扑排序给出构建顺序,回路检测找出“方向都对但结构坏”的环。

卷一的真实图很小,结论却明确:数据层是叶子,状态是热点,组合根是唯一宽依赖点。保持这个形状,改动的传播范围就可预测;破坏这个形状,最先出现的往往不是编译错误,而是失败语义被复制。

阅读导航:上一节:13.1 回声客户端基线 · 下一节:13.3 质量属性场景与风险清单 。

常见错误与边界

  • 把依赖图当成架构图。它只看 import,不看运行时调用与职责。
  • 只检查方向,不检查循环。每条边都合法、整体却排不出顺序的情况很常见。
  • 把 summary 塞进 view。组合根向下穿透多层是设计,不是违规。
  • 让组合根分裂成多个入口。失败语义会被复制,改一处漏两处。
  • 把悬空引用和方向违规同时报。同一个问题报两次,修复者无法排序。
  • 用重复 import 抬高 fanIn。先去重再统计,否则热点是假的。
  • 把类型 import 当成不存在。它不影响运行时,但影响编译与变更成本。
  • 依赖图通过就宣布架构通过。调用审计、职责判断与质量场景仍要单独做。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查