☰
Hutool在Idea中读取Excel:ExcelReader与避坑实践
2026/10/1 13:30:59 网站建设 项目流程

做后端开发或者日常写数据处理脚本的人,几乎都会碰到一个绕不开的场景:把业务方丢过来的 Excel 文件解析成程序能用的结构化数据。我在 Idea 里用 Hutool 这个工具类库做 Excel 文件读取,前后也有好几年了,从最开始图省事随手一写,到后来在正式项目里封装成通用的导入模块,中间踩的坑不算少。这篇文章就把这套东西完整摊开讲一遍——Hutool 是什么、它在 Idea 项目里怎么引入、ExcelReader 的核心 API 怎么用、遇到大文件和多 Sheet 怎么处理、以及那些文档里不会写但实际一定会撞上的问题。不管你是刚接触 Java 没多久、第一次被安排做导入功能的同学,还是已经写过几轮导入、想把手里的代码收一收的老手,下面这些内容应该都能直接用上。

1. Hutool 读 Excel 这件事,到底适合放在什么场景里

1.1 一个真实需求把工具选型逼到台前

先说我最近一次做 Excel 读取的真实背景。运营那边每个月要做一次合作商户的对账,商户名单由对方提供,格式是 xlsx,字段大概有商户编号、商户名称、联系人、手机号、结算周期、开户行、银行账号这么七八列,行数不多,通常几百行,偶尔上千。需求很朴素:上传文件,解析成列表,做一次校验,然后批量入库。

听起来是个再简单不过的功能,但真正动手的时候,选项一大堆。用原生 Apache POI 写?代码量大,光是把单元格类型判断那一堆 if-else 写全就要小半天,而且极容易漏掉日期格式和公式单元格。用 EasyExcel?流式读取内存友好,但对几百行的小文件来说有点杀鸡用牛刀,而且它的 API 风格和 POI 差别不小,团队里没写过的人要重新学。用 Hutool?引入一个依赖,三四行代码就能把整张表读成 List,字段映射也不难。最后我选了 Hutool,理由很简单:这个场景的瓶颈不在性能,而在开发速度和可读性。

1.2 几种常见方案摆在一起对比

把常见的几种 Excel 读取方案摊开对比一下,选型思路会清晰很多。下面这张表是我根据自己实际用过的情况整理的,不是绝对结论,但能反映各自的性格。

方案上手难度内存表现代码量适合的场景
原生 Apache POI偏高DOM 模式吃内存多需要精细控制单元格、公式、样式的场景
EasyExcel中等流式,内存好中几十万行级别的大文件导入
Hutool(底层封 POI)低DOM 模式,随文件增长少中小文件、快速开发、内部工具
JXL低一般少只处理老的 xls,且不想引 POI
直接当 CSV 读最低极好极少文件结构简单、无合并单元格、无样式

看完这张表就能明白,Hutool 的位置很明确:它不跟你抢大文件导入的活,它赢在中小文件上的开发效率。如果你手里的文件动不动几十万行,那我建议直接上流式方案,Hutool 全量载入的模式会让你在内存上很难受。

1.3 Hutool 凭什么被我留下

真正让我一直用 Hutool 的,是几个很具体的细节。第一,ExcelUtil.getReader()支持文件、输入流、路径三种入参,接收前端上传的MultipartFile时,直接拿getInputStream()传进去就行,不用先落盘。第二,read()方法会自动把首行当表头,返回List<Map<String, Object>>,拿到之后按列名取值,代码读起来像是在操作一个简易的数据表。第三,setHeaderAlias()能把中文表头映射成英文字段,业务方改个列名不用动代码逻辑。

还有一点容易被忽略:Hutool 的 Excel 模块本质是对 POI 的一层轻封装,意味着底层能力并没有被剥夺。真遇到需要精细控制的单元格,你随时可以通过reader.getWorkbook()拿到原生Workbook对象,混着写。这种"高层省事、底层可控"的双层结构,是它在实际项目里比很多纯封装库更耐用的原因。

2. 在 Idea 里把 Hutool 装进项目

2.1 Maven 依赖怎么写,为什么建议拆开写

最省事的写法是引全量包:

<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency>

