FlowingDev

深入浅出 Diff:Git 和代码审查的秘密武器

学习 diff 算法如何精确地找出文本间的增、删、改,它是版本控制和代码协作的核心引擎。

试用工具: 差异对比器

一句话概括

所谓 'diff',就是一份计算出来的摘要,它精确地展示了两个文件或文本块之间的差异,告诉你从“之前”到“之后”到底增加了、删除了或改变了什么。

它解决的问题

想象一下 20 世纪 70 年代初的计算机世界。存储设备贵到令人发指,连接到远程计算机的调制解调器比昏昏欲睡的蜗牛还慢。你是贝尔实验室的一名开发人员,需要更新校园另一端服务器上的一个源代码文件。这个文件有几千行,但你只改了其中三行。

你会通过那慢如糖浆的网络连接把整个文件再发一遍吗?想都别想。那纯粹是浪费时间和资源。你真正想要的,是只发送那些改动。

这正是驱使道格拉斯·麦克罗伊(Douglas McIlroy)在 1974 年为 Unix 操作系统创造了最初的 diff 命令的那个问题。他的目标是创造一个工具,能够以编程方式找出将一个文件变成另一个文件所需的最少行级改动。这个 diff 命令的输出,一个“补丁”(patch)文件,体积微小,可以被快速发送。接收者随后可以用一个配套程序 patch,将这些改动应用到他们自己那份原始文件的副本上,从而完成更新。

这个简单而强大的想法——将变化本身作为一种数据隔离开来——是革命性的。它是所有现代版本控制系统(如 Git、Subversion 和 Mercurial)的根本基石。它也是代码审查、文档协作工具(比如 Google Docs 的“建议”模式)以及配置管理系统背后的引擎。它解决了在任何数字文本中追踪和传达演变的核心问题。

底层原理

乍一看,做 diff 似乎很简单:扫描两个文本,标出不同的地方就行了。但要高效地做到这一点,并产出最小、最易读的差异集,却是一个经典的计算机科学问题。其中的秘诀不在于寻找不同之处,而在于寻找相同之处。

最长公共子序列(Longest Common Subsequence, LCS)

大多数 diff 算法,包括驱动了初代 diff 的著名的 Hunt–McIlwain 算法,都是基于解决“最长公共子序列”(LCS)问题。

子序列(subsequence)是指一个序列中的项,它们在原序列中按相同顺序出现,但不一定彼此相邻。LCS 则是两个序列共同拥有的最长的那个子序列。

让我们用一个简单的非代码例子来说明。

  • 原文: The quick red fox
  • 新文: The slow red cat

算法会逐行(在这个例子里是逐词)工作,找到最长公共子序列是:The red。

一旦找到了 LCS,逻辑就很简单了:

  • 原文中任何不在 LCS 里的项,都必然是被删除了。(quick, fox)
  • 新文中任何不在 LCS 里的项,都必然是被添加了。(slow, cat)

通过找到这个最长的共享内容基石,算法就能清晰简洁地识别出围绕在它周围的变化孤岛。这种方法能产生一个最小的差异集,这正是我们想要的一个干净、易于理解的 diff。

从 LCS 到可读的 Diff

找出变化只是战斗的一半。另一半是把它们用一种标准化的、可读的格式呈现出来。如果你看过 GitHub 的 pull request,你很可能见过这个。最常见的格式是“统一差异格式”(unified diff format)。

让我们把它应用到一个稍有不同的例子上:

  • 文件 A (旧):
    An apple a day.
    Keeps the doctor away.
    Or so they say.
    
  • 文件 B (新):
    An apple a day,
    Keeps the doctor away.
    For what it's worth.
    

一个 diff 工具会生成类似下面的东西:

--- a/file_a.txt
+++ b/file_b.txt
@@ -1,3 +1,3 @@
-An apple a day.
+An apple a day,
 Keeps the doctor away.
-Or so they say.
+For what it's worth.

我们来分解一下:

  • --- a/file_a.txt: “来源”文件。- 表示删除内容的来源。
  • +++ b/file_b.txt: “目标”文件。+ 表示增加内容的来源。
  • @@ -1,3 +1,3 @@: 这是“Hunk 头部”。它有点神秘,但能提供上下文。-1,3 的意思是“这个 hunk 从原始文件的第 1 行开始,长度为 3 行”。+1,3 的意思是“这个 hunk 从新文件的第 1 行开始,长度为 3 行”。
  • 以空格( )开头的行是上下文行。它们在两个文件中完全相同,显示出来是为了帮助你理解变化发生的位置。
  • 以 - 开头的行是删除行。它们只存在于“之前”的文本中。
  • 以 + 开头的行是增加行。它们只存在于“之后”的文本中。

这个工具把从 An apple a day. 到 An apple a day, 的变化,不是显示为对单行的修改,而是显示为删除了旧行并添加了新行。这种基于行的方式是大多数传统 diff 工具的核心特征。

超越纯文本:语义化 Diff

标准的基于行的 diff 对散文或代码来说很棒,但在处理像 JSON、XML 或 YAML 这样的结构化数据时就不好使了。

看看这个 JSON:

// 原始
{
  "name": "Alex",
  "role": "Developer"
}

再看看这个:

// 新
{
  "role": "Developer",
  "name": "Alex"
}

一个基于文本的 diff 会认为这是一次完全的清除和重写:

-  "name": "Alex",
-  "role": "Developer"
+  "role": "Developer",
+  "name": "Alex"

