尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java Excel处理利器EasyExcel:注解驱动与流式解析实战

Java Excel处理利器EasyExcel:注解驱动与流式解析实战 1. 项目概述为什么是EasyExcel在Java后端开发里处理Excel的导入导出是个高频且容易“踩坑”的需求。早年我们可能用Apache POI功能强大但API繁琐处理大文件时内存溢出OOM是家常便饭。后来有了JXL但功能又略显单薄。直到阿里巴巴开源的EasyExcel出现它基于POI但做了深度封装和优化核心就两点简单和省内存。简单体现在它的注解驱动模型。你不需要去记忆POI里那些复杂的Cell、Row、Sheet对象操作通过几个注解就能完成Java对象和Excel单元格的映射。省内存则是它采用了SAX模式解析和异步无模型刷写。简单说它解析Excel时不是一次性把整个文件加载到内存而是像读XML一样一行行“流式”读取写入时也是边处理数据边写入磁盘避免在内存中构建完整的DOM树。这对于动辄几十万、上百万行的数据报表场景是至关重要的。所以当你接到一个“把用户列表导出成Excel”或者“上传一个Excel批量更新产品信息”的需求时EasyExcel几乎成了当前Java生态下的首选工具。它解决的不仅是功能实现问题更是生产环境下的稳定性和性能问题。接下来我会结合一个完整的用户信息管理案例拆解如何使用EasyExcel实现稳健的导入导出并分享那些官方文档里不会写的实操细节和避坑指南。2. 核心设计模型驱动与监听器模式EasyExcel的优雅很大程度上源于其清晰的分层设计。理解这个设计是灵活使用它的前提。2.1 注解驱动的数据模型一切始于一个普通的Java Bean。EasyExcel通过一组注解定义了该Bean的属性如何与Excel的表头Header和单元格Cell对应。Data // 使用Lombok简化代码 public class UserDTO { ExcelProperty(value 用户ID, index 0) private Long id; ExcelProperty(value 姓名, index 1) ColumnWidth(20) // 设置列宽 private String name; ExcelProperty(value 年龄, index 2) private Integer age; ExcelProperty(value 邮箱, index 3) ColumnWidth(30) private String email; ExcelProperty(value 入职日期, index 4) DateTimeFormat(yyyy-MM-dd) // 日期格式 private LocalDate joinDate; ExcelProperty(value 薪资, index 5) NumberFormat(#,##0.00) // 数字格式千分位两位小数 private BigDecimal salary; }关键注解解析ExcelProperty: 核心注解。value定义表头名称index定义该字段对应Excel的第几列从0开始。index在导出时用于控制列顺序在导入时用于映射数据强烈建议显式指定避免因Java反射获取字段顺序的不确定性导致错列。ColumnWidth: 设置导出Excel的列宽单位是字符宽度。DateTimeFormat: 指定日期字段的格式化模式。写入时Date/LocalDate/LocalDateTime等对象会按此格式转为字符串读取时字符串会按此格式解析为对象。NumberFormat: 指定数字字段的格式化模式。如会计格式、百分比等。注意ExcelProperty的index属性在复杂表头多行表头时尤为重要。EasyExcel通过index来定位数据单元格而不是表头文字。这意味着即使表头文字被用户修改只要列顺序不变导入功能依然正常。2.2 监听器导入的灵魂组件如果说模型是骨骼那么监听器就是导入功能的神经中枢。EasyExcel采用基于监听器的SAX解析模式这是其高性能的秘诀。为什么需要监听器因为SAX解析是“事件驱动”的。解析器一行行读取Excel每读一行数据不包括表头就会触发一次监听器的invoke方法。这意味着内存友好永远只保持一行数据在内存中处理完即丢弃。灵活可控你可以在invoke方法中对每一行数据进行校验、转换、甚至中断读取。异步处理你可以在invoke方法中将数据放入队列由其他线程异步消费实现极致的吞吐量。一个基础的读取监听器长这样// 注意监听器不能被Spring管理每次读取都要new一个实例。 // 如果需要在监听器里注入Spring Bean可以通过构造器传入。 public class UserDataListener extends AnalysisEventListenerUserDTO { /** * 每隔100条存储数据库然后清理列表方便内存回收 */ private static final int BATCH_COUNT 100; private ListUserDTO cachedDataList new ArrayList(BATCH_COUNT); private UserService userService; // 假设通过构造器传入 public UserDataListener(UserService userService) { this.userService userService; } /** * 每解析一行数据都会调用此方法 * param data 一行数据对应的对象 * param context 分析上下文 */ Override public void invoke(UserDTO data, AnalysisContext context) { // 1. 数据校验示例邮箱格式 if (!isValidEmail(data.getEmail())) { throw new ExcelDataConvertException(context.readRowHolder().getRowIndex(), context.readRowHolder().getCellMap(), new IllegalArgumentException(邮箱格式错误)); } // 2. 数据转换示例姓名去空格 data.setName(data.getName().trim()); cachedDataList.add(data); // 达到BATCH_COUNT模拟批量入库并清空列表 if (cachedDataList.size() BATCH_COUNT) { saveData(); cachedDataList.clear(); } } /** * 所有数据解析完成后调用 */ Override public void doAfterAllAnalysed(AnalysisContext context) { // 确保最后一批数据也被保存 if (!cachedDataList.isEmpty()) { saveData(); } log.info(Excel数据导入完成); } private void saveData() { // 这里调用service进行批量保存 userService.batchSave(cachedDataList); log.info(成功批量存储{}条数据, cachedDataList.size()); } private boolean isValidEmail(String email) { // 简单的邮箱格式校验实际应用请用更严谨的正则 return email ! null email.matches(^[A-Za-z0-9_.-](.)$); } }监听器设计要点批量操作务必像示例中一样使用缓存列表进行批量处理如BATCH_COUNT 100。逐条操作数据库是性能杀手。异常处理在invoke中校验失败时可以抛出RuntimeException如ExcelDataConvertException。EasyExcel会捕获并停止解析将异常信息通过ReadListener的onException方法回调如果需要自定义异常处理需重写onException。状态管理监听器对象是多例的不要在其中保存与单次读取无关的全局状态。3. 实战导出功能深度实现导出功能相对直观但要把细节做好同样需要一番功夫。核心类是EasyExcel.write()。3.1 基础导出与Web响应最常见的场景是通过HTTP接口下载Excel文件。RestController RequestMapping(/api/user) public class UserExportController { Autowired private UserService userService; GetMapping(/export) public void exportUsers(HttpServletResponse response) throws IOException { // 1. 设置响应头 String fileName URLEncoder.encode(用户列表, 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); // 注意filename* 是RFC 5987标准支持中文等特殊字符。旧浏览器可用filename但需做URL编码。 // 2. 查询数据 ListUserDTO userList userService.listAllUsers(); // 假设的业务方法 // 3. 使用EasyExcel写出到HttpServletResponse的OutputStream // 这里“UserDTO.class”指定了写入的数据模型和表头 EasyExcel.write(response.getOutputStream(), UserDTO.class) .sheet(用户信息) // 指定Sheet名称 .doWrite(userList); // 执行写入 } }关键点与避坑响应头Content-Type必须是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet对应.xlsx格式。.xls格式对应application/vnd.ms-excel。文件名编码处理中文文件名是经典问题。filename*utf-8是现代浏览器的推荐做法。为了兼容性可以同时设置filenameURL编码后的和filename*。资源关闭EasyExcel.write()会自动关闭传入的OutputStream无需手动关闭。大数据量导出上面的例子一次性查询所有数据并写入如果数据量巨大如50万行会导致内存压力和数据库查询超时。解决方案是使用分页查询异步刷写。3.2 大数据量分页导出对于海量数据导出必须采用分页查询并利用EasyExcel的WriteSheet和多次doWrite。public void exportLargeData(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-disposition, attachment;filenamelarge_data.xlsx); ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), UserDTO.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(大数据Sheet).build(); int pageNum 1; int pageSize 5000; // 每页5000条 ListUserDTO pageData; do { // 分页查询数据需业务层支持 pageData userService.listUsersByPage(pageNum, pageSize); if (CollectionUtils.isEmpty(pageData)) { break; } // 分批写入Excel excelWriter.write(pageData, writeSheet); pageNum; // 可选每写几页清理一次上下文防止内存缓慢增长对于极端大量数据 if (pageNum % 20 0) { excelWriter.finish(); // 注意finish会关闭流这里用法不对正确做法见下方。 // 实际上对于同一个OutputStream不能多次finish。更优方案是使用SxssfSheet的flush机制。 // 更常见的做法是信任EasyExcel的内存管理或直接使用其“重复多次写入”API。 } } while (pageData.size() pageSize); // 如果查不满一页说明是最后一页 } finally { // 非常重要最终必须finish才会真正写出文件尾并关闭流 if (excelWriter ! null) { excelWriter.finish(); } } }重要提示上面的分页示例中关于finish的注释是关键。实际上EasyExcel在设计上已经考虑了大文件写入其底层在默认情况下会使用SXSSFWorkbookPOI的流式API它会自动将一定行数后的数据刷写到磁盘临时文件。因此在大多数情况下你只需要像基础导出那样调用一次doWrite即使数据量很大EasyExcel和POI也会在内部进行分片处理。更优雅的大数据导出做法是使用“模板填充”或自定义WriteHandler来精确控制内存。上面的分页循环写入方式适用于数据来源不是单一SQL查询而是多个不同查询或混合数据源的场景。3.3 自定义样式与复杂表头有时我们需要导出的Excel带有特定的样式如标题行加粗、背景色或复杂的多级表头。方案一使用WriteHandler回调接口这是最灵活的方式。你可以实现CellWriteHandler、RowWriteHandler等在单元格/行创建时介入并设置样式。Component // 可以注册为Spring Bean复用 public class CustomCellStyleHandler implements CellWriteHandler { // 表头样式 private CellStyle headerCellStyle(WriteSheetHolder writeSheetHolder) { Workbook workbook writeSheetHolder.getParentWriteWorkbookHolder().getWorkbook(); CellStyle style workbook.createCellStyle(); Font font workbook.createFont(); font.setBold(true); // 加粗 font.setFontHeightInPoints((short)12); // 字号 style.setFont(font); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); // 背景色 style.setFillPattern(FillPatternType.SOLID_FOREGROUND); style.setAlignment(HorizontalAlignment.CENTER); // 居中 return style; } Override public void afterCellDispose(CellWriteHandlerContext context) { // 只在表头行设置样式 if (context.getRowIndex() 0) { // 假设表头在第0行 Cell cell context.getCell(); CellStyle style headerCellStyle(context.getWriteSheetHolder()); cell.setCellStyle(style); } // 还可以在这里根据单元格内容、行号等设置不同的数据行样式 } } // 使用时注入Handler EasyExcel.write(outputStream, UserDTO.class) .registerWriteHandler(new CustomCellStyleHandler()) // 注册自定义处理器 .sheet(用户表) .doWrite(data);方案二使用ExcelProperty组合复杂表头对于多级表头可以直接在注解中定义。public class ComplexHeaderDTO { // 第一行表头是“基本信息”它横跨了下方的“姓名”和“年龄”两列 ExcelProperty(value {基本信息, 姓名}) private String name; ExcelProperty(value {基本信息, 年龄}) private Integer age; ExcelProperty(value {联系信息, 邮箱}) private String email; ExcelProperty(value {联系信息, 电话}) private String phone; }导出后Excel表头将呈现为| 基本信息 | 联系信息 | | 姓名 | 年龄 | 邮箱 | 电话 |4. 实战导入功能深度实现导入比导出更复杂因为要处理用户上传文件的不确定性格式错误、数据错误、网络中断等。4.1 基础导入与数据校验基础导入的核心是配置监听器并执行读取。RestController RequestMapping(/api/user) public class UserImportController { PostMapping(/import) public ApiResponseString importUsers(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return ApiResponse.fail(请选择文件); } try { // 1. 创建监听器实例。注意监听器不能是单例每次读取需新建。 UserDataListener listener new UserDataListener(userService); // 2. 读取文件 EasyExcel.read(file.getInputStream(), UserDTO.class, listener) .sheet() // 默认读取第一个sheet .headRowNumber(1) // 指定表头行数默认为1。如果有多级表头需调整。 .doRead(); // 开始同步读取读取完整个方法才返回 return ApiResponse.success(导入成功); } catch (ExcelDataConvertException e) { // 捕获数据转换异常如数字格填了文字 log.error(第{}行第{}列数据解析失败, e.getRowIndex() 1, e.getColumnIndex() 1); return ApiResponse.fail(String.format(第%d行数据格式错误请检查, e.getRowIndex() 1)); } catch (Exception e) { log.error(导入失败, e); return ApiResponse.fail(导入失败 e.getMessage()); } } }数据校验的三种境界基础类型校验由EasyExcel自动完成。例如模型中是Integer ageExcel单元格里是字符串“abc”框架会抛出ExcelDataConvertException。这是第一道防线。业务逻辑校验在监听器的invoke方法中进行。如邮箱格式、手机号格式、年龄范围、数据唯一性等。校验失败可以抛出异常或更优雅地将错误信息收集起来在doAfterAllAnalysed中统一返回。数据库约束校验在批量保存saveData时进行。如唯一索引冲突、外键约束等。这部分错误通常需要回滚事务并给出明确的批处理错误报告。4.2 复杂表头与动态表头导入用户上传的Excel表头可能和你的模型不完全一致比如列顺序换了或者有额外的列。EasyExcel默认按index或value严格匹配。headRowNumber如果表头有多行通过这个参数指定。例如headRowNumber(2)会合并前两行作为最终的表头映射。extraHead与ignoreExcelProperty注解的index是精准定位的利器。即使表头文字不匹配只要列顺序对就能读。你可以设置ExcelProperty(index 2)来读取第三列的数据而不管它表头叫什么。动态表头对于表头完全不固定的场景比如用户自定义报表EasyExcel的模型映射就不适用了。此时应该使用无模型读取。// 无模型读取返回的数据是ListListObject每个内层List代表一行Object是单元格的值 ListObject list EasyExcel.read(file.getInputStream()) .sheet() .headRowNumber(0) // 设置为0表示没有表头所有行都是数据 .doReadSync(); // 同步读取直接返回结果 for (ListObject row : list) { // 手动处理每一行数据 String name (String) row.get(0); // ... 自己实现映射逻辑 }无模型读取给了你最大的灵活性但代价是需要自己编写所有的解析和校验逻辑。4.3 导入结果反馈与错误文件生成一个专业的导入功能不应该只返回“成功”或“失败”而应该告诉用户成功了哪些失败了哪些失败的原因是什么。通常的做法是生成一个带有错误标记的Excel文件供用户下载。实现思路在监听器中不再遇到错误就抛异常而是将错误行及其原因记录到一个列表中。在doAfterAllAnalysed中将成功的数据入库将失败的数据列表可能包含行号、原数据、错误原因返回给控制器。控制器根据失败数据列表生成一个新的Excel。这个Excel可以原样包含失败行的数据并在最后一列追加“错误原因”。// 在监听器中定义 public class UserDataListenerWithError extends AnalysisEventListenerUserDTO { private ListUserDTO successList new ArrayList(); private ListImportError errorList new ArrayList(); // ImportError自定义类包含rowIndex, data, errorMsg Override public void invoke(UserDTO data, AnalysisContext context) { try { // 校验逻辑 validate(data); successList.add(data); } catch (ValidationException e) { errorList.add(new ImportError(context.readRowHolder().getRowIndex(), data, e.getMessage())); } } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!successList.isEmpty()) { userService.batchSave(successList); } // 将errorList通过某种方式如ThreadLocal传递回Controller } } // 在Controller中根据errorList生成错误报告Excel public void generateErrorReport(ListImportError errors, HttpServletResponse response) { // 1. 将errors转换为一个用于导出的DTO列表包含原数据和错误列 ListErrorReportDTO reportData convert(errors); // 2. 使用EasyExcel写出 EasyExcel.write(response.getOutputStream(), ErrorReportDTO.class) .sheet(导入错误报告) .doWrite(reportData); }5. 高级特性与性能调优5.1 读/写拦截器ReadListener/WriteHandler的进阶用法除了设置样式拦截器还能做很多事ReadListener:invokeHead: 在读取表头时调用可用于校验表头是否正确。onException: 发生异常时调用可以在这里进行自定义的异常日志记录或转换。hasNext: 在invoke之前调用返回false可立即停止读取。CellWriteHandler:beforeCellCreate: 在创建单元格前可以设置行高、列宽。afterCellDispose: 在单元格创建后可以基于单元格值设置条件格式。例如将薪资大于10000的单元格标红。5.2 模板填充更灵活的导出对于格式极其复杂、固定的报表如合同、发票使用模型注解导出会很吃力。此时可以用.xlsx文件作为模板用EasyExcel进行填充。用Excel设计好带有占位符的模板。占位符用{}表示如{name},{date}。使用EasyExcel的填充API。// 准备填充数据 MapString, Object data new HashMap(); data.put(name, 张三); data.put(date, LocalDate.now()); data.put(items, listOfItems); // 可以填充列表模板中对应 {.items} 和 {item.property} // 读取模板并填充 String templateFileName template.xlsx; String resultFileName filled_contract.xlsx; EasyExcel.write(resultFileName) .withTemplate(templateFileName) .sheet() .doFill(data);模板填充功能将样式设计和数据逻辑完全分离非常适合由运营或产品人员维护报表样式的场景。5.3 性能调优要点导入侧headRowNumber准确设置。如果表头只有一行却设为2会浪费一行解析。autoTrim默认true会自动去除字符串首尾空格。如果确定数据无需处理可设为false节省微末性能。监听器中的批量大小BATCH_COUNT需要权衡。太小则数据库交互频繁太大则内存中缓存列表过大失去流式读取的意义。通常建议在100-1000之间根据单条数据大小调整。关闭自动关闭流EasyExcel.read()默认会自动关闭输入流。如果你需要自己管理流生命周期可以使用.autoCloseStream(false)。导出侧使用SXSSFEasyExcel默认使用POI的SXSSF模式-1表示不限制内存行数全部刷写到磁盘。对于超大数据可以显式配置.inMemory(false)强制使用SXSSF或通过.autoTrim(false)等减少内存操作。避免在循环中创建样式在WriteHandler中创建CellStyle和Font对象是昂贵的操作。务必缓存这些样式对象在Workbook级别只创建一次。压缩临时文件SXSSF会产生临时文件。确保系统临时目录java.io.tmpdir有足够空间并考虑在JVM参数中调整-Djava.io.tmpdir指向更快的存储设备。6. 常见问题排查与实战心得问题1导入时数字、日期格式解析错误。现象ExcelDataConvertException: Convert data [xxxx] to Integer/LocalDate error。排查检查Excel单元格的实际格式。看似“2023-01-01”的单元格其底层格式可能是文本。检查模型类注解。日期字段是否使用了DateTimeFormat且格式与数据匹配数字字段是否使用了NumberFormat在监听器的invoke方法中打印或日志记录原始数据data看EasyExcel传递给你的是什么对象。有时框架已经帮你转换好了。解决确保Excel单元格格式正确右键-设置单元格格式。使用DateTimeFormat、NumberFormat精确匹配。在监听器中做兼容性处理例如如果data.getJoinDate()是String类型则手动用DateTimeFormatter解析。问题2导出文件打开报“文件已损坏”或内容为空。现象下载的.xlsx文件无法用Excel打开。排查最可能的原因在Web导出中在写出Excel之前或之后向HttpServletResponse的OutputStream写入了其他内容如JSON错误信息或者发生了异常被全局异常处理器包装成了JSON响应。检查Controller方法是否被ResponseBody或RestController注解导致方法返回值也被写入流。确保在EasyExcel.write()之后没有调用response.getWriter().write()等方法。解决Controller方法返回类型设为void。在方法开始正确设置响应头Content-Type,Content-Disposition。使用try-with-resources或确保excelWriter.finish()被调用。在全局异常处理器中排除对导出接口的异常包装。问题3导入大量数据时内存增长最终OOM。现象导入一个200MB的Excel内存持续上涨直至GC overhead limit exceeded。排查确认是否真的使用了EasyExcel的监听器模式。错误的用法是调用doReadSync()一次性读取所有数据到内存。检查监听器中的cachedDataList是否及时清空。确保在saveData()后执行了clear()。检查业务逻辑。是否在监听器中无意间将每一行数据添加到了某个全局的、不断增长的集合中使用JProfiler等工具监控堆内存查看大对象是谁。解决坚持使用监听器模式AnalysisEventListener。优化批量处理逻辑确保缓存列表被及时回收。检查并修复业务代码中的内存泄漏点。问题4复杂表头导入数据映射错位。现象姓名读到了邮箱列里。排查确认模型类ExcelProperty的index设置是否正确。表头行数变化可能导致索引基准变化。使用EasyExcel的head方法打印读取到的表头信息与你的模型定义对比。EasyExcel.read(inputStream) .head(UserDTO.class) .sheet() .doReadSync(); // 或者在监听器的invokeHead方法中打印解决使用index而非value进行映射更稳定。如果表头动态考虑使用无模型读取然后自己写映射逻辑。个人心得测试要用真实的、脏的数据不要只用自己生成的完美Excel测试。让业务人员给你几个他们实际使用的、可能有合并单元格、有空行、有格式问题的文件来测试能发现90%的边界情况。导入做成异步的对于耗时较长的导入任务最好做成异步。接口立即返回一个任务ID前端轮询任务状态。后台使用线程池或消息队列处理导入文件处理完成后将结果成功/失败报告存储起来供用户下载。这能避免HTTP超时和浏览器等待。给导出文件加上“指纹”在文件名或Sheet名中加入导出时间戳如用户列表_20231027_142356.xlsx方便区分不同时间导出的文件也便于问题追溯。依赖管理EasyExcel的版本要与底层POI版本匹配。通过Maven引入easyexcel它会自动关联一个兼容的POI版本。不要自己单独引入不同版本的POI以免冲突。读写分离读和写的模型DTO可以考虑分开UserReadDTO和UserWriteDTO。因为读写需求可能不同读可能需要更多校验注解写可能需要更多样式注解。混在一起会导致类职责不清晰。
返回列表