但我更推荐只引 POI 模块,尤其是项目本身依赖已经比较多的时候:

<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-poi</artifactId> <version>5.8.25</version> </dependency>

为什么建议拆开?hutool-all把所有模块打包在一起,虽然方便,但在依赖分析阶段会让 Idea 的 Maven 面板里多出一堆用不到的传递依赖,而且如果项目里同时引了别的工具库,排查冲突时干扰项更多。hutool-poi只带 Excel 和 Word 相关的类,传递引入 POI 的poi和poi-ooxml,体积小、依赖链清楚。版本号这块,Hutool 5.x 系列目前是主流,5.8.x 的 API 相对稳定,选一个较新的小版本即可,不必追最新。

注意:如果你项目里已经通过别的途径引入了 POI,比如为了做 Word 导出,那么hutool-poi传递进来的 POI 版本可能和你原本的不一致,这时候要在自己的dependencyManagement里统一锁定 POI 版本,避免出现两份同名类。

2.2 Gradle 项目的引入姿势

用 Gradle 的话对应的写法是:

dependencies { implementation 'cn.hutool:hutool-poi:5.8.25' }

Kotlin DSL 写成:

dependencies { implementation("cn.hutool:hutool-poi:5.8.25") }

Gradle 有个比 Maven 更需要注意的点:它默认会做依赖版本冲突解析,选版本更高的那个。如果hutool-poi传递进来的 POI 版本比项目里其他地方引的高,Gradle 会静默替换掉,表面看不出来,实际运行时可能出现方法找不到。排查的办法是在 Idea 的 Gradle 面板里执行dependencies任务,看runtimeClasspath那棵树里org.apache.poi下面的版本到底是哪一个。

2.3 在 Idea 里确认依赖真的进来了

依赖写完,点一下 Idea 右上角的刷新(Maven 是那个带循环箭头的 Reload 按钮,Gradle 是刷新图标)。不确定有没有生效,最直接的验证方式是在代码里敲ExcelUtil,看能不能自动补全出来,能补全说明索引到了。如果补全不出来,去 External Libraries 下面找找有没有hutool-poi这一项。

还有一种情况是依赖明明在,但编译报程序包 cn.hutool.poi.excel 不存在。这种九成是 Idea 的缓存问题,File菜单里做一次 Invalidate Caches 重启,一般就恢复了。我第一次遇到的时候折腾了好久,最后发现就是缓存没刷新,白白怀疑了半天的网络。

3. 把 ExcelReader 的核心用法拆开讲

3.1 三种构造方式对应三种数据来源

Hutool 读 Excel 的入口是ExcelUtil.getReader(),它有几种重载,对应不同的数据来源:

// 1. 从本地文件路径读 ExcelReader reader1 = ExcelUtil.getReader("D:/data/merchant.xlsx"); // 2. 从 File 对象读 ExcelReader reader2 = ExcelUtil.getReader(new File("D:/data/merchant.xlsx")); // 3. 从输入流读,接收前端上传文件时最常用 ExcelReader reader3 = ExcelUtil.getReader(multipartFile.getInputStream());

从输入流读这个能力在处理 Web 上传时特别顺手。前端传上来的文件往往是MultipartFile,你不需要先transferTo存到临时目录再读,直接取流就能解析。少了落盘这一步,既省了磁盘 IO,也避免了清理临时文件的问题。

不过这里有个需要注意的点:输入流读完之后一定要关闭。Hutool 的ExcelReader实现了Closeable,正确做法是用 try-with-resources 包起来,或者显式在 finally 里reader.close()。不关的话在 Windows 环境下文件句柄会一直被占着,后续想删除或者覆盖这个文件就会失败。

3.2 read 与 readAll 的区别,什么时候用哪个

ExcelReader上最常用的两个方法是read()和readAll(),名字很像,返回结构完全不同。

readAll()返回List<List<Object>>,是一个纯粹的二维表,每一行是一个 List,按列的顺序排布。它不关心表头,从第一行开始全部读进来。

ExcelReader reader = ExcelUtil.getReader(file); List<List<Object>> rows = reader.readAll(); // rows.get(0) 是第一行,通常是表头 // rows.get(1) 开始才是数据 reader.close();

