《互联网产品的客户端架构·规模化与架构演进》6.1 Monorepo 与包边界

Monorepo 的难点不是把代码放进一个仓库,而是让包边界可执行。本节把包声明、依赖、公开与内部可见性、子路径导出写成一张依赖图,用拓扑排序与环检测给出构建顺序,再用纯函数判断跨包引用是否合法,并复盘一次深路径引用绕过评审的事故。边界一旦只写在文档里,就会在第一次赶工期时失效。

一次「只改一个包」的发布

某客户端产品把 Web、iOS、Android 与桌面端的共享代码收进同一个仓库,包数量从 6 个涨到 47 个。一次只改了 ui-kit 里一个按钮的圆角,发布却花了 22 分钟:CI 把所有包重新构建了一遍;更糟的是,预览环境启动后白屏,报错指向 @shop/ui-kit/src/internal/theme。

这条路径在代码评审里看起来「只是引用一个常量」,但它穿过了包的公开入口,直接读到了内部实现。构建没有失败,测试也没有失败,因为两边都能解析到文件。真正失效的是边界:仓库把代码放在了一起,却没有定义什么可以跨过去。

边界不是目录,是依赖声明

把包放进 packages/ 只完成了物理分层。可执行的边界至少要回答四个问题:

问题落点失效后的表现
谁依赖谁dependencies构建顺序靠猜,改动影响面算不清
能不能依赖public / internal公开包把内部实现带进发布产物
从哪里进入exports深路径引用绕过入口,重构即破坏
有没有环依赖图构建顺序不存在,只能全量重编

前三项是声明,第四项是声明之后必须计算的属性。很多团队只做第一项,于是 monorepo 退化成一个「很大的仓库」:目录很整齐,依赖仍然靠口头约定。

公开与内部的区分最容易写错。公开包是发布给其他团队或外部应用使用的,它的 API 变更需要版本与公告;内部包只服务于本仓库的构建、工具或实验,可以随时重构。规则只有一条:公开包不得依赖内部包。反过来允许,因为内部工具可以站在稳定 API 上。

exports 是第二条容易被忽略的边界。即使两个包都在仓库内,@shop/ui-kit/src/internal/theme 这种深路径也应该被拒绝;它绕过了入口,入口处的兼容层、副作用顺序与类型收敛全部失效。允许的引用只有两种:包名本身,以及 exports 中显式列出的子路径。

依赖图的模型

代码里的图由五类信息组成:

字段含义例子
name包的唯一标识@shop/http
dir包在仓库中的位置packages/http
visibility对外可见还是仅仓库内可见public / internal
dependencies允许引用的包名["@shop/core"]
exports允许进入的子路径[".", "./retry"]

诊断也只需要四类:重复包名、依赖自身、依赖不存在的包、公开包依赖内部包。环检测是图的属性,不属于单个包,所以单独计算。把声明与计算分开,CI 才能在改动一个包时只重跑受影响的子图,而不是全量构建。

把依赖图写成代码

下面的模块不读文件系统,也不解析 package.json;它接收已经加载的包声明,输出诊断、环、构建顺序与引用判定。这样同一套规则既能给 CI 用,也能给编辑器的快速修复用。

// file: src/build/package-graph.ts
// tier: L1
// verify: node --test test/build/package-graph.test.ts

export type Visibility = "public" | "internal";

export type PackageNode = {
  name: string;
  dir: string;
  visibility: Visibility;
  dependencies: string[];
  exports: string[];
};

export type GraphDiagnostic = {
  code:
    | "duplicate"
    | "self-dependency"
    | "unknown-dependency"
    | "visibility";
  from: string;
  to?: string;
  detail: string;
};

export type ReferenceReason =
  | "ok"
  | "unknown-source"
  | "unknown-target"
  | "self-reference"
  | "undeclared"
  | "visibility"
  | "deep-import";

export type ReferenceResult = {
  ok: boolean;
  reason: ReferenceReason;
  detail: string;
};

export function packageNames(packages: PackageNode[]): string[] {
  return packages.map((pkg) => pkg.name);
}

