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

资讯详情

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

Java-POI导入导出实践:从API选型到性能优化

Java-POI导入导出实践:从API选型到性能优化 简介基于Java-POI的Excel导入导出系统源码实现面向具有Java基础、需要处理Excel读写场景的开发者。代码覆盖HSSF与XSSF两大API可解析.xls与.xlsx格式并实现工作表、行、列、单元格样式、公式等对象的读取与创建导入侧侧重数据解析与校验导出侧包含单元格格式、字体、边框等样式设置同时兼顾分批处理与内存使用等性能优化思路。资源共15个文件以10个Java源文件为主另含1个XML配置文件、1个xlsx与1个xls示例文件、1个.gitignore与1个README.md说明文档压缩包整体仅24KB结构精简便于快速定位核心代码。已有45人学习下载。通过阅读源码和示例文件开发者可以掌握POI读写Excel的常见写法理解数据导入导出机制、错误处理与异常管理方式适用于企业应用中的报表生成、数据迁移场景也可作为课程设计或毕业设计的基础框架。1. 为什么 Java-POI 导入导出系统不该只写两个工具类很多项目的 Excel 导入导出最后都变成一对“万能”工具类readExcel() 负责把数据读成 ListwriteExcel() 负责把 List 写回文件哪都能调用但改需求时谁都不敢碰。这套基于 Java-POI 的导入导出系统源码是标准 Maven 结构pom.xml、src/test/main、README.md它把散落在业务代码里的 POI 操作收拢成一个有边界、可测试的模块。真正拆开之后会发现难点不在 Workbook 怎么打开而在 HSSF、XSSF、SXSSF 三套 API 怎么选单元格类型怎么映射错误怎么定位到具体行大数据量下内存怎么控制以及 POI 版本漏洞怎么防。这些都是 Java 后端做报表导入导出时绕不开的硬问题也是 Java 面试里常被追问的细节。2. 先掰开 POI 对象模型HSSF 与 XSSF 的取舍才是第一步2.1 同是操作 Excel底层完全不同在用 POI 之前必须先把文件格式和 API 对应起来。HSSF 操作的是 Excel 97-2003 的 .xls 格式走的是 OLE2 复合文档结构XSSF 操作的是 Excel 2007 以上使用 Office Open XML 的 .xlsx 格式本质上是一个 zip 包里面装着 xml 和关系文件。两者都有 Workbook、Sheet、Row、Cell 这套抽象接口但底层解析器和内存模型不同导致同样的对象数量在内存占用上有明显差异。XSSF 读写 .xlsx 时会在内存中构建整个 DOM行数一多就容易堆内存溢出HSSF 虽然老但对 .xls 的兼容是唯一选择。还有一个容易被忽略的 SXSSF它是 XSSF 的流式版本只保留一个滑动窗口的行数据适合导出侧大数据量场景。实际选型时我一般按三个问题判断用户手头是什么格式文件能有多大是否需要保留复杂样式。只接收 .xls 的老系统HSSF 逃不掉要生成 .xlsx 且数据量在几万行以内XSSF 最稳妥样式能力最全文件可能超过五万行导出用 SXSSF导入用 XSSF 的 eventmodel 或分批读取。很多“Excel 导入导出系统”只写 HSSF 和 XSSF导致后期性能问题这个项目从一开始就把 SXSSF 放进来确实是更有工程味道的做法。维度HSSFXSSFSXSSF文件后缀.xls.xlsx.xlsx输出内存模型完整对象完整对象滑动窗口适合写入量千/万级万级以下十万级复杂样式支持支持支持支持但有限制常用场景兼容旧系统常规报表大数据量导出这张表不是替代方案而是决定代码从哪里切入的起点。如果选错 API后面写的工具方法再漂亮也一样会在性能边界上出问题。比如用 HSSF 去生成 10 万行的 .xls不仅写盘慢Excel 本身对 .xls 的行数也有上限用户拿到文件反而打不开。2.2 最小读取代码从 InputStream 到 List导入功能的入口几乎都一样拿到上传的 InputStream解析成一个或几个 Sheet。这里不建议直接用 new HSSFWorkbook() 或 new XSSFWorkbook() 去硬编码因为一旦换文件格式就要改类型。POI 的 WorkbookFactory 会读取文件头自动判别底层是 .xls 就返回 HSSFWorkbook是 .xlsx 就返回 XSSFWorkbook。下面这段是导入侧的最小骨架。try (InputStream in file.getInputStream(); Workbook workbook WorkbookFactory.create(in)) { Sheet sheet workbook.getSheetAt(0); for (int i 0; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row null) { continue; } String value new DataFormatter().formatCellValue(row.getCell(0)); System.out.println(第 (i 1) 行: value); } } catch (IOException | InvalidFormatException e) { throw new ExcelImportException(Excel文件解析失败, e); }这段代码的逻辑是try-with-resources 确保 Workbook 和 InputStream 在用完后关闭WorkbookFactory.create 根据文件头自动识别格式不用在业务代码里判断后缀getLastRowNum 返回的是最后一个有内容的行索引注意它从 0 开始所以打印行号时要加 1getRow 返回 null 表示空行跳过即可。这里的参数有两类一是文件来源可以是 multipart 临时文件也可以是磁盘路径二是 Excel 行列下标POI 的行列都是从 0 开始而用户在界面上看到的行号是从 1 开始转换时最容易出错。2.3 单元格类型映射别一上来就 toString读 Excel 时最常见的错误是把所有单元格都转成字符串再靠业务层去解析。POI 的 Cell.getCellType() 返回的是一个枚举不同枚举的值必须走不同处理分支。比如数字单元格可能是整数也可能是日期公式单元格要分情况读缓存值还是计算结果。下面是一段比较通用的单元格取值方法private Object getCellValue(Cell cell) { if (cell null) { return null; } switch (cell.getCellType()) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue(); } double num cell.getNumericCellValue(); if (num Math.floor(num) !Double.isInfinite(num)) { return (long) num; } return num; case BOOLEAN: return cell.getBooleanCellValue(); case FORMULA: return cell.getCachedFormulaResultType() CellType.NUMERIC ? cell.getNumericCellValue() : cell.getStringCellValue(); case BLANK: default: return null; } }这段代码在 POI 4.x 以后比较适用因为 getCellType() 返回的是 CellType 枚举而不是早期的 int。数值单元格先判断是否是日期格式避免把日期读成 5 万多这样的数字接着判断数字是否正好是整数是则转成 long避免 1.0 这种浮点结果公式单元格读取的是 Excel 计算后的缓存值如果要从 Java 侧重新计算公式需要额外引入 FormulaEvaluator。还要注意 DateUtil.isCellDateFormatted 依赖单元格的格式字符串如果自定义格式没被识别日期仍然会被当成普通数字这是导入功能最常见的“日期变数字”问题。3. 导出模块样式隔离与 SXSSF 内存控制3.1 样式对象必须放在循环外很多新人在导出 Excel 时会在 for 循环内创建 CellStyle理由是每行都要设置边框。但 POI 里的 CellStyle 是挂在 Workbook 上的顶层对象同一个 Workbook 中重复创建相同样式不会自动复用而是会占用越来越多的内存导出的文件也会变大。正确的做法是提前创建好有限的几种样式对象循环里只负责应用不负责 new。如果评审这个项目的导出工具类我第一件事就是检查创建样式的代码是否在循环外。样式表一般包括表头样式、正文样式、金额样式、日期样式、合计行样式。每种样式只要在导出开始时创建一次后面所有行共用同一个对象。还有一个容易被忽略的点样式对象在多个 Sheet 之间也不能完全复用。同一个 Workbook 下的 Sheet 默认继承不保证样式兼容跨 Sheet 复用 CellStyle 虽然不会抛异常但在某些 Excel 客户端可能显示异常。更稳妥的做法是按 Sheet 各自创建样式或者从模板加载后直接用模板里的样式这样既保证一致性也降低代码维护成本。3.2 一个带表头、冻结窗格和自动列宽的导出示例try (Workbook workbook new XSSFWorkbook(); OutputStream out response.getOutputStream()) { Sheet sheet workbook.createSheet(订单); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); String[] headers {订单号, 客户, 金额, 下单时间}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); headerRow.getCell(i).setCellStyle(headerStyle); } sheet.createFreezePane(0, 1); sheet.setColumnWidth(0, 20 * 256); sheet.setColumnWidth(2, 12 * 256); // 写入业务数据... workbook.write(out); }这里只创建了一次 headerStyle后续所有表头单元格共用同一个样式对象这正是避免内存问题的关键。createFreezePane(0, 1) 表示第一行固定往下滚动时表头还在两个参数分别是冻结的列数和行数0 表示不冻结列1 表示冻结一行。setColumnWidth 的单位是 1/256 个字符宽度所以 20 * 256 大约等于 20 个英文字符宽度中文列一般需要更大。实际写入数据时日期建议单独设置 CellStyle 的数据格式例如 dataStyle.setDataFormat(workbook.createDataFormat().getFormat(yyyy-mm-dd))然后把 Java Date 传给 setCellValueExcel 才认识原生日期类型。如果直接把日期拼成字符串写入用户后续做筛选和排序时会出现类型不一致。3.3 SXSSF十万行导出的内存控制当需要导出的数据超过五万行XSSFWorkbook 会在内存里保存全部 Row 和 Cell很容易把老应用的堆撑满。SXSSF 的解决思路是只保留一个固定大小的行窗口行从窗口滑出后会被写入磁盘临时文件从而把内存占用控制在窗口大小范围内。创建 SXSSFWorkbook 的常见参数包括窗口大小和临时文件压缩开关参数示例作用rowAccessWindowSize100内存中保留最近 100 行超过部分刷入临时文件compressTmpFilestrue临时文件启用 gzip 压缩减少磁盘占用略增 CPU是否基于模板new SXSSFWorkbook(templateWorkbook)基于已有 Workbook 创建保留模板样式SXSSFWorkbook 的使用方式与 XSSFWorkbook 几乎一样但它有一个明显差异随机访问行的能力被砍掉getRow 和 getCell 不能回头查已经滑出窗口的行。导出场景通常是顺序写完就结束所以问题不大。如果业务需要先统计总行数再合并单元格建议先查列表拿到数据量再创建 Workbook。SXSSFWorkbook workbook new SXSSFWorkbook(100); workbook.setCompressTempFiles(true); Sheet sheet workbook.createSheet(大表); try { for (int i 0; i 100000; i) { Row row sheet.createRow(i); row.createCell(0).setCellValue(i); if (i % 1000 0) { System.out.println(已写 i 行); } } } finally { workbook.dispose(); }这里 new SXSSFWorkbook(100) 的 100 就是 rowAccessWindowSize表示内存中最多保留 100 行超出部分写入临时文件。setCompressTempFiles(true) 开启临时文件压缩适合磁盘紧张的环境。dispose() 必须放在 finally 中否则临时文件会残留在系统临时目录里。每 1000 行打印一个进度日志是为了在长时间导出时确认程序没有卡死这个习惯在接入 Web 接口时尤其有用。3.4 模板导出复杂样式不必每次重画以前维护过一套月报导出模板里有合并单元格、固定边框、页眉页脚完全用代码重画非常痛苦。更好的方案是把 xlsx 文件当作模板放到 classpath/templates 下用 XSSFWorkbook 打开后往里填充数据。XSSFWorkbook 加载模板后现有样式和行列设置都保留在内存中我们只修改目标单元格最后另存为一份新文件。try (InputStream templateIn new FileInputStream(report_template.xlsx); XSSFWorkbook workbook new XSSFWorkbook(templateIn); OutputStream out new FileOutputStream(report_2025.xlsx)) { XSSFSheet sheet workbook.getSheetAt(0); sheet.getRow(1).getCell(0).setCellValue(2025-01-01); sheet.getRow(1).getCell(1).setCellValue(12345.67); workbook.write(out); }模板导出时要注意的问题包括模板里的公式不会自动重算需要 FormulaEvaluator 手动触发填充数据时不要改动模板里的合并区域如果模板单元格有数据验证填入内容后需确认与验证规则匹配。getRow(1).getCell(0) 的行列都是从 0 开始的这个索引必须和模板设计稿核对清楚否则数据会落在错误的单元格里而 Excel 本身不会报告这种错误。模板文件最好放到 src/main/resources/templates 下避免直接依赖外部目录否则换环境部署时容易找不到文件。4. 导入侧行级校验、错误定位与事务边界4.1 导入流程的三段拆分导入不是“读 Excel 然后 insert 数据库”这么简单。常见烂尾代码是把解析、校验、入库全写在 Servlet 的 doPost 里10MB 文件就超时。更合理的方式是把导入拆成三段读取阶段只负责把 Excel 转换成内存中的 List 校验阶段负责字段格式、必填、业务存在性检查入库阶段使用批量 SQL 或 ORM 的 batch 方法落库。每一段都有明确的输入输出才能在中间插入错误收集和事务控制。读取阶段如果发现文件格式损坏直接抛错不进校验校验阶段收集所有错误后统一返回而不是遇到第一条错误就中断否则用户改一遍错又要上传一次文件。这三段拆分还有一个好处校验逻辑可以脱离 Excel 独立测试。比如从 Excel 读出来的 List和从页面表单组装出来的 List走的是同一套校验方法和入库方法。这样导入功能的新增校验不需要每次都用真实文件去测单元测试覆盖起来快得多。4.2 行级错误收集Excel 导入的用户体验很大程度上取决于错误信息能不能定位到具体行。实现方式是在校验方法中传入当前行号。下面是一个简单的错误集合public class ImportResultT { private final ListT validRows new ArrayList(); private final ListString errors new ArrayList(); public void addError(int rowIndex, String message) { errors.add(第 (rowIndex 1) 行: message); } public boolean hasError() { return !errors.isEmpty(); } }然后在校验时这样用for (int i 0; i rows.size(); i) { RowData row rows.get(i); if (row.getOrderNo() null || row.getOrderNo().isEmpty()) { result.addError(i, 订单号不能为空); } if (!NumberUtil.isNumber(row.getAmount())) { result.addError(i, 金额必须是数字); } }这里的行号输出为什么 i 要加 1POI 的 Row 下标从 0 开始而 Excel 用户看到的第一行是 1所以 addError 方法内部处理最不容易漏。另一个常见错误是在循环外统一 catch 异常导致只报“第 0 行出错”用户根本不知道哪里要改。所有错误先收集如果 errors 非空接口返回错误列表前端可以逐行提示只有 errors 为空才允许走入库。对几万行的文件把全部错误一次返回可能导致消息过大可以限制最多返回 100 条并提示“错误过多请先修正前 100 条”。4.3 从 Excel 到数据库类型的转换规则Excel 单元格的真实类型和数据库字段类型不是一一对应的。比如用户可能在一个“数量”列里输入“1,000”POI 读成字符串直接传给数据库的 int 字段会失败。因此导入类型的转换策略要明确下面是一张我在做类型映射时常用的对照表Excel 表现用户输入目标 Java 类型处理方式文本型数字“1,000.50”BigDecimal去千分位和空格再转 BigDecimal原生日期2024-01-01LocalDateDateUtil 识别后转 Instant下拉枚举“已支付”enum字符串匹配枚举名布尔是/否/true/falseBoolean显式映射集合不接受 1/0转换逻辑应该统一放在一个转换器里而不是在业务代码里到处写 try/catch。下面这段是日期转换的典型写法private LocalDate parseDate(Cell cell, int rowIndex, ImportResultRowData result) { if (cell null || cell.getCellType() CellType.BLANK) { return null; } try { if (cell.getCellType() CellType.NUMERIC DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue().toInstant() .atZone(ZoneId.systemDefault()).toLocalDate(); } String text new DataFormatter().formatCellValue(cell); return LocalDate.parse(text, DateTimeFormatter.ofPattern(yyyy-MM-dd)); } catch (Exception e) { result.addError(rowIndex, 日期格式应为 yyyy-MM-dd); return null; } }这段代码逻辑分两路先识别 Excel 原生日期单元格再回退到文本解析。参数 rowIndex 用于把错误定位到具体行第三个参数 result 负责收集所有校验问题。catch 块只接普通异常不要在这里 catch Throwable否则系统内部错误也会被当成用户输入错误。如果用户填写的日期是“2024/01/01”而这个方法只接受 yyyy-MM-dd会走 catch 并记录错误提示里最好带上期望格式避免用户反复试错。4.4 批量入库与事务边界校验通过后数据量可能还有几万行。逐条 insert 性能太差常见做法是 JDBC batch 或 MyBatis-Batch。更关键的是事务边界导入过程的任何一条 SQL 失败是否要求全部回滚如果是“先校验再批量插入”校验阶段已经把类型错误拦住了但数据库唯一键冲突、数据库不可用仍可能发生。如果两条导入之间有关联比如导入订单同时生成明细应该把所有 SQL 放在一个事务里如果只是一张流水表可以每 500 条一批提交避免大事务锁表时间过长。我给业务模块留了一个 batchSize 参数默认 1000实际按数据库压力调整。Transactional(rollbackFor Exception.class) public ImportResultRowData importOrders(ListRowData rows) { ImportResultRowData result validate(rows); if (result.hasError()) { return result; } for (int i 0; i rows.size(); i 1000) { ListRowData batch rows.subList(i, Math.min(i 1000, rows.size())); orderMapper.batchInsert(batch); } return result; }这段代码里validate 放在事务方法内部但实际执行顺序是先全部校验再进入循环写库。batchSize 1000 是常见值过大会使单条 SQL 的 values 非常长过小则失去批量优势。Transactional(rollbackFor Exception.class) 保证校验通过后只要中途抛任何异常前几批已提交的数据也会回滚。这里要注意 subList 返回的是原列表的视图不会额外复制数据但在循环中如果同时修改原列表内容会导致并发修改问题所以调用方不要边遍历边删除。5. 上线前补课POI 版本漏洞、日志口径与自测脚本5.1 POI 版本别卡在 4.1.0这是经常被漏掉的一点。Apache POI 4.1.0 及之前版本里XSSFExportToXML 在解析外部实体时存在 XXE 漏洞攻击者可以构造一个带恶意 DTD 的 .xlsx 文件让导入方读取本地文件或发起请求。这类攻击常见于“上传 Excel 自动解析”的功能所以导入接口不能信任任何上传文件。修复方式是升级到修复版本并在读取前对 zip 内的 XML 做安全配置。具体到这个项目pom.xml 里应该直接锁定 4.1.2 以上版本并定期检查依赖管理。mvn dependency:tree -Dincludesorg.apache.poi这个命令只过滤 POI 相关依赖输出里看 poi、poi-ooxml 的版本号。如果看到 4.1.0 或更旧立即升级。除升级外导入接口要限制文件大小和允许的 MIME 类型不要把 .xlsm 宏文件直接交给 POI 解析因为宏文件可能携带宏代码后端解析宏没有业务意义反而扩大攻击面。5.2 日志里必须出现的四个指标排查导入导出问题最怕所有信息都只保留“成功/失败”。我在日志框架里固定记录四样文件行数、处理行数、耗时毫秒、失败行数。示例格式可以用一行 loglogger.info(Excel导入完成, file{}, totalRows{}, successRows{}, failedRows{}, costMs{}, fileName, totalRows, successRows, failedRows, System.currentTimeMillis() - start);totalRows 来自文件的最后一行索引加一successRows 是入库成功数failedRows 是校验错误数costMs 用于判断是否需要调整 batchSize 或 SXSSF 的窗口大小。这行日志在排障时价值最大。如果只记录 successRows 和 failedRows文件中间空行多时行数对不上很难判断是解析问题还是数据问题所以 totalRows 必须单独记。5.3 不写界面的 JUnit 自测导入导出功能常被界面挡住测试起来很烦。我第一次接这个模块就在 src/test 下放了两个测试一个用临时文件验证导出后能重新读回一个用构造坏数据验证错误行号。导出测试关键点是写出的文件必须能被 WorkbookFactory 重新打开并断言单元格值。导入测试关键是构造一个第 3 行缺少必填项的 Excel断言 errors 包含“第 3 行”而不是只有“解析失败”。这样改代码时能立刻知道有没有破坏原有行为。Test void exportShouldBeReReadable() throws IOException { File file File.createTempFile(export, .xlsx); try (Workbook wb new XSSFWorkbook(); FileOutputStream out new FileOutputStream(file)) { Sheet sheet wb.createSheet(测试); sheet.createRow(0).createCell(0).setCellValue(订单号); sheet.createRow(1).createCell(0).setCellValue(SO001); wb.write(out); } try (Workbook readBack WorkbookFactory.create(file)) { assertEquals(SO001, readBack.getSheetAt(0).getRow(1).getCell(0).getStringCellValue()); } file.delete(); }第一个 try 写文件第二个 try 重新打开断言能取回字符串。使用临时文件而不是测试目录是为了避免 CI 环境清理不干净使用 try-with-resources 确保文件句柄释放。file.delete 放在最后清理临时文件如果断言失败这个删除语句不会执行但测试框架会自行标记失败不会影响下一次运行。本文还有配套的精品资源点击获取
返回列表