Published on

大多数 bug 出在层与层的交界处:写代码前的两次停顿

Authors
  • avatar
    Name
    Jack Qin
    Twitter

前面几篇讲的都是"怎么写"。这一篇讲的是不同的东西:写之前先想什么。 因为有一大类 bug,根本不是"代码写错了",而是"动手之前少想了一步"——它们在写下第一行之前就已经注定了,再仔细的实现也救不回来。

这类 bug 有两个来源,它们看起来无关,却共享同一个解药。一个是重复代码导致的不一致,一个是层边界处的格式假设。两者都不能靠"写得更小心"来防,因为问题不在某一行代码里,而在代码与代码之间、层与层之间的关系里。能挡掉它们的,是动手前的两次停顿——一次搜索复用,一次画出数据流。这篇就拆这两次停顿各自在防什么、为什么有效。

两类 bug,一个共同点

第一类是重复代码导致的不一致。 复制粘贴一段校验逻辑到另一个文件,当时省了五分钟;三个月后有人修了原处的 bug,复制出去的那份纹丝不动,行为开始分叉。这里的关键认知是:复制粘贴的代价不在复制的那一刻,而在未来每一次只改了其中一份的时刻。重复是不一致 bug 的头号来源,因为它把"一个事实"变成了"多个会各自漂移的副本"。

第二类是层边界处的格式假设。 API 返回格式 A,前端却假设是格式 B;数据库存的是 X,service 转成 Y 时丢了字段;同一段逻辑在多个层各实现一遍,还各不相同。这背后是一个统计事实:大多数 bug 发生在层与层的交界处,而不是层内部。 因为层内部你看得见全部上下文,而边界两侧往往是不同的人、不同的时间写的,双方对"数据长什么样"的假设从未对齐过。

两类 bug 的共同点是:它们都能在动手前用一次"停顿 + 提问"挡掉。所以下面把这两次停顿写成可操作的步骤。

停顿一:写新代码前,先搜一下它是不是已经存在

这次停顿防的是重复。它的逻辑很简单——你无法和一段你不知道存在的代码保持一致,所以第一步是先去找它。

第一步:先搜。

# 搜相似的函数名
grep -r "functionName" .

# 搜相似的逻辑
grep -r "keyword" .

第二步:问这几个问题。

问题如果是……
已经有相似函数了吗?用它或扩展它
这个模式别处用过吗?跟随既有模式
这能不能做成共享工具?放到正确的位置去创建
我是不是在从另一个文件复制代码?——抽取成共享

最后一行是最重要的:复制粘贴是一个停止信号,不是捷径。 当你的手指准备 Ctrl+C 一段逻辑时,那正是该停下来抽取成共享的时刻。

什么时候该抽象,什么时候不该——这里有个对称的陷阱,两边一样常见:

  • 该抽象:同样的代码出现 3 次以上;逻辑复杂到会有 bug;多人可能都需要它。
  • 不该抽象:只用一次;琐碎的一行;抽象本身比重复还复杂。

抽象是有成本的。在该 DRY 的地方 DRY,但别在只用一次的地方造一个比重复更难懂的抽象——"过度抽象"和"放任重复"是同一枚硬币的两面,都是没有诚实评估"这段逻辑到底会出现几次"。

一个隐蔽的坑:不对称机制产出同一份输出。 这是重复问题的一个高级变体,值得单独记。当两套不同机制必须产出同一份文件集时(例如 init 用递归目录拷贝,update 用手动 files.set()),结构性改动(重命名、移动、新增子目录)只会通过自动那套机制传播。手动那套会悄悄漂移——它不是被复制的代码,但它和自动机制之间存在一个隐式的"必须产出相同结果"的契约,而这个契约没有任何东西在强制。

  • 症状:init 完美工作,但 update 把文件创建到了错误路径,或者干脆漏掉了文件。
  • 预防清单:迁移目录结构时,搜出所有引用旧结构的代码路径;如果一条路径是自动推导的(glob/拷贝)、另一条是手动列举的,手动那条必须同步更新;加一个回归测试,比对两套机制的输出。

