在软件开发和运维中,“对比” 是一个极高频的需求。

  • 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% 的业务场景:

  1. DiffUtils:工具入口。负责计算差异(diff)和应用差异(patch)。
  2. Patch (补丁):差异的集合。它保存了从“旧文件”变到“新文件”所需的所有步骤。
  3. Delta (差异点)Patch 中的最小单元,代表一处具体的变更。
    • INSERT:新增
    • DELETE:删除
    • CHANGE:修改
  4. 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();
}

避坑指南

在生产环境落地时,请注意以下几点:

  1. 内存溢出 (OOM)
    Diff 算法(Myers)在计算超大文件差异时,内存消耗较大。
    • 建议:限制对比文本的大小(如 < 5MB),或仅读取文件的前 N 行进行预览。
  2. 换行符的痛
    \r\n\n 混合会导致整行被标记为“不同”。
    • 建议:对比前先标准化:text.replaceAll("\r\n", "\n")
  3. 编码问题
    读取文件流时务必指定 StandardCharsets.UTF_8,否则中文会出现乱码。

总结

java-diff-utils 是一个纯 Java 实现、零依赖、功能强大的对比库。

  • 做审计:用 DiffUtils.diff() 分析字段变化。
  • 做展示:用 DiffRowGenerator 生成高亮 HTML。
  • 做同步:用 DiffUtils.patch() 进行增量更新。

希望这篇文章能帮你轻松搞定业务中的“找茬”需求!


Reference:


Q.E.D.


寻门而入,破门而出