《互联网产品的客户端架构·端形态与体验基础》4.2 依赖方向与包边界

从模块图出发把依赖方向写成可判定规则:依赖只能指向内层,反向边必须在构建前失败。给出 TypeScript 实现的模块图模型、反向依赖检查、Tarjan 强连通分量循环检测与依赖优先构建排序,并用本机 Node 26 真实测试输出现场验证,最后讨论包边界、公开入口与类型依赖的落地方式。

依赖方向是一条可判定规则

4.1 讨论的是“一次调用有没有越层”,这一节讨论更底层的问题:模块之间的 import 允不允许存在。两者的区别很重要。调用审计看的是运行期路径,依赖检查看的是编译期知识关系。一个模块可以从不调用另一个模块,但仍然 import 了它的类型;反过来,动态调用可以绕过 import 检查。

依赖方向的规则只有一句:依赖必须指向内层。视图可以依赖状态,状态可以依赖领域,领域可以依赖数据访问接口,数据访问层依赖共享工具。反过来,领域 import 视图、数据层 import 领域,都是反向依赖。

这条规则的价值不在“图好看”,而在变更成本。内层被外层依赖,内层改接口会影响外层;外层被内层依赖,内层就被外层的细节污染,无法独立测试和复用。循环依赖则把两者绑成一个整体,任何一处改动都可能触发整圈重新验证。

层等级与允许的边

把层映射成等级,依赖方向就变成一个比较运算:

层等级可以依赖
view3view、state、domain、data、shared
state2state、domain、data、shared
domain1domain、data、shared
data0data、shared
shared-1shared

规则是“目标等级不高于来源等级”。同层允许,因为层内协作不改变依赖方向;shared 是叶子,只能依赖自己,任何业务层都不应该被共享工具反向依赖。这张表是方向检查的默认上限,项目可以叠加更严格的策略。

这张表只检查反向依赖,不检查跨层。view -> data 的方向是向内的,等级从 3 到 0,不会在这里报错。它是方向合法但访问越层:本书的「回声」采用更严格的策略,视图不得 import 数据层,哪怕只是拿类型,这条策略由 4.3 的检查器执行。方向检查不重复报同一条边,否则团队会收到两条告警,却分不清该改 import 还是改调用。

循环依赖为什么不能靠约定

循环依赖最麻烦的地方是它经常看起来无害。两个状态模块互相引用一个类型,TypeScript 能编译,运行时也可能正常,因为模块初始化顺序恰好是对的。换一个打包器、换一个入口、把某个 import 改成动态加载,顺序变了,运行时才炸。

更常见的情况是循环依赖让测试无法隔离。A 依赖 B,B 又依赖 A,想单独测 A 就必须把 B 及其依赖全部拉起来。测试跑得越来越慢,最后大家开始跳过测试。

约定“不要写循环依赖”没有用,因为写的时候往往看不见全图。人看到的是两个文件,工具看到的是整张图。循环检测必须由工具做,而且要在构建前做。

模块图、反向检查与循环检测

下面的文件把模块图定义成“模块 + 有向边”,实现三件事:反向依赖检查、强连通分量循环检测、依赖优先的构建排序。

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

export type NodeLayer = "view" | "state" | "domain" | "data" | "shared";

export interface ModuleRef {
  name: string;
  layer: NodeLayer;
}

export interface DependencyEdge {
  /** from 依赖 to,箭头指向被依赖方 */
  from: string;
  to: string;
}

export interface ModuleGraph {
  modules: ModuleRef[];
  edges: DependencyEdge[];
}

export interface DirectionViolation {
  rule: "reverse-dependency";
  from: string;
  to: string;
  message: string;
}

export interface Cycle {
  /** 强连通分量内的模块,按字典序排列 */
  modules: string[];
  /** 一条具体回路,首尾相同 */
  path: string[];
}

export interface BuildOrder {
  /** 被依赖的模块排在前面 */
  order: string[];
  /** 因循环依赖无法排序的模块 */
  blocked: string[];
}

/** 依赖只允许从高等级指向低等级 */
export const NODE_RANK: Record<NodeLayer, number> = {
  view: 3,
  state: 2,
  domain: 1,
  data: 0,
  shared: -1,
};

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

  const adj = new Map<string, string[]>();
  for (const name of names) adj.set(name, []);
  for (const edge of graph.edges) {
    if (!layers.has(edge.from)) throw new Error(`未知模块: ${edge.from}`);
    if (!layers.has(edge.to)) throw new Error(`未知模块: ${edge.to}`);
    const list = adj.get(edge.from)!;
    if (!list.includes(edge.to)) list.push(edge.to);
  }
  for (const list of adj.values()) list.sort();
  return { names, layers, adj };
}

