《互联网产品的客户端架构·规模化与架构演进》11.2 代码所有权与评审

从一次合并后三周无人认领的线上问题出发,把代码所有权拆成路径规则、owner 映射、覆盖度、评审路由与无人负责路径识别五部分,给出可运行的 TypeScript 模块与测试。文中说明优先级覆盖、通配规则边界、兜底 owner 的取舍,以及所有权规则为什么会悄悄失效。

一次没人认领的线上问题

某客户端的发布流程里有一条约定:每个合并请求至少要有两位评审人,其中一位必须是相关模块的 owner。约定执行了半年,直到一次线上问题暴露了漏洞。

问题出在一个共享的桥接层:它既不属于平台组,也不属于业务组,是两位工程师在赶版本时临时加的。代码合并时,评审人是从整个客户端团队里随机抽的,没人真正看过这段逻辑。三周后桥接层超时,值班同学在群里问「这块谁负责」,沉默了两个小时,最后由一位已经转岗的工程师凭记忆修好。

复盘时团队发现两件事。第一,仓库里根本没有一份可执行的 owner 映射,所谓「相关模块 owner」全靠自觉。第二,即使有映射,也覆盖不到这个新建目录,因为没有人规定「新增目录必须同时登记 owner」。

代码所有权要解决的不是「谁写得多」,而是三个更具体的问题:每条路径有没有明确负责人、一次变更应该找谁评审、哪些路径处于无人负责状态。

owner 映射是一组带优先级的规则