export function validateGraph(packages: PackageNode[]): GraphDiagnostic[] {
  const diagnostics: GraphDiagnostic[] = [];
  const firstSeen = new Map<string, number>();
  for (const pkg of packages) {
    if (firstSeen.has(pkg.name)) {
      diagnostics.push({
        code: "duplicate",
        from: pkg.name,
        detail: `重复的包名 ${pkg.name},首次出现在下标 ${firstSeen.get(pkg.name)}`,
      });
    } else {
      firstSeen.set(pkg.name, firstSeen.size);
    }
  }

  const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
  for (const pkg of packages) {
    for (const dep of pkg.dependencies) {
      if (dep === pkg.name) {
        diagnostics.push({
          code: "self-dependency",
          from: pkg.name,
          to: dep,
          detail: `${pkg.name} 声明依赖自身`,
        });
        continue;
      }
      const target = byName.get(dep);
      if (!target) {
        diagnostics.push({
          code: "unknown-dependency",
          from: pkg.name,
          to: dep,
          detail: `${pkg.name} 依赖了不存在的包 ${dep}`,
        });
        continue;
      }
      if (pkg.visibility === "public" && target.visibility === "internal") {
        diagnostics.push({
          code: "visibility",
          from: pkg.name,
          to: dep,
          detail: `公开包 ${pkg.name} 不得依赖内部包 ${dep}`,
        });
      }
    }
  }
  return diagnostics;
}

function canonicalCycle(cycle: string[]): { key: string; cycle: string[] } {
  let pivot = 0;
  for (let i = 1; i < cycle.length; i += 1) {
    if (cycle[i] < cycle[pivot]) pivot = i;
  }
  const rotated = [...cycle.slice(pivot), ...cycle.slice(0, pivot)];
  return { key: rotated.join(" -> "), cycle: rotated };
}

export function detectCycles(packages: PackageNode[]): string[][] {
  const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
  const state = new Map<string, 0 | 1 | 2>();
  const stack: string[] = [];
  const found = new Map<string, string[]>();

  const visit = (name: string): void => {
    state.set(name, 1);
    stack.push(name);
    for (const dep of byName.get(name)?.dependencies ?? []) {
      if (dep === name || !byName.has(dep)) continue;
      const depState = state.get(dep) ?? 0;
      if (depState === 0) {
        visit(dep);
      } else if (depState === 1) {
        const start = stack.indexOf(dep);
        const { key, cycle } = canonicalCycle(stack.slice(start));
        found.set(key, cycle);
      }
    }
    stack.pop();
    state.set(name, 2);
  };

  for (const pkg of packages) {
    if ((state.get(pkg.name) ?? 0) === 0) visit(pkg.name);
  }
  return [...found.keys()].sort().map((key) => found.get(key) as string[]);
}

export function buildOrder(packages: PackageNode[]): {
  order: string[];
  cycles: string[][];
} {
  const names = [...new Set(packages.map((pkg) => pkg.name))].sort();
  const known = new Set(names);
  const indegree = new Map(names.map((name) => [name, 0]));
  const dependents = new Map<string, string[]>(
    names.map((name) => [name, []]),
  );

  for (const pkg of packages) {
    if (!known.has(pkg.name)) continue;
    const deps = [...new Set(pkg.dependencies)].filter(
      (dep) => known.has(dep) && dep !== pkg.name,
    );
    indegree.set(pkg.name, deps.length);
    for (const dep of deps) {
      dependents.get(dep)?.push(pkg.name);
    }
  }

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

  return { order, cycles: detectCycles(packages) };
}

export function allowedReference(
  packages: PackageNode[],
  from: string,
  to: string,
  importPath: string,
): ReferenceResult {
  const source = packages.find((pkg) => pkg.name === from);
  if (!source) {
    return {
      ok: false,
      reason: "unknown-source",
      detail: `未知来源包 ${from}`,
    };
  }
  const target = packages.find((pkg) => pkg.name === to);
  if (!target) {
    return {
      ok: false,
      reason: "unknown-target",
      detail: `未知目标包 ${to}`,
    };
  }
  if (from === to) {
    return {
      ok: false,
      reason: "self-reference",
      detail: `同一包内应使用相对路径,而不是包名 ${to}`,
    };
  }
  if (!source.dependencies.includes(to)) {
    return {
      ok: false,
      reason: "undeclared",
      detail: `${from} 未在依赖中声明 ${to}`,
    };
  }
  if (source.visibility === "public" && target.visibility === "internal") {
    return {
      ok: false,
      reason: "visibility",
      detail: `公开包 ${from} 不得引用内部包 ${to}`,
    };
  }
  if (importPath !== to && !importPath.startsWith(`${to}/`)) {
    return {
      ok: false,
      reason: "deep-import",
      detail: `引用路径 ${importPath} 不以包名 ${to} 开头`,
    };
  }
  const subpath =
    importPath === to ? "." : `./${importPath.slice(to.length + 1)}`;
  if (!target.exports.includes(subpath)) {
    return {
      ok: false,
      reason: "deep-import",
      detail: `${to} 未导出子路径 ${subpath}`,
    };
  }
  return { ok: true, reason: "ok", detail: `${importPath} 允许引用` };
}

