news 2026/10/15 7:18:03

Java读取Excel转JSON:POI与EasyExcel实战与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java读取Excel转JSON:POI与EasyExcel实战与性能优化

我们做后端接口时经常碰一个需求:用户传一个 Excel 文件上来,后端需要解析里面的数据,然后以 JSON 格式扔给前端。比如批量导入用户、批量创建订单、上传商品清单,前端先预览一遍数据再提交。还有一个场景是导出报表时先从库里查出来组装成 Excel,再转成 JSON 给到报表工具渲染。无论哪一种,核心动作都是“把 Excel 里的行列结构变成 JSON 的数组对象”,听起来简单,真正做起来坑不少。

这篇就把 Java 读取 Excel 并返回 JSON 这件事从头到尾捋一遍。我会围绕真实项目里最常见的需求来做:单 Sheet、带表头、少量数据、一次性读入内存、输出标准的 JSON 数组。然后会延伸到动态列、多 Sheet、大文件分批读这些进阶场景,最后附上我实际踩过的一些问题。适合刚接触 Excel 解析的新手,也适合已经在用 POI 但对性能调优没把握的人。文中代码基于 Java 8+,用的是阿里 EasyExcel 和 Apache POI 两套方案,你可以按项目情况选。

1. 先搞清楚需求,再选解析方案

1.1 这个任务的实际场景是什么

“读取 Excel 返回 JSON”看上去是工具类的事情,但不同场景对实现方式的要求差别非常大。我简单分三类:

第一类是接口上传解析,前端上传一个 .xlsx 文件,后端解析后返回 JSON 给前端做回显预览。这种场景文件一般不大,几 MB 以内,几千行数据,对解析速度要求不高,但对格式兼容性要求高,用户可能拿 .xls 也可能拿 .xlsx,甚至可能是从 WPS 导出的文件。

第二类是后台批量导入,文件可能来自运营导出的数据,格式不固定,有合并单元格、有备注行、有各种格式的日期和数字。这种场景最麻烦的地方不是读取,而是数据清洗和字段映射,JSON 只是中间产物,后续要落库。

第三类是大数据量同步,文件可能有几十万行,几十 MB 甚至上百 MB,如果一次性把整张表读进内存,再把所有行转成 JSON,轻则 GC 频繁,重则直接内存溢出。这种场景不能用常规的简单读取方式,必须走流式或分批处理。

无论哪种场景,最终交付给调用方时无非两种形态:要么返回一个 JSON 数组,每个元素对应一行数据;要么返回一个对象,里面有表头信息和数据行,形如{"headers":["姓名","年龄"],"rows":[{"姓名":"张三","年龄":20}]}。我在实际项目里更倾向后者,因为前端拿到表头才能动态渲染列,而不是写死在页面上。

1.2 主流方案对比:POI、EasyExcel、FastExcel

Java 读 Excel 的库说来说去就那几个,真正在生产环境里长期跑的就两个:Apache POI 和阿里 EasyExcel(底层也是基于 POI 的 SAX 模式封装)。FastExcel 本质是 EasyExcel 的早期分支,API 几乎一样,现在直接用 EasyExcel 就行。

POI 是老兵,功能最全,支持 Excel 的几乎所有特性:样式、公式、合并单元格、图表、批注。它有两种读取模式:UserModel(用户模式)和EventModel(事件模式)。用户模式就是我们熟悉的Workbook workbook = WorkbookFactory.create(inputStream),一次把整个 Excel 的 DOM 结构加载到内存,遇到几万行就会很吃力。事件模式则是逐行解析,省内存,但 API 繁琐,要自己处理 Sheet、Row、Cell 事件,一般人扛不住。

EasyExcel 的聪明之处在于把 POI 的 EventModel 封装成了简单的监听器模式,你只需要定义好表头对应的实体或 Map,再写一个AnalysisEventListener,它内部按行回调,内存占用小得多,读 10 万行也非常从容。代价是对 Excel 的高级特性支持不如 POI 完整,比如合并单元格需要自己额外处理,公式默认不计算,样式信息不能直接拿。

我的选择习惯是:小文件、简单表头、需要精确控制样式和公式时用 POI;大文件、对性能和内存敏感、只需要纯数据时用 EasyExcel。如果你的项目就是一个普通的 Web 服务,绝大多数情况用 EasyExcel 就够了,开发效率高,代码也好维护。下面我会把两条路线都讲一遍,你先看场景再选。

2. 读取 Excel 的核心流程解析

2.1 一张 Excel 表是怎么映射成 JSON 的