/**
 * 检查反向依赖。方向正确但跨层的调用(view -> data)不在本函数职责内,
 * 由 4.1 的运行期调用审计和 4.3 的架构规则分别处理。
 */
export function checkDirection(graph: ModuleGraph): DirectionViolation[] {
  const { layers } = normalize(graph);
  const violations: DirectionViolation[] = [];
  for (const edge of graph.edges) {
    const fromLayer = layers.get(edge.from)!;
    const toLayer = layers.get(edge.to)!;
    if (NODE_RANK[toLayer] > NODE_RANK[fromLayer]) {
      violations.push({
        rule: "reverse-dependency",
        from: edge.from,
        to: edge.to,
        message: `${fromLayer} 依赖了更外层的 ${toLayer}: ${edge.from} -> ${edge.to}`,
      });
    }
  }
  return violations.sort(
    (a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to),
  );
}

function findCyclePath(
  start: string,
  members: Set<string>,
  adj: Map<string, string[]>,
): string[] {
  const stack = [start];
  const onPath = new Set([start]);

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

  return walk(start) ?? [start, start];
}

/**
 * 用 Tarjan 算法求强连通分量,每个分量输出一条具体回路。
 * 复杂度 O(V + E),结果按回路字符串排序,便于直接比对。
 */
export function findCycles(graph: ModuleGraph): Cycle[] {
  const { names, adj } = normalize(graph);
  const index = new Map<string, number>();
  const low = new Map<string, number>();
  const onStack = new Set<string>();
  const stack: string[] = [];
  const components: string[][] = [];
  let nextIndex = 0;

  const strongConnect = (node: string): void => {
    index.set(node, nextIndex);
    low.set(node, nextIndex);
    nextIndex += 1;
    stack.push(node);
    onStack.add(node);

    for (const target of adj.get(node) ?? []) {
      if (!index.has(target)) {
        strongConnect(target);
        low.set(node, Math.min(low.get(node)!, low.get(target)!));
      } else if (onStack.has(target)) {
        low.set(node, Math.min(low.get(node)!, index.get(target)!));
      }
    }

    if (low.get(node) === index.get(node)) {
      const component: string[] = [];
      for (;;) {
        const member = stack.pop()!;
        onStack.delete(member);
        component.push(member);
        if (member === node) break;
      }
      components.push(component.sort());
    }
  };

  for (const name of names) {
    if (!index.has(name)) strongConnect(name);
  }

  const cycles: Cycle[] = [];
  for (const component of components) {
    const selfLoop =
      component.length === 1 && (adj.get(component[0]) ?? []).includes(component[0]);
    if (component.length === 1 && !selfLoop) continue;
    const members = new Set(component);
    cycles.push({
      modules: component,
      path: findCyclePath(component[0], members, adj),
    });
  }

  return cycles.sort((a, b) => a.path.join(">").localeCompare(b.path.join(">")));
}

/**
 * 按依赖先后给出构建顺序。依赖环里的模块不会进入 order,
 * 而是留在 blocked,调用方必须显式处理,不能默认顺序正确。
 */
export function buildOrder(graph: ModuleGraph): BuildOrder {
  const { names, adj } = normalize(graph);
  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 [from, targets] of adj) {
    indegree.set(from, targets.length);
    for (const target of targets) dependents.get(target)!.push(from);
  }

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

  const emitted = new Set(order);
  return {
    order,
    blocked: names.filter((name) => !emitted.has(name)),
  };
}

几个实现选择值得说明。

第一,checkDirection 只报反向边,不报跨层边。这样一条 view -> data 的 import 不会在这里被误报成“方向错误”,而是留给访问规则处理。规则边界清楚,修复建议才清楚。

第二,循环检测用 Tarjan 求强连通分量,复杂度是 O(V + E)。分量大小大于 1,或者单节点带自环,才算循环。返回的 path 是一条具体回路,首尾相同,方便直接贴进报错信息。分量内模块按字典序排序,回路从字典序最小的模块开始,因此同样的图每次得到同样的输出。

