EasyExcel之文件导出最佳实践

文件导出

官方文档:写Excel | Easy Excel (alibaba.com)

引言

当使用 EasyExcel 进行 Excel 文件导出时,我最近在工作中遇到了一个需求。因此,我决定写这篇文章来分享我的经验和解决方案。如果你对这个话题感兴趣,那么我希望这篇文章对你有所帮助。

本文的目标是介绍 EasyExcel 的基本概念、使用方法以及解决特定问题的技巧。通过使用 EasyExcel,我们可以提高文件导出的效率,简化代码,并实现更灵活的数据导出。

在阅读完本文后,你将能够了解 EasyExcel 的核心功能和常用操作,掌握如何根据实际需求进行配置和定制。此外,我还将分享一些实用的技巧和最佳实践,帮助你更好地利用 EasyExcel 完成文件导出任务。

最后,我要再次感谢你的关注和支持。如果你觉得这篇文章对你有所帮助,请不要吝啬点赞、收藏和关注我的其他文章或资源。这将是对我最大的鼓励和支持!
文章内容会持续更新!!!

为何选择 EasyExcel 而不是 POI?

选择使用 EasyExcel 而不是 POI 的原因主要有以下几点:

  1. EasyExcel 在尽可能节约内存的情况下支持读写大型 Excel 文件。具体来说,它通过一行一返回的方式解决了 POI 解析 Excel 非常耗费内存的问题。
  2. EasyExcel 是开源的,代码放在 GitHub 上,如果遇到问题,可以随时提出 issue。
  3. EasyExcel 社区活跃,网上的相关文档也比较多,这对于使用者来说是一个很大的优势。
  4. 虽然 POI 是目前使用最多的用来做 excel 解析的框架,但其 userModel 模式在处理大文件时存在明显的缺陷,比如内存消耗大和有并发问题等。而 EasyExcel 则很好地解决了这些问题。
  5. 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());
		}
	}

}

性能问题

每次做字段值映射转换的时候都需要查数据库,太影响性能了。

  1. 考虑引入缓存,达到只需查询一次数据即可。(使用 Spring 框架的缓存注解 @Cacheable
  2. 将字典数据在服务启动时加载到内存中,并在转换器中直接使用内存中的字典数据而不是每次都查询数据库。
  3. 声明静态变量解决。(推荐)

解决方案

一、使用 Spring 框架的缓存注解

参考笔者这篇文章:Cacheable注解小记 | DreamRain

使用说明:

  • 该方法使用 @Cacheable("dictionaryCache") 注解来定义缓存区域为 “dictionaryCache”,并且可以根据 dictionaryType 参数来查询字典数据。
  • 当方法第一次被调用时,数据将被查询并放入缓存中,以后的调用将直接从缓存中获取数据。
  • 缓存找不到时会报错,转换失败
二、将字典数据在服务启动时加载到内存中
  1. 创建一个单例的字典数据加载类,该类在应用启动时加载字典数据到内存中。你可以使用 @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 使用缓存):

优点:

  1. 使用了 @Cacheable 注解,Spring 会自动处理缓存相关逻辑,包括缓存的清除、存储、失效等,减轻了你的工作负担。
  2. 缓存数据在运行时动态从数据库中获取,因此数据保持最新,不需要手动更新。
  3. 可以灵活地在其他地方使用 DictionaryService 服务,而不需要关心缓存细节。

缺点:

  1. 需要依赖 Spring 缓存机制,可能需要较多配置和依赖,不如手动控制灵活。
  2. 当有多个不同字典类型需要缓存时,可能需要创建多个不同的缓存,增加了管理复杂度。

第二种方法(DictionaryDataService 使用内存缓存):

优点:

  1. 简单明了,不依赖 Spring 缓存机制,适用于小规模应用或特定场景。
  2. 在应用启动时加载字典数据到内存中,查询字典数据的速度非常快,适用于频繁查询字典数据的场景。