技术上说这没错,但在语义上毫无用处。JSON 对象中键的顺序通常无关紧要。一个语义化 diff 工具则要聪明得多。它会先将文本解析成数据结构,然后再比较这些结构。它会正确地识别出这两个 JSON 对象是完全相同的,因此没有任何差异。这对于比较配置文件、API 响应或任何其他你关心其含义而非文本格式的结构化数据至关重要。

真实世界的案例

一个字符的 Bug 搞垮了整个支付流程

一个初级开发者正在集成一个新的支付提供商。他从文档中复制了 API 请求示例,填上自己的密钥,然后运行。失败了。他又试了一次。又失败了。他花了几个小时死盯着自己的代码和文档,坚信它们一模一样。沮丧之下,他把“能用”的文档示例粘贴到 diff 检查工具的一侧,把自己的代码粘贴到另一侧。

起初,看起来完全一样。但随后他注意到,在他的 API 密钥那一行末尾有一个微小的、高亮的标记。一个单一的、看不见的尾随空格。从网页上复制粘贴时带上了它,而他的代码忠实地把它一起发送了出去,导致密钥失效。diff 工具,它把空格看作和任何其他字符一样,是唯一能发现它的“眼睛”。

经验之谈: Diff 是你的终极显微镜。它没有任何预设,会精确地告诉你那里有什么,包括那些能让系统瘫痪的隐形字符。

配置漂移引发的灾难

一个高流量网站开始出现奇怪的、间歇性的错误。值班工程师 Maya 百思不得其解。上一次部署是一周前,一直很稳定。日志里没有任何明确的线索。她的“蜘蛛感应”告诉她,服务器上有什么东西被手动改过了。

她从 Git 仓库里拉取了官方的 Nginx 配置文件,然后 SSH 到生产服务器上,复制了实际运行的配置。她把两者都粘贴到一个 diff 工具里。有了!有三行不一样。有人上周为了修复一个小问题,直接在服务器上加了一条“临时”重定向规则,然后忘得一干二净。这个“修复”现在正与新的流量模式发生冲突。Maya 删掉了那几行“流氓”代码,错误就消失了。团队立即实施了一项新政策:每天对照 Git 审计服务器配置。

经验之谈: 版本控制系统是你的“事实之源”。将现实情况与这个事实之源进行 diff,是检测“配置漂移”和发现未经授权或被遗忘的改动的最佳方式。

“我瞅着没问题”的代码审查

高级开发者 Ben 收到了一个新员工的 pull request。标题是“更新”。diff 是一片红红绿绿的海洋,横跨 20 个文件,超过 3000 行。它包含了一个新功能、一个不相关 bug 的修复、一次从制表符到空格的大规模代码格式化,以及一个库的升级。这根本没法审查。那个 bug 修复正确吗?新功能有没有引入安全漏洞?这些全都被隐藏在雪花般的空格改动中了。

Ben 拒绝了这个 PR,并附上了一条友善的留言:“欢迎!一个 PR 的 diff 应该只讲一个故事。你这个一下子想讲四个。能麻烦你拆成四个独立的 PR 吗?”新员工照做了。格式化 PR 立刻被批准了。Bug 修复很容易验证。库升级也直截了当。而那个新功能,终于可以凭其自身的价值被审查了。

经验之谈: 一个 diff 的价值与其规模和复杂性成反比。代表单一逻辑变更的小而专注的 diff,才易于审查、理解和日后调试。

常见错误和陷阱

  • 忽略空白字符。 从制表符到空格的转换,或添加一个尾随换行符,在 diff 工具看来可能像是对整个文件的巨大改动。虽然有时这是故意的,但更多时候它只是制造噪音,掩盖了真正有意义的变更。请配置你的工具来适当地忽略或高亮显示空白字符的变化。
  • “语义盲”的 diff。 如前所述,对 JSON 或 XML 这样的结构化数据使用纯文本 diff 可能会极具误导性。重新排序属性或键,看起来像是个巨大的变化,而功能上却什么也没变。对于这些格式,请务必使用具备语义感知能力的 diff 工具。
  • 忘记上下文。 diff 告诉你什么变了,但它从不告诉你为什么变了。那是提交信息(commit message)或 pull request 描述的工作。没有上下文的 diff 就像一个没有问题的答案;你很难判断它是对是错。
  • 创建“缝合怪”式的 diff。 把不相关的改动(一个 bug 修复、一个新功能、一个拼写错误修正)塞进一个提交里,会让这个 diff 简直是阅读的噩梦。这也使得日后想回滚其中某个改动而不影响其他改动变得不可能。每个提交都应该是一个单一、逻辑上、原子性的变更。

为什么你应该关注它

对现代开发者来说,理解 diff 不是可选项;它就像知道如何使用键盘一样基础。你每天都会多次遇到 diffs:

  • 当你运行 git status 或 git diff 查看自己未提交的工作时。
  • 当你创建一个 pull request 供同事审查时。
  • 当你审查别人的 pull request 时。
  • 当你使用 git blame 找出某一行代码是谁写的、为什么这么写时。
  • 当你通过比较一个能用的配置和一个坏掉的配置来调试问题时。

即使对于非开发者,这个概念也同样强大。它就是 Word 文档里的“修订”功能。它就是维基百科文章的版本历史。它就是看到一份合同在不同草稿之间是如何演变的能力。理解 diff 就是理解我们如何在数字世界中管理和沟通变化。它是可审计、可验证的进度日志。

深入探索

理论搞定,动手试试吧——100% 在你的浏览器中运行。

试用工具: 差异对比器