
上周帮同事看一个导入功能需求本身不复杂后台传一个Excel把里面的订单明细读出来落库。他已经在IDEA里写了小两百行POI代码从WorkbookFactory.create开始一层层剥Sheet、Row、Cell中间还夹着六七处CellType判断最后卡在日期列上,明明是2024-03-15读出来是45266。我让他把这段全删了换成Hutool的ExcelUtil同样的功能剩下不到三十行。这篇就围绕「在IDEA里用Hutool工具类读取Excel文件」这件事,把我在实际项目里反复用到的读取姿势、踩过的坑、以及性能边界一次讲清楚。不管你是第一次在IDEA里引第三方依赖,还是已经用Hutool写过导出但读取这块一直没敢深用,下面的内容都能直接抄。核心会讲明白三件事getReader的几种入口怎么选、readAll和read(0,1,Class)分别适合什么场景、什么样的Excel不该用readAll。1. 为什么我在IDEA里读Excel最终选了Hutool而不是裸POI1.1 POI原生那套代码的啰嗦程度写一次就够了先说清楚Hutool在Excel这件事上到底做了什么。它没有自己实现一套xls/xlsx解析器,底层依然是Apache POIExcelUtil是POI之上的一层封装。这个前提很重要意味着POI能读的Hutool都能读POI的坑Hutool也逃不掉只是它替你挡掉了一部分。裸POI读一个表头在第一行、数据从第二行开始的xlsx代码大致是这样try (Workbook wb WorkbookFactory.create(new File(order.xlsx))) { Sheet sheet wb.getSheetAt(0); ListOrder list new ArrayList(); for (int i 1; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row null) continue; Order o new Order(); Cell c0 row.getCell(0); if (c0 ! null) { c0.setCellType(CellType.STRING); o.setOrderNo(c0.getStringCellValue()); } Cell c1 row.getCell(1); if (c1 ! null c1.getCellType() CellType.NUMERIC) { o.setAmount(BigDecimal.valueOf(c1.getNumericCellValue())); } // 后面还有十几个字段... list.add(o); } }问题不在长在于每一列都要重复判断空、判断类型、手动转型。二十列的表格写下来就是四五百行而且改一列字段要动三处。这类代码最大的成本不是写是三个月后回来改。1.2 Hutool的封装切掉的正是这三块重复劳动ExcelUtil做的封装集中在三件事上。第一是行遍历和空行过滤setIgnoreEmptyRow(true)默认就开不用自己判断row null。第二是单元格类型到Java类型的转换默认的CellEditor会根据单元格类型和格式自动转成String、Double、Date、Boolean不用手写switch。第三是表头到对象字段的映射通过Alias注解或者addHeaderAlias把「姓名」这一列直接灌进name字段。同样的需求换成HutoolExcelReader reader ExcelUtil.getReader(FileUtil.file(order.xlsx)); ListOrder list reader.read(0, 1, Order.class);两行。read(0, 1, Order.class)的三个参数分别是表头所在行索引、数据起始行索引、目标类型。索引从0开始算所以表头在第一行就是0数据从第二行开始就是1。这个对比不是要吹Hutool多神而是想说清楚选型的逻辑如果你的Excel结构固定、就是标准的「表头数据行」Hutool能省掉80%的胶水代码如果你需要处理大量合并单元格、多个sheet交叉引用、或者单元格里有富文本和批注那还是要回到POI层面自己写。Hutool在读取这块的定位是「覆盖八成常见场景」剩下两成它会把底层的Sheet、Row、Cell暴露给你。1.3 什么情况下我会反过来放弃Hutool有三个场景我会犹豫。一是超大文件几十万行的xlsxreadAll()会把整个工作簿加载进内存OOM是迟早的事这时候要用Hutool的SAX流式接口后面第6节会细讲。二是模板结构极度不规整表头可能在第3行也可能在第5行列位置还会变这种动态定位Hutool帮不上太多还不如先做一轮探测。三是需要保留单元格的原始样式信息做回写那基本就是POI的活。判断标准很朴素数据是给人看的表格用Hutool数据是给程序读的结构化文件看情况。2. 在IDEA里把Hutool读Excel的最小可运行工程跑起来2.1 hutool-all不等于自带POI这是新手第一个跟头很多人在IDEA里加了这一条依赖就以为完事了dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency然后运行报NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook。原因是hutool-all这个包虽然叫all但它聚合的是Hutool自己的各个模块hutool-core、hutool-poi、hutool-http等第三方依赖依然是可选的。POI要你自己引dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependencypoi-ooxml会自动带上poi和poi-ooxml-lite处理xlsx够用了。如果你还要读老版本的xlspoi本身已经包含在里面不用额外加。只有在处理带宏的xlsm且需要保留宏的时候才需要额外考虑poi-ooxml-full。2.2 版本搭配的实测组合Hutool和POI之间的版本兼容没有官方强约束表但从实际跑下来的情况看下面这几组是稳的Hutool版本POI版本实测结果5.8.x5.2.3正常推荐组合5.8.x5.2.5正常5.8.x4.1.2正常但POI 4对xlsx的样式支持弱一些5.3.x5.2.x有概率报方法找不到Hutool侧调用的POI API变了5.7.x3.17正常但太老不建议新项目用真正容易出问题的是Hutool版本太旧、POI版本太新。因为Hutool内部是直接调用POI的类和方法POI大版本升级时有方法签名调整旧Hutool编译时绑定的签名在新POI里找不到就会NoSuchMethodError。所以原则是Hutool尽量用5.8.x的最新版POI跟着用5.2.x。2.3 IDEA里最容易卡住的三个环境问题引完依赖在IDEA里跑不通九成是下面三种情况。第一种Maven没重新导入。右侧Maven面板点一下刷新或者CtrlShiftO触发重新导入看External Libraries下面有没有cn.hutool:hutool-all和org.apache.poi:poi-ooxml。如果代码里import cn.hutool.poi.excel.ExcelUtil是红的基本都是这个原因。第二种文件路径。IDEA里跑main方法和跑Spring Boot应用工作目录Working directory是不一样的。默认情况下Run Configuration里的Working directory是项目根目录所以new File(src/main/resources/template.xlsx)能读到。但打成JAR之后这个路径就失效了因为resources下的文件被压进JAR包了。正确姿势是走classpathInputStream in ResourceUtil.getStream(template.xlsx); ExcelReader reader ExcelUtil.getReader(in);ResourceUtil.getStream是Hutool提供的会按classpath顺序找。这样本地跑和打JAR跑行为一致。第三种编码。xls是老格式理论上可以带编码信息但实践中遇到过GBK编码的xls被当成ISO-8859-1解析中文表头全乱码。xlsx是zipXML本身就是UTF-8不太会出这个问题。如果确实碰到乱码且确认是xls最省事的做法是让对方另存为xlsx。3. 四种读取姿势从List到Bean映射3.1 动手之前先搞清楚文件里到底几个sheet、表头在第几行这一步经常被跳过然后就出现「读出来全是null」的情况。我的习惯是先写一段探测代码把文件结构打出来ExcelReader reader ExcelUtil.getReader(FileUtil.file(order.xlsx)); // 遍历所有sheet for (int i 0; i reader.getSheetCount(); i) { reader.setSheet(i); Console.log(sheet[{}] name{}, i, reader.getSheet().getSheetName()); }getSheetCount()拿sheet数量setSheet(int)切换setSheet(String)按名字切换。切换之后getSheet()能拿到原始POI的Sheet对象getLastRowNum()看有多少行getRow(0)看第一行长什么样。为什么要做这一步因为业务方给的模板经常和文档对不上。约定表头在第一行实际可能上面有标题行、制表日期行表头跑到第三行了。这时候read(0, 1, Bean.class)必然读不出来得改成read(2, 3, Bean.class)。3.2 getReader的三种入口怎么选ExcelUtil.getReader有几个重载实际用得最多的是这三个// 1. 文件路径 ExcelReader r1 ExcelUtil.getReader(D:/data/order.xlsx); // 2. File对象 ExcelReader r2 ExcelUtil.getReader(FileUtil.file(order.xlsx)); // 3. 输入流classpath资源、上传流 ExcelReader r3 ExcelUtil.getReader(ResourceUtil.getStream(template.xlsx));还有带sheet索引的ExcelUtil.getReader(File file, int sheetIndex)等价于先getReader再setSheet。这里有个细节值得说。Hutool从InputStream读的时候会自动检测文件类型来判断是xls还是xlsx。检测方式是读取流的头几个字节看魔数xlsx是PKzip头xls是D0 CF 11 E0OLE2头。所以它内部会把流包一层支持回退的流。这意味着两点一是不用自己判断后缀名二是传入的流最好是可重复读取的如果你传的是一个已经读过一部分的流可能会判错。3.3 readAll拿Mapread拿Bean按场景选两种最常用的读取方式差别很清楚。readAll()返回ListMapString, Objectkey是表头文字value是单元格值ExcelReader reader ExcelUtil.getReader(FileUtil.file(order.xlsx)); ListMapString, Object rows reader.readAll(); for (MapString, Object row : rows) { String orderNo Convert.toStr(row.get(订单号)); BigDecimal amount Convert.toBigDecimal(row.get(金额)); }适合表头不固定、字段动态的场景比如用户自定义列导出、数据预览页面。缺点是没有编译期检查row.get(订单号)写错了只能运行时发现。read(headerRowIndex, startRowIndex, Class)返回强类型Listpublic class Order { Alias(订单号) private String orderNo; Alias(下单时间) private Date orderTime; Alias(金额) private BigDecimal amount; Alias(收货人) private String receiver; }ListOrder list ExcelUtil.getReader(FileUtil.file(order.xlsx)) .read(0, 1, Order.class);Alias来自cn.hutool.core.annotation.Alias作用是把Excel表头中文映射到Java字段。不写Alias的话Hutool会拿字段名去匹配表头orderNo匹配不到「订单号」就是null。这一点很多人踩过。3.4 addHeaderAlias不改实体也能做列名映射有些项目的实体是共用的不能随便加注解。这时候用addHeaderAliasExcelReader reader ExcelUtil.getReader(file); reader.addHeaderAlias(订单号, orderNo); reader.addHeaderAlias(下单时间, orderTime); reader.addHeaderAlias(金额, amount); ListMapString, Object rows reader.readAll();注意参数顺序第一个是Excel里实际出现的表头文字第二个是你想要的别名Map的key或Bean的字段名。顺序反了不会报错只会静默读不到值。这个方法还有个隐藏用法配合readAll()做列筛选。只有加了alias的列才会出现在结果Map里其他列被忽略。当你只关心表格里的三四列、不想把整个宽表都读进来时这个特性很实用。4. 实测中最容易翻车的几类数据类型、空值、合并单元格4.1 数字列全变成1.0订单号带上了小数点这是最高频的问题没有之一。Excel里的订单号「1001」如果没有被设置成文本格式POI读出来就是数值类型默认转换器会返回Double1001.0。然后Convert.toStr得到的字符串就是1001.0落库直接报格式错误。治本的办法是要求模板制作方把订单号、身份证号、手机号这类列设置成文本格式。但现实是业务方不会配合所以得从代码上兜。方案一读数后清洗Object v row.get(订单号); String orderNo new BigDecimal(String.valueOf(v)) .stripTrailingZeros() .toPlainString();注意不要用NumberUtil的toStr直接转也不要用Double.toString再截断浮点数精度会有意外。走BigDecimal比较稳。方案二在读取阶段就用自定义CellEditor截住public class PlainNumberCellEditor implements CellEditor { Override public Object edit(Workbook workbook, Cell cell) { if (cell.getCellType() CellType.NUMERIC) { double d cell.getNumericCellValue(); if (d Math.floor(d) !Double.isInfinite(d)) { return new BigDecimal(String.valueOf(d)).toBigInteger().toString(); } } return DefaultCellEditor.INSTANCE.edit(workbook, cell); } }ExcelReader reader ExcelUtil.getReader(file); reader.setCellEditor(new PlainNumberCellEditor());这个editor的逻辑是遇到数值单元格且是整数直接返回不带小数点的字符串其他情况交回默认实现。判断d Math.floor(d)是为了区分整数和真正的小数金额列是99.5这种就不会被误转。需要注意DefaultCellEditor.INSTANCE这个静态实例是Hutool提供的默认实现别自己new一个空的返回null。4.2 日期列的三种形态一套代码全兜住日期列在Excel里可能是三种东西真正的日期格式单元格POI返回Date、文本格式的「2024-03-15」、以及没设置日期格式的数值序列号45266。第一种和第二种Hutool的默认转换器都能处理Convert.toDate对yyyy-MM-dd、yyyy/MM/dd、yyyy-MM-dd HH:mm:ss都能解析。麻烦的是第三种单元格是数值但没有日期格式默认返回DoubleBean里的Date字段会因为转换失败变成null。处理思路是在CellEditor里加一段启发式判断public class FlexibleDateCellEditor implements CellEditor { Override public Object edit(Workbook workbook, Cell cell) { if (cell.getCellType() CellType.NUMERIC !org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) { double v cell.getNumericCellValue(); // 20000~60000 大致对应 1954~2064 年避开金额和年龄 if (v 20000 v 60000) { return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v); } } return DefaultCellEditor.INSTANCE.edit(workbook, cell); } }这个区间是经验值。Excel的日期序列号从1900-01-01开始算1对应1900年45266对应2023年底。取20000到60000可以覆盖1954年到2064年同时避开绝大多数金额和年龄数值。这是一种折中如果表格里同时有「金额35000」和日期序列号会有误判风险这时候就不能靠启发式得要求模板规范化。4.3 合并单元格只有左上角有值其他都是nullExcel的合并单元格在底层是一个CellRangeAddress只有左上角那个格子存了值其余格子在POI层面是空的或者返回null。这是Excel的数据模型决定的不是Hutool的问题。如果业务数据是「客户名称」列合并了三行你按行读就会得到第一行有值、后两行null。要让后两行也带上得手动补齐ExcelReader reader ExcelUtil.getReader(file); Sheet sheet reader.getSheet(); // 先记录每个合并区域左上角的值 for (CellRangeAddress region : sheet.getMergedRegions()) { int firstRow region.getFirstRow(); int firstCol region.getFirstColumn(); Row row sheet.getRow(firstRow); if (row null) continue; Cell src row.getCell(firstCol); if (src null) continue; src.setCellType(CellType.STRING); String value src.getStringCellValue(); // 把值填满整个区域 for (int r firstRow; r region.getLastRow(); r) { Row target sheet.getRow(r); if (target null) target sheet.createRow(r); for (int c firstCol; c region.getLastColumn(); c) { Cell tc target.getCell(c); if (tc null) tc target.createCell(c); tc.setCellValue(value); } } } ListOrder list reader.read(0, 1, Order.class);这段必须在调用read之前执行。注意sheet.getMergedRegions()返回的顺序不保证是从上到下但因为我们只是填值不影响最终结果。如果合并的列是分类字段而不是关键字段另一种做法是在业务层做「向下填充」读完List之后遍历一遍遇到null就取上一个非null的值。这种写法代码更短但前提是该列确实应该向下继承。4.4 表头里的空格、换行和重复列名表头文字和Alias里的字符串必须完全一致中间多一个空格、一个换行都会导致匹配失败。这不是Hutool的锅但确实是排查时最耗时间的一类问题。我一般先用一段代码把真实表头打出来用方括号包住这样空格和换行都能看见Row header reader.getSheet().getRow(0); for (int i 0; i header.getLastCellNum(); i) { Cell c header.getCell(i); Console.log([{}] [{}], i, c null ? : c.toString()); }输出如果是[订单 号]而不是[订单号]那就知道问题在哪了。常见来源是单元格里用了AltEnter换行或者复制粘贴时带了不可见字符。处理办法有两种。一是在addHeaderAlias之前先对表头做trim但Hutool没有直接提供表头清洗的钩子得在读取后用Map的key做一次映射。二是用readAll()读成Map之后自己在Map里做规范化key的查找MapString, Object normalized new HashMap(); for (Map.EntryString, Object e : row.entrySet()) { normalized.put(e.getKey().replaceAll([\\s\\u00A0], ), e.getValue()); }\u00A0是不间断空格普通\s匹配不到这个坑单独踩过一次。重复列名的情况比较少见但也会遇到两个列都叫「备注」。readAll()返回的Map里后一列会覆盖前一列数据丢失且不报错。这种情况只能改用read()拿ListListObject按列索引取值或者要求模板改列名。把上面几类问题整理成一张对照表排查的时候可以直接查现象根本原因处理方式数字带小数点单元格是数值类型BigDecimal清洗或自定义CellEditor日期为null数值序列号未设日期格式CellEditor加区间判断合并单元格部分行空Excel只存左上角读前填充或业务层向下填充表头匹配不上存在空格或换行打印原始表头排查做key规范化列值错乱存在重复列名改用按索引读取中文乱码xls编码问题转为xlsx5. 把读到的数据落库分批、校验、事务的实操套路5.1 先校验再入库别让脏数据进事务读出来的List不要直接扔给批量插入。我的习惯是先跑一遍全量校验把不合法的行挑出来全部通过才开事务。public class ImportResult { private ListOrder valid new ArrayList(); private ListString errors new ArrayList(); }ImportResult result new ImportResult(); for (int i 0; i list.size(); i) { Order o list.get(i); if (StrUtil.isBlank(o.getOrderNo())) { result.getErrors().add(第 (i 2) 行订单号为空); continue; } if (o.getAmount() null || o.getAmount().compareTo(BigDecimal.ZERO) 0) { result.getErrors().add(第 (i 2) 行金额不合法); continue; } result.getValid().add(o); }行号从i 2起算是因为索引0对应Excel的第2行第1行是表头。这个细节看起来小但用户拿着错误提示去Excel里找行的时候差一行会让他们很不爽。如果错误行较多建议用errors的长度做阈值判断比如超过总行数的20%就整体拒绝不部分导入。部分成功部分失败的导入最难解释用户搞不清到底进没进。5.2 分批插入的批次大小怎么定数据量上千之后就不能一条条insert了。MyBatis-Plus的saveBatch默认批次是1000实际调优下来500到1000是比较稳的区间。太大了单条SQL会超长MySQL默认的max_allowed_packet是4MB一行数据如果有长文本字段1000条很容易撑爆。超过5000条我会自己切分int batchSize 500; for (int i 0; i valid.size(); i batchSize) { int end Math.min(i batchSize, valid.size()); ListOrder sub valid.subList(i, end); orderMapper.batchInsert(sub); }subList返回的是视图不是拷贝如果后面还要对这个List做修改会抛ConcurrentModificationException。要么用new ArrayList(valid.subList(i, end))包一层要么确保切分之后不再动原List。这个坑我在两个项目里各踩过一次。5.3 事务边界要包在循环外面分批插入的时候Transactional加在哪个方法上很关键。如果加在循环里面调用的那个方法上5000条数据会被拆成10个独立事务中途失败前面的数据就残留了。正确做法是把事务加在外层的导入方法上整个导入要么全成功要么全回滚。Transactional(rollbackFor Exception.class) public ImportResult importFromExcel(MultipartFile file) { ListOrder list readExcel(file.getInputStream()); ImportResult result validate(list); if (!result.getErrors().isEmpty()) { return result; // 有错误直接返回不开插入 } batchInsert(result.getValid()); return result; }rollbackFor Exception.class不能省。Spring默认只对RuntimeException回滚而Hutool的很多操作抛的是IORuntimeException它是RuntimeException的子类所以这条其实能回滚但如果你在中间catch了异常又transform成了自定义的checked exception不加这行就不会回滚。这个细节吃过亏。另外提醒一句导入方法本身如果耗时长事务会一直持有数据库连接和锁。十万行级别的导入建议改成异步任务加进度查询不要放在HTTP请求线程里同步跑前端会超时。6. 十万行以上的ExcelreadAll会OOM改用SAX流式读取6.1 readAll为什么吃内存readAll()的工作方式是先用POI把整个工作簿完整加载到内存构建出Workbook、Sheet、Row、Cell这一整套对象树然后再遍历转成Map或Bean。xlsx是压缩的XML解压之后的对象树通常比原文件大十几到几十倍。我实测过一组数据环境是默认的JVM堆参数xlsx文件大小与内存占用大致是这样文件大小行数×列数readAll峰值内存结果1MB5千×10约60MB正常10MB5万×10约450MB勉强需要调大堆30MB15万×10超过1.5GB大概率OOM80MB40万×10-必OOM所以在动手读之前先看一眼文件大小。超过10MB我就不会用readAll()了。6.2 ExcelUtil.readBySax的用法和它的代价Hutool提供了基于SAX的流式读取接口逐行回调不在内存里保留整个工作簿ExcelUtil.readBySax( ResourceUtil.getStream(big-order.xlsx), 0, new RowHandler() { Override public void handle(int sheetIndex, long rowIndex, ListObject rowList) { if (rowIndex 0) { return; // 跳过表头 } // 逐行处理比如直接攒批入库 Order o new Order(); o.setOrderNo(Convert.toStr(rowList.get(0))); o.setAmount(Convert.toBigDecimal(rowList.get(1))); // ... } } );第一个参数是输入流第二个是sheet索引第三个是行处理器。rowIndex是全局行号从0开始0就是表头那行。代价有三个得提前知道。第一拿到的rowList是ListObject没有表头信息全靠列索引可读性差。第二类型转换比readAll弱日期、数字的自动识别不如常规路径智能很多值拿到的是字符串形态需要自己Convert。第三合并单元格、单元格样式这些信息拿不到SAX模式下只看值。所以我的用法是把readBySax当成一个「管道」在handle里直接攒批入库每500行刷一次不在内存里堆ListListOrder buffer new ArrayList(500); ExcelUtil.readBySax(in, 0, (sheetIndex, rowIndex, rowList) - { if (rowIndex 0) return; buffer.add(toOrder(rowList)); if (buffer.size() 500) { orderMapper.batchInsert(new ArrayList(buffer)); buffer.clear(); } }); if (!buffer.isEmpty()) { orderMapper.batchInsert(buffer); }这里把buffer传进batchInsert之前包了一层new ArrayList()是为了防止MyBatis在异步执行时持有原List引用clear()之后数据没了。注意RowHandler是函数式接口可以直接写lambda上面这段用lambda比匿名内部类清爽得多。6.3 换流式读取之后的效果对照同一个30MB、15万行的文件同样的机器换成readBySax之后峰值内存稳定在150MB上下主要开销是缓冲区那500条数据加上POI的SAX解析器本身。处理时间上流式读取比一次性加载慢大概15%到20%因为XML是边解边处理没有随机访问。这点时间换内存安全我觉得很值。还有一点要注意readBySax在解析过程中如果发生格式错误会直接抛异常中断已经处理过的批次已经入库了如果你在事务里就没问题如果不在事务里就会产生部分数据。所以流式读取配合「按批次开独立事务、记录一个导入批次号」的做法比较稳妥重跑的时候先按批次号删旧数据。最后分享一个小习惯。我现在读任何Excel之前都会先跑一段三行的探测代码打印文件大小、sheet数量、第一个sheet的行数和第一行内容。这三行代码帮我省掉过无数次「读出来全空」的排查时间尤其是在接手别人给的模板的时候。工具是死的模板是活的先看清楚手里这个文件长什么样再决定用哪套读法这个顺序比记住某个API重要得多。