export function transitiveDependents(
  packages: PackageNode[],
  name: string,
): string[] {
  const reverse = new Map<string, Set<string>>();
  for (const pkg of packages) {
    for (const dep of pkg.dependencies) {
      if (!reverse.has(dep)) reverse.set(dep, new Set());
      reverse.get(dep)?.add(pkg.name);
    }
  }
  const seen = new Set<string>();
  const queue = [...(reverse.get(name) ?? [])];
  while (queue.length > 0) {
    const current = queue.shift() as string;
    if (seen.has(current)) continue;
    seen.add(current);
    for (const dependent of reverse.get(current) ?? []) queue.push(dependent);
  }
  return [...seen].sort();
}

allowedReference 的检查顺序是刻意的:先确认来源与目标存在,再拒绝同包名引用,然后检查依赖声明、可见性与子路径。顺序决定错误信息,也决定修复建议;如果把可见性放在依赖声明之前,一个未声明的引用会被误报成边界违规,开发者会去改可见性而不是补依赖。

buildOrder 使用 Kahn 算法,并在每一轮对就绪集合排序。排序不是为了正确性,而是为了可重现:同样的输入必须得到同样的构建顺序,否则缓存键、日志与并行调度都会抖动。

测试与真实输出

在实验工程根目录执行:

node --test test/build/package-graph.test.ts

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

✔ 无环图没有环 (0.051208ms)
✔ 拓扑顺序满足依赖先后 (0.184791ms)
✔ 有环时拓扑序排除环内包 (0.075542ms)
✔ 传递依赖方按名字排序 (0.101875ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.523667

二十个用例覆盖了诊断、环、拓扑序与引用判定。它们固定的是规则本身;package.json 的读取、工作区链接与打包器解析属于集成层,需要另外的端到端测试。

判据与反例

判断一个 monorepo 是否有边界,不看目录树,看下面五条能否自动回答:

判据通过的样子反例
依赖可枚举每个包的依赖都在声明里代码能 import,声明里没有
环可发现CI 输出具体环路径构建偶发失败,没人知道为什么
顺序可重现同一提交得到同一拓扑序并行调度导致产物顺序抖动
可见性可执行公开包引用内部包直接失败只在评审规范里写了一句
入口可收敛深路径引用被拒绝src/internal/... 散落在业务代码

最常见的反例是「能跑就算合法」。打包器确实能解析深路径,TypeScript 也能找到类型,于是评审通过;直到内部文件重命名,十几个业务包同时编译失败。边界的作用不是阻止一次构建,而是让未来的重构有退路。

第二个反例是把环当成风格问题。两个包互相引用时,构建工具可能通过全量编译「绕过去」,但增量构建、缓存与按包发布都会失效。环不是慢,而是不可预测。

第三个反例是只检查直接依赖。app -> http -> core 中,app 没有直接声明 core,却可能通过 http 的入口间接拿到 core 的类型。幽灵依赖让依赖图看起来干净,实际影响面仍然存在。判据要能沿着传递闭包回答「改 core 会影响到谁」。

小结

Monorepo 的价值不是「代码在一起」,而是「边界可计算」。

  • 包边界由依赖、可见性与导出子路径三件事组成,缺一项就退化成目录约定。
  • 环检测与拓扑排序是图的属性,必须由工具计算,不能靠人肉评审。
  • 引用判定要给出具体原因,开发者才知道该补依赖、改入口还是拆包。

先让非法引用在本地失败,再谈构建加速。顺序反了,加速只会让错误更快地进入产物。

阅读导航:上一节:5.3 故障演练与恢复验证 · 下一节:6.2 产物治理与缓存 。

常见错误与边界

  • 只按目录划分包,不声明依赖。构建顺序与实际引用脱节,改动影响面无法计算。
  • 允许公开包依赖内部包。内部实现一旦进入公开 API,后续重构都要考虑外部消费者。
  • 用 exports 只做发布配置,不做仓库内约束。深路径引用仍然能绕过入口。
  • 把环依赖留给打包器「自动处理」。全量构建能过,增量构建与按包发布都会失效。
  • 只检查直接依赖,忽略传递依赖。幽灵依赖会让影响面分析偏小。
  • 拓扑排序不排序就绪集合。同样的提交得到不同构建顺序,缓存与日志无法比较。
  • 诊断只返回布尔值。开发者不知道是缺声明、越可见性还是深路径,修复只能靠猜。
  • 把边界规则写进文档但不进 CI。第一次赶工期,深路径引用就会重新出现。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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