最朴素的做法是给每个目录写一个 owner。但真实仓库里,规则会重叠:src/platform/** 归平台组,src/platform/legacy/** 又归遗留系统维护组。没有优先级,结果取决于规则的遍历顺序,同一个路径可能在不同工具里得到不同 owner。

字段含义约束
pattern路径匹配规则支持精确路径、*、** 与目录后缀
owner最终负责团队稳定标识,不写个人昵称
reviewers需要参与评审的角色可空,会自动去重排序
priority冲突时的覆盖顺序越大越优先,缺省为 0

优先级相同时,先声明的规则胜出。这条规则看起来随意,但它保证了确定性:同样的规则列表,无论运行多少次、在哪台机器上,都会得到同一个 owner。确定性比「更聪明」的冲突消解更重要。

匹配规则要克制。支持精确路径、单星号、双星号与 /** 目录后缀就够了。引入正则或复杂 glob 方言,会让规则本身变成需要维护的代码,而且不同语言的实现细节不一致,最终没人敢改。

/** 的语义要明确:它匹配目录本身,也匹配目录下的所有子孙路径,但不匹配前缀相同的兄弟目录。src/platform/** 不应该匹配 src/platformx/a.ts。这个边界在实现里必须写清楚,否则一次重构就会把 owner 分错。

覆盖度不是百分比,而是缺口清单

很多团队把所有权覆盖率做成一个百分比,挂在看板上。百分比适合汇报,不适合行动。真正有用的是缺口清单:哪些路径没有任何规则命中。

指标定义用途
total参与统计的路径总数确认统计范围
owned至少命中一条规则的路径数计算覆盖比例
unowned没有命中任何规则的路径清单直接生成待办
ratioowned / total趋势观察,不单独作为目标

空集合的覆盖率定义为 1,而不是 0。这不是数学上的严密,而是工程上的选择:没有路径时不存在缺口,报 0 会让看板出现虚假告警。

覆盖度统计必须使用真实的变更路径,而不是整个仓库的文件列表。一个从未被修改的第三方目录没有 owner,并不影响评审;一条本周要合并的路径没有 owner,才是必须处理的问题。把统计范围收窄到「最近一个发布周期内变更过的路径」,缺口清单会短得多,也更容易推动。

缺口清单还要能区分「新增路径」和「历史遗留路径」。新增路径没有 owner,说明登记流程有问题,应该在合并前阻断;历史路径没有 owner,说明存量治理还没做完,应该进入技术债台账。两者混在一起,团队只会看到一条永远清不完的清单。

评审路由要收敛,而不是扩散

评审路由的目标是「找对人」,不是「找所有人」。一条变更路径可能同时命中多条规则,但最终只需要一组 owner 和一组 reviewer。

路由规则可以归纳为四步:

  1. 对每条变更路径求出优先级最高的 owner。
  2. 汇总所有路径的 owner,去重。
  3. 汇总这些 owner 规则上的 reviewer,去重。
  4. 对无人负责的路径单独标记,触发升级。

去重之后要排序。排序不是为了好看,而是为了让评审请求的内容稳定:同一组路径无论顺序如何,生成的评审人列表都一样,机器人才不会每次 @ 不同的人。

兜底 owner 是一个有争议的设计。它能让评审请求总是有人接,但也会掩盖「无人负责」这个事实。折中做法是:兜底 owner 只负责把请求转给正确的人,不负责实际评审;同时 escalate 标记必须保留,让流程知道这次路由是降级结果。

没有兜底 owner 时,无人负责路径应该直接失败,而不是静默跳过。评审请求发不出去,比发给一个不相关的人更容易被发现。

无人负责路径与陈旧规则

所有权映射有两种失效方式,方向相反。

第一种是路径没有规则。新增目录、临时脚本、跨团队共建模块最容易出现。检测方法很直接:拿变更路径逐条匹配,收集没有命中的路径。

第二种是规则没有路径。团队重组、目录迁移、模块下线之后,旧规则还留在配置里。它不会造成错误归属,但会让人误以为某个目录仍有人负责。检测方法同样直接:拿规则逐条匹配现有路径,收集从未命中的规则。

两种失效要一起看。只查缺口,会积累一堆指向空气的规则;只查陈旧规则,会漏掉新增目录。把两份清单放在同一张治理看板上,才能看出所有权结构是在收敛还是在膨胀。

陈旧规则不能自动删除。有些规则是为即将合并的分支准备的,有些规则覆盖的是低频但重要的路径。正确做法是标记出来,由 owner 确认后删除,并在删除时留下记录。

把所有权写成代码

下面的模块实现路径规范化、通配匹配、优先级解析、覆盖度统计、评审路由与陈旧规则识别。所有函数都是纯函数,不读文件系统,规则与路径由调用方传入,方便在持续集成里对真实变更集运行。

// file: src/governance/code-ownership.ts
// tier: L1
// verify: node --test test/governance/code-ownership.test.ts

export type OwnerRule = {
  pattern: string;
  owner: string;
  reviewers?: string[];
  priority?: number;
};

export type OwnerMatch = {
  path: string;
  owner: string;
  pattern: string;
  priority: number;
  reviewers: string[];
};

export type CoverageReport = {
  total: number;
  owned: number;
  unowned: string[];
  ratio: number;
};

export type ReviewRoute = {
  owners: string[];
  reviewers: string[];
  unowned: string[];
  escalate: boolean;
};

function escapeRegExp(text: string): string {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function normalizePath(input: string): string {
  let path = input.trim().replace(/\\/g, "/");
  while (path.startsWith("./")) {
    path = path.slice(2);
  }
  return path.replace(/\/+$/, "");
}

export function matchPattern(pattern: string, target: string): boolean {
  const p = normalizePath(pattern);
  const path = normalizePath(target);
  if (p.endsWith("/**")) {
    const base = p.slice(0, -3);
    return path === base || path.startsWith(`${base}/`);
  }
  if (!p.includes("*")) {
    return p === path;
  }
  const source = p
    .split("**")
    .map((part) => part.split("*").map(escapeRegExp).join("[^/]*"))
    .join(".*");
  return new RegExp(`^${source}$`).test(path);
}

export function resolveOwner(rules: OwnerRule[], target: string): OwnerMatch | undefined {
  let best: OwnerMatch | undefined;
  for (const rule of rules) {
    if (!matchPattern(rule.pattern, target)) {
      continue;
    }
    const priority = rule.priority ?? 0;
    if (best === undefined || priority > best.priority) {
      best = {
        path: normalizePath(target),
        owner: rule.owner,
        pattern: rule.pattern,
        priority,
        reviewers: [...(rule.reviewers ?? [])],
      };
    }
  }
  return best;
}

export function resolveAll(rules: OwnerRule[], paths: string[]): OwnerMatch[] {
  const matches: OwnerMatch[] = [];
  for (const path of paths) {
    const match = resolveOwner(rules, path);
    if (match !== undefined) {
      matches.push(match);
    }
  }
  return matches;
}

export function coverageReport(rules: OwnerRule[], paths: string[]): CoverageReport {
  const unowned = paths
    .filter((path) => resolveOwner(rules, path) === undefined)
    .map((path) => normalizePath(path));
  const total = paths.length;
  const owned = total - unowned.length;
  return {
    total,
    owned,
    unowned,
    ratio: total === 0 ? 1 : owned / total,
  };
}

export function unownedPaths(rules: OwnerRule[], paths: string[]): string[] {
  return coverageReport(rules, paths).unowned;
}

export function coverageByOwner(rules: OwnerRule[], paths: string[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const path of paths) {
    const match = resolveOwner(rules, path);
    if (match === undefined) {
      continue;
    }
    counts[match.owner] = (counts[match.owner] ?? 0) + 1;
  }
  return counts;
}

export function busiestOwner(rules: OwnerRule[], paths: string[]): string | undefined {
  const counts = coverageByOwner(rules, paths);
  let best: string | undefined;
  for (const [owner, count] of Object.entries(counts)) {
    if (best === undefined || count > counts[best]) {
      best = owner;
    }
  }
  return best;
}

export function reviewRoute(
  rules: OwnerRule[],
  paths: string[],
  fallback?: string,
): ReviewRoute {
  const owners = new Set<string>();
  const reviewers = new Set<string>();
  const unowned: string[] = [];
  for (const path of paths) {
    const match = resolveOwner(rules, path);
    if (match === undefined) {
      unowned.push(normalizePath(path));
      if (fallback !== undefined) {
        owners.add(fallback);
      }
      continue;
    }
    owners.add(match.owner);
    for (const reviewer of match.reviewers) {
      reviewers.add(reviewer);
    }
  }
  return {
    owners: [...owners].sort(),
    reviewers: [...reviewers].sort(),
    unowned: [...new Set(unowned)].sort(),
    escalate: unowned.length > 0,
  };
}

export function staleRules(rules: OwnerRule[], paths: string[]): string[] {
  return rules
    .filter((rule) => !paths.some((path) => matchPattern(rule.pattern, path)))
    .map((rule) => rule.pattern);
}

export function fullyOwned(rules: OwnerRule[], paths: string[]): boolean {
  return unownedPaths(rules, paths).length === 0;
}

实现里有三个细节值得说明。

第一,resolveOwner 只在优先级严格大于当前最优时才替换,因此同优先级下先声明的规则胜出。这个比较符号很容易写反,一旦写成大于等于,规则顺序就会悄悄改变归属。

第二,reviewRoute 用 Set 去重,最后统一排序。如果直接按遍历顺序输出,路径顺序变化会导致评审人顺序变化,机器人可能重复 @ 同一个人。

第三,staleRules 判断的是「规则是否命中过任何路径」,而不是「规则是否是最终 owner」。一条被高优先级规则完全覆盖的低优先级规则,仍然算命中,只是不生效。是否要把它也标记为冗余,是另一个更严格的治理问题,不应该混进基础检测。

判据与反例

判断一套所有权机制是否可信,可以看下面几条。

判据合格表现反例
确定性同规则同路径永远同一 owner依赖遍历顺序或哈希
可解释能说出命中哪条规则只返回一个名字
有缺口无人负责路径能列出来覆盖率 100% 但没有清单
能升级无人负责时流程可见静默跳过或随机分配
规则可回收陈旧规则能被识别配置只增不减
路由稳定评审人列表排序去重每次 @ 不同的人

一个常见反例是把 owner 写成个人。个人会转岗、休假、离职,而团队标识相对稳定。即使最终要落到人,也应该先映射到团队,再由团队内部的值班表决定具体评审人。把个人写进仓库配置,等于把组织变动变成构建失败。

另一个反例是「覆盖率 100% 就代表治理完成」。只要兜底规则写成 **,覆盖率永远是 100%,但所有路径都归同一个 owner,评审路由失去意义。覆盖度必须和 owner 分布一起看:如果某个 owner 覆盖了超过一半的路径,说明规则粒度太粗,或者团队结构已经失衡。

还有一种反例是「规则只增不减」。每次组织调整都加一条新规则,旧规则保留「以防万一」。一年后配置里有上百条规则,没人知道哪些还有效。规则数量本身不是问题,无法判断有效性才是问题。陈旧规则检测就是为此存在的。

最后一种反例是「评审路由绕开 owner」。机器人因为找不到 owner,直接把请求发给整个团队群。看起来问题解决了,实际上无人负责的路径被永久掩盖。正确的降级是显式升级,而不是扩大广播范围。

小结

代码所有权是一组可执行的规则,不是一张组织架构图。

  • owner 映射要有优先级与稳定的冲突规则,同优先级先声明者胜出。
  • 覆盖度要输出缺口清单,而不是只报一个百分比。
  • 评审路由汇总 owner 与 reviewer,去重排序,无人负责时显式升级。
  • 无人负责路径和陈旧规则要一起检测,一个管增量,一个管存量。

所有权写进配置、接进流水线,才会真正约束合并;停留在文档里的 owner 表,三周后就会和仓库对不上。

阅读导航:上一节:11.1 架构决策记录与契约 · 下一节:11.3 技术债台账与治理节奏 。

常见错误与边界

  • owner 写成个人昵称。转岗或离职后规则失效,团队标识更稳定。
  • 冲突规则没有优先级。归属取决于遍历顺序,工具之间结果不一致。
  • /** 匹配到前缀相同的兄弟目录。一次重构就会把 owner 分错。
  • 只报覆盖率百分比。没有缺口清单,团队不知道该处理哪条路径。
  • 兜底规则写成 **。覆盖率永远 100%,但路由失去区分度。
  • 无人负责时静默跳过。评审请求发不出去,问题被掩盖。
  • 规则只增不减。陈旧规则让配置膨胀,没人敢删。
  • 评审人列表不排序。路径顺序变化会导致机器人反复 @ 不同的人。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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