《互联网产品的客户端架构·端形态与体验基础》4.3 用测试固化分层

把视图不能直接访问数据层、领域不能反向依赖视图、模块之间不能有循环依赖写成可执行架构规则,给出 TypeScript 检查器、带原因的例外机制与失败样例报告,并用本机 Node 26 真实测试输出证明三条规则都能拦住真实违规,最后说明如何接进本地检查与 CI 门禁。

为什么架构规则要写成测试

分层图挂在墙上,代码照样越层;评审清单写进文档,赶进度时照样跳过。这不是团队不认同规则,而是规则没有被机器执行。人只能在看到的 diff 里判断,工具能看到整张模块图。

架构测试就是把“不允许”写成断言:视图不能直接 import 数据层,领域层不能 import 视图,模块之间不能有循环依赖,import 的目标必须存在。规则一旦可执行,违规就从“评审意见”变成“构建失败”,修复成本从上线后前移到提交前。

这一节的目标不是做一个通用静态分析器,而是把三条最关键的规则写成一个可运行的检查器。它接收模块清单,输出结构化违规报告。真实项目里,这份清单可以由构建脚本从 import 语句生成,检查器本身保持纯逻辑。

三条规则与一条边界规则

规则含义为什么重要
view-to-data视图不能直接 import 数据层换数据源不应改视图
domain-to-view领域不能 import 视图规则应能脱离 UI 测试与复用
cycle模块之间不能有循环依赖初始化顺序与测试隔离
unknown-importimport 的目标必须是已声明模块防止路径漂移与拼写错误绕过检查

前三条对应 4.1 和 4.2 的结论,第四条是检查器自身的安全网。如果 import 的目标不在模块清单里,检查器不能假装它“没有层”就跳过,否则一个拼错的路径就能让违规消失。

检查器如何工作

下面的文件复用 4.2 的 findCycles,把模块清单转成图,再按规则逐条扫描。例外必须带原因,并且会保留在报告里,不能被静默吞掉。

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

import { findCycles } from "./dependency.ts";
import type { ModuleGraph } from "./dependency.ts";

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

export type RuleId =
  | "view-to-data"
  | "domain-to-view"
  | "cycle"
  | "unknown-import";

export interface SourceModule {
  /** 仓库内路径,同时作为模块标识 */
  path: string;
  layer: ArchLayer;
  /** 该文件 import 的模块路径 */
  imports: string[];
}

export interface ArchViolation {
  rule: RuleId;
  file: string;
  target?: string;
  message: string;
}

export interface Exception {
  file: string;
  rule: RuleId;
  /** 例外必须写明原因,空字符串直接拒绝 */
  reason: string;
}

export interface Waived {
  file: string;
  rule: RuleId;
  reason: string;
}

export interface ArchReport {
  checked: number;
  violations: ArchViolation[];
  suppressed: Waived[];
}