缺点:

  1. 手动加载字典数据到内存,如果数据库中的数据发生变化,需要手动同步内存数据,容易出现数据不一致的问题。
  2. 不支持自动过期和失效处理,需要自己编写逻辑来处理缓存的更新和失效。
  3. 在大规模应用中,如果内存占用较多,可能会影响应用性能。

场景分析:

  • 如果你的应用要求字典数据保持实时性,能够自动过期和更新,使用第一种方法更为合适。
  • 如果应用规模较小、字典数据变化不频繁,或者希望简化配置,第二种方法也是一个不错的选择。
  • 理论上来说,第一种用的更为广泛

@PostConstruct 注解

@PostConstruct 是 Java EE(Enterprise Edition)的注解之一,它标识在类实例化后,但在类投入使用之前要执行的方法。通常在使用 Spring 框架或其他依赖注入框架时,@PostConstruct 注解用于在 bean 的初始化过程中执行一些额外的初始化操作。以下是关于 @PostConstruct 注解的一些重要信息:

  1. 生命周期回调方法@PostConstruct 用于定义在 bean 的生命周期中何时应该执行的初始化方法。它提供了一个方便的方式来执行一些准备工作,如数据加载、资源初始化等。
  2. 执行时机@PostConstruct 注解的方法会在 Spring 容器创建 bean 实例后,依赖注入之前执行。这意味着它是在 bean 的构造函数之后,依赖注入之前执行的,用于初始化 bean 的各种属性。
  3. 方法签名:被 @PostConstruct 注解的方法没有参数。方法名可以随意命名,但通常为 initinitializepostConstruct 等。
  4. 依赖注入和容器管理@PostConstruct 注解通常与依赖注入和容器管理框架(如 Spring、Java EE 容器等)一起使用。容器会在执行构造函数和依赖注入后,自动调用被 @PostConstruct 注解的初始化方法。
  5. 异常处理:如果 @PostConstruct 注解的方法抛出异常,容器会将异常捕获并处理,通常会导致 bean 创建失败。这可以用于在初始化阶段检测配置错误或其他问题。
  6. 多次调用@PostConstruct 注解的方法只会被调用一次,即使 bean 在容器中被多次注入也是如此。
  7. 典型用途@PostConstruct 常用于执行一些需要在 bean 初始化时进行的操作,例如数据库连接的建立、资源初始化、数据加载等。

内存与缓存

以下是 “加载到内存” 和 “放入缓存” 的相关概念。

  1. 加载到内存

    • 加载到内存通常指将数据、资源或对象从持久存储(如硬盘或数据库)加载到【计算机的内存】中,以便在应用程序中使用。
    • 这是一个通用操作,常见于应用程序的启动过程或在需要访问数据时。
  2. 放入缓存

    • 放入缓存是一种特定的加载到内存操作,它指的是将数据或计算结果存储在一个【临时存储区域】中,通常在内存中,以提高后续访问的性能。
    • 缓存通常包括缓存键(用于检索数据)和缓存值(实际数据或计算结果)。
  3. 内存加载的情况

    • 内存加载可能是一次性的,例如在应用程序启动时加载配置文件。(一次性初始化)

    • 内存加载也可能是动态的,例如从数据库中加载实时数据或通过用户请求加载。

      • 如果数据的变化频率较低,可以使用定时刷新
      • 如果数据变化频繁,懒加载异步加载可能更合适。
      • 缓存加载器适用于需要复杂逻辑来获取数据的情况。
  4. 缓存的情况

    • 缓存是一种性能优化技术,通过将频繁访问的数据存储在内存中,以减少重复访问持久存储的开销。
    • 缓存通常采用一定策略,例如缓存过期时间或根据内存大小来管理缓存。

综上所述:

  • 加载到内存是一种广泛的操作,它可以用于不同的用途,
  • 而缓存是一种内存加载的具体应用,它的主要目的是提高数据访问的性能。缓存通常包括一些管理策略,以确保缓存数据的有效性和一致性。

EasyExcel之文件导出最佳实践_第1张图片

使用案例

参考官方文档案例: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.coobirdgroupId>
    <artifactId>thumbnailatorartifactId>
    <version>0.4.14version>