在写代码之前,脑子里先有一个固定的映射模型。Excel 是二维表格,一个 Sheet 由若干行组成,每行由若干列组成。JSON 的数组对象里,一个对象的字段名通常取自第一行表头,字段值取自对应列的单元格内容。所以核心流程就是:定位 Sheet → 确定表头行 → 逐行读取单元格 → 按列映射成键值对 → 收集成数组。

这里面有几个关键点:

表头行不一定是第 0 行。很多真实文件第一行是标题,比如“2024年销售数据”,第二行才是字段名。所以你在设计解析方法时,必须支持传入一个headRowNumber参数,告诉解析器从哪一行开始取表头。

空行要不要忽略。Excel 里经常有整行空的脏数据,用 POI 读取时row == null或者row.getLastCellNum() <= 0都需要判断。EasyExcel 默认不会回调全空行,但如果你用 POI 就得自己过滤。

单元格类型。Excel 单元格有字符串、数字、布尔值、日期、公式等类型。数字可能是整数 20,也可能是浮点 3.14,还有可能是文本型身份证号"110101..."。如果统一按字符串处理,后面 JSON 里全是字符串,前端可能不好做类型判断;如果按原始类型处理,身份证号、手机号、长数字会被科学计数法搞坏,这是最常见的坑。所以读取规则应当是:默认按字符串读取用于展示,同时保留原始类型信息供业务判断。EasyExcel 的CellData或者ReadCellData就能拿到类型,POI 里可以用cell.getCellType()判断后手动转换。

2.2 日期、数字、公式这些特殊字段怎么处理

日期是最容易出诡异问题的。用 POI 读取一个值为 2024-01-15 的单元格,getNumericCellValue()返回的是 45241 这种 Excel 序列数,如果直接把它放到 JSON 里,前端看到的是一堆数字。正确做法是用DateUtil.isCellDateFormatted(cell)判断,再通过cell.getDateCellValue()拿到 Java Date,最后格式化输出。

EasyExcel 对日期的处理要简单些,它默认会根据单元格格式自动转换成字符串,但转换出来的格式可能不是你想要的,比如它可能返回"2024-01-15 00:00:00",而前端只想要"2024-01-15"。这时可以用@DateTimeFormat("yyyy-MM-dd")注解直接标注在实体类的对应字段上,解析后就自动按这个格式输出。

数字的处理同样要小心。文本型数字单元格在 POI 里getCellType()返回STRING,直接getStringCellValue()拿到的就是原文本,没问题。但常规数字单元格拿到的是 double,如果值是身份证号110101199001011234,double 表示不了这么长,POI 会把它变成1.101011990010112E17,你在 JSON 里看到的就是丑成座机的科学计数法,而且精度已经丢了。所以涉及身份证号、银行卡号、手机号这类字段,要么源文件里设置成文本格式,要么在读取时强制按字符串处理再调用DecimalFormat格式化,new BigDecimal(cell.getNumericCellValue()).toPlainString()是最稳的方式。

公式单元格默认不计算。POI 里如果你不主动调用FormulaEvaluator,getCellValue拿到的只是公式字符串SUM(A1:A10)而不是结果。EasyExcel 默认也是拿公式本身,除非你在读取时开启自动计算或者用公式计算结果缓存。业务上如果只是展示数据,公式结果拿不到很尴尬。我在一个项目里遇到过模板带合计行,用户上传后前端预览时看到的合计是SUM(...)这样的内容,排查半天才发现是公式读取的问题。后面直接改用 POI 的WorkbookEvaluator先求值再读,或者干脆要求用户上传前先把公式转成值。

3. 两种主流实现方案实操

3.1 方案一:用 Apache POI 完整读取并转 JSON

先看依赖,如果你用 Maven:

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson2</artifactId> <version>2.0.43</version> </dependency>

注意 POI 5.x 要求 Java 8 以上,JDK9 以上还需要手动处理模块问题,但一般现代项目没问题。如果你的项目比较老用的是 POI 3.x,建议升级到 5.x,老版本存在一堆奇奇怪怪的解析 bug。

然后写一个工具类,核心思路是:拿到 Workbook → 获取 Sheet → 读取表头行 → 遍历数据行 → 组装 Map → 收集成 List。我用Map<String, Object>表示一行数据,字段名就是表头内容,这样不需要为每张表都建一个实体类,通用性最强。