/** view 直接访问 data 的规则在这里集中声明,测试与 CI 共用同一份文本 */
export function checkArchitecture(
  modules: SourceModule[],
  exceptions: Exception[] = [],
): ArchReport {
  const byPath = new Map<string, SourceModule>();
  for (const module of modules) {
    if (byPath.has(module.path)) throw new Error(`重复模块: ${module.path}`);
    byPath.set(module.path, module);
  }

  for (const exception of exceptions) {
    if (exception.reason.trim().length === 0) {
      throw new Error(`例外 ${exception.file}/${exception.rule} 必须写明原因`);
    }
    if (!byPath.has(exception.file)) {
      throw new Error(`例外指向未声明模块: ${exception.file}`);
    }
  }

  const violations: ArchViolation[] = [];
  for (const module of modules) {
    for (const target of module.imports) {
      const imported = byPath.get(target);
      if (!imported) {
        violations.push({
          rule: "unknown-import",
          file: module.path,
          target,
          message: `${module.path} 导入了未声明的模块 ${target}`,
        });
        continue;
      }
      if (module.layer === "view" && imported.layer === "data") {
        violations.push({
          rule: "view-to-data",
          file: module.path,
          target,
          message: `视图 ${module.path} 直接访问数据层 ${target}`,
        });
      }
      if (module.layer === "domain" && imported.layer === "view") {
        violations.push({
          rule: "domain-to-view",
          file: module.path,
          target,
          message: `领域 ${module.path} 反向依赖视图 ${target}`,
        });
      }
    }
  }

  const graph: ModuleGraph = {
    modules: modules.map((module) => ({ name: module.path, layer: module.layer })),
    edges: modules.flatMap((module) =>
      module.imports
        .filter((target) => byPath.has(target))
        .map((target) => ({ from: module.path, to: target })),
    ),
  };
  for (const cycle of findCycles(graph)) {
    violations.push({
      rule: "cycle",
      file: cycle.path[0],
      target: cycle.path[1],
      message: `循环依赖: ${cycle.path.join(" -> ")}`,
    });
  }

  const kept: ArchViolation[] = [];
  const suppressed: Waived[] = [];
  for (const violation of violations) {
    const exception = exceptions.find(
      (item) => item.file === violation.file && item.rule === violation.rule,
    );
    if (exception) {
      suppressed.push({
        file: violation.file,
        rule: violation.rule,
        reason: exception.reason,
      });
    } else {
      kept.push(violation);
    }
  }

  const byLocation = (a: ArchViolation, b: ArchViolation): number =>
    a.rule.localeCompare(b.rule) ||
    a.file.localeCompare(b.file) ||
    (a.target ?? "").localeCompare(b.target ?? "");

  kept.sort(byLocation);
  suppressed.sort(
    (a, b) => a.rule.localeCompare(b.rule) || a.file.localeCompare(b.file),
  );

  return { checked: modules.length, violations: kept, suppressed };
}

/** 把报告转成可直接写进 CI 日志的文本行 */
export function formatReport(report: ArchReport): string[] {
  const lines = [`已检查 ${report.checked} 个模块`];
  if (report.violations.length === 0) {
    lines.push("未发现架构违规");
  }
  for (const violation of report.violations) {
    const target = violation.target ? ` -> ${violation.target}` : "";
    lines.push(`[${violation.rule}] ${violation.file}${target}: ${violation.message}`);
  }
  for (const waived of report.suppressed) {
    lines.push(`[waived:${waived.rule}] ${waived.file}: ${waived.reason}`);
  }
  return lines;
}

实现上有三个刻意的选择。

第一,先校验例外,再扫描违规。空原因的例外直接抛错,指向不存在模块的例外也直接抛错。这样“临时豁免”不会变成永久的隐形规则。

第二,未知 import 单独成规则。它看起来像小事,实际是最容易被利用的绕过方式:把路径写错,检查器找不到目标层,违规就消失了。把未知目标当成错误,边界才是闭合的。

第三,违规排序固定为“规则、文件、目标”。测试可以直接比对整个数组,CI 日志也稳定,不会因为遍历顺序变化而产生噪声 diff。

失败样例

测试里构造了一个六模块的失败样例:发现页视图直接 import 数据仓库,领域模块 import 了 toast 视图,两个状态模块互相 import。检查器输出的真实报告如下:

已检查 6 个模块
[cycle] src/state/draft-store.ts -> src/state/feed-store.ts: 循环依赖: src/state/draft-store.ts -> src/state/feed-store.ts -> src/state/draft-store.ts
[domain-to-view] src/domain/feed.ts -> src/view/toast.ts: 领域 src/domain/feed.ts 反向依赖视图 src/view/toast.ts
[view-to-data] src/view/feed.ts -> src/data/feed-repo.ts: 视图 src/view/feed.ts 直接访问数据层 src/data/feed-repo.ts

三条违规分别对应三条规则,顺序稳定。报告里保留了完整回路,读者不需要回去翻模块清单就能定位。实际接入 CI 时,这份文本可以直接作为失败信息,也可以转成 JSON 交给代码扫描平台。

测试与真实输出