批量改完之后,做三连:回看——都改到了吗?再搜——grep 找有没有漏的。再想——这是不是该抽象了?

停顿二:做跨层功能前,先把数据流画出来

这次停顿防的是边界处的格式假设。它的逻辑是:bug 既然集中在交界处,那就在动手前把每个交界处显式地摊开来看。

第一步:画出数据流。

来源 → 转换 → 存储 → 取出 → 转换 → 展示

对每一个箭头问三个问题:数据是什么格式?这里可能出什么错?谁负责校验?每个箭头都是一个潜在的格式假设点,画出来就是把隐式假设变成显式提问。

第二步:识别边界。

边界常见问题
API ↔ Service类型不匹配、字段缺失
Service ↔ Database格式转换、null 处理
Backend ↔ Frontend序列化、日期格式
Component ↔ Componentprops 形状变化

第三步:为每个边界定义契约。 每个边界都要回答:确切的输入格式是什么?确切的输出格式是什么?会发生哪些错误?

三个常见的跨层错误,每个都对应一条解药:

  • 隐式格式假设:不检查就假设日期格式。→ 正解:在边界处做显式格式转换。
  • 散落的校验:同一件事在多个层各校验一遍。→ 正解:在入口点校验一次,内层信任已校验的数据。
  • 漏抽象(leaky abstraction):组件知道数据库 schema。→ 正解:每一层只认识它的邻居。

最后那条"每层只认识邻居"是这次停顿的核心准则。组件不该知道数据库的列名,service 不该知道前端的渲染细节——一旦某一层越过邻居去认识更远的层,边界就漏了,远端的任何改动都会直接捅穿过来。

反例

// 反例 1:从另一个文件复制校验逻辑,而不是抽取共享
// fileA.ts
function isValidEmail(s: string) {
  return /.+@.+/.test(s)
}
// fileB.ts
function isValidEmail(s: string) {
  return /.+@.+/.test(s)
} // ❌ 复制粘贴,日后必分叉
// 正解:抽到共享工具,两处 import
// 反例 2:跨层隐式格式假设
// 后端返回 "2026-05-30T00:00:00Z"(UTC),前端直接当本地时间用
const day = new Date(dto.date).getDate() // ❌ 时区一偏就错一天
// 正解:在边界处显式约定并转换日期格式
// 反例 3:漏抽象,组件认识数据库列名
function Row({ data }) {
  return <td>{data.asset_tbl_col_07}</td>; // ❌ 组件不该知道 DB schema
}
// 正解:在边界把 DTO 映射成语义化模型,组件只认识模型
// 反例 4:同一校验散落多层,各写各的
// 入口校验一遍、service 又校验一遍(规则还略有不同) // ❌
// 正解:在入口点校验一次,内层信任已校验的数据

落地建议

  1. 动手前先 grep:写任何"看起来通用"的函数前,先搜它是不是已经存在;复制粘贴是一个停止信号,不是捷径。
  2. 3 次法则:同一段逻辑出现第 3 次时再抽象;只用一次就别造抽象。
  3. 跨 3 层就画图:功能跨 3 层以上、涉及多团队、数据格式复杂或历史上出过 bug 时,把数据流画出来,逐箭头定契约。
  4. 校验一次,定在入口:别让同一规则在多层各活一份。
  5. 每层只认识邻居:组件不该认识数据库 schema;在边界处把 DTO 映射成语义模型。
  6. 批量改完三连:回看 → grep 漏网 → 想要不要抽象。

可迁移的那一层

这两次停顿表面上一个讲复用、一个讲数据流,但它们防的是同一类东西:关系型 bug——不在任何单行代码里,而在代码与代码、层与层之间的关系里。 重复是"多个副本之间该保持一致却没人强制"的关系,边界假设是"两侧对数据形状的理解该对齐却从未对齐"的关系。这类 bug 用"写得更仔细"是防不住的,因为问题压根不在你正在写的那一行。

唯一有效的办法,是在动手前花几十秒把这些关系显式地摊开:这段逻辑已经存在吗?这条数据要穿过几个边界、每个边界两侧的契约是什么?写代码之前的这两次停顿,挡掉的往往比写代码时的全部小心加起来还多。