public class ExcelToJsonUtil { public static String readExcelToJson(InputStream inputStream, int headRowNumber) { List<Map<String, Object>> rowList = new ArrayList<>(); try (Workbook workbook = WorkbookFactory.create(inputStream)) { Sheet sheet = workbook.getSheetAt(0); // 读取表头 Row headRow = sheet.getRow(headRowNumber); String[] headers = new String[headRow.getLastCellNum()]; for (int i = 0; i < headRow.getLastCellNum(); i++) { Cell cell = headRow.getCell(i); headers[i] = getCellValueAsString(cell, false); } // 遍历数据行 int lastRowNum = sheet.getLastRowNum(); for (int rowIndex = headRowNumber + 1; rowIndex <= lastRowNum; rowIndex++) { Row row = sheet.getRow(rowIndex); if (row == null || isEmptyRow(row)) { continue; } Map<String, Object> rowData = new LinkedHashMap<>(); for (int colIndex = 0; colIndex < headers.length; colIndex++) { Cell cell = row.getCell(colIndex); String value = getCellValueAsString(cell, true); rowData.put(headers[colIndex], value); } rowList.add(rowData); } } catch (Exception e) { throw new RuntimeException("Excel 解析失败", e); } return JSON.toJSONString(rowList); } private static boolean isEmptyRow(Row row) { if (row == null) { return true; } for (int i = 0; i < row.getLastCellNum(); i++) { Cell cell = row.getCell(i); if (cell != null && cell.getCellType() != CellType.BLANK && !getCellValueAsString(cell, true).trim().isEmpty()) { return false; } } return true; } private static String getCellValueAsString(Cell cell, boolean trim) { if (cell == null) { return ""; } switch (cell.getCellType()) { case STRING: return trim ? cell.getStringCellValue().trim() : cell.getStringCellValue(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { Date date = cell.getDateCellValue(); return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); } else { return new BigDecimal(cell.getNumericCellValue()).toPlainString(); } case BOOLEAN: return String.valueOf(cell.getBooleanCellValue()); case FORMULA: // 这里直接取公式缓存的结果,如果缓存不存在就得用 evaluator CellType cachedType = cell.getCachedFormulaResultType(); if (cachedType == CellType.NUMERIC) { return new BigDecimal(cell.getNumericCellValue()).toPlainString(); } else if (cachedType == CellType.STRING) { return cell.getStringCellValue(); } return ""; case BLANK: return ""; default: return ""; } } }

这个版本有几个地方值得注意。我用BigDecimal转数字而不是直接String.valueOf(cell.getNumericCellValue()),就是为了避免科学计数法。日期格式固定成了“年-月-日 时:分:秒”,如果前端只需要日期,可以再处理一下。公式单元格这里用getCachedFormulaResultType()去拿缓存结果,大多数情况下 Excel 文件本身是保存了计算结果的,所以能取到。如果取不到,你会得到一个空的或者公式字符串,那就得换WorkbookEvaluator来主动计算了。

调用方式:

@PostMapping("/parse") public String parseExcel(@RequestParam("file") MultipartFile file) { try (InputStream inputStream = file.getInputStream()) { return ExcelToJsonUtil.readExcelToJson(inputStream, 0); } catch (IOException e) { return "文件读取失败"; } }

注意WorkbookFactory.create()本身已经释放了原来的输入流,所以在你try-with-resources里再关一次 InputStream 会重复关闭,但没关系,Java 的 InputStream 重复 close 是安全的。我习惯把流关闭放到外面由调用方控制,避免被工具类偷偷关掉。

3.2 方案二:用 EasyExcel 监听器模式,兼顾性能和开发效率

EasyExcel 的 API 设计比 POI 优雅太多,核心思路是你给它一个监听器,它在解析文件时按行回调你。先加依赖:

<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency>

然后写监听器。这里我选择用Map<Integer, String>接收每行数据,避免为每张表建实体类。监听器里维护一个List<Map<String, String>>,最后解析完成后从中取数据。

public class MapDataListener implements AnalysisEventListener<Map<Integer, String>> { private final List<Map<String, String>> rows = new ArrayList<>(); private final String[] headers; public MapDataListener(String[] headers) { this.headers = headers; } @Override public void invoke(Map<Integer, String> data, AnalysisContext context) { // data 的 key 是列索引(从0开始),value 是对应单元格的字符串值 LinkedHashMap<String, String> row = new LinkedHashMap<>(); for (int i = 0; i < headers.length; i++) { String value = data.get(i); row.put(headers[i], value == null ? "" : value); } rows.add(row); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 全部解析完成 } public List<Map<String, String>> getRows() { return rows; } }

读取入口:

public static String easyExcelToJson(InputStream inputStream, int headRowNumber) { List<Map<String, String>> headList = new ArrayList<>(); // 这里用另一个监听器先读表头 EasyExcel.read(inputStream) .sheet() .headRowNumber(headRowNumber) .registerReadListener(new AnalysisEventListener<Map<Integer, String>>() { @Override public void invoke(Map<Integer, String> data, AnalysisContext context) { headList.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) {} }) .doRead(); // 实际上 EasyExcel 在读取表头时不会回调 invoke,需要特殊处理

等等,这里有个坑。EasyExcel默认在读取数据行时,会自动跳过表头行,但它不会把表头行的内容回调给你。如果你需要拿到表头作为 JSON 的 key,有两个办法:一是用实体类加注解,二是把表头也当数据读,然后自己截取。我常用的是第二种:设置.headRowNumber(0),也就是不跳过任何行,然后把所有行都作为数据回调,再由我在业务层判断第几行是真正的表头。这样做虽然简单,但会丢失 EasyExcel 的自动表头跳过功能,只适合小文件。

更规范的做法是:用ExcelReader的read(headRowNumber, headList, dataListener)方法,它能一次性读取表头和数据。

public static String easyExcelReadWithHead(InputStream inputStream) { List<Map<Integer, String>> headList = new ArrayList<>(); List<Map<String, String>> dataList = new ArrayList<>(); ReadListener<Map<Integer, String>> headListener = new AnalysisEventListener<Map<Integer, String>>() { @Override public void invoke(Map<Integer, String> data, AnalysisContext context) { headList.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) {} }; MapDataListener dataListener = new MapDataListener(new String[0]); EasyExcel.read(inputStream) .sheet() .headRowNumber(0) // 0 表示从第一行开始全部当数据读 .registerReadListener(headListener) .doRead(); // 此时 headList 包含所有行的数据,第一行就是表头 // 但你想拿到表头名称还不能直接用,因为 Map 的 key 是 0,1,2...

EasyExcel 的Map<Integer, String>模式拿不到表头文字,表头文字只有在使用@ExcelProperty注解的实体类模式下才自动映射。所以如果你确实要通用地拿表头,建议用 POI 读一次表头,再用 EasyExcel 读数据,两者结合也行。不过很多场景我们本身就知道字段名(比如导入模板是系统固定的),就直接用实体类了。

用实体类的方式展示一下:定义一个 PersonExcel 类,字段上标好@ExcelProperty("姓名"),然后注册监听器接收实体对象,最后JSON.toJSONString(list)输出。这种写法最省事,适合模板固定的上传接口。

public class PersonExcel { @ExcelProperty("姓名") private String name; @ExcelProperty("年龄") private Integer age; // 省略 getter 和 setter }

读取时:

List<PersonExcel> list = new ArrayList<>(); EasyExcel.read(inputStream) .head(PersonExcel.class) .sheet() .doReadSync(); String json = JSON.toJSONString(list);

doReadSync()方法在 3.x 里非常实用,它同步读取一个小文件并返回全部数据,适合最多几万行的场景。如果文件很大,直接用同步方法会把数据全塞进内存,和 POI 的 UserModel 没区别。那种情况还是要用监听器模式,逐行处理。

3.3 两种方案的优缺点和选型建议

POI 方案胜在可控性强,你能拿到 Excel 的所有原始形态:合并单元格位置、行列大小、单元格样式、公式内容。如果你做的是一个 Excel 处理工具网站,需要支持各种畸形表头、复杂格式,POI 是你的唯一选择。

EasyExcel 方案胜在代码极简,性能好,底层做了很多优化,比如自动识别xlsx和xls,内部用 SAX 解析只占少量内存,对百万行文件也能流畅处理。我最近几年新写的代码全部用 EasyExcel,除非遇到需要操作样式的需求才回去写 POI。

如果只是“上传一个普通 Excel 然后返回预览 JSON”,我建议直接上 EasyExcel 的同步读取模式,代码最短,也最不容易出错。

4. 动态表头、多 Sheet 与大文件性能优化

4.1 动态表头和动态列怎么映射到 JSON

前面我们提到表头行不固定,列数也不固定。比如运营每个月导出一个统计表,这个月有“转化率”列,下个月没有。如果我们用实体类写死字段,这种动态列就只能干瞪眼。动态列的正确做法是走 Map 模式。

EasyExcel 的 Map 模式里,每个invoke回调的Map<Integer, String>的 key 就是列下标,你可以提前读一次表头(用 POI 或 EasyExcel 读第一行作为 Map<Integer,String>),然后把列下标映射成表头文字。之后遍历数据行时就可以拼出Map<String, String>。这样代码不用改就能兼容任意列结构的 Excel。

POI 那边的处理更直接,先读取表头行,得到列索引和表头文字的映射,然后遍历数据行时按索引取值即可。我们前面的 POI 示例已经天然支持动态列,只要表头数组长度 = 最大列号即可。

要注意的是,当表头列数不一致时,比如第一行有 3 列,第二行有 5 列,POI 的row.getLastCellNum()会返回 5,读取时缺少的列会拿到 null,我们代码里已经做空判断,填成空字符串,JSON 里就是"列名":"",前端渲染时要注意过滤空字段,不然表格看起来有很多空列。

4.2 多 Sheet 文件的处理思路

一个 Excel 文件里可能有多个 Sheet,每个 Sheet 表头还不一样。如果接口的目的是“把所有 Sheet 都转成 JSON”,那 JSON 结构应该设计成:

[ { "sheetName": "会员列表", "data": [ {"姓名":"张三"}, {"姓名":"李四"} ] }, { "sheetName": "订单列表", "data": [ {"订单号":"A001"}, {"订单号":"A002"} ] } ]

POI 里遍历所有 Sheet 很简单:workbook.getNumberOfSheets()拿到数量,然后getSheetAt(i)逐个处理。EasyExcel 里需要用ExcelReaderSheetBuilder配合ExcelReader.read()多次读取,或者用EasyExcel.read(inputStream).sheet("Sheet1")指定,如果要读取所有 Sheet 就循环调用。

实操时我一般用 POI 来做多 Sheet 的通用解析,因为你得先拿到每个 Sheet 的 name 和表头行,再做自适应。EasyExcel 也能做,但代码绕一些。如果是用 EasyExcel,你可以先ExcelReader excelReader = EasyExcel.read(inputStream).build(),然后ReadSheet数组里指定多个 sheet,通过excelReader.read()一次性注册多个监听器,每个 sheet 一个监听器,回调里按 sheet 区分数据。

4.3 几十万行数据怎么避免 OOM

先说结论:小文件随便用同步读取没问题,大文件必须用监听器流式解析,而且不要在监听器里无限累加数据。什么叫合理?你解析完一行就要处理一行,要么直接转 JSON 响应给前端(但前端拿那么多数据也会卡死),要么入库,要么写到 Redis 或中间文件。只在最后返回一个“处理成功”的提示。

如果你非要把百万行的 JSON 返回给前端,这本身就不是技术问题,而是产品设计问题。没有一个浏览器能无压力渲染百万行数据表格。正确的方案是后端分页读取:解析第一页 1000 行,返回给前端展示,点击下一页时再从原文件继续解析。这个场景用 EasyExcel 和 POI 事件模型都能做到,但 EasyExcel 更省心,因为AnalysisEventListener天然就是逐行回调的,你可以在回调里判断if (context.readRowHolder().getRowIndex() > currentPageEnd)就暂停,或者干脆用一个全局的BatchStop信号。

内存方面,EasyExcel 内部使用SAXParser解析 xlsx,不会一次性构建整个 Document 模型。但要注意,如果你在 invoke 里把每一行的 Map 都 add 到一个内存 List,那和 POI UserModel 也没区别,内存照样炸。所以大文件必须边读边消费,或者分批消费后清空 List。

POI 的 UserModel 模式读 10 万行,会占到大概 500MB 以上堆内存,而且耗时很久。POI 的事件模式(XSSFSheetXMLHandler)可以做到流式读取,但事件 API 写起来太痛苦,我日常几乎不用。

4.4 对 EasyExcel 的内存优化建议

EasyExcel 官方给过一个ReadOptions里有estimateCount参数,这个参数是预估值,影响内部集合初始化大小,如果你明确知道大概行数,可以设置它减少扩容开销,但不要完全依赖它。

另外一个容易忽略的参数是memory相关配置:EasyExcel.read().readCache()。不过实际项目中我一般不去调太底层的参数,只要做到“边读边消费、不累积超大 List”,内存基本不会出问题。如果实在要全部汇总做统计,考虑用 Java 的流式聚合,或者存入临时数据库表再查。

还有一个技巧:对于只需要读取但不需要样式和大小的场景,可以指定autoCloseStream(false)配合自己管理流,另外可以设置ignoreEmptyRow(true)跳过空行,减少无效回调。ignoreEmptyRow默认好像已经对了,但明确写出来更稳。

5. 常见问题与排查技巧实录

5.1 科学计数法搞坏了手机号和身份证号

这是我被问过最多的问题,也是 Excel 读取最常见的坑。用户上传的表里,手机号和身份证号默认就是数字格式的单元格,POI 读取时getNumericCellValue()得到 double,toString()就成了1.3912345678E10或1.101011990E17,而精度已经丢失。EasyExcel 相对好一点,因为它在数字转字符串时用了内部处理,但如果你用Integer或Long接收,还是可能变形。

解决办法有两个:第一,源文件层面,让用户把这类列格式设为文本,但很多用户不懂;第二,代码层面,遇到这类列强制按字符串处理,POI 里就对数字列统一走BigDecimal.toPlainString(),EasyExcel 里用String接收并用Converter自定义转换。我一般建议后端在读取时对电话、证件号这类列做二次清洗,能避免 90% 的脏数据。

5.2 日期格式五花八门

真实用户上传的日期可能是 2024/1/1、2024-01-01、01-01-2024、44562 数字,甚至汉字“二〇二四年一月一日”。如果你的解析代码只支持一种格式,谁用谁炸。POI 里可以用DateUtil.isValidExcelDate加java.text.SimpleDateFormat做兜底,但格式多了根本忙不过来。

通用做法是:读出来统一转成yyyy-MM-dd HH:mm:ss标准格式,如果源字符串不是合法的日期,就原样输出,让使用方自己处理。EasyExcel 的实体类上可以加多个@DateTimeFormat,但只能一个,所以碰到多种日期格式还是建议先全部按字符串读,再由业务层做日期归一化。

我自己还遇到过 Excle 里的日期显示正常,但用 POI 读出来是数字的情况,通常是因为单元格格式是“常规”而不是“日期”。这时用getDateCellValue()拿到的是错乱的日期,因为 POI 并不知道它是日期。所以还需要做数字序列转日期的基础函数:Date date = DateUtil.getJavaDate(doubleValue),再格式化。

5.3 文件后缀名与真实格式不一致

用户把 .xlsx 改成 .csv,然后报错解析失败。或者把 xls 改成 xlsx,POI 打开时抛OfficeXmlFileException。这是最常见的兼容性问题。代码层面我们要做防御:先判断文件名后缀和后端的ContentType,再用WorkbookFactory.create让它自己根据文件真实内容识别。EasyExcel 会比较聪明,它会根据二进制头区自动判断并支持 xls、xlsx、csv,但 csv 不算严格意义的 Excel,遇到分隔符乱码还是要手动处理。

如果系统里必须支持 .xls 老格式,Maven 里千万别只引入poi-ooxml,还需要引入poi-scratchpad或者单独引poi且不排除poi-ooxml-schemas。5.x 之后依赖管理简单多了,但如果你只在中央仓库拉包,踩到NoClassDefFoundError: org/apache/poi/POIXMLDocument这类错大概率是缺了poi-ooxml。

5.4 合并单元格导致数据重复或遗漏

表头有合并单元格时,POI 里headRow.getCell(0)和headRow.getCell(1)的内容是相同的(只有第一个 cell 有值,其他是 blank)。如果你用列索引做表头映射,会出现多个字段名相同,JSON 里后面 key 覆盖前面 key,数据丢失。

解决方式是在读表头时判断合并区域:sheet.getMergedRegions()返回所有合并区域,遍历区域判断某个 cell 是否属于该区域,如果属于就用getCellValue(region.getFirstRow(), region.getFirstColumn())取被合并区域的原始值。EasyExcel 默认不处理合并单元格,需要单独开启EasyExcel.read().sheet().registerReadListener()中手动处理MergedCellEvent,或者干脆在数据行也做类似取值。这个确实是通用解析器的难点之一,如果你的业务不用复杂表头,可以忽略,但作为工具类我还是建议至少把表头合并处理了。

5.5 空行和多余的行尾内容

Excel 里一个 Sheet 有无数行“概念上的空行”,sheet.getLastRowNum()返回的是有内容的最后一行,但中间可能夹杂几行空行。EasyExcel 默认忽略空行,POI 需要自己判断。我们代码里已经有isEmptyRow方法,调用方也可以指定读取到哪一行,比如只读前 100 行,把lastRowNum手动限制一下,能避免用户表格里在末尾写了一堆备注导致数据异常。

另一个坑是有些单元格被设置了空字符串而不是真的空,比如用户敲了一个空格,看起来是空,但getStringCellValue()返回" "。我们代码里用trim()处理了,JSON 里就不会出现一堆空格。

5.6 响应 JSON 时字段顺序乱掉

如果你用HashMap存每行数据,输出 JSON 的字段顺序是不确定的。前端如果要固定展示顺序,就用LinkedHashMap,它能保证插入顺序。我们在 POI 示例里用的就是LinkedHashMap,EasyExcel 的 Map 默认就是LinkedHashMap,所以顺序没问题。

另一个常见情况是同一行列数不一样,比如第一行有 6 列,第二行只有 4 列,POI 的row.getLastCellNum()返回第二行的真实列号,所以headRow.getLastCellNum()一般取的是最大列号,但如果你用row.getLastCellNum()作为循环条件去遍历数据行,就会漏掉后面列的 null 值。正确做法是以表头行headRow.getLastCellNum()为准。

6. 封装一个好用的通用工具类

6.1 把所有细节吸收到一个入口方法里

一个通用的excelToJson(InputStream is, boolean hasHeader, int headRow)方法会大大减少业务代码重复。我建议把参数设计成:

  • headRowNumber:表头所在行(从 0 开始)
  • startRow:从第几行开始读数据
  • maxRows:最多读取多少行(0 表示全部)
  • ignoreEmptyRow:是否跳过空行
  • sheetIndex:读取第几个 sheet

然后内部先用 EasyExcel 或者 POI 去实现。我这里的通用方法习惯用 POI,因为它的表头处理更直接,也不容易踩 EasyExcel 的“表头拿不到”的坑。

我在前面的 POI 版本基础上补一下分页和指定 Sheet 能力:

public static String excelToJson(InputStream is, int sheetIndex, int headRowNumber, int readRows) { List<Map<String, Object>> result = new ArrayList<>(); try (Workbook workbook = WorkbookFactory.create(is)) { Sheet sheet = workbook.getSheetAt(sheetIndex); Row headRow = sheet.getRow(headRowNumber); int colNum = headRow.getLastCellNum(); String[] headers = new String[colNum]; for (int i = 0; i < colNum; i++) { headers[i] = getCellValueAsString(headRow.getCell(i), true); } int lastRow = Math.min(sheet.getLastRowNum(), headRowNumber + readRows - 1); for (int rowIndex = headRowNumber + 1; rowIndex <= lastRow; rowIndex++) { Row row = sheet.getRow(rowIndex); if (row == null || isEmptyRow(row)) { continue; } Map<String, Object> map = new LinkedHashMap<>(); for (int colIndex = 0; colIndex < colNum; colIndex++) { map.put(headers[colIndex], getCellValueAsString(row.getCell(colIndex), true)); } result.add(map); } return JSON.toJSONString(result); } catch (Exception e) { log.error("excel转json失败", e); return "[]"; } }

实际业务里,这个方法返回值不应该只是一个字符串,最好是返回List<Map<String, Object>>,由 Service 层自己决定要序列化还是直接遍历入库。因为只拿字符串的话,你想改个字段都没法改,还得反序列化一次。

6.2 错误处理与日志的最佳实践

不要在整个工具类里抛出大而全的“解析失败”异常,你应该尽可能把错误信息细化到行和列。比如某一行第 3 列的内容是公式结果,解析时为空,你直接返回空数组会让前端一头雾水。建议方法里增加一个回调接口ExcelErrorHandler,当某一行解析失败时回调,把行号和错误内容收集起来,最后和正常数据一起返回,前端可以在预览界面上高亮显示错误行。这个设计在导入场景非常有用。

日志方面,解析大文件时不要每读一行打印一行日志,那样日志文件会爆炸。我一般只在doAfterAllAnalysed或finally里打一个“解析完成”日志,加上总行号和耗时毫秒数就够了。

6.3 单元测试怎么覆盖这些边界

写工具类一定要有单元测试,而且测试数据要贴近真实。我常用的几个用例:

  • 一个简单的单 Sheet、两列三行、标准字符串数字日期数据
  • 一个包含合并单元格表头的文件
  • 一个包含科学计数法数字的文件(用来验证身份证号)
  • 一个包含空行和空字符串的文件
  • 一个 .xls 老格式文件和一个 .xlsx 新格式文件
  • 一个超过 10 万行的文件(用来验证难用性和性能)

测试时用EasyExcel.write或 POI 的XSSFWorkbook直接生成这些测试文件就够了,不要依赖手动 Excel。JUnit 里断言 JSON 里的值,比如手机号不能是科学计数法,日期统一成指定格式。

7. 后续扩展:不只是 JSON,还能干更多事

解析 Excel 返回 JSON 只是第一步,实际项目往往还要往前再走几步。比如把 JSON 解析成业务对象做校验;根据返回的 JSON 渲染到前端表格;或者把 Excel 转成 CSV、SQL 导入脚本。

如果你愿意,也可以把读取能力继续封装成一个ExcelReader泛型工具,支持任意 DTO 的映射,参考 EasyExcel 的实体模式,但底层自己控制。这样项目里如果慢慢开始依赖 POI,你还能在 POI 之上加一层缓存和格式优化。

我自己的经验是靠一个足够好用的通用工具类,后面做任何导入导出需求都可以先套它,省掉的开发时间不可估量。比如我之前做过一个多租户后台,用户经常上传各种不同模板的 Excel,每次都要写一套新的解析逻辑。后面我把通用解析器做好后,新模板上线只需要配置表头行号和字段映射关系,前后端代码几乎零改动,直接解析返回 JSON,前端拿 JSON 动态渲染界面。那套东西上线后稳定跑了两年多,维护成本非常低。

另外提醒一句,Excel 解析这种事有很强的“本地依赖”倾向,如果只是简单的展示预览或小数据量处理,你完全可以丢给纯前端用SheetJS解析。但如果涉及后端校验、落库、权限控制,那一定还是在 Java 这边处理好,别把原始文件传给前端再返回 JSON,绕圈子还容易暴露数据。服务端处理是更稳妥的路径。

好的,把上面的代码和思路整理一下,基本上覆盖了“Java 读取 Excel 返回 JSON”的完整链路。从选型到实现,从普通场景到大文件优化,再到常见问题排查,核心就是:分清业务场景,选对工具;处理特殊的单元格类型;封装通用方法;别让大文件在你的内存里裸奔。真到了线上出问题的时候,回头看肯定是这几个点里的某一个出了问题。现在可以把这一套直接用到项目里了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 7:17:32

全栈实战:资金分配与审批流程系统开发详解

手头这个项目是给一家中型制造企业做的内部资金审批系统&#xff0c;标题就是“全栈项目实例&#xff08;资金分配工作流程&#xff09;”。核心需求很简单&#xff1a;财务发起一笔资金分配&#xff0c;按部门、按项目拆清楚&#xff0c;然后走审批流&#xff0c;领导逐级审&a…

作者头像 李华
网站建设 2026/10/15 7:17:29

企业安全不只看装了什么设备,更要看“有没有人认领”

近期&#xff0c;境外一家连锁零售企业的联网制冷设备遭到远程入侵&#xff0c;影响范围涉及数百家门店。这件事值得关注的&#xff0c;不只是"联网冷柜也可能出现安全问题"。更值得思考的是&#xff1a;当越来越多设备接入企业内部&#xff0c;它们有没有真正进入资…

作者头像 李华
网站建设 2026/10/15 7:17:19

合肥清蓝观智:依托科学GEO方法论,打造安徽AI口碑营销新标杆

随着生成式人工智能成为消费者获取品牌信息的重要入口&#xff0c;位于安徽合肥的清蓝观智人工智能科技有限公司&#xff08;以下简称“清蓝观智”&#xff09;正凭借其在GEO&#xff08;生成式引擎优化&#xff09;领域的专业服务能力&#xff0c;成为区域市场中备受关注的品牌…

作者头像 李华
网站建设 2026/10/15 7:17:13

零Token文本分类:用开源编码器替代大模型API的工程实践

先摆一张账单。我这边有个内容打标服务&#xff0c;每天要判几十万条短文本&#xff1a;这评论是好评还是差评、这个工单该转给哪个组、这条消息要不要进风控队列。我的第一版方案图省事&#xff0c;直接调闭源 API&#xff0c;输入一段文本让它"智能判断"。结果一个…

作者头像 李华
网站建设 2026/10/15 7:17:08

Spring Boot小型医疗机构HIS系统源码解析:从挂号到发药的业务闭环

很多小诊所、社区站&#xff0c;其实一直都处于一个挺尴尬的状态&#xff1a;管理全靠Excel&#xff0c;开药靠人工记&#xff0c;收费靠计算器&#xff0c;一到月底对账就头大。所以我看到这个 Spring Boot 小型医疗机构 HIS 项目时&#xff0c;第一反应是&#xff1a;终于有一…

作者头像 李华
网站建设 2026/10/15 7:17:02

Word排版资源合集:从样式库到自动化脚本的完整搭建指南

做了很多年文档处理&#xff0c;我电脑里最值钱的东西不是什么高级工具&#xff0c;而是一个自己一点点攒出来的 Word 排版资源合集。所谓资源合集&#xff0c;不是收藏一堆模板就完事&#xff0c;而是把排版过程中会反复用到的样式、参数、脚本和判断标准&#xff0c;打包成一…

作者头像 李华