
在软件开发和运维中,“对比” 是一个极高频的需求。
- Code Review 时,我们需要知道代码改了哪里;
- 配置中心回滚时,我们需要确认新旧配置的差异;
- 文档管理中,用户需要看到修订记录。
如果只靠 equals(),我们只能知道“不一样”,但不知道“哪里不一样”。
今天为大家介绍 Java 生态中最强大的文本差异对比库 —— java-diff-utils。我们将从基础用法讲起,一直到如何生成类似 GitHub 的高亮对比视图。
01 准备工作
目前(2025年),该库在 GitHub 持续维护中,最新稳定版本已更新至 4.16。请注意使用新的 Group ID io.github.java-diff-utils。
<dependency>
<groupId>io.github.java-diff-utils</groupId>
<artifactId>java-diff-utils</artifactId>
<version>4.16</version>
</dependency>
02 核心概念速览
在使用之前,了解这 4 个核心类,能帮你应对 99% 的业务场景:
DiffUtils:工具入口。负责计算差异(diff)和应用差异(patch)。Patch(补丁):差异的集合。它保存了从“旧文件”变到“新文件”所需的所有步骤。Delta(差异点):Patch中的最小单元,代表一处具体的变更。INSERT:新增DELETE:删除CHANGE:修改
DiffRowGenerator:可视化神器。它不关注底层算法,而是致力于把差异转换成“人眼易读”的格式(如 Side-by-Side 视图),是本文进阶部分的重点。

03 基础实战:找出差异
最简单的用法是对比两个 List<String>,查看哪些行发生了变化。
import com.github.difflib.DiffUtils;
import com.github.difflib.patch.AbstractDelta;
import com.github.difflib.patch.Patch;
import java.util.Arrays;
import java.util.List;
public class BasicDiffDemo {
public static void main(String[] args) {
List<String> original = Arrays.asList("Spring", "SpringBoot", "MyBatis");
List<String> revised = Arrays.asList("Spring", "SpringCloud", "MyBatis");
// 1. 计算差异
Patch<String> patch = DiffUtils.diff(original, revised);
// 2. 遍历差异点
for (AbstractDelta<String> delta : patch.getDeltas()) {
System.out.println("差异类型: " + delta.getType());
System.out.println("原位置: " + delta.getSource());
System.out.println("新位置: " + delta.getTarget());
}
}
}
输出结果:
差异类型: CHANGE
原位置: [position: 1, size: 1, lines: [SpringBoot]]
新位置: [position: 1, size: 1, lines: [SpringCloud]]
这意味着索引 1 的行发生了变更。
04 生成 Unified Diff 格式
为了生成更容易阅读(类似 Git diff 的效果),我们可以使用 UnifiedDiffUtils 生成标准格式输出。
// ... 承接上文
List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(
"file_v1.txt",
"file_v2.txt",
original,
patch,
0 // context size (上下文行数)
);
unifiedDiff.forEach(System.out::println);
输出:
--- file_v1.txt
+++ file_v2.txt
@@ -2,1 +2,1 @@
-SpringBoot
+SpringCloud
05 进阶实践:生成“人看得懂”的对比图
在企业级应用中,用户更习惯看 “行内高亮” 的效果(比如只高亮修改的单词,而不是整行)。
我们需要使用 DiffRowGenerator。下图展示了它如何将原始数据转换为带 HTML 标记的 DiffRow。