read()返回List<Map<String, Object>>,它默认把第一行当作表头,后续每一行组装成一个 Map,key 是表头文字,value 是单元格的值。取值的时候按列名取,语义清晰。

ExcelReader reader = ExcelUtil.getReader(file); List<Map<String, Object>> list = reader.read(); for (Map<String, Object> row : list) { String name = (String) row.get("商户名称"); // 处理每一行 } reader.close();

什么时候用哪个?如果你的表结构固定、列顺序不会变,readAll()更快更直接,纯粹按位置取。如果列可能会调整顺序、或者表头文字对业务有意义,read()更稳,因为它靠列名而不是列索引定位。我自己的习惯是:内部固定格式的模板文件用readAll(),外部提供的、格式不完全可控的文件用read()。

3.3 表头别名和数据类型处理

read()用中文列名取值有个明显问题:代码里到处是中文 key,后期重构或者国际化会很别扭。Hutool 提供了setHeaderAlias()来解决:

ExcelReader reader = ExcelUtil.getReader(file); Map<String, String> alias = new LinkedHashMap<>(); alias.put("商户编号", "merchantNo"); alias.put("商户名称", "merchantName"); alias.put("联系人", "contact"); alias.put("手机号", "mobile"); reader.setHeaderAlias(alias); List<Map<String, Object>> list = reader.read();

设置别名之后,返回的 Map 里 key 就变成了你定义的英文字段名,中文列名到字段名的映射关系集中在一处维护,改起来也方便。

数据类型这块,Hutool 返回的值默认是 POI 解析出来的原始类型:文本是 String,数字可能是 Double,日期是 Date,布尔是 Boolean。这里有个我踩过不止一次的坑:手机号、身份证号这类看起来像数字的字段,如果 Excel 里没有明确按文本格式存储,POI 会读成 Double。一个 13800138000 读出来变成 1.3800138E10,直接存库就废了。解决办法有两个,要么在读取时用CellEditor把这一列强制转成字符串,要么在业务层判断类型后手动格式化。

ExcelReader reader = ExcelUtil.getReader(file); // 关闭默认的数字格式化,让数值保持原样 reader.disableDefaultStyle();

disableDefaultStyle()这个方法是用来关闭 Hutool 对样式的默认处理的,在某些格式化场景下有用,但要注意它影响的是样式输出,不是值类型,别搞混了。

4. 从零实现一个能上生产的读取工具

4.1 先把结果对象的字段定下来

不管后面逻辑怎么写,第一步一定是把 Excel 里的数据映射成一个明确的 Java 对象,而不是在业务代码里到处Map.get()。我一般会定义这样一个类:

public class MerchantImportDTO { private String merchantNo; private String merchantName; private String contact; private String mobile; private String bankName; private String bankAccount; private Date settleDate; // getter / setter 省略 }

有人会说,直接BeanUtil.copyProperties一行就转过去了。确实可以,Hutool 的BeanUtil配合read()返回的 Map 能自动填充字段,代码短得惊人:

List<Map<String, Object>> rows = reader.read(); List<MerchantImportDTO> list = BeanUtil.copyToList(rows, MerchantImportDTO.class);

但我要提醒一句,自动拷贝虽然爽,字段类型不匹配的时候它不一定报错,可能静默给你塞个 null 或者类型转换失败后留空。生产环境的导入,我还是建议老老实实逐字段赋值,顺带做校验,控制权在自己手里,出问题也好定位。

4.2 读取主流程代码逐段说明

下面这个方法是封装好的完整读取流程,把校验、转换、异常处理都串起来:

public List<MerchantImportDTO> readMerchantExcel(InputStream inputStream) { List<MerchantImportDTO> result = new ArrayList<>(); try (ExcelReader reader = ExcelUtil.getReader(inputStream)) { // 1. 指定别名,把中文表头映射为英文字段 Map<String, String> alias = new LinkedHashMap<>(); alias.put("商户编号", "merchantNo"); alias.put("商户名称", "merchantName"); alias.put("联系人", "contact"); alias.put("手机号", "mobile"); alias.put("开户行", "bankName"); alias.put("银行账号", "bankAccount"); alias.put("结算日期", "settleDate"); reader.setHeaderAlias(alias); // 2. 从第二行开始读,第一行是表头 List<Map<String, Object>> rows = reader.read(0, 1); // 3. 逐行转换与校验 for (int i = 0; i < rows.size(); i++) { Map<String, Object> row = rows.get(i); MerchantImportDTO dto = new MerchantImportDTO(); dto.setMerchantNo(StrUtil.trim(Convert.toStr(row.get("merchantNo")))); dto.setMerchantName(StrUtil.trim(Convert.toStr(row.get("merchantName")))); dto.setContact(StrUtil.trim(Convert.toStr(row.get("contact")))); dto.setMobile(formatMobile(row.get("mobile"))); dto.setBankName(StrUtil.trim(Convert.toStr(row.get("bankName")))); dto.setBankAccount(formatAccount(row.get("bankAccount"))); dto.setSettleDate(Convert.toDate(row.get("settleDate"))); // 4. 必填校验 if (StrUtil.isBlank(dto.getMerchantNo())) { throw new IllegalArgumentException("第 " + (i + 2) + " 行商户编号为空"); } if (StrUtil.isBlank(dto.getMerchantName())) { throw new IllegalArgumentException("第 " + (i + 2) + " 行商户名称为空"); } result.add(dto); } } catch (IOException e) { throw new RuntimeException("读取 Excel 失败", e); } return result; }

这里有几个值得展开说的点。

reader.read(0, 1)这个重载,第一个参数是表头所在行索引,第二个参数是数据起始行索引。默认read()就是从第 0 行当表头、第 1 行开始是数据,本质上等价。但显式写出来,看代码的人一眼就知道你的表结构,比默认行为更清楚。

formatMobile()和formatAccount()是专门处理数字精度问题的两个小方法。手机号、银行账号这类超长数字,POI 读出来可能是 Double,也可能是科学计数法字符串,统一格式化才能保证入库正确:

private String formatMobile(Object value) { if (value == null) { return null; } if (value instanceof Double) { return new BigDecimal(value.toString()).toPlainString(); } return Convert.toStr(value).trim(); }

用BigDecimal的toPlainString()而不是直接Double.toString(),就是为了避免再冒出来科学计数法。这个细节不注意,13800138000 会在某一步又变回 1.38E10。

4.3 大文件与多 Sheet 的处理策略

前面说过 Hutool 是全量载入,所以文件大了必须换思路。我的判断标准大概是:一万行以内,Hutool 的read()完全够用,内存占用在可接受范围内;到了一万到十万行这个区间,就要开始留意 JVM 堆大小,必要时调大-Xmx;超过十万行,就别硬扛了,改用 POI 的 SAX 事件模式或者别的流式方案。

如果确实要用 Hutool 处理偏大的文件,有一个操作能帮上忙:只读数据、跳过样式处理。Excel 里如果数据量不大但带了一堆格式,样式解析反而是大头。读取前可以调reader.disableDefaultStyle(),减少一部分处理开销。不过这只是缓解,不是根治。

多 Sheet 是另一个高频场景。Hutool 的ExcelReader默认只读第一个 Sheet,要读其他 Sheet 需要先切:

ExcelReader reader = ExcelUtil.getReader(file); int sheetCount = reader.getSheetCount(); for (int i = 0; i < sheetCount; i++) { reader.setSheet(i); List<Map<String, Object>> sheetData = reader.read(); // 处理第 i 个 Sheet } reader.close();

setSheet()既可以传索引,也可以传 Sheet 名:

reader.setSheet("2024年1月");

按名字切适合 Sheet 名有明确业务含义的情况,比如按月份分 Sheet 的对账单。按索引切则适合 Sheet 名不固定、但顺序有约定俗成含义的场景。这里要注意一个顺序问题:setSheet()必须写在read()之前,写完read()之后再切 Sheet 是无效的,因为数据已经读出来了。我第一次用的时候就犯过这个错,切了 Sheet 发现数据没变,还以为方法坏了。

5. 踩过的坑与排查清单

5.1 依赖冲突引发的 NoClassDefFoundError

这是最容易在项目集成阶段卡住人的一类问题。表现是代码编译通过,运行时报NoClassDefFoundError或者NoSuchMethodError,堆栈里指向org.apache.poi下面的某个类。

根本原因通常是项目里存在多份 POI,或者 POI 的版本和 Hutool 期望的不一致。排查顺序我一般是这样走:先在 Idea 的 Maven/Gradle 面板里看依赖树,搜索org.apache.poi,确认最终生效的是哪个版本。如果发现有多个版本被不同路径引进来,就在dependencyManagement里统一锁死。

<dependencyManagement> <dependencies> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.3</version> </dependency> </dependencies> </dependencyManagement>

锁版本之后一定要重新刷新依赖再跑一次。很多人锁完不刷新,还以为问题没解决。另外,POI 5.x 相比 4.x 有一些包路径调整,从老项目升级过来时特别注意这个,如果代码里直接用了 POI 的类,可能也要跟着改。

5.2 日期、精度和空单元格三个高频陷阱

这三个问题我几乎每做一个新项目就会碰到一两个,做成长度、类型、空值三张清单会直观很多:

问题现象根本原因解决办法
日期读出来是数字 45000 这种Excel 内部日期存的是序列号,格式决定显示用Convert.toDate()转换,或自定义 CellEditor
手机号变成 1.38E10数字被当作 Double 处理用 BigDecimal 转字符串,或用文本格式列
空单元格读成 null 或空字符串POI 对空白单元格不创建对象读取后统一用StrUtil.isBlank()判断
整数 100 读成 100.0POI 数字类型统一是 Double业务层判断后取整,或单元格本身设为整数格式

日期这个问题尤其容易迷惑人。一个看起来正常的日期列,程序读出来却是 45000 这样的数字,因为 Excel 底层把日期存成从 1900 年某个基准日算起的天数,显示成日期只是格式渲染的结果。Hutool 的Convert.toDate()能处理大部分情况,但如果你拿到的本来就是数字,它可能转不明白,这时候要靠单元格的格式信息来判断。

5.3 内存溢出与线程安全

内存溢出的典型症状是OutOfMemoryError: Java heap space,堆栈指向 POI 的XSSFWorkbook或者 SAX 解析那块。前面反复说了,Hutool 全量载入,文件大了必然吃内存。有一次我接了个用户上传的 Excel,单看行数才两万,但里面塞了大量的样式和批注,读的时候直接 OOM。后来把读取逻辑拆成校验行数、分片处理,先看文件大小和 Sheet 数,超过阈值就提示用户拆分上传,问题就没了。

线程安全这块,ExcelReader不是线程安全的,一个实例同时在多个线程里 read 会出问题。多线程处理多个文件时,每个线程各自创建自己的ExcelReader,不要复用。如果要做并发读取,用线程池把任务拆开,每个任务内部独立开 reader、独立关闭。

提示:处理不可信来源的 Excel 时,最好限制单次上传的文件大小和 Sheet 数量。既有安全考虑,也能避免单个请求把服务内存拖垮。

5.4 排查速度清单

把上面这些整理成一个快速排查清单,遇到问题时对着过一遍,能省不少时间。

  • 编译不过、包找不到:先刷新依赖,再 Invalid Caches 重启 Idea。
  • 运行时报 POI 相关异常:查依赖树,统一 POI 版本。
  • 数字精度不对:检查单元格类型,用 BigDecimal 转字符串。
  • 日期不对:确认是序列号还是 Date,用Convert.toDate()或自定义编辑器。
  • 空值报错:所有取值处统一做 null 和空字符串判断。
  • 内存溢出:评估文件规模,超过阈值换流式方案或限制上传大小。
  • 数据只读到一部分:确认setSheet()在read()之前调用,确认数据起始行参数没写错。

最后分享一个我在实际封装里养成的小习惯:任何一次 Excel 导入,都在读取的同一层记一条日志,写清楚文件大小、Sheet 数、解析行数、耗时。看着不起眼,但线上真出问题的时候,这条日志往往是定位"到底是文件问题还是逻辑问题"的第一手依据。导入功能这东西,写起来快,出事的时候没有可观测的线索才是最要命的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询