EasyExcel之文件导出最佳实践
文件导出
引言
当使用 EasyExcel 进行 Excel 文件导出时,我最近在工作中遇到了一个需求。因此,我决定写这篇文章来分享我的经验和解决方案。如果你对这个话题感兴趣,那么我希望这篇文章对你有所帮助。
本文的目标是介绍 EasyExcel 的基本概念、使用方法以及解决特定问题的技巧。通过使用 EasyExcel,我们可以提高文件导出的效率,简化代码,并实现更灵活的数据导出。
在阅读完本文后,你将能够了解 EasyExcel 的核心功能和常用操作,掌握如何根据实际需求进行配置和定制。此外,我还将分享一些实用的技巧和最佳实践,帮助你更好地利用 EasyExcel 完成文件导出任务。
最后,我要再次感谢你的关注和支持。如果你觉得这篇文章对你有所帮助,请不要吝啬点赞、收藏和关注我的其他文章或资源。这将是对我最大的鼓励和支持!😘
文章内容会持续更新!!!
为何选择 EasyExcel 而不是 POI?
选择使用 EasyExcel 而不是 POI 的原因主要有以下几点:
- EasyExcel 在尽可能节约内存的情况下支持读写大型 Excel 文件。具体来说,它通过一行一返回的方式解决了 POI 解析 Excel 非常耗费内存的问题。
- EasyExcel 是开源的,代码放在 GitHub 上,如果遇到问题,可以随时提出 issue。
- EasyExcel 社区活跃,网上的相关文档也比较多,这对于使用者来说是一个很大的优势。
- 虽然 POI 是目前使用最多的用来做 excel 解析的框架,但其 userModel 模式在处理大文件时存在明显的缺陷,比如内存消耗大和有并发问题等。而 EasyExcel 则很好地解决了这些问题。
- EasyExcel 底层对象其实还是使用 poi 包的那一套,只是将 poi 包的一部分抽了出来,摒弃掉了大部分业务相关的属性。
总的来说,EasyExcel 在处理大数据量的 Excel 文件导出方面,相比 POI 具有明显的优势,这也是为什么越来越多人选择使用 EasyExcel 的原因。
简单来说就是,因为 EasyExcel 性能更好。
EasyExcel 简介
EasyExcel 是一个基于 Java 的开源库,用于简化和优化 Excel 文件的读写操作。它提供了一种简单而高效的方式来处理大量数据的导入和导出,特别适用于大数据量的处理。
EasyExcel 具有以下特点:
- 高性能:通过使用高效的数据模型和批量写入技术,EasyExcel 能够快速地处理大量数据,提高文件导出的效率。
- 灵活性:EasyExcel 支持多种数据类型和格式,可以方便地导出各种类型的数据,包括文本、数字、日期等。
- 简洁的 API:EasyExcel 提供了简洁易用的 API,使得开发者可以快速上手并实现文件导出功能。
Date 字段问题
报错
{ "code": "1", "message": "导出文件失败:java.lang.NoSuchMethodError: org.apache.poi.ss.usermodel.Cell.setCellValue(Ljava/time/LocalDateTime;)V" }
这是因为要导出的列里面有 Date
类型,EasyExcel 识别不了
解决方法
1、编写 Date 转换器
public class DateConverter implements Converter<Date> { private static final String PATTERN_YYYY_MM_DD = "yyyy-MM-dd"; @Override public Class<Date> supportJavaTypeKey() { return Date.class; } /** * easyExcel导出数据类型转换 * @param cellData * @param contentProperty * @param globalConfiguration * @return * @throws Exception */ @Override public Date convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { String value = cellData.getStringValue(); SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_YYYY_MM_DD); return sdf.parse(value); } /** * easyExcel导入Date数据类型转换 * @param context * @return * @throws Exception */ @Override public WriteCellData<String> convertToExcelData(WriteConverterContext<Date> context) throws Exception { Date date = context.getValue(); if (date == null) { return null; } SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_YYYY_MM_DD); return new WriteCellData<>(sdf.format(date)); } }
然后,修改导出视图类中的 Date 类型字段,加入 converter = DateConverterUtil.class
转换属性。
@ExcelProperty(value = "最新维保时间", index = 3, converter = DateConverter.class) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date time;
2、用官方的注解
使用 EasyExcel 的 @DateTimeFormat
注解:
// 字段类型为String,否则注解可能会无效 @DateTimeFormat(value = "yyyy-MM-dd HH:mm:ss") private String alarmTime;
对象转换使用案例:
1、在需要使用时,转换时间值
// 1、获取导出列表 List<Alarm> list = alarmService.list(); List<AlarmExportVO> list2 = list.stream().map(item -> { AlarmExportVO exportVO = new AlarmExportVO(); exportVO.setEnterpriseTown(item.getEnterpriseTown()); exportVO.setMarketSupervision(item.getMarketSupervision()); exportVO.setDeviceName(item.getDeviceName()); exportVO.setAlarmType(item.getAlarmType()); exportVO.setAlarmLevel(item.getAlarmLevel()); // item.getAlarmTime() 返回的是一个 Date 对象 Date alarmTime = item.getAlarmTime(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formattedTime = sdf.format(alarmTime); exportVO.setAlarmTime(formattedTime);
2、在实体类中进行修改,重写 get 方法
public String getAlarmTime() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.format(alarmTime); }
自定义字典映射转换器
反例:
直接用 @Resource
注入 bean 使用,会报错,不被 Spring IoC 管理。
List<DictDTO> dictList = documentMapper.getDict(CERTIFICATE);
报错:
注入的 bean
,查数据库的时候,报 空指针异常
,导致转换数据的时候第一条字典映射转换就失败
原因猜测:
- 在 EasyExcel 中,转换器通常是默认通过构造函数
new
出来的,而不是由 Spring 容器管理的Bean
。 - 因此,在转换器中,如果你需要使用 Spring 容器中的其他
Bean
,你需要手动获取这些 Bean,而不是通过 Spring 的依赖注入。
💡 Spring 管理的 bean 通常是由 Spring 容器负责创建、配置和管理的。当你使用
new
运算符直接实例化一个对象时,这个对象不会由 Spring 容器来管理,因此 Spring 不会介入该对象的生命周期和依赖注入。
解决方法
通过 Spring 容器提供的方法来获取已经由 Spring 管理的 bean。
DocumentMapper bean = SpringUtil.getBean(DocumentMapper.class); List<DictDTO> dictList = bean.getDict(CERTIFICATE);
参考:
EasyExcel 使用Converter 转换注入时报nullPoint异常_converter null入参_地平线上的新曙光的博客-CSDN博客
完整代码:
public class DocumentDictConverter implements Converter<String> { @Override public Class<?> supportJavaTypeKey() { return String.class; } @Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; } @Override public String convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { return cellData.getStringValue(); } /** * 这里是写的时候会调用 * * @return */ @Override public WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) { // 获取字典列表 DocumentMapper bean = SpringUtil.getBean(DocumentMapper.class); List<DictDTO> dictList = bean.getDict(CERTIFICATE); HashMap<String, String> dictMap = new HashMap<>(16); for (DictDTO dictDTO : dictList) { dictMap.put(dictDTO.getDictValue(), dictDTO.getDictName()); } // 根据字典映射进行值转换 String convertedValue = dictMap.get(context.getValue()); if (convertedValue != null) { return new WriteCellData<>(convertedValue); } else { return new WriteCellData<>(context.getValue()); } } }
性能问题
每次做字段值映射转换的时候都需要查数据库,太影响性能了。
- 考虑引入缓存,达到只需查询一次数据即可。(使用 Spring 框架的缓存注解
@Cacheable
) - 将字典数据在服务启动时加载到内存中,并在转换器中直接使用内存中的字典数据而不是每次都查询数据库。
- 声明静态变量解决。(推荐)
解决方案
一、使用 Spring 框架的缓存注解
参考笔者这篇文章:Cacheable注解小记 | DreamRain
使用说明:
- 该方法使用
@Cacheable("dictionaryCache")
注解来定义缓存区域为 “dictionaryCache”,并且可以根据dictionaryType
参数来查询字典数据。 - 当方法第一次被调用时,数据将被查询并放入缓存中,以后的调用将直接从缓存中获取数据。
- 缓存找不到时会报错,转换失败。
二、将字典数据在服务启动时加载到内存中
- 创建一个单例的字典数据加载类,该类在应用启动时加载字典数据到内存中。你可以使用
@PostConstruct
注解来标记一个初始化方法,该方法在 Spring 容器加载完所有 bean 后执行。
@Service public class DictionaryDataService { private Map<String, String> certificateDict = new HashMap<>(); @Autowired private DocumentExpiredMapper documentExpiredMapper; // 这个方法会在 bean 初始化时被自动调用 @PostConstruct public void init() { // 在 bean 初始化时执行一些初始化操作 List<DictDTO> dictList = documentExpiredMapper.getDict(CERTIFICATE); for (DictDTO dictDTO : dictList) { certificateDict.put(dictDTO.getDictValue(), dictDTO.getDictName()); } } public String getCertificateDictValue(String key) { return certificateDict.get(key); } }
2、修改转换器类,使用内存中的字典数据进行转换:
@Override public WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) { DictionaryDataService bean = SpringUtil.getBean(DictionaryDataService.class); String convertedValue = bean.getCertificateDictValue(context.getValue()); if (convertedValue != null) { return new WriteCellData<>(convertedValue); } else { return new WriteCellData<>(context.getValue()); } }
通过这种方式,字典数据在应用启动时加载到内存中,以后的值转换操作都会使用内存中的数据,避免了重复查询数据库的性能开销。这是一种常见的性能优化方法。
三、声明静态变量解决(推荐)
- 在每次调用导出接口时都查一次数据库,并只需查询一次。
- 从而减轻了数据值映射转换时查询数据库的压力,并且确保数据为最新数据,还无需考虑删除缓存问题。
@Service public class DictionaryService { // 声明静态变量存储字典数据 public static HashMap<String, HashMap<String, String>> hashMap = new HashMap<>(); @Resource private DocumentExpiredMapper documentExpiredMapper; /** * 将字典数据存入静态变量 * @param dictionaryType * @return */ public HashMap<String, String> getDict(String dictionaryType) { if (hashMap.containsKey(dictionaryType)){ return hashMap.get(dictionaryType); } List<DictDTO> dictList = documentExpiredMapper.getDict(dictionaryType); HashMap<String, String> dictMap = new HashMap<>(dictList.size()); for (DictDTO dictDTO : dictList) { dictMap.put(dictDTO.getDictValue(), dictDTO.getDictName()); } hashMap.put(dictionaryType, dictMap); return dictMap; } }
前两者优劣分析
第一种方法(DictionaryService
使用缓存):
优点:
- 使用了
@Cacheable
注解,Spring 会自动处理缓存相关逻辑,包括缓存的清除、存储、失效等,减轻了你的工作负担。 - 缓存数据在运行时动态从数据库中获取,因此数据保持最新,不需要手动更新。
- 可以灵活地在其他地方使用
DictionaryService
服务,而不需要关心缓存细节。
缺点:
- 需要依赖 Spring 缓存机制,可能需要较多配置和依赖,不如手动控制灵活。
- 当有多个不同字典类型需要缓存时,可能需要创建多个不同的缓存,增加了管理复杂度。
第二种方法(DictionaryDataService
使用内存缓存):
优点:
- 简单明了,不依赖 Spring 缓存机制,适用于小规模应用或特定场景。
- 在应用启动时加载字典数据到内存中,查询字典数据的速度非常快,适用于频繁查询字典数据的场景。
缺点:
- 手动加载字典数据到内存,如果数据库中的数据发生变化,需要手动同步内存数据,容易出现数据不一致的问题。
- 不支持自动过期和失效处理,需要自己编写逻辑来处理缓存的更新和失效。
- 在大规模应用中,如果内存占用较多,可能会影响应用性能。
场景分析:
- 如果你的应用要求字典数据保持实时性,能够自动过期和更新,使用第一种方法更为合适。
- 如果应用规模较小、字典数据变化不频繁,或者希望简化配置,第二种方法也是一个不错的选择。
- 理论上来说,第一种用的更为广泛
@PostConstruct 注解
@PostConstruct
是 Java EE(Enterprise Edition)的注解之一,它标识在类实例化后,但在类投入使用之前要执行的方法。通常在使用 Spring 框架或其他依赖注入框架时,@PostConstruct
注解用于在 bean 的初始化过程中执行一些额外的初始化操作。以下是关于 @PostConstruct
注解的一些重要信息:
- 生命周期回调方法:
@PostConstruct
用于定义在 bean 的生命周期中何时应该执行的初始化方法。它提供了一个方便的方式来执行一些准备工作,如数据加载、资源初始化等。 - 执行时机:
@PostConstruct
注解的方法会在 Spring 容器创建 bean 实例后,依赖注入之前执行。这意味着它是在 bean 的构造函数之后,依赖注入之前执行的,用于初始化 bean 的各种属性。 - 方法签名:被
@PostConstruct
注解的方法没有参数。方法名可以随意命名,但通常为init
、initialize
、postConstruct
等。 - 依赖注入和容器管理:
@PostConstruct
注解通常与依赖注入和容器管理框架(如 Spring、Java EE 容器等)一起使用。容器会在执行构造函数和依赖注入后,自动调用被@PostConstruct
注解的初始化方法。 - 异常处理:如果
@PostConstruct
注解的方法抛出异常,容器会将异常捕获并处理,通常会导致 bean 创建失败。这可以用于在初始化阶段检测配置错误或其他问题。 - 多次调用:
@PostConstruct
注解的方法只会被调用一次,即使 bean 在容器中被多次注入也是如此。 - 典型用途:
@PostConstruct
常用于执行一些需要在 bean 初始化时进行的操作,例如数据库连接的建立、资源初始化、数据加载等。
内存与缓存
以下是 “加载到内存” 和 “放入缓存” 的相关概念。
-
加载到内存:
- 加载到内存通常指将数据、资源或对象从持久存储(如硬盘或数据库)加载到【计算机的内存】中,以便在应用程序中使用。
- 这是一个通用操作,常见于应用程序的启动过程或在需要访问数据时。
-
放入缓存:
- 放入缓存是一种特定的加载到内存操作,它指的是将数据或计算结果存储在一个【临时存储区域】中,通常在内存中,以提高后续访问的性能。
- 缓存通常包括缓存键(用于检索数据)和缓存值(实际数据或计算结果)。
-
内存加载的情况:
-
内存加载可能是一次性的,例如在应用程序启动时加载配置文件。(一次性初始化)
-
内存加载也可能是动态的,例如从数据库中加载实时数据或通过用户请求加载。
- 如果数据的变化频率较低,可以使用定时刷新。
- 如果数据变化频繁,懒加载或异步加载可能更合适。
- 缓存加载器适用于需要复杂逻辑来获取数据的情况。
-
-
缓存的情况:
- 缓存是一种性能优化技术,通过将频繁访问的数据存储在内存中,以减少重复访问持久存储的开销。
- 缓存通常采用一定策略,例如缓存过期时间或根据内存大小来管理缓存。
综上所述:
- 加载到内存是一种广泛的操作,它可以用于不同的用途,
- 而缓存是一种内存加载的具体应用,它的主要目的是提高数据访问的性能。缓存通常包括一些管理策略,以确保缓存数据的有效性和一致性。
使用案例
参考官方文档案例:web中的写并且失败的时候返回json
@Override public void exportDocument(DocumentDTO documentDTO, HttpServletResponse response) throws IOException { // 1、获取证件临期告警列表 List<DocumentVO> documentList = getDocumentList(documentDTO); // 2、 Excel文件标题 String title = "Excel文件标题"; // 3、告警类型值替换 List<DocumentExportVO> exportVOList = BeanUtil.copy(documentList, DocumentExportVO.class); // 4、EasyExcel导出 try { // 设置内容类型 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); // 设置字符编码 response.setCharacterEncoding("utf-8"); // 这里URLEncoder.encode可以防止中文乱码 当然和easy excel没有关系 String fileName = URLEncoder.encode(title, "UTF-8").replaceAll("\\+", "%20"); // 设置响应头 response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); // 这里需要设置不关闭流 EasyExcel.write(response.getOutputStream(), DocumentExportVO.class) .autoCloseStream(Boolean.FALSE) .sheet("模板") .doWrite(exportVOList); } catch (Exception e) { // 重置response response.reset(); String errorMessage = "导出文件失败:" + e.getMessage(); returnResult(response, errorMessage); // e.printStackTrace(); } }
代码规范问题:
// 将错误信息全部打印在控制台 e.printStackTrace();
- 全部错误信息打印出来,有助于排查
转换器类
的问题(不会打印到日志文件中,但会一直刷控制台) - 但是一般生产情况不能打印出来,因为可能会引发事故
导出图片并压缩
代码仓库:excel-demo
要压缩图片,您可以使用 Java 中的图像处理库,例如 ImageIO 或 Thumbnails 库。
下面是使用 Thumbnails 库压缩图片的示例讲述。
1、添加依赖
首先,确保您已将 Thumbnails 库添加到您的项目依赖项中。在 Maven 项目中,您可以在 pom.xml
文件中添加以下依赖项:
<dependency> <groupId>net.coobird</groupId> <artifactId>thumbnailator</artifactId> <version>0.4.14</version> </dependency>
2、使用 Thumbnails 库内部方法
-
使用
File.createTempFile()
方法创建一个临时文件,然后通过toFile()
方法将压缩后的图片保存到临时文件中。最后,将临时文件的路径设置到ExcelExportVO
对象的file
属性中。临时文件的生命周期由操作系统管理,通常在程序退出后会自动删除。(可以设置手动删除)
-
使用
Thumbnails.of()
方法加载原始图片,然后通过scale()
方法设置压缩比例。
try { // 1.1 压缩图片并保存到临时文件 File compressedFile = File.createTempFile("compressed_image", ".jpg"); // 1.2 压缩图片 Thumbnails.of(new URL(item.getFile())) .scale(0.5) // 设置压缩比例 .toFile(compressedFile); // 1.3 将临时文件路径设置到ExcelExportVO对象中 exportVO.setFile(compressedFile.toURI().toURL()); } catch (MalformedURLException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException("压缩图片失败!!!", e); }
压缩比例讲解
选择压缩比例的大小取决于您的需求和偏好,以及图像的具体情况。
- 较高的压缩比例会导致图像更大程度地被压缩,文件大小更小,但可能会损失一些图像细节和质量。
- 较低的压缩比例可以保留更多的图像细节和质量,但文件大小会相对较大。
总结
- 一般来说,如果您更关注图像的质量和细节保留,可以选择较低的压缩比例,如 0.8。这样可以在一定程度上减小文件大小,同时保持图像的视觉质量。
- 如果您更关注文件大小的减小,可以选择较高的压缩比例,如 0.5,以获得更小的文件大小,但可能会牺牲一些图像细节和质量。
- 压缩比例值越小,文件大小就越小。
3、完整代码示例
手动删除资源的写法
这种压缩的效果会更好,但是需要手动删除资源。
@Test void export() { // 1、获取导出列表 List<TblExcel> list = excelService.list(); List<ExcelExportVO> list2 = list.stream().map(item -> { ExcelExportVO exportVO = new ExcelExportVO(); exportVO.setName(item.getName()); try { // 1.1 压缩图片并保存到临时文件 File compressedFile = File.createTempFile("compressed_image", ".jpg"); // 1.2 压缩图片 Thumbnails.of(new URL(item.getFile())) .scale(0.5) // 设置压缩比例 .toFile(compressedFile); // 1.3 将临时文件路径设置到ExcelExportVO对象中 exportVO.setFile(compressedFile.toURI().toURL()); } catch (MalformedURLException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException("压缩图片失败!!!", e); } return exportVO; }).collect(Collectors.toList()); // 2、导出 EasyExcel.write("D:\\TblExcel.xls") .sheet("模板") .head(ExcelExportVO.class) .doWrite(list2); // 3、使用完毕后手动删除临时文件 for (ExcelExportVO exportVO : list2) { try { File compressedFile = new File(exportVO.getFile().toURI()); if (!compressedFile.delete()) { // 删除操作失败,记录日志或进行其他错误处理 log.error("删除临时文件失败: " + compressedFile.getAbsolutePath()); } } catch (Exception e) { // 处理删除临时文件的异常 e.printStackTrace(); } } }
自动删除资源的写法
- 在使用
try-with-resources
来压缩图像并保存到临时文件后,该临时文件会在try-with-resources
块结束时自动关闭。因此,我们无需另外手动操作删除临时文件。 - 但是这种方法压缩的效果没有上面那种方法好。
代码如下:
/** * Thumbnails 压缩图片导出 -- 使用 try-with-resources 自动关闭资源版 * 压缩效果较差 */ @Test void export11() { // 1、获取导出列表 List<TblExcel> list = excelService.list(); List<ExcelExportVO> list2 = list.stream().map(item -> { ExcelExportVO exportVO = new ExcelExportVO(); exportVO.setName(item.getName()); try { // 压缩图像并保存到临时文件 File compressedFile; try (OutputStream outputStream = new FileOutputStream(compressedFile = File.createTempFile("compressed_image", ".jpg"))) { Thumbnails.of(new URL(item.getFile())) .scale(0.5) // 设置压缩比例 .toOutputStream(outputStream); } // 将临时文件路径设置到ExcelExportVO对象中 exportVO.setFile(compressedFile.toURI().toURL()); } catch (IOException e) { throw new RuntimeException("压缩图片失败!!!", e); } return exportVO; }).collect(Collectors.toList()); // 2、导出 EasyExcel.write("D:\\TblExcel.xls") .sheet("模板") .head(ExcelExportVO.class) .doWrite(list2); }
原因分析
-
在第一个方法中,我使用了
Thumbnails.of(new URL(item.getFile())).scale(0.5).toFile(compressedFile);
这一行代码来压缩图片。这个方法将压缩后的图片直接保存到了文件系统中,然后在 ExcelExportVO 对象中设置的是临时文件的路径。 -
而在第二个方法中,我使用了
Thumbnails.of(new URL(item.getFile())).scale(0.5).toOutputStream(outputStream);
这一行代码来压缩图片。这个方法将压缩后的图片数据写入到了一个输出流(OutputStream)中,而没有将其直接保存到文件系统。这意味着,虽然压缩后的图片数据被存储在了内存中的字节数组中,但并没有实际地创建一个新的临时文件。因此,在第二个方法中,ExcelExportVO 对象中的文件路径实际上指向的是一个尚未存在的临时文件。
当 EasyExcel 将这些对象写入到 Excel 文件时,它会尝试打开每个 ExcelExportVO 对象中的文件路径。在第一个方法中,因为临时文件已经存在,所以 EasyExcel 可以成功地打开并读取这些文件。但在第二个方法中,由于临时文件并未实际创建,所以 EasyExcel 无法打开这些文件。
因此,尽管两个方法都实现了压缩图片的功能,但由于第二个方法没有将压缩后的图片数据实际保存到文件系统中,所以在导出的 Excel 文件中可能不会包含这些图片数据,从而导致导出的文件更小。
总结
- 第一种,需要手写代码删除临时文件,但是压缩效果好
- 第二种,不需要手写代码删除临时文件,但是压缩效果较差。
图片压缩效果说明
测试数据:100 条记录,每条记录包含一张【图片URL】。
- 压缩前:Excel 文件大小为 67.8M
- 压缩后:
- (手动删除资源版)Excel 文件大小为 2.68M
- (自动关闭资源版)Excel 文件大小为 23.7M
- 选择压缩比例为 0.8 时,文件大小为 6.01M
指定字段导出
代码如下:
// 根据用户传入字段 假设我们只要导出 file Set<String> includeColumnFiledNames = new HashSet<String>(); includeColumnFiledNames.add("file"); // 2、导出 EasyExcel.write("D:\\TblExcel.xls") .sheet("模板") .head(ExcelExportVO.class) .includeColumnFieldNames(includeColumnFiledNames) // .includeColumnIndexes(Collections.singleton(1)) .doWrite(list2);
具体说明:
-
includeColumnFieldNames 是根据字段名指定导出列(建议导出视图 VO 不要指定 index 属性,否则会有导出会有空列)
-
includeColumnIndexes 是根据索引指定导出列,即使实体类中没有指定
index
属性一样可以使用-
List<Integer> columnList = Arrays.asList(0, 1, 2, 3, 4);
-
自动列宽
使用官方自带的处理器:
// 自动列宽 .registerWriteHandler((new LongestMatchColumnWidthStyleStrategy()))
具体说明:
- 可以自己根据官方的父类继承,重写处理器来使用;
- 也可结合【自动列宽处理器 + 实体类注解】来一起使用。
自定义自适应列宽处理策略:
- 使用动态表头时,官方的自动列宽策略不够好用,所以重写了一下方法
- 以下内容是指定第四列的列宽,其余列自定义
public class CustomColumnWidthStyleStrategy extends AbstractColumnWidthStyleStrategy { public CustomColumnWidthStyleStrategy() { } @Override protected void setColumnWidth(WriteSheetHolder writeSheetHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { Sheet sheet = writeSheetHolder.getSheet(); sheet.setColumnWidth(3, 8000); } }
合并单元格导出
1、自定义Excel合并表格策略
- 需要实现 CellWriteHandler,Cell 是列,ROW 是行
- 这里针对的是列合并处理
public class CustomMergeStrategy implements CellWriteHandler { /** * 合并列索引。 */ private final List<Integer> mergeColumnIndexes; /** * 构造函数。 * * @param mergeColumnIndexes 合并列索引集合。 */ public CustomMergeStrategy(List<Integer> mergeColumnIndexes) { this.mergeColumnIndexes = mergeColumnIndexes; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 校验:如果当前是表头,则不处理。 if (isHead) { return; } // 校验:如果当前是第一行,则不处理。 if (relativeRowIndex == 0) { return; } // 校验:如果当前列索引不在合并列索引列表中,则不处理。 Integer columnIndex = cellDataList.get(0).getColumnIndex(); if (!this.mergeColumnIndexes.contains(columnIndex)) { return; } // 获取:当前表格、当前行下标、上一行下标、上一行对象、上一列对象。 Sheet sheet = cell.getSheet(); int rowIndexCurrent = cell.getRowIndex(); int rowIndexPrev = rowIndexCurrent - 1; Row rowPrev = sheet.getRow(rowIndexPrev); Cell cellPrev = rowPrev.getCell(cell.getColumnIndex()); // 获取:当前单元格值、上一单元格值。 Object cellValueCurrent = cell.getCellTypeEnum() == CellType.STRING ? cell.getStringCellValue() : cell.getNumericCellValue(); Object cellValuePrev = cellPrev.getCellTypeEnum() == CellType.STRING ? cellPrev.getStringCellValue() : cellPrev.getNumericCellValue(); // 校验:如果当前单元格值与上一单元格值不相等,则不处理。 if (!cellValueCurrent.equals(cellValuePrev)) { return; } List<CellRangeAddress> mergedRegions = sheet.getMergedRegions(); boolean merged = false; for (int i = 0; i < mergedRegions.size(); i++) { CellRangeAddress cellRangeAddress = mergedRegions.get(i); if (cellRangeAddress.isInRange(rowIndexPrev, cell.getColumnIndex())) { // 移除合并单元格。 sheet.removeMergedRegion(i); // 设置合并单元格的结束行。 cellRangeAddress.setLastRow(rowIndexCurrent); // 重新添加合并单元格。 sheet.addMergedRegion(cellRangeAddress); merged = true; break; } } if (!merged) { CellRangeAddress cellRangeAddress = new CellRangeAddress(rowIndexPrev, rowIndexCurrent, cell.getColumnIndex(), cell.getColumnIndex()); sheet.addMergedRegion(cellRangeAddress); } } }
2、实际使用
// ...... // 导出 EasyExcel.write(response.getOutputStream()) .head(headList) // 自动列宽 .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // .registerWriteHandler(new CustomMergeStrategy(Arrays.asList(1, 2))) .registerWriteHandler(new CustomMergeStrategy(Collections.singletonList(1))) .autoCloseStream(Boolean.FALSE) .sheet("模板") .doWrite(resultList); // ...... // 获取表格头列表 private List<List<String>> getHeadList(SettleDTO settleDTO) { List<List<String>> headList = new ArrayList<>(); Date startDate = DateUtil.parse(settleDTO.getStartDay(), "yyyy-MM-dd"); Date endDate = DateUtil.parse(settleDTO.getEndDay(), "yyyy-MM-dd"); SimpleDateFormat sdf = new SimpleDateFormat("MM月dd日"); String startDay = sdf.format(startDate); String endDay = sdf.format(endDate); String day = startDay + "-" + endDay; ArrayList<String> headColumn1 = new ArrayList<>(); headColumn1.add("第一列"); headList.add(headColumn1); ArrayList<String> headColumn2 = new ArrayList<>(); headColumn2.add("第二列"); headList.add(headColumn2); ArrayList<String> headColumn3 = new ArrayList<>(); headColumn3.add(day); headColumn3.add("第三列"); headList.add(headColumn3); return headList; }
代码解析
拓展性说明:
- 做了一个拓展性处理,可根据列索引来指定需要执行合并的列。
以下是 afterCellDispose
方法的各参数的含义:
-
WriteSheetHolder writeSheetHolder
:当前正在写入的 Sheet 的持有者。它包含有关当前 Sheet 的信息,例如 Sheet 的索引、当前行的索引以及其他相关详细信息。 -
WriteTableHolder writeTableHolder
:当前正在写入的表格的持有者。它包含有关当前表格的信息,例如表格的名称、起始行的索引以及其他相关详细信息。 -
List<WriteCellData<?>> cellDataList
:这是一个WriteCellData
对象的列表,表示要写入当前单元格的数据。如果单元格跨越多个列或行,该列表可能包含多个WriteCellData
对象。 -
Cell cell
:表示当前正在处理的单元格。它提供有关单元格位置、样式和其他属性的信息。 -
Head head
:表示当前单元格的头部(标题)。它包含有关头部的信息,如字段名、类类型以及其他相关详细信息。-
如果传入的不是一个
class
文件(实体类)时,head 可能会为 null,例如上面示例(.head(headList)
),传入的是个列表集合,此时 head 为 null
-
-
Integer relativeRowIndex
:表示当前行在当前 Sheet 中的相对索引。一开始就是表头之下的第一行。 -
Boolean isHead
:这是一个布尔标志,指示当前单元格是否为表头单元格。如果isHead
为true
,则表示当前单元格是表头单元格;否则,它是数据单元格。
总的来说,这些参数提供了有关当前 Excel 写入过程状态的上下文信息。它们允许您基于正在处理的 Sheet、表格、单元格和头部,以及当前行在 Sheet 中的位置执行自定义逻辑。
打包成压缩包导出
压缩包导出有以下两种情况:
- 指定本地路径导出
- 写入响应流导出
指定路径导出
一、可以使用 hutool 的 ZipUtil 工具类
/** * 压缩包导出 -- hutool */ @Test void export6() throws IOException { List<TblExcel> list = new ArrayList<>(); list.add(new TblExcel("张三", "abc")); List<ByteArrayInputStream> ins = new ArrayList<>(); // 导出第一个Excel ByteArrayOutputStream out1 = new ByteArrayOutputStream(); EasyExcel.write(out1, TblExcel.class).sheet("第一个").doWrite(list); ins.add(new ByteArrayInputStream(out1.toByteArray())); // 导出第二个Excel ByteArrayOutputStream out2 = new ByteArrayOutputStream(); EasyExcel.write(out2, TblExcel.class).sheet("第二个").doWrite(list); ins.add(new ByteArrayInputStream(out2.toByteArray())); // 将多个 InputStream 压缩到一个 zip 文件 File zipFile = new File("C:\\Users\\乔\\Desktop\\noModelWrite.zip"); String[] fileNames = {"1.xlsx", "2.xlsx"}; InputStream[] inputStreams = ins.toArray(new InputStream[0]); cn.hutool.core.util.ZipUtil.zip(zipFile, fileNames, inputStreams); }
二、手写一个 ZipUtil 工具类
public class ZipUtil { /** * 默认编码,使用平台相关编码 */ private static final Charset DEFAULT_CHARSET = Charset.defaultCharset(); /** * 将文件流压缩到目标流中 * * @param out 目标流,压缩完成自动关闭 * @param fileNames 流数据在压缩文件中的路径或文件名 * @param ins 要压缩的源,添加完成后自动关闭流 */ public static void zip(OutputStream out, List<String> fileNames, List<InputStream> ins) throws IOException { zip(out, fileNames.toArray(new String[0]), ins.toArray(new InputStream[0])); } /** * 将文件流压缩到目标流中 * * @param out 目标流,压缩完成自动关闭 * @param fileNames 流数据在压缩文件中的路径或文件名 * @param ins 要压缩的源,添加完成后自动关闭流 */ public static void zip(File out, List<String> fileNames, List<InputStream> ins) throws IOException { FileOutputStream outputStream = new FileOutputStream(out); zip(outputStream, fileNames.toArray(new String[0]), ins.toArray(new InputStream[0])); outputStream.flush(); } /** * 将文件流压缩到目标流中 * * @param out 目标流,压缩完成自动关闭 * @param fileNames 流数据在压缩文件中的路径或文件名 * @param ins 要压缩的源,添加完成后自动关闭流 */ public static void zip(OutputStream out, String[] fileNames, InputStream[] ins) throws IOException { ZipOutputStream zipOutputStream = null; try { zipOutputStream = getZipOutputStream(out, DEFAULT_CHARSET); zip(zipOutputStream, fileNames, ins); } catch (IOException e) { throw new IOException("压缩包导出失败!", e); } finally { IOUtils.closeQuietly(zipOutputStream); } } /** * 将文件流压缩到目标流中 * * @param zipOutputStream 目标流,压缩完成不关闭 * @param fileNames 流数据在压缩文件中的路径或文件名 * @param ins 要压缩的源,添加完成后自动关闭流 * @throws IOException IO异常 */ public static void zip(ZipOutputStream zipOutputStream, String[] fileNames, InputStream[] ins) throws IOException { if (ArrayUtils.isEmpty(fileNames) || ArrayUtils.isEmpty(ins)) { throw new IllegalArgumentException("文件名不能为空!"); } if (fileNames.length != ins.length) { throw new IllegalArgumentException("文件名长度与输入流长度不一致!"); } for (int i = 0; i < fileNames.length; i++) { add(ins[i], fileNames[i], zipOutputStream); } } /** * 添加文件流到压缩包,添加后关闭流 * * @param in 需要压缩的输入流,使用完后自动关闭 * @param fileName 压缩的路径 * @param out 压缩文件存储对象 * @throws IOException IO异常 */ private static void add(InputStream in, String fileName, ZipOutputStream out) throws IOException { if (null == in) { return; } try { out.putNextEntry(new ZipEntry(fileName)); IOUtils.copy(in, out); } catch (IOException e) { throw new IOException(e); } finally { IOUtils.closeQuietly(in); closeEntry(out); } } /** * 获得 {@link ZipOutputStream} * * @param out 压缩文件流 * @param charset 编码 * @return {@link ZipOutputStream} */ private static ZipOutputStream getZipOutputStream(OutputStream out, Charset charset) { if (out instanceof ZipOutputStream) { return (ZipOutputStream) out; } return new ZipOutputStream(out, DEFAULT_CHARSET); } /** * 关闭当前Entry,继续下一个Entry * * @param out ZipOutputStream */ private static void closeEntry(ZipOutputStream out) { try { out.closeEntry(); } catch (IOException e) { // ignore } } }
测试代码如下:
@Test void export5() throws IOException { List<TblExcel> list = new ArrayList<>(); list.add(new TblExcel("张三", "abc")); List<InputStream> ins = new ArrayList<>(); OutputStream out1 = new ByteArrayOutputStream(); OutputStream out2 = new ByteArrayOutputStream(); // 2、导出 EasyExcel.write(out1) .sheet("第一个") .head(ExcelExportVO.class) .doWrite(list2); ins.add(outputStream2InputStream(out1)); // 写法可参考上一个 hutool 的示例 EasyExcel.write(out2) .sheet("第二个") .head(ExcelExportVO.class) .doWrite(list2); ins.add(outputStream2InputStream(out2)); File zipFile = new File("C:\\Users\\乔\\Desktop\\noModelWrite.zip"); // 压缩包内流的文件名 List<String> paths = Arrays.asList("1.xlsx", "2.xlsx"); ZipUtil.zip(zipFile, paths, ins); // 工具类使用 } /** * 输出流转输入流;数据量过大请使用其他方法 * * @param out * @return */ private ByteArrayInputStream outputStream2InputStream(OutputStream out) { Objects.requireNonNull(out); ByteArrayOutputStream bos; bos = (ByteArrayOutputStream) out; return new ByteArrayInputStream(bos.toByteArray()); }
写入响应流导出
@Override public void exportSettleZip(TestDTO dto, HttpServletResponse response) throws IOException { // 1、参数校验 if (StringUtils.isEmpty(dto.getStartDay()) || StringUtils.isEmpty(dto.getEndDay())) { throw new IllegalArgumentException("日期不能为空!!"); } // 2、获取所有入驻企业的行业类型 List<String> filedTypes = FiledTypeEnum.getValues(); // 3、所有Excel导出并压缩 ByteArrayOutputStream zipStream = new ByteArrayOutputStream(); try (ZipOutputStream zipOut = new ZipOutputStream(zipStream)) { // zipOut for (String filedType : filedTypes) { // 获取当前行业类型的入驻企业数据 List<TestVO> entSettleList = enterpriseMapper.getEntSettleList(dto, filedType); // 创建一个字节流,用于存储当前行业类型的 Excel 数据 ByteArrayOutputStream excelStream = new ByteArrayOutputStream(); // 使用 EasyExcel 导出 Excel 数据 EasyExcel.write(excelStream) .head(getHeadList(dto)) // 获取表头 .registerWriteHandler(new CustomColumnWidthStyleStrategy()) // 自适应列宽策略 .registerWriteHandler(new CustomMergeStrategy(Collections.singletonList(1))) // 单元格合并策略 .sheet(filedType + "模板") // 设置 Excel 表格名 .doWrite(entSettleList); // 写入 Excel 数据 // 将 Excel 写入 ZipOutputStream -- 这三行代码用于将一个 Excel 文件的数据写入到 ZIP 文件中 ZipEntry zipEntry = new ZipEntry(filedType + "信息导出.xlsx"); // 表示 ZIP 文件中的一个文件名称 zipOut.putNextEntry(zipEntry); // 将刚刚创建的 ZipEntry 对象添加到 ZipOutputStream 中,表示开始写入 ZIP 文件的一个新文件。 zipOut.write(excelStream.toByteArray()); // 将之前在内存中生成的 Excel 文件数据写入到 ZIP 文件中的当前条目 // 关闭当前 ZipEntry zipOut.closeEntry(); // 关闭当前 Excel 字节流 excelStream.close(); } } // 4、将压缩包写入响应流 String start = dto.getStartDay().replaceAll("-", ""); String end = dto.getEndDay().replaceAll("-", ""); String zipName = "模板数据" + start + "-" + end + ".zip"; zipName = URLEncoder.encode(zipName, "UTF-8"); try { response.setContentType("application/zip"); response.setHeader("Content-Disposition", "attachment;filename=" + zipName); response.getOutputStream().write(zipStream.toByteArray()); // 重点关注 } finally { // 关闭 ByteArrayOutputStream zipStream.close(); } }
代码用途
- 这段代码实例,实现了根据不同的行业类型导出对应的 Excel 文件,并将这些 Excel 文件压缩成一个 ZIP 文件。
- 主要用于在 Web 环境下导出 Excel 数据并进行压缩,方便用户一次性下载多个行业类型的数据。
以下是对代码的详细解释
- **参数校验:**检查传入的日期参数是否为空,若为空则抛出异常。
- **获取所有的行业类型:**通过
FiledTypeEnum.getValues()
获取所有的行业类型。(此方法在枚举类里面定义) - **所有 Excel 导出并压缩:**使用
ZipOutputStream
创建一个 ZIP 文件,然后遍历所有行业类型,为每个行业类型生成对应的 Excel 文件,并将其写入 ZIP 文件中。- 在每个行业类型的循环中,获取当前行业类型的入驻企业数据。
- 创建一个
ByteArrayOutputStream
用于存储当前行业类型的 Excel 数据。 - 使用 EasyExcel 导出 Excel 数据,包括设置表头、列宽策略和单元格合并策略。
- 将当前行业类型的 Excel 数据写入 ZIP 文件,并关闭当前 ZipEntry。
- 关闭当前 Excel 字节流。
- **将压缩包写入响应流:**将生成的 ZIP 文件写入响应流,实现下载功能。设置响应头的文件名,并使用
URLEncoder.encode
处理中文文件名。最后,关闭ByteArrayOutputStream
。
以下是一些关于流的概念说明
流(Stream)是用于在程序之间传输数据的抽象。流可以是输入流(Input Stream),用于从某个源读取数据,也可以是输出流(Output Stream),用于将数据写入某个目标。
现在来解释一下这段代码中各个流的用法:
- ByteArrayOutputStream: 这是一个字节数组输出流,它会在内存中创建一个字节数组缓冲区,所有写入到这个流的数据都会被保存在这个缓冲区中。在这段代码中,用于将每个行业类型的 Excel 数据保存在内存中。
- 为什么需要它: 因为我们需要在内存中生成 Excel 数据,而不是将其写入到硬盘。
EasyExcel.write
方法的参数是一个输出流,而ByteArrayOutputStream
就是一个方便在内存中存储字节数据的流。 - 为什么需要关闭: 关闭流是为了释放占用的系统资源。在这里,通过
ByteArrayOutputStream
的close
方法,确保所有关联的资源被释放,尤其是关闭底层的字节数组。
- 为什么需要它: 因为我们需要在内存中生成 Excel 数据,而不是将其写入到硬盘。
- ZipOutputStream: 这是一个用于写入 ZIP 文件的输出流。ZIP 文件是一种存档文件,可以包含多个文件或目录,通过压缩来减小文件大小。
- 为什么需要它: 在这段代码中,我们希望将每个行业类型的 Excel 数据写入一个 ZIP 文件中,以便用户可以一次性下载多个文件。
- 为什么需要关闭: 关闭
ZipOutputStream
将确保 ZIP 文件的完整性。在这里,通过zipOut.closeEntry()
来关闭当前 ZIP 文件的条目(即一个文件),并准备开始下一个 ZIP 条目。
- ZipEntry: 在 ZIP 文件中,每个文件或目录都对应一个条目,这个条目就是
ZipEntry
。在这段代码中,用于表示 ZIP 文件中的每个 Excel 文件。- 为什么需要它: 我们希望 ZIP 文件中有多个文件,每个文件对应一个行业类型的 Excel 数据。
ZipEntry
就是用于表示 ZIP 文件中的每个文件。 - 为什么需要关闭: 在
ZipOutputStream
中,每次调用putNextEntry
方法都会创建一个新的ZipEntry
,表示一个新的文件。通过zipOut.closeEntry()
来关闭当前 ZIP 条目,以确保下一次写入时不会影响到前一个 ZIP 条目。
- 为什么需要它: 我们希望 ZIP 文件中有多个文件,每个文件对应一个行业类型的 Excel 数据。
总体来说,这些流的使用是为了在【内存】中生成多个 Excel 文件,并将这些文件写入一个 ZIP 文件中,最终提供给用户进行下载。关闭流是为了释放资源,确保数据完整性。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南