Java 代码实现
import com.github.difflib.text.DiffRow;
import com.github.difflib.text.DiffRowGenerator;
import java.util.Arrays;
import java.util.List;
public class VisualDiffDemo {
public static void main(String[] args) {
// 1. 准备数据
List<String> oldList = Arrays.asList("Hello World", "Test Case");
List<String> newList = Arrays.asList("Hello Java", "Test Case");
// 2. 构建生成器 (核心配置)
DiffRowGenerator generator = DiffRowGenerator.create()
.showInlineDiffs(true) // 开启行内细节对比
.inlineDiffByWord(true) // 按单词粒度,而非字符粒度
.mergeOriginalRevised(true) // 合并模式,方便通过CSS控制显示
.oldTag(f -> f ? "<span class='del'>" : "</span>") // 自定义旧文本包裹标签
.newTag(f -> f ? "<span class='ins'>" : "</span>") // 自定义新文本包裹标签
.build();
// 3. 生成可视化差异行
List<DiffRow> rows = generator.generateDiffRows(oldList, newList);
// 4. 打印结果模拟
for (DiffRow row : rows) {
System.out.println("Type: " + row.getTag()); // 4.16版本新特性
System.out.println("Old: " + row.getOldLine());
System.out.println("New: " + row.getNewLine());
System.out.println("---");
}
}
}
控制台输出片段:
Type: CHANGE
Old: Hello <span class='del'>World</span><span class='ins'>Java</span>
New: Hello Java
---
Type: EQUAL
Old: Test Case
New: Test Case
---
拿到这些带 span 标签的字符串后,前端只需要配合简单的 CSS:
.del { background: #fdaeb7; text-decoration: line-through; }
.ins { background: #bef5cb; }
即可渲染出专业的对比效果。
06 企业级场景:复杂对象 JSON 审计
实际业务中,我们往往比较的是两个复杂的 User 对象或 Config 实体。直接对比 toString() 很难看。最佳实践是将对象序列化为 JSON,并按行分割进行比对。
public static Patch<String> diffObjects(Object oldObj, Object newObj) {
Gson gson = new GsonBuilder().setPrettyPrinting().create();
// 1. 转换为漂亮的 JSON 字符串
String oldJson = gson.toJson(oldObj);
String newJson = gson.toJson(newObj);
// 2. 按行分割,方便 Diff 库处理
List<String> oldLines = Arrays.asList(oldJson.split("\n"));
List<String> newLines = Arrays.asList(newJson.split("\n"));
// 3. 执行比对
return DiffUtils.diff(oldLines, newLines);
}
示例:
JSONObject obj1 = com.alibaba.fastjson.JSON.parseObject("{\n" +
" \"id\": \"8dcd0193-29c1-4996-8b74-3913b9349e48\",\n" +
" \"status\": \"normal\",\n" +
" \"from_source\": \"api\",\n" +
" \"from_end_user_id\": \"27d6c068-c389-4317-8e2b-a1ef2229b249\",\n" +
" \"from_end_user_session_id\": \"zml\",\n" +
" \"from_account_id\": null,\n" +
" \"from_account_name\": null,\n" +
" \"name\": \"展示订单数据\",\n" +
"\n" +
" \"summary\": \"图表展示 用户订单数据\",\n" +
" \"read_at\": 1763721437,\n" +
" \"created_at\": 1763721185,\n" +
" \"updated_at\": 1763721437,\n" +
" \"annotated\": false,\n" +
" \"model_config\": {\n" +
" \"model\": null,\n" +
" \"pre_prompt\": null\n" +
" },\n" +
" \"message_count\": 1,\n" +
" \"user_feedback_stats\": {\n" +
" \"like\": 0,\n" +
" \"dislike\": 0\n" +
" },\n" +
" \"admin_feedback_stats\": {\n" +
" \"like\": 0,\n" +
" \"dislike\": 0\n" +
" },\n" +
" \"status_count\": {\n" +
" \"success\": 1,\n" +
" \"failed\": 0,\n" +
" \"partial_success\": 0\n" +
" }\n" +
"}");
JSONObject obj2 = com.alibaba.fastjson.JSON.parseObject("{\n" +
" \"id\": \"77f93824-467f-481b-9707-83b219216362\",\n" +
" \"status\": \"normal\",\n" +
" \"from_source\": \"api\",\n" +
" \"from_end_user_id\": \"463e72dc-1fed-4ec5-9a07-3330eca915fc\",\n" +
" \"from_end_user_session_id\": \"admin\",\n" +
" \"from_account_id\": null,\n" +
" \"from_account_name\": null,\n" +
" \"name\": \"三个图\",\n" +
" \"summary\": \"用表格展示一下 房地产税收信息 每年的 税收和减免信息\",\n" +
"\n" +
" \"read_at\": 1763639082,\n" +
" \"created_at\": 1763639052,\n" +
" \"updated_at\": 1763639112,\n" +
" \"annotated\": false,\n" +
" \"model_config\": {\n" +
" \"model\": null,\n" +
" \"pre_prompt\": null\n" +
" },\n" +
" \"message_count\": 3,\n" +
" \"user_feedback_stats\": {\n" +
" \"like\": 0,\n" +
" \"dislike\": 0\n" +
" },\n" +
" \"admin_feedback_stats\": {\n" +
" \"like\": 0,\n" +
" \"dislike\": 0\n" +
" },\n" +
" \"status_count\": {\n" +
" \"success\": 3,\n" +
" \"failed\": 0,\n" +
" \"partial_success\": 0\n" +
" }\n" +
"}");
Patch<String> stringPatch = diffObjects(obj1, obj2);
stringPatch.getDeltas().forEach(System.out::println);
输出:
[ChangeDelta, position: 1, lines: [ "summary": "图表展示 用户订单数据",, "created_at": 1763721185,, "message_count": 1,] to [ "summary": "用表格展示一下 房地产税收信息 每年的 税收和减免信息",, "created_at": 1763639052,, "message_count": 3,]]
[ChangeDelta, position: 11, lines: [ "updated_at": 1763721437,] to [ "updated_at": 1763639112,]]
[ChangeDelta, position: 16, lines: [ "from_end_user_id": "27d6c068-c389-4317-8e2b-a1ef2229b249",, "name": "展示订单数据",, "from_end_user_session_id": "zml",, "read_at": 1763721437,, "id": "8dcd0193-29c1-4996-8b74-3913b9349e48",] to [ "from_end_user_id": "463e72dc-1fed-4ec5-9a07-3330eca915fc",, "name": "三个图",, "from_end_user_session_id": "admin",, "read_at": 1763639082,, "id": "77f93824-467f-481b-9707-83b219216362",]]
[ChangeDelta, position: 23, lines: [ "success": 1,] to [ "success": 3,]]
07 企业级封装与 Patch 应用
为了更方便地在业务中使用,我们可以封装一个 Service,并补充缺失的行类型判断方法。此外,DiffUtils 还支持反向还原功能。
1. 封装差异对象 & 补充 determineRowType
在 v4.16 中,DiffRow 对象自带了 Tag 枚举,我们可以直接利用它。
public class DiffService {
/**
* 核心业务方法:对比并返回前端可用的 DTO
*/
public List<DiffViewDTO> compare(List<String> oldLines, List<String> newLines) {
DiffRowGenerator generator = DiffRowGenerator.create()
.showInlineDiffs(true)
.inlineDiffByWord(true)
.ignoreWhiteSpaces(true) // 忽略无意义空格
.build();
List<DiffRow> rows = generator.generateDiffRows(oldLines, newLines);
// 转换为业务DTO
return rows.stream().map(row -> {
DiffViewDTO dto = new DiffViewDTO();
dto.setOldHtml(row.getOldLine());
dto.setNewHtml(row.getNewLine());
dto.setChangeType(determineRowType(row)); // 获取变更类型
return dto;
}).collect(Collectors.toList());
}
/**
* 获取行变更类型 (CHANGE, INSERT, DELETE, EQUAL)
*/
private String determineRowType(DiffRow row) {
// v4.16 直接提供了 getTag() 方法
return row.getTag().name();
}
}
2. Patch 的妙用:数据还原
场景:你只有“旧文件”和“Patch 补丁”,想还原出“新文件”。这在增量传输中非常有用。
// 生成补丁
Patch<String> patch = DiffUtils.diff(oldList, newList);
// ... 传输 patch 对象 ...
// 应用补丁,还原得到 newList
try {
List<String> restoredList = DiffUtils.patch(oldList, patch);
} catch (PatchFailedException e) {
// 如果 oldList 被篡改,无法匹配 patch 中的上下文,会报错
e.printStackTrace();
}
避坑指南
在生产环境落地时,请注意以下几点:
- 内存溢出 (OOM):
Diff 算法(Myers)在计算超大文件差异时,内存消耗较大。- 建议:限制对比文本的大小(如 < 5MB),或仅读取文件的前 N 行进行预览。
- 换行符的痛:
\r\n和\n混合会导致整行被标记为“不同”。- 建议:对比前先标准化:
text.replaceAll("\r\n", "\n")。
- 建议:对比前先标准化:
- 编码问题:
读取文件流时务必指定StandardCharsets.UTF_8,否则中文会出现乱码。
总结
java-diff-utils 是一个纯 Java 实现、零依赖、功能强大的对比库。
- 做审计:用
DiffUtils.diff()分析字段变化。 - 做展示:用
DiffRowGenerator生成高亮 HTML。 - 做同步:用
DiffUtils.patch()进行增量更新。
希望这篇文章能帮你轻松搞定业务中的“找茬”需求!
Reference:
Q.E.D.