dependency>

2、使用 Thumbnails 库内部方法

  1. 使用 File.createTempFile() 方法创建一个临时文件,然后通过 toFile() 方法将压缩后的图片保存到临时文件中。最后,将临时文件的路径设置到 ExcelExportVO 对象的 file 属性中。

    临时文件的生命周期由操作系统管理,通常在程序退出后会自动删除。(可以设置手动删除)

  2. 使用 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);
    }
原因分析
  1. 在第一个方法中,我使用了 Thumbnails.of(new URL(item.getFile())).scale(0.5).toFile(compressedFile); 这一行代码来压缩图片。这个方法将压缩后的图片直接保存到了文件系统中,然后在 ExcelExportVO 对象中设置的是临时文件的路径。

  2. 而在第二个方法中,我使用了 Thumbnails.of(new URL(item.getFile())).scale(0.5).toOutputStream(outputStream); 这一行代码来压缩图片。这个方法将压缩后的图片数据写入到了一个输出流(OutputStream)中,而没有将其直接保存到文件系统。这意味着,虽然压缩后的图片数据被存储在了内存中的字节数组中,但并没有实际地创建一个新的临时文件。因此,在第二个方法中,ExcelExportVO 对象中的文件路径实际上指向的是一个尚未存在的临时文件。

当 EasyExcel 将这些对象写入到 Excel 文件时,它会尝试打开每个 ExcelExportVO 对象中的文件路径。在第一个方法中,因为临时文件已经存在,所以 EasyExcel 可以成功地打开并读取这些文件。但在第二个方法中,由于临时文件并未实际创建,所以 EasyExcel 无法打开这些文件。

因此,尽管两个方法都实现了压缩图片的功能,但由于第二个方法没有将压缩后的图片数据实际保存到文件系统中,所以在导出的 Excel 文件中可能不会包含这些图片数据,从而导致导出的文件更小。

总结
  1. 第一种,需要手写代码删除临时文件,但是压缩效果
  2. 第二种,不需要手写代码删除临时文件,但是压缩效果较差

图片压缩效果说明

测试数据:100 条记录,每条记录包含一张【图片URL】。

  1. 压缩前:Excel 文件大小为 67.8M
  2. 压缩后
    • (手动删除资源版)Excel 文件大小为 2.68M
    • (自动关闭资源版)Excel 文件大小为 23.7M
  3. 选择压缩比例为 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);

