尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java文件比对实战:基于EasyExcel与OpenCSV的数据差异检测方案

Java文件比对实战:基于EasyExcel与OpenCSV的数据差异检测方案 简介面向需要在Java环境中完成Excel或CSV表格数据差异比对的开发者这份压缩包给出了一套以指定列作为键值、比对目标列差异的完整实现方案。资源共24个文件包含9个Java源文件、9个已编译class文件以及jxl、javacsv、commons-logging等jar依赖并附Eclipse工程配置导入后即可运行和二次修改。核心思路是利用第三方库读取表格将键值列组合成HashMap键来匹配行记录再针对指定列逐一比较输出新增、缺失和值变更结果。对于键相同但目标列不同的情况会明确标记键只在一边出现时则分别提示新增或缺失行。源码结构清晰既可直接用于数据校验、报表对账和批量文件比较也可作为开发工具模板灵活扩展。压缩包整体仅666KB轻量易用目前已有2537人学习下载适合初中级Java开发者快速上手。1. 从需求说起这类“文件比对”到底在比什么先说我接到这个需求时的真实场景。运营那边甩过来两个Excel一个叫“上个月用户余额”一个叫“这个月用户余额”让我把余额有变化的用户找出来。文件不大几千行但字段有十几列肉眼核对根本不现实。这种“给我比一下两个文件哪里不一样”的需求在数据核对、跨系统对账、迁移校验、接口回归测试里实在太常见了。但要先泼盆冷水很多同学一听“文件比对”第一时间想到的是“把两个文件每一行逐一比较”。这个思路在绝大多数场景下是错的。你要比的是业务含义上的差异不是字节层面的差异。两个文件的列顺序可能不同行顺序可能不同甚至多出几列无关字段——这些都不该算作差异。正确的做法是把问题拆成三层键值差异哪些记录只存在于文件A、哪些只存在于文件B。这里的“键”由业务决定比如用户ID、订单号、流水号或者ID日期这种联合键。值差异两文件都存在的记录指定的若干列比如余额、状态、更新时间是否相等。结构差异列名、列数是否一致。这个一般作为前置校验不一致直接抛异常不需要往下比。这个需求的特点是技术难度不高但细节极多。用错了工具、忽略了类型转换、没考虑大数据量都很容易翻车。这篇就把我从选型到落地、再到踩坑的完整过程整理出来代码可以直接抄。2. 技术选型POI、EasyExcel、OpenCSV怎么组合2.1 为什么不用原生POI硬扛Apache POI确实能读写Excel但直接用POI做数据提取有几个痛点。第一代码啰嗦。读一个xlsx你要自己处理Workbook、Sheet、Row、Cell四级结构还要自己判断CellType是字符串还是数字还是日期十行代码可能只够读一行数据。第二内存占用高。POI的XSSFWorkbook是把整个工作簿加载进内存的一个50MB的xlsx能吃掉几百MB堆内存。你要是拿它去比对几十万行的文件JVM直接给你脸色看。第三CSV还得另搞一套。CSV本质是纯文本用POI去读反而绕远路。CSV有自己的转义规则引号、逗号、换行解析不对就出乱子。2.2 我的组合方案我的做法是CSV用OpenCSVExcel用EasyExcel。原因很直接OpenCSV处理标准CSV格式够轻够稳自带转义处理不用自己写逗号切分。注意我说的是标准CSV用UTF-8的BOM头、用逗号分隔的那种。你要是碰到用分号分隔的自己在CSVReader构造时换分隔符就行。EasyExcel是阿里开源的本质是对POI的封装优化但走了SAX模式逐行解析读10万行Excel的堆内存占用能控制在十几MB级别比原生POI香太多。两者都是解析入库不依赖正则表达式硬抠文本遇到脏数据时方便做类型转换和异常捕获。2.3 数据集规模分档数据量不同方案也不同。我按经验分成三档你根据自己的实际情况对号入座数据量推荐方案说明万行以内全量读入内存Map比对最简单直接代码可读性好1万~50万行流式读取 全量MapEasyExcel/OpenCSV流式读内存可控50万行以上分块 落库 / 布隆过滤器不建议纯内存了后面单独讲大多数业务比对场景落在前两档。下面的代码示例按第二档来写兼容第一档。3. 核心设计联合键与行模型的映射策略3.1 联合键怎么构造需求里“以某几列作为键值”本质是构造一个复合主键。最稳的做法是专门写一个KeyHolder类而不是图省事用String.join(-, values)。原因很实际如果用String.join万一某个字段本身是空字符串拼出来的是a--b另一个文件的同一条记录如果字段是null拼出来可能是a-null-b键就对不上了。更麻烦的是如果字段值里本身就包含分隔符用户昵称叫“张三-李四”就是合法的键值碰撞的坑会埋得很深。所以我建议这么写public record KeyHolder(ListString values) { Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof KeyHolder other)) return false; // 逐字段比较null和空字符串视为相等 for (int i 0; i values.size(); i) { String a normalize(values.get(i)); String b normalize(other.values.get(i)); if (!a.equals(b)) return false; } return true; } Override public int hashCode() { int hash 0; for (String v : values) { hash 31 * hash normalize(v).hashCode(); } return hash; } private String normalize(String s) { // 注意这里做了trim是因为Excel里经常有多余空格 return s null ? : s.trim(); } }用record是因为它天然携带equals/hashCode需要重写的契约但注意record的默认equals是逐字段比较而ListString的equals已经把null处理掉了所以其实直接用record就够了。我再加一层normalize是为了把前后空格也归一化实际文件里“张三”和“张三 ”真的会被当成两个人这种坑我踩过。3.2 行数据模型行数据不建议用MapString, String一把梭。虽然写起来灵活但后续取列值、比对列变化、输出差异结果都会变得非常隐晦。我习惯定义一个RowDatapublic class RowData { // 列名 - 原始字符串值 private final MapString, String columns new LinkedHashMap(); // 该行来自哪个文件便于排查 private String sourceFile; public void put(String colName, String value) { columns.put(colName, value); } public String get(String colName) { return columns.getOrDefault(colName, ); } public MapString, String getColumns() { return columns; } public String getSourceFile() { return sourceFile; } public void setSourceFile(String sourceFile) { this.sourceFile sourceFile; } }这里有个隐藏设计点列名统一用字符串。不管是Excel表头还是CSV首行读进来都转成字符串键后续比对列的值差异时直接按列名取不用关心文件里列的顺序。这也是比“按列号索引”更稳的方式——万一两个文件的列顺序不一致按列号就全军覆没了。3.3 两阶段Map比对模型主流程用两个Map一个装“键-行数据”一个装“键-该行的列值哈希”。第二个Map是性能优化用的省得比对时对每一行逐列compare直接比较拼接字符串的哈希值即可不相等再细比对。MapKeyHolder, RowData baseMap new HashMap(); MapKeyHolder, RowData targetMap new HashMap();我管它叫“基线文件和目标文件”语义更清楚。比对时遍历baseMap在targetMap里找对应键找不到就是“已删除”找到了就比对指定列的值有差异就是“值变更”。最后再遍历targetMap找baseMap里不存在的键就是“新增”。4. 实战代码CSV与Excel双格式的统一读写层4.1 统一读入接口先定义统一接口后续不管是读CSV还是读Excel都返回同样的结构调用方不需要关心文件后缀public interface FileDataReader { // 返回列名列表和行数据列表 ParseResult read(File file, ListString keyColumns) throws Exception; } public record ParseResult(ListString headers, ListRowData rows) {}这个接口的好处是业务代码只面向抽象后续想扩展支持xls老版本Excel只需要新增一个实现类。我这套代码同时支持xlsx和csv都走这个接口。4.2 CSV读取实现OpenCSVpublic class CsvDataReader implements FileDataReader { Override public ParseResult read(File file, ListString keyColumns) throws Exception { ListString headers; ListRowData rows new ArrayList(); // 关键点显式指定UTF-8否则Windows下直接乱码 try (Reader reader new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8); CSVReader csvReader new CSVReader(reader)) { // 跳过表头 String[] headerArr csvReader.readNext(); if (headerArr null) { return new ParseResult(Collections.emptyList(), rows); } headers Arrays.stream(headerArr).map(String::trim).toList(); String[] line; int rowNum 1; while ((line csvReader.readNext()) ! null) { rowNum; // 跳过空行 if (line.length 1 line[0].isBlank()) { continue; } RowData row new RowData(); row.setSourceFile(file.getName()); // 某行字段数不足时用空白补齐避免index越界 int maxCols Math.max(headers.size(), line.length); for (int i 0; i maxCols; i) { String colName i headers.size() ? headers.get(i) : COL_ i; String value i line.length ? line[i] : ; row.put(colName, value); } rows.add(row); } } return new ParseResult(headers, rows); } }易错点是第10行那个UTF_8。CSV文件如果带BOM头首列列名会带着\uFEFF那后续按列名取时就取不到。我一般读表头时顺手把BOM清掉if (i 0 headerArr[0].startsWith(\uFEFF)) { headerArr[0] headerArr[0].substring(1); }4.3 Excel读取实现EasyExcel监听器EasyExcel对POI的封装很到位但它的API风格是监听器式的很多人第一次写会懵。核心逻辑在AnalysisEventListener里public class ExcelDataReader implements FileDataReader { Override public ParseResult read(File file, ListString keyColumns) throws Exception { ListString headers new ArrayList(); ListRowData rows new ArrayList(); // 第一遍只读表头 // 第二遍读数据 // EasyExcel没有只读表头的模式所以用监听器内部状态区分 AtomicBoolean firstRow new AtomicBoolean(true); // 这里用的是无模型读取即不绑定实体类 // 因为字段是动态的绑定DTO反而死板 ReadListenerMapInteger, String listener new AnalysisEventListener() { Override public void invoke(MapInteger, String data, AnalysisContext context) { if (firstRow.getAndSet(false)) { // 表头index - 列名 for (int i 0; i data.size(); i) { headers.add(data.get(i) null ? COL_ i : data.get(i).trim()); } return; } RowData row new RowData(); row.setSourceFile(file.getName()); for (int i 0; i headers.size(); i) { row.put(headers.get(i), data.get(i) null ? : data.get(i).toString().trim()); } rows.add(row); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 解析完成回调无需处理 } }; // 注意headRowNumber(1) 表示第一行是表头 EasyExcel.read(file, listener) .sheet() .headRowNumber(1) .doRead(); return new ParseResult(headers, rows); } }这里有两个细节要提醒EasyExcel.read(file, listener)不带Class参数就是按Map模式读取每一行转成MapInteger, Stringkey是列索引。这样动态字段不用绑死DTO。.headRowNumber(1)告诉EasyExcel跳过表头但监听器里第一个invoke拿到的其实还是表头行所以内部用firstRow标记处理。这个处理方式可能让一些同学困惑但实测能稳定工作。4.4 按文件后缀路由public static ParseResult readFile(File file, ListString keyColumns) throws Exception { String fileName file.getName().toLowerCase(Locale.ROOT); FileDataReader reader; if (fileName.endsWith(.csv)) { reader new CsvDataReader(); } else if (fileName.endsWith(.xlsx) || fileName.endsWith(.xls)) { reader new ExcelDataReader(); } else { throw new IllegalArgumentException(不支持的文件类型: fileName); } return reader.read(file, keyColumns); }到这里读文件这层已经能复用了。你现在可以拿任意两个同构文件列名一致、键列一致调用这个方法拿到ListRowData。5. 核心比对逻辑三个集合、两趟遍历5.1 比对主流程数据都进内存了比对逻辑其实很直白。我把结果分成三类新增只在目标文件里、删除只在基线文件里、变更键相同但指定比较列的值不同。public CompareResult compare(ParseResult base, ParseResult target, ListString keyColumns, ListString compareColumns) { MapKeyHolder, RowData baseMap indexByKey(base.rows(), keyColumns); MapKeyHolder, RowData targetMap indexByKey(target.rows(), keyColumns); CompareResult result new CompareResult(); // 第一趟遍历基线文件 for (Map.EntryKeyHolder, RowData entry : baseMap.entrySet()) { KeyHolder key entry.getKey(); RowData baseRow entry.getValue(); RowData targetRow targetMap.get(key); if (targetRow null) { result.addDeleted(baseRow); } else { // 只比较指定列 ListString diffColumns findDiffColumns(baseRow, targetRow, compareColumns); if (!diffColumns.isEmpty()) { result.addChanged(baseRow, targetRow, diffColumns); } // 用过的键从targetMap里移除第二趟就只需要处理新增 targetMap.remove(key); } } // 第二趟targetMap剩下的全是新增 for (RowData row : targetMap.values()) { result.addAdded(row); } return result; }5.2 键索引构建private MapKeyHolder, RowData indexByKey(ListRowData rows, ListString keyColumns) { MapKeyHolder, RowData map new HashMap(); for (RowData row : rows) { ListString keyValues keyColumns.stream().map(row::get).toList(); KeyHolder key new KeyHolder(keyValues); // 如果同一个键出现多行说明源文件里有重复数据 RowData existing map.putIfAbsent(key, row); if (existing ! null) { System.out.println(警告键值重复 - keyValues 文件中的多行会被覆盖请检查源数据); } } return map; }重复键处理这里我选择“警告保留第一行”。也有同事用“把所有重复行都推给人工复核”更稳但实现复杂。数据核对场景里重复键一般是上游数据质量问题直接暴露给调用方比默默覆盖要好。5.3 值差异比对值比对绝不能用String.equals一把梭。原因有二一个是1.0和1Excel里一个单元格如果设置过格式导出后数值变成带小数点的文本肉眼看着一样字符串可比对不通过。另一个是2024-01-01 00:00:00和2024/1/1同样是日期格式不同导致的误报。所以对可转为数值的列先尝试数值归一化对日期列先尝试日期解析。实在解析不了再退回字符串比较。private ListString findDiffColumns(RowData baseRow, RowData targetRow, ListString compareColumns) { ListString diffCols new ArrayList(); for (String col : compareColumns) { String v1 baseRow.get(col); String v2 targetRow.get(col); if (!valueEquals(v1, v2)) { diffCols.add(col); } } return diffCols; } private boolean valueEquals(String v1, String v2) { if (v1 null v2 null) return true; if (v1 null || v2 null) return false; String s1 v1.trim(); String s2 v2.trim(); // 尝试数值比较 BigDecimal d1 parseBigDecimal(s1); BigDecimal d2 parseBigDecimal(s2); if (d1 ! null d2 ! null) { return d1.compareTo(d2) 0; // 注意不能用equals因为1.0和1.0的scale不同 } // 尝试日期比较 LocalDateTime t1 parseDateTime(s1); LocalDateTime t2 parseDateTime(s2); if (t1 ! null t2 ! null) { return t1.isEqual(t2); } return s1.equals(s2); }parseBigDecimal里用new BigDecimal(str)包一层try-catch解析失败返回null。parseDateTime里依次尝试各种常见格式yyyy-MM-dd HH:mm:ss、yyyy/MM/dd HH:mm:ss、yyyy-MM-dd、yyyy/MM/dd、时间戳字符串等。这块代码比较长就不全贴了思路就是把格式列表做成常量数组循环解析。5.4 结果对象与输出版本结果对象的定义public class CompareResult { private final ListRowData addedRows new ArrayList(); private final ListRowData deletedRows new ArrayList(); private final ListChangedRow changedRows new ArrayList(); public record ChangedRow(RowData baseRow, RowData targetRow, ListString diffColumns) {} // 省略getter和add方法 }输出我一般生成三个sheet或者三个csv文件新增、删除、变更。变更表里必须给出旧值、新值、变更列名三列否则业务方拿到也不知道改了啥。我习惯把变更表扩展成“一行只放一个变更列”的明细格式键1键2变更列旧值新值这样后续按列名透视、统计修改量都很方便。6. 常见问题与性能优化实录6.1 内存溢出几十万行数据怎么扛全量Map在50万行以下问题不大但如果行数太多RowData里每个Map字段开销也不小。优化思路是按需裁剪只保留键列和需要比对的列其他列直接丢弃。public static ParseResult filterColumns(ParseResult result, ListString keepColumns) { ListRowData newRows new ArrayList(result.rows().size()); for (RowData row : result.rows()) { RowData newRow new RowData(); for (String col : keepColumns) { newRow.put(col, row.get(col)); } newRows.add(newRow); } return new ParseResult(result.headers(), newRows); }在readFile完之后立刻执行filterColumns你的内存占用立刻降到原来的三分之一甚至更低。6.2 读完CSV才发现乱码CSV的编码问题是重灾区。实际场景里有的CSV是UTF-8有的带BOM有的干脆是GBKWindows Excel另存为出来常见。我处理编码的思路是自动探测private static Charset detectCharset(File file) throws IOException { try (InputStream in new FileInputStream(file)) { byte[] head in.readNBytes(3); if (head.length 3 head[0] (byte) 0xEF head[1] (byte) 0xBB head[2] (byte) 0xBF) { return StandardCharsets.UTF_8; } // 没有BOM默认UTF-8如果项目里GBK文件多可以把默认改成GBK return StandardCharsets.UTF_8; } }严格来说没有BOM时无法100%确定编码但结合实际使用场景“无BOM默认UTF-8”是最大公约数。真碰到GBK文件把上面代码里的默认值改成Charset.forName(GBK)就好。6.3 EasyExcel读取时类型转换异常EasyExcel.read(file, listener)不带实体类就不会有类型转换但如果你图方便绑定了DTO遇到“某个单元格是数字但DTO字段是String”时会直接抛异常。处理办法要绑定DTO时用ExcelProperty(converter ...)自定义转换器或者干脆像我一样不绑定DTO拿到MapInteger, String自己转。第二种更省心。6.4 大文件可以边读边比吗可以用流式读取模式每个文件只保留当前正在读的Map不要同时维护两份全量数据。做法是先把基线文件全量索引成Map然后流式读目标文件读一行比一行用完即弃。这样内存占用始终只取决于基线文件大小。代码改动不复杂核心是把ExcelDataReader里的rows集合换成回调式API但这里就不展开写了——除非你的数据量真的到了百万级别否则全量加载换成filterColumns足够应付。6.5 数值型ID被读成了科学计数法这是Excel读取最经典的坑一列身份证号或长数字ID在Excel里显示正常EasyExcel读出来变成1.20240101E8这种科学计数法。处理办法如果是数字列在RowData.put里对值做一次扩展new BigDecimal(s).toPlainString()把科学计数法转回普通数字字符串。更推荐的方案是让上游导出时把ID列设置成文本格式。但你不能指望所有上游都配合所以代码里处理更保险。代码如下private static String normalizeNumeric(String value) { if (value null) return ; String v value.trim(); if (v.contains(E) || v.contains(e)) { try { return new BigDecimal(v).toPlainString(); } catch (NumberFormatException ignored) { // 不是合法数字原样返回 } } return v; }在CsvDataReader和ExcelDataReader里读到的每个值都过一遍这个方法。6.6 常见问题速查表症状根因解决方案键对不上明明同一条记录被判为新增删除键列值有前后空格KeyHolder里trim数字列误报差异1.0 vs 1BigDecimal比较日期列误报差异格式不同日期解析后比较CSV中文乱码编码不匹配BOM探测 显式UTF-8内存溢出全量Map自定义对象过多只保留必要列 / 流式比较ID变成科学计数法Excel数字单元格BigDecimal转PlainString同键重复行源数据质量问题警告并保留第一行7. 演进方向从单机比对到可复用工具最后分享一个我后来做的扩展。第一阶段做完单机比对后我又把它做成了命令行小工具支持三个参数-base指定基线文件、-target指定目标文件、-keys指定键列、-compare指定比较列输出目录用时间戳隔离。这样运营同事不需要找我改代码自己敲一条命令就跑起来效率高很多。再往后如果比对的文件越来越大、次数越来越频繁单机方案总会有天花板。我当时调研过几条路落库比对把两个文件导入数据库用SQL取差集比如NOT IN或FULL OUTER JOIN适合百万行以上且可以利用索引提高查询效率。流式FileChannel 外部排序Java自己实现外部归并排序配合两两归并比对。实现复杂度高收益不一定比落库大。引入计算引擎用Spark或Flink做分布式比对那是另一个量级的复杂度一般项目不需要。我个人在实际操作中的体会是这类工具的需求核心往往不在算法而在“用什么口径定义差异”。你跟业务方对“什么是变化”、“哪些列不算数”、“空值怎么处理”的沟通时间往往比写代码的时间还长。所以做之前先把口径对齐用代码把口径固化下来后续反复跑才有价值。这个工具本身也可以继续迭代成一个小型中间件接入邮件通知、生成可视化的离线报告、把比对结果自动归档甚至做成web服务暴露一个上传接口、返回一个差异报告下载地址。只要第一版的读取层、比对层、输出层拆得够干净往上加功能都不难。本文还有配套的精品资源点击获取
返回列表