文件导出
官方文档:写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";
@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 文件中,最终提供给用户进行下载。关闭流是为了释放资源,确保数据完整性。
学习参考
-
使用easyExcel导入导出Date类型的转换问题 (mfbz.cn)
-
代码规范:禁用e.printStackTrace()打印异常_e.printstacktrace()禁用-CSDN博客
-
代码规范之e.printStackTrace()-CSDN博客
-
【温情提醒】工作中要少用e.printStackTrace()的致命原因之一_printtrace问题-CSDN博客
-
视频:Easy Excel 13:导出图片内容
-
使用 easyExcel 生成多个 excel 并打包成zip压缩包-CSDN博客