1. 从单表到多表:为什么EasyExcel的多Sheet/多Table处理是刚需?
如果你做过企业级的数据处理,尤其是报表系统或者数据中台,肯定遇到过这样的场景:业务方甩过来一个Excel文件,打开一看,里面密密麻麻十几个Sheet页,每个Sheet里可能还套着好几个格式迥异的表格。财务要的月度汇总、销售要的客户明细、运营要的活动数据,全挤在一个文件里。这时候,如果还用传统的POI或者简单的单Sheet导入导出工具,光是解析文件结构、区分数据边界就能写到你头皮发麻,更别提后续的数据校验和业务逻辑处理了。
这就是EasyExcel在处理“多Sheet”和“多Table”场景下价值凸显的地方。它不是一个简单的Excel读写库,而是一个面向复杂业务数据交换的解决方案。核心痛点在于数据结构的映射和读写性能的平衡。一个Sheet对应一个Java对象列表,这是基础操作。但当多个逻辑上独立的数据集(例如,订单头、订单明细、商品信息)需要放在同一个Sheet的不同区域,或者分散到不同Sheet时,就需要更精细的控制。EasyExcel通过其WriteSheet、WriteTable以及监听器模型,提供了一套声明式的API,让我们能用接近描述业务逻辑的方式,来处理这些复杂的Excel结构。
我经历过一个数据迁移项目,源系统导出的Excel包含一个“主数据”Sheet和五个“交易流水”Sheet。初期用简单循环处理,内存溢出和类型转换错误频发。切换到EasyExcel的多Sheet读取配合自定义监听器后,代码量减少了60%,处理速度提升了一倍,而且内存使用变得稳定可控。这背后的关键,就是吃透了EasyExcel对于复杂结构的抽象能力。
2. 核心概念拆解:Sheet、Table与Java对象的映射关系
很多人刚开始用EasyExcel容易混淆Sheet和Table,觉得都是“表”,其实它们在EasyExcel的语境下有明确的职责划分。理解这个,是玩转多结构导出的前提。
Sheet(工作表):这是Excel文件的一级容器,一个文件至少有一个Sheet。在EasyExcel中,WriteSheet对象代表一个准备写入的Sheet。它的核心配置包括Sheet名称(sheetName)、索引(sheetNo)等。通常,一个WriteSheet会写入一组相同结构的数据,比如所有的“用户信息”记录。
Table(表格):这是Sheet内部的二级容器。一个Sheet里可以包含多个Table,这些Table在物理位置上是上下排列的。WriteTable对象用于向同一个Sheet内写入多组结构不同的数据。这是实现“一个Sheet,多个子表”的关键。每个WriteTable需要独立指定表头(head)和与之匹配的数据。
映射关系与代码模型:
// 假设我们要导出一个包含两种数据的Sheet: // 1. 顶部:公司概要信息(一个Table,一行数据,多个字段) // 2. 底部:员工列表(另一个Table,多行数据,不同字段) // 定义数据对象 @Data public class CompanySummary { @ExcelProperty("公司名称") private String name; @ExcelProperty("统计月份") private String month; @ExcelProperty("员工总数") private Integer totalStaff; } @Data public class Employee { @ExcelProperty("工号") private String id; @ExcelProperty("姓名") private String employeeName; @ExcelProperty("部门") private String department; } // 在写入时,逻辑如下: // 1. 先创建Sheet WriteSheet writeSheet = EasyExcel.writerSheet("月度报告").build(); // 2. 创建第一个Table,用于写入CompanySummary List<List<String>> companyHead = Arrays.asList( Arrays.asList("公司名称"), Arrays.asList("统计月份"), Arrays.asList("员工总数") ); WriteTable companyTable = EasyExcel.writerTable(0).head(companyHead).build(); // 3. 创建第二个Table,用于写入Employee列表 List<List<String>> employeeHead = Arrays.asList( Arrays.asList("工号"), Arrays.asList("姓名"), Arrays.asList("部门") ); WriteTable employeeTable = EasyExcel.writerTable(1).head(employeeHead).build(); // 4. 按顺序写入 excelWriter.write(companySummaryList, writeSheet, companyTable); excelWriter.write(employeeList, writeSheet, employeeTable);通过这样的设计,EasyExcel将文件物理结构(Sheet)和业务逻辑结构(Table)解耦。在读取时,同样可以通过实现多个AnalysisEventListener来分别监听不同Table的数据,或者在一个监听器里根据行号、内容来判断数据属于哪个逻辑表。
注意:
WriteTable的构建顺序就是其在Sheet中的写入顺序。writerTable(0)会先写,写在上面;writerTable(1)会接着写在下面。中间默认会有一个空行作为分隔,这个空行可以通过relativeHeadRowIndex等参数进行微调。
3. 实战:分步实现多Sheet与多Table的导出
理论清楚了,我们来看一个完整的导出案例。需求是:生成一份项目报告,包含两个Sheet。第一个Sheet“项目概览”里,上方是项目基本信息(一个Table),下方是核心成员列表(另一个Table)。第二个Sheet“任务清单”是一个简单的任务列表。
3.1 环境准备与依赖配置
首先,确保你的Spring Boot项目中引入了EasyExcel的依赖。我强烈建议使用阿里云官方仓库的版本,稳定性和兼容性最好。
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> <!-- 请使用当前最新稳定版 --> </dependency>这里有个小坑:如果你的项目还用了老版本的POI,可能会引发冲突。EasyExcel底层依赖POI,但它已经管理了特定版本的POI依赖。如果出现NoSuchMethodError或ClassNotFoundException,检查一下你的依赖树,把其他地方引入的POI排除掉。
<exclusion> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> </exclusion> <exclusion> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> </exclusion>3.2 定义数据模型与注解
数据模型的定义决定了导出的灵活度。除了基本的@ExcelProperty,还有几个注解在复杂场景下特别有用。
// 项目概览 - 基本信息表 @Data public class ProjectInfo { @ExcelProperty(value = "项目编号", index = 0) private String projectCode; @ExcelProperty(value = "项目名称", index = 1) private String projectName; @ExcelProperty(value = {"项目经理", "姓名"}, index = 2) // 复杂表头,占两行 private String managerName; @ExcelProperty(value = {"项目经理", "联系方式"}, index = 3) private String managerPhone; @ExcelProperty(value = "立项日期", index = 4) @DateTimeFormat("yyyy-MM-dd") private Date startDate; // 忽略某些字段,不导出到Excel @ExcelIgnore private String internalRemark; } // 项目概览 - 核心成员表 @Data public class CoreMember { @ExcelProperty(value = "序号", index = 0) private Integer order; @ExcelProperty(value = "成员姓名", index = 1) private String name; @ExcelProperty(value = "角色", index = 2) private String role; @ExcelProperty(value = "投入工时", index = 3) private Double manHours; } // 任务清单Sheet的数据模型 @Data public class TaskItem { @ExcelProperty(value = "任务ID", index = 0) private String taskId; @ExcelProperty(value = "任务描述", index = 1) private String description; @ExcelProperty(value = "状态", index = 2) private String status; // 如:进行中、已完成、已延期 @ExcelProperty(value = "截止日期", index = 3) @DateTimeFormat("yyyy-MM-dd") private Date deadline; }@DateTimeFormat注解可以确保日期类型按照指定格式写入Excel单元格,避免变成一串数字。@ExcelIgnore则非常实用,有些内部字段(如数据库ID、创建时间)不需要导出,用这个注解标记即可。
3.3 组装数据与执行导出
这是最核心的步骤,我们需要创建ExcelWriter,并管理多个WriteSheet和WriteTable。
@Service public class ComplexExportService { public void exportComplexReport(HttpServletResponse response) throws IOException { // 1. 设置响应头,告诉浏览器这是一个要下载的Excel文件 String fileName = URLEncoder.encode("项目综合报告_" + System.currentTimeMillis(), "UTF-8").replaceAll("\\+", "%20"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); // 2. 获取ExcelWriter对象。这里直接输出到HttpServletResponse的OutputStream。 // 切记:最后必须调用finish(),并且不要关闭这个OutputStream,Spring MVC会处理。 ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build(); // --- 开始构建第一个Sheet: 项目概览 --- WriteSheet projectOverviewSheet = EasyExcel.writerSheet(0, "项目概览").build(); // 3. 构建第一个Table:项目基本信息 (Table 0) // 注意:head(List<List<String>>) 的里层List代表一列,外层List代表一行。这里我们是一个简单的单行表头。 List<List<String>> projectInfoHead = new ArrayList<>(); projectInfoHead.add(Collections.singletonList("项目编号")); projectInfoHead.add(Collections.singletonList("项目名称")); projectInfoHead.add(Arrays.asList("项目经理", "姓名")); // 复杂表头,两行 projectInfoHead.add(Arrays.asList("项目经理", "联系方式")); projectInfoHead.add(Collections.singletonList("立项日期")); WriteTable projectInfoTable = EasyExcel.writerTable(0) .head(projectInfoHead) .relativeHeadRowIndex(0) // 表头从Sheet的第0行开始写 .build(); // 4. 构建第二个Table:核心成员列表 (Table 1) // 这个Table会紧接着上一个Table的数据下方开始写入。 List<List<String>> memberHead = new ArrayList<>(); memberHead.add(Collections.singletonList("序号")); memberHead.add(Collections.singletonList("成员姓名")); memberHead.add(Collections.singletonList("角色")); memberHead.add(Collections.singletonList("投入工时")); WriteTable coreMemberTable = EasyExcel.writerTable(1) .head(memberHead) // 不指定relativeHeadRowIndex,EasyExcel会自动计算位置,放在上一个Table的数据下方。 .build(); // 5. 模拟数据 List<ProjectInfo> projectInfoList = Collections.singletonList( new ProjectInfo("PROJ-2023-001", "CRM系统重构", "张三", "13800138000", new Date()) ); List<CoreMember> memberList = Arrays.asList( new CoreMember(1, "李四", "后端开发", 120.5), new CoreMember(2, "王五", "前端开发", 80.0), new CoreMember(3, "赵六", "测试工程师", 60.0) ); // 6. 按顺序写入第一个Sheet excelWriter.write(projectInfoList, projectOverviewSheet, projectInfoTable); excelWriter.write(memberList, projectOverviewSheet, coreMemberTable); // --- 开始构建第二个Sheet: 任务清单 --- WriteSheet taskSheet = EasyExcel.writerSheet(1, "任务清单").build(); // 这个Sheet只有一个简单的表,所以直接用write方法,不需要单独建Table。 List<TaskItem> taskList = Arrays.asList( new TaskItem("TASK-001", "数据库设计评审", "已完成", parseDate("2023-10-01")), new TaskItem("TASK-002", "用户模块接口开发", "进行中", parseDate("2023-11-15")), new TaskItem("TASK-003", "集成测试", "未开始", parseDate("2023-12-01")) ); excelWriter.write(taskList, taskSheet); // 7. 至关重要的一步:完成写入并释放资源 excelWriter.finish(); } private Date parseDate(String dateStr) { try { return new SimpleDateFormat("yyyy-MM-dd").parse(dateStr); } catch (ParseException e) { return new Date(); } } }这段代码有几个关键点:
ExcelWriter的生命周期:必须在所有数据写入后调用finish(),它负责将内存中的数据流刷到输出流并关闭一些内部资源。但传入的OutputStream(如response.getOutputStream())不要手动关闭。- Table的写入顺序:
writerTable(0),writerTable(1)的编号决定了它们在Sheet中的上下位置。写的时候也要按这个顺序调用write方法。 - 表头定义:
head参数接受List<List<String>>。这是EasyExcel定义表头最灵活的方式。内层List表示一列的表头内容(从上到下),外层List表示所有列的集合。对于复杂表头(多行),只需在内层List中放入多个字符串即可。
3.4 样式自定义与高级配置
默认导出的Excel是朴素的黑白格。企业报告往往需要加粗标题、添加颜色、设置列宽。EasyExcel通过WriteHandler(写入处理器)和CellWriteHandler(单元格写入处理器)来实现。
例如,我们想给“项目概览”Sheet的第一个Table(项目信息)的表头加上蓝色背景和加粗字体:
// 自定义样式处理器 public class ProjectInfoStyleHandler implements CellWriteHandler { @Override public void afterCellDispose(CellWriteHandlerContext context) { // 只处理第一个Sheet的第一个Table的表头行 if (context.getSheetIndex() == 0 && context.getTableNo() != null && context.getTableNo() == 0 && context.getRowIndex() == 0) { // 假设表头在第0行 Cell cell = context.getCell(); CellStyle cellStyle = context.getWriteWorkbookHolder().getCachedWorkbook().createCellStyle(); // 设置背景色 cellStyle.setFillForegroundColor(IndexedColors.SKY_BLUE.getIndex()); cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 设置字体加粗 Font font = context.getWriteWorkbookHolder().getCachedWorkbook().createFont(); font.setBold(true); cellStyle.setFont(font); // 设置居中 cellStyle.setAlignment(HorizontalAlignment.CENTER); cell.setCellStyle(cellStyle); } } } // 在创建ExcelWriter时注册这个处理器 ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()) .registerWriteHandler(new ProjectInfoStyleHandler()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 自动列宽策略 .build();CellWriteHandler提供了丰富的钩子(beforeCellCreate,afterCellDispose等),允许你在单元格创建前后插入逻辑。通过context对象可以获取当前写入的Sheet索引、Table编号、行号、列号、单元格值等信息,从而进行精确的样式控制。
实操心得:样式处理器的注册顺序有时会影响最终效果。通常,全局的处理器(如自动列宽)先注册,针对特定范围的处理器后注册。如果样式没生效,检查一下你的判断条件(
SheetIndex,TableNo,RowIndex)是否准确,特别是TableNo在非Table写入时为null,需要小心处理。
4. 逆向操作:复杂Excel文件的读取与数据分拣
导出搞定了,导入才是真正的挑战。业务方发来的Excel千奇百怪,合并单元格、多余的空行、表头格式不统一都是家常便饭。EasyExcel通过ReadListener(读取监听器)模型来处理导入,它的核心思想是事件驱动,一行一行地处理数据,内存友好。
4.1 设计多Sheet读取策略
对于多个Sheet,有两种主流读取方式:
- 为每个Sheet定义独立的监听器:结构清晰,各司其职。适合Sheet之间结构差异大、业务逻辑完全独立的场景。
- 使用一个监听器,在内部根据Sheet名或索引进行分发:代码更集中,适合Sheet结构类似或需要跨Sheet关联数据的场景。
这里演示第一种方式,假设我们要读取刚才导出的那个“项目综合报告.xlsx”。
首先,为“项目概览”Sheet定义监听器。由于这个Sheet里有两个Table,我们需要在监听器里做数据分拣。
// 项目基本信息监听器 public class ProjectInfoListener extends AnalysisEventListener<Map<Integer, String>> { /** * 存储解析到的项目基本信息。 * 因为Sheet里有多个Table,我们先用Map接住每一行,再根据业务规则判断它属于哪个Table。 */ private List<ProjectInfo> projectInfoList = new ArrayList<>(); private List<CoreMember> coreMemberList = new ArrayList<>(); // 表头行(第一行)的内容,用于判断当前读到哪个Table了 private List<String> headerRow; // 标记当前正在读取哪个Table。0-未知/未开始,1-项目信息表,2-成员表 private int currentTableType = 0; @Override public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) { // 读取到表头时触发。这里将表头信息转换为字符串列表保存。 headerRow = headMap.values().stream() .map(ReadCellData::getStringValue) .collect(Collectors.toList()); // 根据表头特征判断Table类型 if (headerRow != null && headerRow.contains("项目编号")) { currentTableType = 1; // 进入项目信息表区域 } else if (headerRow != null && headerRow.contains("成员姓名")) { currentTableType = 2; // 进入核心成员表区域 } } @Override public void invoke(Map<Integer, String> dataRow, AnalysisContext context) { // 每一行数据解析完成后触发。dataRow的key是列索引,value是单元格字符串值。 if (currentTableType == 1) { // 处理项目信息表数据(通常只有一行) ProjectInfo info = new ProjectInfo(); info.setProjectCode(dataRow.get(0)); info.setProjectName(dataRow.get(1)); // 复杂表头,项目经理信息在索引2和3 // 这里假设“姓名”和“联系方式”在同一个对象里,实际可能需要更复杂的映射 projectInfoList.add(info); // 通常项目信息只有一行,读到下一行表头时就会切换currentTableType } else if (currentTableType == 2) { // 处理核心成员表数据 CoreMember member = new CoreMember(); member.setOrder(Integer.valueOf(dataRow.get(0))); member.setName(dataRow.get(1)); member.setRole(dataRow.get(2)); member.setManHours(Double.valueOf(dataRow.get(3))); coreMemberList.add(member); } // 如果遇到空行或不符合任何Table特征的行,可以选择跳过 } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 整个Sheet解析完毕 System.out.println("项目信息读取完成: " + projectInfoList); System.out.println("核心成员读取完成,共" + coreMemberList.size() + "条"); // 这里可以将数据存入数据库或进行后续业务处理 } public List<ProjectInfo> getProjectInfoList() { return projectInfoList; } public List<CoreMember> getCoreMemberList() { return coreMemberList; } }这个监听器的关键在于invokeHead方法。我们通过读取到的表头内容来判断数据区域的类型。这是一种基于内容规则的分拣策略,非常灵活。当然,如果文件格式非常规范,你也可以通过ReadSheet的headRowNumber和tableNo等参数进行更精确的绑定,但对于混合Table的Sheet,内容判断往往是更可靠的方式。
然后,为“任务清单”Sheet定义一个更简单的监听器:
public class TaskItemListener extends AnalysisEventListener<TaskItem> { // 直接使用TaskItem对象接收,需要@ExcelProperty注解的索引与文件列序严格对应 private List<TaskItem> taskList = new ArrayList<>(); @Override public void invoke(TaskItem taskItem, AnalysisContext context) { taskList.add(taskItem); } @Override public void doAfterAllAnalysed(AnalysisContext context) { System.out.println("任务清单读取完成: " + taskList); } public List<TaskItem> getTaskList() { return taskList; } }4.2 执行读取与监听器绑定
最后,编写读取代码,将监听器绑定到对应的Sheet。
public void importComplexReport(MultipartFile file) throws IOException { // 1. 创建第一个Sheet的监听器实例 ProjectInfoListener projectInfoListener = new ProjectInfoListener(); // 读取第一个Sheet。注意:sheetNo从0开始。 ReadSheet readSheet1 = EasyExcel.readSheet(0) .head(Map.class) // 对于混合Table,先用Map接收原始数据 .registerReadListener(projectInfoListener) .build(); // 2. 创建第二个Sheet的监听器实例 TaskItemListener taskItemListener = new TaskItemListener(); // 读取第二个Sheet,直接映射到TaskItem对象 ReadSheet readSheet2 = EasyExcel.readSheet(1) .head(TaskItem.class) .registerReadListener(taskItemListener) .build(); // 3. 执行同步读取(doReadSync)或异步读取(read) // 这里使用doReadSync,它会阻塞直到所有Sheet读完,适合文件不大、逻辑简单的场景。 // 对于大文件,建议使用read方法,配合监听器异步处理。 EasyExcel.read(file.getInputStream()) .doReadAll(Arrays.asList(readSheet1, readSheet2)); // 传入所有要读的Sheet // 4. 从监听器中获取解析结果 List<ProjectInfo> importedProjectInfo = projectInfoListener.getProjectInfoList(); List<CoreMember> importedMembers = projectInfoListener.getCoreMemberList(); List<TaskItem> importedTasks = taskItemListener.getTaskList(); // ... 后续业务处理,如数据校验、入库等 }重要提示:使用
Map.class作为head类型时,invoke方法参数就是Map<Integer, String>,key是列索引(从0开始),value是单元格字符串。这种方式给了我们最大的灵活性来处理不规则数据,但代价是需要自己写逻辑进行字段映射和类型转换。对于结构规整的Sheet,直接绑定Java对象(如TaskItem.class)更省事。
4.3 处理导入中的常见“坑点”
- 空行和空值:业务Excel里经常有空白行做分隔。在监听器的
invoke方法里,如果dataRow为空或所有值都为空,可以直接return跳过,避免插入空数据。 - 数据类型转换错误:这是最常出问题的地方。比如Excel里看起来是数字“001”,读进来可能是字符串也可能是整数1。在
invoke里做转换时(如Integer.valueOf),一定要用try-catch包裹,并记录错误行号和原因,给用户友好的提示,而不是让整个导入崩溃。 - 表头行数不固定:有时表头占两行,有时三行。
headRowNumber参数可以指定从第几行开始读数据(表头行数)。但像我们之前那种混合Table,可能需要动态判断。这时可以在监听器的invokeHead里更精细地控制。 - 大文件内存溢出:
AnalysisEventListener是逐行解析的,本身不会内存溢出。问题往往出在invoke方法里,如果把所有数据都先加到List里,文件超大时这个List就会撑爆内存。解决方案是分批次处理,每积累一定数量(如2000条)就执行一次批量入库或处理,然后清空临时列表。private static final int BATCH_COUNT = 2000; private List<Object> cachedDataList = new ArrayList<>(BATCH_COUNT); @Override public void invoke(Object data, AnalysisContext context) { cachedDataList.add(data); if (cachedDataList.size() >= BATCH_COUNT) { saveData(); // 批量处理 cachedDataList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { saveData(); // 处理最后一批数据 }
5. 性能调优与生产环境实践
当数据量达到十万甚至百万级时,一些默认配置就需要调整了。EasyExcel的默认设置偏向于功能性和兼容性,在极端性能场景下可以优化。
5.1 导出性能优化
- 关闭自动列宽计算:
LongestMatchColumnWidthStyleStrategy会自动遍历所有数据计算最合适的列宽,数据量大时非常耗时。如果对列宽要求不严格,可以不注册这个处理器,或者手动设置固定列宽。 - 使用
useDefaultStyle(false):如果不需要任何单元格样式,创建ExcelWriter时可以传入此配置,能减少一些样式对象的创建开销。 - 分批次查询和写入:这是最重要的优化点。不要一次性从数据库查询百万数据放到内存里再写入。应该使用分页查询,查一批(如5000条),写一批。
int pageSize = 5000; int pageNum = 1; ExcelWriter excelWriter = ...; WriteSheet writeSheet = ...; while (true) { List<YourData> batchList = dataService.getDataByPage(pageNum, pageSize); if (batchList.isEmpty()) { break; } excelWriter.write(batchList, writeSheet); pageNum++; batchList.clear(); // 及时释放当前批次引用,帮助GC } excelWriter.finish(); - 谨慎使用
inMemory模式:默认情况下,EasyExcel会使用SXSSFWorkbook(流式写入),数据先写到临时文件,再合并到最终输出流,内存占用小。除非有特殊需求(如大量使用样式导致SXSSF受限),否则不要轻易切换到完全在内存中操作的XSSFWorkbook模式。
5.2 导入性能优化
- 调整
ReadCacheSize:在构建ExcelReader时,可以设置读取缓存大小。默认值通常够用,但在极端情况下可以微调。EasyExcel.read(inputStream) .readCache(new MapCache()) // 使用Map缓存,默认 .readCacheSize(1000) // 调整缓存行数 .sheet() .doRead(); - 异步处理与批量提交:如前所述,在监听器的
invoke方法中实现批量处理,并考虑将处理逻辑(如数据校验、转换、入库)放入单独的线程池,避免阻塞读取线程。但要注意事务边界和错误回滚。 - 文件格式选择:
.xlsx文件(基于XML)的读取效率通常比旧的.xls(二进制)格式要高,尤其是大文件。
5.3 稳定性保障与监控
- 超时与中断处理:对于HTTP导出接口,要设置合理的响应超时时间。对于非常耗时的导出,可以考虑改为异步任务,生成文件后提供下载链接。在导入时,也要有机制能中断长时间运行的读取任务。
- 内存监控:在生产环境,需要监控导入导出服务的内存使用情况。特别是导出,如果采用分页查询,要确保每一批数据写出后,Java堆中的对象能被及时垃圾回收。可以观察GC日志,避免出现Full GC。
- 错误隔离与重试:对于导入任务,某一行数据的格式错误不应该导致整个任务失败。应该在监听器里捕获每行的异常,记录到错误列表,并继续处理后续行。任务结束后,将成功数据和错误报告一并返回给用户。
6. 进阶:动态表头、模板导出与数据校验
对于更复杂的业务,比如报表格式经常变动,或者需要导出带有复杂公式、图表、固定样式的文件,还有更高级的用法。
6.1 动态表头生成
有时导出的列是不固定的,由用户在前端选择决定。这时就不能用固定的@ExcelProperty注解了。我们需要动态构建表头。
public void exportWithDynamicHeaders(HttpServletResponse response, List<String> selectedFields) throws IOException { // selectedFields 例如: ["姓名", "部门", "销售额"] // 1. 动态构建表头 List<List<String>> head = new ArrayList<>(); for (String field : selectedFields) { head.add(Collections.singletonList(field)); } // 2. 动态查询数据。这里假设数据是List<Map<String, Object>>格式,Map的key是字段名。 List<Map<String, Object>> dataList = dataService.getDataByFields(selectedFields); // 3. 将数据Map按表头顺序转换为List<List<Object>> List<List<Object>> excelData = new ArrayList<>(); for (Map<String, Object> rowMap : dataList) { List<Object> rowData = new ArrayList<>(); for (String field : selectedFields) { rowData.add(rowMap.get(field)); } excelData.add(rowData); } // 4. 写入Excel。注意,这里没有对应的Java对象,所以用WriteCellData直接写值。 ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build(); WriteSheet writeSheet = EasyExcel.writerSheet("动态报表") .head(head) // 设置动态表头 .build(); // 使用write方法的重载版本,直接写入List<List<Object>> excelWriter.write(excelData, writeSheet); excelWriter.finish(); }这种方式完全脱离了固定的数据模型,非常灵活,但代价是需要手动处理数据组装和类型转换。
6.2 基于模板的导出
当Excel文件有严格的格式要求(如公司LOGO、固定的说明文字、复杂的单元格样式和公式)时,最好使用模板导出。先让业务人员在Excel中设计好模板文件(.xlsx),程序中只负责向特定位置填充数据。
- 准备模板:在Excel中设计好样式和固定内容,在需要填充数据的地方用
{}占位,例如{projectName}。 - 使用模板写入:
模板导出能完美保留格式,但需要确保模板中的占位符和程序中的key一致,并且列表填充的区域要预留足够的行。// 获取模板文件的输入流 InputStream templateInputStream = this.getClass().getClassLoader().getResourceAsStream("templates/project_report_template.xlsx"); ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()) .withTemplate(templateInputStream) .build(); WriteSheet writeSheet = EasyExcel.writerSheet().build(); // 准备填充数据,这是一个Map,key是模板中的占位符 Map<String, Object> fillData = new HashMap<>(); fillData.put("projectName", "CRM系统重构"); fillData.put("reportDate", new Date()); // 对于列表数据,key对应模板中表格区域的占位符 fillData.put("memberList", coreMemberList); // coreMemberList是List<CoreMember> // 执行填充 FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); // 列表数据强制新行 excelWriter.fill(fillData, writeSheet); excelWriter.fill(new FillWrapper("member", coreMemberList), fillConfig, writeSheet); excelWriter.finish(); templateInputStream.close();
6.3 数据校验与错误反馈
导入的数据必须经过校验。EasyExcel本身提供了简单的校验注解,如@NotNull,但业务校验往往更复杂。我推荐在监听器的invoke方法中,或在doAfterAllAnalysed之后,进行集中校验。
public class ValidatingListener extends AnalysisEventListener<YourData> { private List<YourData> successList = new ArrayList<>(); private List<RowError> errorList = new ArrayList<>(); // 自定义错误信息类 @Override public void invoke(YourData data, AnalysisContext context) { // 基本格式校验 if (StringUtils.isBlank(data.getName())) { errorList.add(new RowError(context.readRowHolder().getRowIndex(), "姓名不能为空")); return; } // 业务逻辑校验 if (data.getAge() != null && data.getAge() < 18) { errorList.add(new RowError(context.readRowHolder().getRowIndex(), "年龄必须大于等于18岁")); return; } successList.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 所有行解析完毕,可以进行跨行校验,比如唯一性检查 Set<String> nameSet = new HashSet<>(); for (YourData data : successList) { if (!nameSet.add(data.getName())) { // 找到重复项,从successList移除,加入errorList // 这里需要记录行号,可能需要额外维护一个映射 } } // 最终,successList是校验通过的数据,errorList包含所有错误信息 } }对于错误,可以生成一个详细的错误报告Excel,列出错误行号、列名和原因,供用户下载修正。这比直接抛出一个异常要友好得多。
从简单的单表导出导入,到复杂的多Sheet多Table处理,再到动态表头、模板填充和严格的数据校验,EasyExcel提供了一套渐进式的工具集。关键在于理解其核心抽象(Writer/Reader, Sheet, Table, Listener)并灵活组合。在实际项目中,我建议从最简单的场景开始,随着业务复杂度的提升,逐步引入更高级的特性,并始终把数据准确性、处理性能和用户体验放在首位。