
1. 项目概述当Excel遇上复杂业务在后台管理、数据报表、批量操作这些日常开发场景里处理Excel文件就像吃饭喝水一样常见。但真到了要处理那种一个文件里塞了好几个不同结构的工作表Sheet或者一个工作表里嵌着多个独立数据区域Table的复杂Excel时很多开发者就开始头疼了。手动解析代码冗长且脆弱。用原生的Apache POI功能强大但API繁琐处理样式和内存溢出就得费不少功夫。这时候阿里开源的EasyExcel就成了一个“救星”。它底层封装了POI但在易用性和内存消耗上做了大量优化。不过官方文档和大多数教程讲的都是单Sheet、单Table的导入导出。一旦需求变成“导出一个包含‘用户信息’、‘订单列表’、‘统计摘要’三个独立Sheet的文件”或者“导入一个Sheet但这个Sheet里从第5行开始是一个用户表从第20行开始又是一个日志表”很多朋友就不知道从何下手了。我最近刚完成一个数据中台的项目里面充满了这类需求。经过一番折腾和踩坑我总结出了一套用EasyExcel应对多Sheet和多Table场景的实战方法。这篇文章我就把这些核心思路、具体代码和避坑经验毫无保留地分享出来无论是刚接触EasyExcel的新手还是正在被复杂Excel需求困扰的老手都能直接拿来就用。2. 核心思路与方案设计处理多Sheet和多Table核心在于理解EasyExcel的两个层次写导出和读导入。它们的处理哲学完全不同。2.1 多Sheet导出基于模板的组装艺术多Sheet导出最直观。EasyExcel的写入器ExcelWriter支持连续写入多个Sheet。关键点在于每个Sheet的数据模型和表头通常是不同的。这里有三种主流方案动态构建Sheet为每个Sheet创建一个独立的WriteSheet对象并指定不同的head表头和clazz数据模型类。这是最灵活的方式适合Sheet结构差异大、且需要动态生成的场景。使用模板文件预先用Excel画好一个包含多个Sheet、且每个Sheet都有完整表头和样式的模板文件。导出时ExcelWriter以这个模板为基础只需要填充数据即可。这种方式能完美保留复杂的样式、合并单元格、公式等是追求导出效果美观时的首选。注解驱动在每个数据模型类上使用ExcelProperty等注解定义表头。为每个Sheet准备对应的数据列表然后依次写入。这种方式代码清晰但样式控制能力较弱。方案选型建议如果对表格样式如字体、颜色、边框、列宽有要求或者Sheet结构固定强烈推荐模板方式。如果数据模型变化频繁或者需要极致的动态性则采用动态构建。2.2 多Table导出单个Sheet内的空间规划多Table导出是指在一个Sheet内从上到下排列着多个独立的数据表格。这比多Sheet更棘手因为你需要精确控制每个Table的起始行。核心思路是把整个Sheet的写入过程看作一次在坐标纸上作画。你需要知道每个Table的表头从第几行开始数据从第几行开始。WriteSheet对象可以通过relativeHeadRowIndex属性来设置表头的相对起始行索引。例如第一个Table从第0行开始写入10行数据含表头行。那么第二个Table的relativeHeadRowIndex就应该设置为10。这意味着第二个Table的表头会被写入到第10行。EasyExcel会自动累计之前写入的行数。注意这里最容易混淆的是“行索引”的计算。EasyExcel的行索引是从0开始的。而且relativeHeadRowIndex指的是相对于当前Sheet已写入内容的起始行而不是绝对行号。在连续写入时这个“相对”特性非常有用但计算时需要格外小心。2.3 多Sheet导入分而治之的监听策略导入多Sheet文件时EasyExcel要求我们为每一个需要读取的Sheet单独注册一个ReadListener监听器。监听器是EasyExcel读取的核心它负责处理每一行数据解析完成后的回调。标准做法是为每种结构的数据定义一个对应的数据模型类DTO和一个监听器。然后用EasyExcel.read()方法绑定文件、监听器和Sheet序号或名称。// 伪代码示例读取第一个Sheet用户信息和第二个Sheet订单信息 ExcelReader excelReader EasyExcel.read(fileName).build(); // 读取第一个Sheet索引为0使用UserDataListener ReadSheet readSheet1 EasyExcel.readSheet(0).head(UserDTO.class).registerReadListener(new UserDataListener()).build(); // 读取第二个Sheet索引为1使用OrderDataListener ReadSheet readSheet2 EasyExcel.readSheet(1).head(OrderDTO.class).registerReadListener(new OrderDataListener()).build(); excelReader.read(readSheet1, readSheet2); excelReader.finish();2.4 多Table导入监听器内的状态机这是最复杂的情况。一个Sheet里混杂着多种结构的数据EasyExcel没有原生的“多Table”读取概念。我们必须自己在一个ReadListener中实现一个简单的状态机State Machine。实现原理在监听器中定义几个状态比如STATE_FIND_TABLE_A_HEADER,STATE_READING_TABLE_A_DATA,STATE_FIND_TABLE_B_HEADER。在invoke()方法中检查当前行的数据。如果发现某一行符合Table A的表头特征例如第一列的值是“用户ID”就将状态切换到STATE_READING_TABLE_A_DATA。在STATE_READING_TABLE_A_DATA状态下将行数据转换为Table A的DTO对象并保存。当遇到空行或发现符合Table B表头特征的行时切换状态到STATE_FIND_TABLE_B_HEADER并开始处理Table B的数据。在doAfterAllAnalysed()方法中对收集到的所有Table A和Table B的数据进行最终处理如批量入库。这种方法对Excel文件的格式有较强要求通常需要约定Table之间至少有一个空行作为分隔或者表头有可识别的标志性内容。3. 实战演练多Sheet与多Table导出理论讲完了我们上代码。假设我们要导出一个“项目报告”包含两个Sheet项目概览单个Table和成员贡献两个上下排列的Table。3.1 准备数据模型与模板首先定义两个数据模型。// 项目概览模型 Data public class ProjectOverviewDTO { ExcelProperty(项目编号) private String projectCode; ExcelProperty(项目名称) private String projectName; ExcelProperty(负责人) private String owner; ExcelProperty(当前状态) private String status; // ... 其他字段 } // 成员贡献模型 Data public class MemberContributionDTO { ExcelProperty(value 成员姓名, index 0) // 注意index用于多Table定位 private String memberName; ExcelProperty(value 完成任务数, index 1) private Integer completedTasks; ExcelProperty(value 代码行数, index 2) private Integer codeLines; // ... 其他字段 }为了获得更好的样式我们先用Excel手动创建一个模板文件project_report_template.xlsx。Sheet1命名为“项目概览”第一行是项目编号、项目名称等表头并设置好加粗、居中、背景色。Sheet2命名为“成员贡献”。第1行第一个Table的表头如成员姓名、完成任务数、代码行数。第5行第二个Table的表头可以是成员姓名、BUG数、评审次数并且将第4行作为两个Table之间的分隔说明行写上“贡献详情二”。3.2 实现多Sheet导出基于模板public void exportMultiSheetReport(HttpServletResponse response) throws IOException { // 1. 准备数据 ListProjectOverviewDTO overviewList getOverviewData(); // 模拟获取数据 ListMemberContributionDTO contributionList1 getContributionDataPart1(); ListMemberContributionDTO contributionList2 getContributionDataPart2(); // 2. 获取模板文件流通常放在resources/templates下 ClassPathResource templateResource new ClassPathResource(templates/project_report_template.xlsx); InputStream templateInputStream templateResource.getInputStream(); // 3. 创建ExcelWriter指定模板 ExcelWriter excelWriter EasyExcel.write(response.getOutputStream()) .withTemplate(templateInputStream) .build(); // 4. 写入第一个Sheet项目概览 WriteSheet overviewSheet EasyExcel.writerSheet(0) // 对应模板的第一个Sheet .head(ProjectOverviewDTO.class) .build(); excelWriter.write(overviewList, overviewSheet); // 5. 写入第二个Sheet成员贡献 WriteSheet contributionSheet EasyExcel.writerSheet(1) // 对应模板的第二个Sheet .head(MemberContributionDTO.class) .relativeHeadRowIndex(0) // 第一个Table从模板第0行即第1行开始填充 .build(); excelWriter.write(contributionList1, contributionSheet); // 6. 在同一个Sheet内写入第二个Table // 关键新建一个WriteSheet但指向同一个Sheet索引并设置新的起始行。 // 假设第一个Table占了3行数据1行表头2行数据中间有1行分隔那么第二个Table从第4行开始。 // 注意需要根据模板中第二个Table表头的实际位置来算。我们模板里第二个表头在第5行索引4。 WriteSheet secondTableSheet EasyExcel.writerSheet(1) .head(MemberContributionDTO.class) // 假设第二个Table表头结构相同 .relativeHeadRowIndex(4) // 从第5行索引4开始写表头 .build(); // 注意这里需要另一个DTO或复用但表头索引可能不同。更常见的做法是第二个Table有不同表头这里仅为演示。 excelWriter.write(contributionList2, secondTableSheet); // 7. 设置响应头关闭流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(项目报告, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); excelWriter.finish(); templateInputStream.close(); }实操要点withTemplate()方法让导出基于模板样式得以保留。写入同一个Sheet的第二个Table时必须新建一个WriteSheet对象并指定正确的relativeHeadRowIndex。这个行号需要你根据模板布局和第一个Table的数据量精确计算。如果第二个Table的表头结构不同你需要为其创建新的head列表或使用不同的DTO类。3.3 实现动态多Table导出无模板如果没有模板需要完全用代码生成则需要更精细地控制行号。public void exportMultiTableDynamic(HttpServletResponse response) { ExcelWriter excelWriter EasyExcel.write(response.getOutputStream()).build(); WriteSheet sheet EasyExcel.writerSheet(动态报表).build(); // 第一个Table WriteTable table1 EasyExcel.writerTable(0) // Table也有索引 .head(ProjectOverviewDTO.class) // Table1的表头 .relativeHeadRowIndex(0) // 从Sheet的第0行开始 .build(); excelWriter.write(getOverviewData(), sheet, table1); // 计算第二个Table的起始行第一个Table的行数表头1行 数据N行 int table1Rows 1 getOverviewData().size(); int gapRows 2; // 假设两个Table之间空2行 int table2StartRow table1Rows gapRows; // 第二个Table WriteTable table2 EasyExcel.writerTable(1) .head(MemberContributionDTO.class) .relativeHeadRowIndex(table2StartRow) .build(); excelWriter.write(getContributionDataPart1(), sheet, table2); // ... 可以继续添加更多Table excelWriter.finish(); }这里引入了WriteTable对象。它比复用WriteSheet更清晰专门用于定义Sheet内的一个表格区域管理自己的表头和起始位置。这是处理复杂布局的推荐方式。4. 实战演练多Sheet与多Table导入导入的核心是监听器。我们分别实现多Sheet和多Table的导入。4.1 多Sheet导入实现假设要导入的文件有两个Sheet基础信息和明细列表。// 基础信息监听器 public class BasicInfoListener extends AnalysisEventListenerBasicInfoDTO { private ListBasicInfoDTO cachedDataList new ArrayList(); private static final int BATCH_COUNT 100; Override public void invoke(BasicInfoDTO data, AnalysisContext context) { cachedDataList.add(data); if (cachedDataList.size() BATCH_COUNT) { saveData(); // 批量保存 cachedDataList.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { saveData(); // 处理最后一批数据 log.info(基础信息Sheet读取完成共{}条。, cachedDataList.size()); } private void saveData() { // 调用MyBatis或JPA的批量插入方法 if (!cachedDataList.isEmpty()) { basicInfoMapper.batchInsert(cachedDataList); } } } // 明细列表监听器结构类似略 // 导入服务方法 public void importMultiSheetFile(MultipartFile file) { try (ExcelReader excelReader EasyExcel.read(file.getInputStream()).build()) { // 注册第一个Sheet的监听器 ReadSheet sheet1 EasyExcel.readSheet(0) .head(BasicInfoDTO.class) .registerReadListener(new BasicInfoListener()) .build(); // 注册第二个Sheet的监听器 ReadSheet sheet2 EasyExcel.readSheet(1) .head(DetailDTO.class) .registerReadListener(new DetailListener()) .build(); excelReader.read(sheet1, sheet2); // 开始读取 } catch (IOException e) { throw new RuntimeException(文件读取失败, e); } }4.2 多Table导入实现状态机模式假设一个Sheet内先是一个“部门”表格空一行后是一个“员工”表格。Data public class DepartmentDTO { ExcelProperty(部门编码) private String deptCode; ExcelProperty(部门名称) private String deptName; } Data public class EmployeeDTO { ExcelProperty(工号) private String empId; ExcelProperty(姓名) private String empName; ExcelProperty(所属部门编码) private String deptCode; } // 复合监听器 public class MultiTableSheetListener extends AnalysisEventListenerMapInteger, String { // 使用Map接收因为表头不固定我们需要检查单元格内容来判断状态 private ListDepartmentDTO deptList new ArrayList(); private ListEmployeeDTO empList new ArrayList(); private ImportState currentState ImportState.FIND_DEPT_HEADER; private enum ImportState { FIND_DEPT_HEADER, // 寻找部门表头 READING_DEPT_DATA, // 读取部门数据 FIND_EMP_HEADER, // 寻找员工表头 READING_EMP_DATA // 读取员工数据 } Override public void invokeHeadMap(MapInteger, String headMap, AnalysisContext context) { // 监听表头行用于判断进入哪个Table String firstHeadCell headMap.get(0); // 获取第一列的表头内容 if (部门编码.equals(firstHeadCell)) { currentState ImportState.READING_DEPT_DATA; log.info(发现部门表头开始读取部门数据。); } else if (工号.equals(firstHeadCell)) { currentState ImportState.READING_EMP_DATA; log.info(发现员工表头开始读取员工数据。); } // 如果不是已知表头可能处于空行或未知区域状态不变或重置为FIND } Override public void invoke(MapInteger, String data, AnalysisContext context) { // 根据当前状态处理数据行 switch (currentState) { case READING_DEPT_DATA: // 将Map转换为DepartmentDTO DepartmentDTO dept new DepartmentDTO(); dept.setDeptCode(data.get(0)); dept.setDeptName(data.get(1)); if (StringUtils.isNotBlank(dept.getDeptCode())) { // 避免空行 deptList.add(dept); } else { // 遇到空行可能部门数据结束切换到寻找员工表头状态 currentState ImportState.FIND_EMP_HEADER; } break; case READING_EMP_DATA: EmployeeDTO emp new EmployeeDTO(); emp.setEmpId(data.get(0)); emp.setEmpName(data.get(1)); emp.setDeptCode(data.get(2)); if (StringUtils.isNotBlank(emp.getEmpId())) { empList.add(emp); } break; case FIND_DEPT_HEADER: case FIND_EMP_HEADER: default: // 在寻找表头状态时遇到数据行忽略或记录警告 break; } } Override public void doAfterAllAnalysed(AnalysisContext context) { // 所有行解析完毕进行批量持久化操作 if (!deptList.isEmpty()) { departmentService.batchSave(deptList); log.info(导入部门数据 {} 条。, deptList.size()); } if (!empList.isEmpty()) { // 保存员工前可能需要根据deptCode关联部门ID这里省略关联逻辑 employeeService.batchSave(empList); log.info(导入员工数据 {} 条。, empList.size()); } } } // 使用这个监听器进行导入 public void importMultiTableSheet(MultipartFile file) { // 注意这里head(Map.class)表示我们接收原始Map数据由监听器自己判断和转换 EasyExcel.read(file.getInputStream(), Map.class, new MultiTableSheetListener()) .sheet() // 读取第一个Sheet .doRead(); }关键点与避坑指南状态判断依赖表头invokeHeadMap方法能捕获到每一行的表头信息。在复杂Table中表头行是切换状态的关键信号。确保你的Excel文件表头有明确、可识别的特征。空行处理在invoke方法中当转换出的对象关键字段为空时可以将其视为空行并作为切换状态的触发器之一。数据转换由于使用MapInteger, String接收数据你需要手动将Map的value按索引位置转换到DTO字段上。这要求你对Excel列的顺序有严格约定。性能与内存即使文件很大监听器模式也是逐行解析内存占用低。但要注意cachedDataList的批次大小设置避免频繁的数据库IO。5. 常见问题、性能优化与高级技巧在实际使用中你肯定会遇到一些坑。下面是我总结的常见问题和解决方案。5.1 导出相关问题问题1导出文件打开报错“文件已损坏”或部分内容丢失。原因最常见的原因是输出流未正确关闭或者在中途发生异常导致文件写入不完整。excelWriter.finish()方法必须被调用它负责写入必要的文件尾信息。解决确保将ExcelWriter和InputStream如果用了模板的关闭操作放在finally块中或使用try-with-resources语法ExcelWriter未实现AutoCloseable需手动finish。ExcelWriter excelWriter null; InputStream templateIn null; try { // ... 构建和写入操作 excelWriter.finish(); // 确保执行 } catch (IOException e) { // 处理异常 } finally { if (excelWriter ! null) { excelWriter.finish(); // finish是幂等的多次调用无害 } IOUtils.closeQuietly(templateIn); }问题2多Table导出时第二个Table的表头覆盖了第一个Table的数据。原因relativeHeadRowIndex计算错误。你计算的是第一个Table的数据行数但忘记加上表头自身占的一行。解决总行数 表头行数通常为1 数据行数。如果Table有复杂的多行表头需要相应增加。问题3导出速度慢大数据量时内存溢出OOM。原因一次性将所有数据加载到List中再写入数据量极大时必然出问题。解决使用分页查询重复写入。不能用同一个WriteSheet但可以用同一个ExcelWriter。ExcelWriter excelWriter EasyExcel.write(outputStream).build(); WriteSheet writeSheet EasyExcel.writerSheet(大数据).head(YourDTO.class).build(); int pageSize 1000; int pageNum 1; ListYourDTO data; do { data yourService.getDataByPage(pageNum, pageSize); // 分页查询 excelWriter.write(data, writeSheet); pageNum; } while (!data.isEmpty()); excelWriter.finish();5.2 导入相关问题问题1监听器的invoke方法不被调用或者数据为空。原因head(DTO.class)与Excel实际表头不匹配。检查ExcelProperty的value或index是否与文件列顺序对应。文件Sheet名或索引指定错误。使用了Map接收但未正确处理表头映射。解决先使用最简单的代码测试。可以注册一个InvocationHandler或使用EasyExcel.read(...).sheet().doReadSync()同步读取到List中先确认数据能正常解析。问题2导入时如何做数据校验如重复、格式、业务逻辑最佳实践在监听器的invoke方法中进行单行校验将错误信息记录到当前行的某个字段或一个独立的错误集合中。在doAfterAllAnalysed中进行批量校验如数据库唯一性检查。所有校验不通过的数据都不放入最终的cachedDataList而是收集起来生成错误报告。public class ValidatingListener extends AnalysisEventListenerYourDTO { private ListYourDTO validData new ArrayList(); private ListImportError errors new ArrayList(); Override public void invoke(YourDTO data, AnalysisContext context) { // 单行校验 if (StringUtils.isBlank(data.getName())) { errors.add(new ImportError(context.readRowHolder().getRowIndex(), 姓名不能为空)); return; // 此条数据无效跳过 } // 格式校验 if (!isValidEmail(data.getEmail())) { errors.add(new ImportError(context.readRowHolder().getRowIndex(), 邮箱格式错误)); return; } validData.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 批量业务校验例如检查数据库唯一性 ListString names validData.stream().map(YourDTO::getName).collect(Collectors.toList()); ListString existingNames yourService.findExistingNames(names); // ... 处理重复逻辑从validData中移除重复项并记录错误 if (errors.isEmpty()) { yourService.batchSave(validData); } else { // 将errors生成错误Excel文件供用户下载修正 generateErrorReport(errors); } } }问题3如何中断导入过程解决在invoke或invokeHeadMap方法中如果遇到严重错误希望停止读取可以抛出ExcelAnalysisException或其子类。EasyExcel会捕获这个异常并停止解析。Override public void invoke(YourDTO data, AnalysisContext context) { if (FORBIDDEN_STATUS.equals(data.getStatus())) { throw new ExcelAnalysisStopException(遇到禁止状态导入已停止); } // ... 正常处理 }5.3 高级技巧自定义转换器与样式处理器自定义转换器处理特殊数据如“是/否”转Boolean字符串转枚举。public class StatusConverter implements ConverterString { Override public String convertToExcelData(String value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { // 导出将实体类的String值转为Excel的String return 有效.equals(value) ? 是 : 否; } Override public String convertToJavaData(ReadCellData? cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { // 导入将Excel的String值转为实体类的String String cellValue cellData.getStringValue(); return 是.equals(cellValue) ? 有效 : 无效; } } // 在DTO字段上使用 Data public class YourDTO { ExcelProperty(value 状态, converter StatusConverter.class) private String status; }自定义样式处理器实现CellWriteHandler接口可以在单元格写入前后拦截设置特定样式。例如给数值大于100的单元格标红。Component public class HighlightCellHandler implements CellWriteHandler { Override public void afterCellDispose(CellWriteHandlerContext context) { Cell cell context.getCell(); // 判断是否为数据行非表头且是特定列例如第3列索引2 if (context.getRowIndex() 0 context.getColumnIndex() 2) { Object value context.getCellData().getValue(); if (value instanceof Number ((Number) value).doubleValue() 100) { CellStyle style cell.getSheet().getWorkbook().createCellStyle(); Font font cell.getSheet().getWorkbook().createFont(); font.setColor(IndexedColors.RED.getIndex()); style.setFont(font); cell.setCellStyle(style); } } } } // 在写入时注册 WriteSheet writeSheet EasyExcel.writerSheet() .head(YourDTO.class) .registerWriteHandler(new HighlightCellHandler()) .build();处理包含多Sheet和多Table的复杂Excel本质上是将复杂问题分解为对单个Sheet和单个Table的精确控制。导出时善用WriteTable和relativeHeadRowIndex来规划布局导入时利用监听器和状态机来解析混合数据。记住对于样式复杂的导出模板是你的好朋友对于结构混乱的导入清晰的业务约定如空行分隔、固定表头比任何代码都重要。在实际项目中我通常会为复杂的报表需求先和产品经理或业务方用Excel原型对好格式将这个原型文件作为模板。对于导入则会提供一份严格的“数据准备规范”文档。这些前置沟通能省去后期大量的调试和返工时间。最后EasyExcel的官方GitHub仓库和Issues里有很多宝藏遇到诡异问题时去那里搜一搜往往能有意外收获。