具体说明:

  1. includeColumnFieldNames 是根据字段名指定导出列(建议导出视图 VO 不要指定 index 属性,否则会有导出会有空列

  2. 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 方法的各参数的含义:

  1. WriteSheetHolder writeSheetHolder:当前正在写入的 Sheet 的持有者。它包含有关当前 Sheet 的信息,例如 Sheet 的索引、当前行的索引以及其他相关详细信息。

  2. WriteTableHolder writeTableHolder:当前正在写入的表格的持有者。它包含有关当前表格的信息,例如表格的名称、起始行的索引以及其他相关详细信息。

  3. List> cellDataList:这是一个 WriteCellData 对象的列表,表示要写入当前单元格的数据。如果单元格跨越多个列或行,该列表可能包含多个 WriteCellData 对象。

  4. Cell cell:表示当前正在处理的单元格。它提供有关单元格位置、样式和其他属性的信息。

  5. Head head:表示当前单元格的头部(标题)。它包含有关头部的信息,如字段名、类类型以及其他相关详细信息。

    • 如果传入的不是一个 class 文件(实体类)时,head 可能会为 null,例如上面示例(.head(headList)),传入的是个列表集合,此时 head 为 null

  6. Integer relativeRowIndex:表示当前行在当前 Sheet 中的相对索引。一开始就是表头之下的第一行。

  7. Boolean isHead:这是一个布尔标志,指示当前单元格是否为表头单元格。如果 isHeadtrue,则表示当前单元格是表头单元格;否则,它是数据单元格。

总的来说,这些参数提供了有关当前 Excel 写入过程状态的上下文信息。它们允许您基于正在处理的 Sheet、表格、单元格和头部,以及当前行在 Sheet 中的位置执行自定义逻辑。

打包成压缩包导出

压缩包导出有以下两种情况:

  1. 指定本地路径导出
  2. 写入响应流导出

指定路径导出

一、可以使用 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 数据并进行压缩,方便用户一次性下载多个行业类型的数据。

以下是对代码的详细解释

  1. **参数校验:**检查传入的日期参数是否为空,若为空则抛出异常。
  2. **获取所有的行业类型:**通过 FiledTypeEnum.getValues() 获取所有的行业类型。(此方法在枚举类里面定义)
  3. **所有 Excel 导出并压缩:**使用 ZipOutputStream 创建一个 ZIP 文件,然后遍历所有行业类型,为每个行业类型生成对应的 Excel 文件,并将其写入 ZIP 文件中。
    • 在每个行业类型的循环中,获取当前行业类型的入驻企业数据。
    • 创建一个 ByteArrayOutputStream 用于存储当前行业类型的 Excel 数据。
    • 使用 EasyExcel 导出 Excel 数据,包括设置表头、列宽策略和单元格合并策略。
    • 将当前行业类型的 Excel 数据写入 ZIP 文件,并关闭当前 ZipEntry。
    • 关闭当前 Excel 字节流。
  4. **将压缩包写入响应流:**将生成的 ZIP 文件写入响应流,实现下载功能。设置响应头的文件名,并使用 URLEncoder.encode 处理中文文件名。最后,关闭 ByteArrayOutputStream

以下是一些关于流的概念说明

流(Stream)是用于在程序之间传输数据的抽象。流可以是输入流(Input Stream),用于从某个源读取数据,也可以是输出流(Output Stream),用于将数据写入某个目标。

现在来解释一下这段代码中各个流的用法:

  1. ByteArrayOutputStream: 这是一个字节数组输出流,它会在内存中创建一个字节数组缓冲区,所有写入到这个流的数据都会被保存在这个缓冲区中。在这段代码中,用于将每个行业类型的 Excel 数据保存在内存中。
    • 为什么需要它: 因为我们需要在内存中生成 Excel 数据,而不是将其写入到硬盘。EasyExcel.write 方法的参数是一个输出流,而 ByteArrayOutputStream 就是一个方便在内存中存储字节数据的流。
    • 为什么需要关闭: 关闭流是为了释放占用的系统资源。在这里,通过 ByteArrayOutputStreamclose 方法,确保所有关联的资源被释放,尤其是关闭底层的字节数组。
  2. ZipOutputStream: 这是一个用于写入 ZIP 文件的输出流。ZIP 文件是一种存档文件,可以包含多个文件或目录,通过压缩来减小文件大小。
    • 为什么需要它: 在这段代码中,我们希望将每个行业类型的 Excel 数据写入一个 ZIP 文件中,以便用户可以一次性下载多个文件。
    • 为什么需要关闭: 关闭 ZipOutputStream 将确保 ZIP 文件的完整性。在这里,通过 zipOut.closeEntry() 来关闭当前 ZIP 文件的条目(即一个文件),并准备开始下一个 ZIP 条目。
  3. ZipEntry: 在 ZIP 文件中,每个文件或目录都对应一个条目,这个条目就是 ZipEntry。在这段代码中,用于表示 ZIP 文件中的每个 Excel 文件。
    • 为什么需要它: 我们希望 ZIP 文件中有多个文件,每个文件对应一个行业类型的 Excel 数据。ZipEntry 就是用于表示 ZIP 文件中的每个文件。
    • 为什么需要关闭:ZipOutputStream 中,每次调用 putNextEntry 方法都会创建一个新的 ZipEntry,表示一个新的文件。通过 zipOut.closeEntry() 来关闭当前 ZIP 条目,以确保下一次写入时不会影响到前一个 ZIP 条目。

总体来说,这些流的使用是为了在【内存】中生成多个 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博客

你可能感兴趣的:(#,工具库篇,java,导出,EasyExcel)