第三,buildOrder 用 Kahn 算法给出依赖优先的顺序。环里的模块不会进入 order,而是进入 blocked。调用方必须显式处理 blocked,不能假设“排序结果少几个模块也没关系”。构建脚本如果忽略 blocked,就会在运行时才遇到初始化顺序问题。

测试与真实输出

测试文件 test/layers/dependency.test.ts 覆盖十种情况:干净 DAG、三类反向依赖、方向正确但跨层的边、双模块环、三模块环、自环、多个不相交环、无环构建顺序、有环构建顺序,以及未知/重复模块的报错。

在实验工程根目录执行:

node --test test/layers/dependency.test.ts

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

✔ 逐层向内的模块图没有反向依赖 (1.008084ms)
✔ 领域依赖视图、数据依赖领域、共享依赖业务层都被判为反向 (9.449042ms)
✔ 方向正确但跨层的 view -> data 不由反向检查负责 (0.111792ms)
✔ 两个模块互相依赖时能被检出,回路从字典序最小的模块开始 (0.23725ms)
✔ 三个模块绕一圈的循环依赖能给出完整路径 (0.105791ms)
✔ 自依赖也被视为循环,而不是被静默忽略 (0.077375ms)
✔ 多个互不相交的环分别报告,顺序稳定 (0.098625ms)
✔ 无环图给出依赖优先的构建顺序 (0.135833ms)
✔ 有环时构建顺序只包含可排序部分,环内模块进入 blocked (0.097792ms)
✔ 未知模块或重复模块直接抛错,不产生半份报告 (0.309791ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 180.524417

注意“方向正确但跨层的 view -> data 不由反向检查负责”这条用例:它断言 checkDirection 返回空数组。这不是遗漏,而是刻意的职责划分。如果这条边也在这里报错,团队会收到两条重复告警,却不知道应该改 import 还是改调用。

包边界落在目录与入口上

模块图是逻辑模型,落地时要映射到目录和包入口。一个可操作的约定是:

  • 每个层一个顶层目录,层内按业务域再分。
  • 每个包只暴露一个 index.ts 作为公开入口,外部只能从入口 import。
  • 禁止深层 import,例如 data/feed/repo/internal.ts。深层 import 让包边界形同虚设。
  • shared 不依赖任何业务层,只放无业务含义的工具与类型。
  • 构建脚本先生成模块图,再跑方向检查与循环检测;失败就停止打包。

这套约定不依赖具体工具。没有 monorepo 也可以用目录加 lint 规则实现;有 monorepo 可以用包的 exports 字段进一步限制入口。关键不是工具强弱,而是“边界有没有被机器检查”。

包边界还有一个容易忽略的维度:类型依赖。只 import 一个类型,运行时可能被完全擦除,但它仍然建立编译期依赖。如果领域层 import 了视图的 Props 类型,领域层就被视图的组件形状绑住了。类型-only import 也应该走公开入口,并且遵守同样的方向规则。

小结

依赖方向是一条可判定规则:把层映射成等级,目标等级高于来源等级就是反向依赖。反向依赖让内层被外层细节污染,循环依赖则把模块绑成一个无法单独测试的整体。

检测不能靠约定,要靠图算法。Tarjan 求强连通分量能找到所有环并给出具体回路,Kahn 排序能把环内模块隔离到 blocked。方向检查与访问检查分开,报错才能指向正确的原因。

下一节把这两节的判断变成架构测试:视图不能直接访问数据层、领域不能 import 视图、模块之间不能有环,全部写成可在 CI 里失败的断言。

阅读导航:上一节:4.1 视图、状态、领域与数据访问 · 下一节:4.3 用测试固化分层 。

常见错误与边界

  • 把方向检查当成完整的分层检查。方向检查只拦反向边,跨层访问要由更严格的架构规则补上。
  • 用目录结构证明分层成立。目录只能证明文件放在哪,不能证明 import 方向。
  • 让 shared 依赖业务层。共享工具一旦知道业务,就不再共享。
  • 允许深层 import。深层 import 会绕过包入口,边界检查失去意义。
  • 只在运行时检测循环。初始化顺序问题应该在构建前失败。
  • 忽略 blocked 列表。环内模块没有构建顺序,静默跳过会把问题推迟到运行时。
  • 把类型 import 当成没有依赖。类型依赖同样建立编译期耦合。
  • 把方向检查和访问检查合成一条规则。合并后报错原因会变得含混,修复建议也不准确。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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