测试文件 test/layers/arch-rules.test.ts 覆盖十种情况:干净模块图、视图直连数据、领域反向依赖、状态间循环、未知 import、失败样例的三条违规与顺序、带理由的例外、空理由例外、例外指向未知模块、重复模块路径。

在实验工程根目录执行:

node --test test/layers/arch-rules.test.ts

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

✔ 逐层向内的干净模块图没有违规 (1.116208ms)
✔ 视图直接 import 数据层会被 rule view-to-data 拦住 (0.158625ms)
✔ 领域层 import 视图会被 rule domain-to-view 拦住 (0.086958ms)
✔ 状态层之间的循环依赖单独报告,并给出完整回路 (0.188958ms)
✔ 未声明的 import 被当成边界违规,而不是静默跳过 (0.090958ms)
✔ 失败样例稳定产出三条违规,顺序可直接比对 (8.367167ms)
✔ 有理由的例外可以豁免,但豁免记录必须保留在报告里 (0.178167ms)
✔ 空理由的例外被拒绝,避免用「临时」两个字绕过规则 (0.299666ms)
✔ 例外指向不存在的模块时直接抛错 (0.141333ms)
✔ 重复声明同一个模块路径会抛错,防止层归属被悄悄覆盖 (0.202333ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 194.255834

例外必须带理由

任何架构规则都会遇到迁移期。旧代码暂时不符合新规则,团队需要一个过渡窗口。问题不在于有没有例外,而在于例外是否可见、是否有期限。

这个检查器要求例外包含 reason,空字符串直接拒绝。报告里保留 suppressed 列表,格式是 [waived:规则] 文件: 原因。这样例外不会消失,只会在每次构建时被打印出来。更进一步的做法是给例外加到期日期,到期后自动失效;本节保持 L1 的纯逻辑,没有引入时钟,日期检查可以放在 CI 脚本层。

例外应该按“文件 + 规则”精确匹配,而不是按目录或整个层豁免。按目录豁免会让新文件自动继承豁免,规则就名存实亡。

接进 CI 的位置

架构测试最适合放在两个位置。

第一个位置是提交前的本地检查。开发者改完 import 就能看到违规,反馈循环最短。它不需要完整构建,只需要生成模块清单并跑检查器。

第二个位置是 CI 的独立阶段,位于类型检查之后、打包之前。放在打包之前,违规不会产出构建产物;放在类型检查之后,可以避免把语法错误误报成架构违规。

模块清单的生成方式取决于工程:可以用 TypeScript 编译器 API 解析 import,可以用 bundler 的依赖图,也可以用一个简单的脚本扫描源文件。检查器只依赖清单,不依赖解析方式,因此可以先用最粗糙的扫描跑起来,再逐步替换。

小结

架构规则只有变成可执行测试,才会真正约束代码。视图不能直接访问数据层、领域不能 import 视图、模块之间不能有循环依赖,这三条规则覆盖了客户端分层最常见的退化方式。

检查器本身要保持纯逻辑:输入模块清单,输出结构化报告。未知 import 要当成错误,例外要带原因并保留在报告里,违规排序要稳定。这样它才能同时服务于本地检查、CI 门禁和代码评审。

下一章进入状态管理:本地状态与共享状态的边界在哪里,服务端状态为什么需要单独的缓存与失效策略。

阅读导航:上一节:4.2 依赖方向与包边界 · 下一节:5.1 本地状态与共享状态 。

常见错误与边界

  • 把架构规则写进文档就认为执行了。没有断言的规则只是建议。
  • 只检查目录,不检查 import。目录正确、依赖错误的项目很常见。
  • 跳过未知 import。检查器找不到目标就放行,等于给违规留后门。
  • 允许按目录整体豁免。新文件会自动继承豁免,规则逐渐失效。
  • 例外不写原因。半年后没人知道这个豁免为什么存在。
  • 把循环检测放在运行时。初始化顺序问题应该在构建前失败。
  • 违规顺序不稳定。测试无法整体比对,CI 日志也充满噪声。
  • 把检查器做成通用静态分析平台。先跑通三条关键规则,再谈扩展。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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