文件导出
官方文档:写Excel | Easy Excel (alibaba.com)
引言
当使用 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";@Overridepublic Class<Date> supportJavaTypeKey() {return Date.class;}/*** easyExcel导出数据类型转换* @param cellData* @param contentProperty* @param globalConfiguration* @return* @throws Exception*/@Overridepublic 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*/@Overridepublic 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> {@Overridepublic Class<?> supportJavaTypeKey() {return String.class;}@Overridepublic CellDataTypeEnum supportExcelTypeKey() {return CellDataTypeEnum.STRING;}@Overridepublic String convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {return cellData.getStringValue();}/*** 这里是写的时候会调用** @return*/@Overridepublic 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<>();@Autowiredprivate DocumentExpiredMapper documentExpiredMapper;// 这个方法会在 bean 初始化时被自动调用@PostConstructpublic 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、修改转换器类,使用内存中的字典数据进行转换:
@Overridepublic 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<>();@Resourceprivate 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
@Overridepublic 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) {// 重置responseresponse.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、完整代码示例
手动删除资源的写法
这种压缩的效果会更好,但是需要手动删除资源。
@Testvoid 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 自动关闭资源版* 压缩效果较差*/@Testvoid 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
指定字段导出
代码如下:
// 根据用户传入字段 假设我们只要导出 fileSet<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() {}@Overrideprotected 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;}@Overridepublic 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*/@Testvoid export6() throws IOException {List<TblExcel> list = new ArrayList<>();list.add(new TblExcel("张三", "abc"));List<ByteArrayInputStream> ins = new ArrayList<>();// 导出第一个ExcelByteArrayOutputStream out1 = new ByteArrayOutputStream();EasyExcel.write(out1, TblExcel.class).sheet("第一个").doWrite(list);ins.add(new ByteArrayInputStream(out1.toByteArray()));// 导出第二个ExcelByteArrayOutputStream 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}}
}
测试代码如下:
@Testvoid 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());}
写入响应流导出
@Overridepublic 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)) { // zipOutfor (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 文件中的当前条目// 关闭当前 ZipEntryzipOut.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 {// 关闭 ByteArrayOutputStreamzipStream.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 文件中,最终提供给用户进行下载。关闭流是为了释放资源,确保数据完整性。
学习参考
-
使用easyExcel导入导出Date类型的转换问题 (mfbz.cn)
-
代码规范:禁用e.printStackTrace()打印异常_e.printstacktrace()禁用-CSDN博客
-
代码规范之e.printStackTrace()-CSDN博客
-
【温情提醒】工作中要少用e.printStackTrace()的致命原因之一_printtrace问题-CSDN博客
-
视频:Easy Excel 13:导出图片内容
-
使用 easyExcel 生成多个 excel 并打包成zip压缩包-CSDN博客