写点什么

🏆【Alibaba 工具型技术系列】「EasyExcel 技术专题」摒除 OOM!让你的 Excel 操作变得更加优雅和安全

发布于: 1 小时前
🏆【Alibaba工具型技术系列】「EasyExcel技术专题」摒除OOM!让你的Excel操作变得更加优雅和安全

前提概要

针对于后端开发者而言的,作为报表的导入和导出是一个很基础且有很棘手的问题!之前常用的工具和方案大概有这么几种:


  1. JXL(Java Excel API 工具服务),此种只支持 xls 的文件格式,而且对于内存的管理特别的差,现在基本不用了!

  2. 目前大多数会操作 Excel 工具服务或者解析都是利用 Apache POI 进行操作。

  3. 其他第三方的工具很多也是基于 POI 作为实现基础!

存在隐患问题

因为当数据量特别大的时候,比如:说 Excel 导出,如果数据量在百万级,很有可能会出现俩点内存溢出的问题以及页面极具卡顿。

解决方案

  • 首先,在百万级大数据量 Excel 文件的导出我们可以采用分批查询数据来避免内存溢出的剧增!

  • 此外,POI 给出的方案是:使用 SXSSFWorkbook 方式缓存数据到文件上以解决下载大文件 EXCEL 卡死页面的问题。

  • SXSSFWorkbook 数据模型:主要可以解决在下载传输到浏览器的时候大 Excel 文件转换的输出流内存溢出

  • 此外其还可以通过其构造函数执指定在内存中缓存的行数剩余的会自动缓存在硬盘的临时目录上,同时,并不会存在页面卡顿的情况

  • SXSSF 机制而言还是需要手动进行封装及定制化开发增加了一定的工作量!

  • POI 的操作方式仍然还是存在内存占用过大的问题,仍会存在内存溢出的隐患

  • 存在空循环和整除的时候数据有缺陷的问题

更优秀的选择

对此阿里巴巴发明咯一个“万金油”!EasyExcel(简单 Excel 操作工具,让 Excel 变得更简单!),除此之外它可以将解析的 Excel 的内存占用控制在 KB 级别,并且绝对不会内存溢出,还有就是速度极快, 不用想了就它了!



EasyExcel 的介绍说明

EasyExcel 是一个基于 Java 实现的、以节省内存为主要目标的的读写 Excel 文件的开源项目。经过官方统计,在尽可能节约内存的情况下支持读写百 M 的 Excel 文件的读写操作能力!


  • 源码库(github 地址):https://github.com/alibaba/easyexcel

  • 官方文档:https://alibaba-easyexcel.github.io/index.html

技术原理对比

POI

当利用 POI 去读取 Excel 时,首先会将数据全部加载到内存中,然后返回给调用者,当数据量比较大时,及其容易发生 OOM。以下是执行流程图:

EasyExcel

与 POI 不用的是,EasyExcel 主要是采用 sax 模式一行一行解析,并将一行的解析结果以观察者的模式通知处理,即使数据量较大时也不会发生 OOM,以下是其执行流程图


技术原理图


借用官方图


节省内存的开销


借用官方图片:https://alibaba-easyexcel.github.io/images/large.png


64M 内存 1 分钟内读取 75M(46W 行 25 列)的 ExcelExcel 内存开销图。当然还有急速模式能更快,但是内存占用会在 100M 多一点。其实就是拿空间换时间!


Maven 仓库依赖

我用的版本是 2.2.6


<dependency>  <groupId>com.alibaba</groupId>  <artifactId>easyexcel</artifactId>  <version>2.2.6</version></dependency>
复制代码


注: 系统内部如果有 POI 的包一定让 poi 和 poi-ooxml 的版本要保持一致。


注: 如果是 springboot2.0,则不需要 poi 依赖,如果是 1.0,则需要 poi 依赖,并且 poi 和 poi-ooxml 的版本要保持一致。


<!-- poi --> <dependency>          <groupId>org.apache.poi</groupId>          <artifactId>poi</artifactId>          <version>3.17</version> </dependency> <dependency>          <groupId>org.apache.poi</groupId>          <artifactId>poi-ooxml</artifactId>          <version>3.17</version> </dependency>
复制代码

基础 API 介绍(参考官方文档)

  • EasyExcel 入口类,用于构建开始各种操作,属于典型的门面+工厂模式


public void testReadEntity() {  // 被读取的文件绝对路径  String fileName = "path/testDemo.xlsx";  // 接收解析出的目标对象(Entity)指的是你的实体类   List<Entity> entityList = new ArrayList<>();  // 这里需要指定读用哪个class去读,然后读取第一个sheet文件流会自动关闭  // excel中表的列要与对象的字段相对应   EasyExcel.read(fileName, Entity.class, new AnalysisEventListener<Student>() {    // 每解析一条数据都会调用该方法    @Override    public void invoke(Entity entity, AnalysisContext analysisContext) {      System.out.println("解析一条Row行对象:" + JSON.toJSONString(entity));      entityList.add(student);  }    // 解析完毕的回调方法    @Override    public void doAfterAllAnalysed(AnalysisContext analysisContext) {        System.out.println("excel文件读取完毕!");    }  }).sheet().doRead();}
// 通过Map作为整体的数据结构模型public void readWithoutObj() { // 被读取的文件绝对路径 String fileName = "path/testDemo.xlsx"; // 接收结果集,为一个List列表,每个元素为一个map对象,key-value对为excel中每个列对应的值 List<Map<Integer,String>> resultList = new ArrayList<>(); EasyExcel.read(fileName, new AnalysisEventListener<Map<Integer,String>>() { @Override public void invoke(Map<Integer, String> map, AnalysisContext analysisContext) { System.out.println("解析到一条数据:" + JSON.toJSONString(map)); resultList.add(map); } @Override public void doAfterAllAnalysed(AnalysisContext analysisContext) { System.out.println("excel文件解析完毕!" + JSON.toJSONString(resultList)); } }).sheet().doRead();}
复制代码


  • invoke 方法代表每解析一行就会调用一次,data 数据表示解析出来一行的数据。

  • doAfterAllAnalysed 该方法表示将所有数据解析完毕以后才会去调用该方法。

  • 内部实现机制:(可以理解成一个 excel 对象,一个 excel 只要构建一个)

  • ExcelReaderBuilder 构建出一个 ReadWorkbook

  • excelType 当前 excel 的类型 默认会自动判断

  • inputStream 与 file 二选一。读取文件的流,如果接收到的是流就只用,不用流建议使用 file 参数。因为使用了 inputStream easyexcel 会帮忙创建临时文件,最终还是 file

  • file 与 inputStream 二选一。读取文件的文件。

  • autoCloseStream 自动关闭流。

  • readCache 默认小于 5M 用 内存,超过 5M 会使用 EhCache,这里不建议使用这个参数。

  • useDefaultListener @since 2.1.4 默认会加入 ModelBuildEventListener 来帮忙转换成传入 class 的对象,设置成 false 后将不会协助转换对象,自定义的监听器会接收到 Map<Integer,CellData>对象,如果还想继续接听到 class 对象,请调用 readListener 方法,加入自定义的 beforeListener、 ModelBuildEventListener、 自定义的 afterListener 即可。

  • 方法体现:

  • EasyExcel.read 该方法是用来创建 ExcelReaderBuilder 对象,该对象就是用来解析 Excel 文档

  • read 方法需要传入三个参数:

  • 第一个参数:需要解析文件的路径,当然除了传入一个文件路径以外,还可以传入 InputStream

  • 第二参数:数据类型的 Class 类型对象,可以不传

  • 第三个参数:事件监听器,在之前介绍这款框架时说过,该框架是基于 SAX 的一种解析,加载一行数据到内存就会去解析一行,主要是为了节约内存。

  • ExcelWriterBuilder 构建出一个 WriteWorkbook

  • excelType 当前 excel 的类型 默认 xlsx

  • outputStream 与 file 二选一。写入文件的流

  • file 与 outputStream 二选一。写入的文件

  • templateInputStream 模板的文件流

  • templateFile 模板文件

  • autoCloseStream 自动关闭流。

  • password 写的时候是否需要使用密码

  • useDefaultStyle 写的时候是否是使用默认头

  • WriteTable(就把 excel 的一个 Sheet,一块区域看一个 table)参数

  • tableNo 需要写入的编码。默认 0

  • 内部实现机制:(可以理解成 excel 里面的一页,每一页都要构建一个)

  • ExcelReaderSheetBuilder:构建出一个 ReadSheet 对象。

  • sheetNo 需要读取 Sheet 的编码,建议使用这个来指定读取哪个 Sheet。

  • sheetName 根据名字去匹配 Sheet,excel 2003 不支持根据名字去匹配。

  • ExcelWriterSheetBuilder 构建出一个 WriteSheet 对象。

  • 需要写入的编码。默认 0

  • sheetName 需要些的 Sheet 名称,默认同 sheetNo

  • 内部实现机制:(可以理解成 excel 里面的一页,每一页都要构建一个)

  • ReadListener:每一行读取完毕后都会调用 ReadListener 来处理数据

  • WriteHandler :每一个操作包括创建单元格、创建表格等都会调用 WriteHandler 来处理数据


所有配置都是继承的,Workbook 的配置会被 Sheet 继承,所以在用 EasyExcel 设置参数的时候,在 EasyExcel...sheet()方法之前作用域是整个 sheet,之后针对单个 sheet。


  • 相关使用注解

  • ExcelProperty index 指定写到第几列,默认根据成员变量排序。value 指定写入的名称,默认成员变量的名字,多个 value 可以参照快速开始中的复杂头。

  • ExcelIgnore 默认所有字段都会写入 excel,这个注解会忽略这个字段

  • DateTimeFormat 日期转换,将 Date 写到 excel 会调用这个注解。里面的 value 参照 java.text.SimpleDateFormat

  • NumberFormat 数字转换,用 Number 写 excel 会调用这个注解。里面的 value 参照 java.text.DecimalFormat

  • ExcelIgnoreUnannotated 默认不加 ExcelProperty 的注解的都会参与读写,加了不会参与。

  • 使用案例:


    @Data    public class TestEntity {      /**       * 从0开始,2代表强制读取第三个,       * 不建议 index 和 name 同时用       */      @ExcelProperty(index = 2)      private Double doubleData;      /**       * 用名字去匹配,这里需要注意,       * 名字重复,会导致只有一个字段读取到数据       */      @ExcelProperty("字符串标题")      private String string;
@ExcelProperty("日期标题") private Date date; }
复制代码


    @Data    public class MultiHeaderEntity implements Serializable {
@ExcelProperty(value = {"一层信息","二层1"}) private Integer id;
@ExcelProperty(value = {"一层信息","二层2"}) private String name;
@ExcelProperty(value = {"一层信息","二层4"}) private String description;
@ExcelProperty(value = {"一层信息","二层3"}) private Date birthday; }
复制代码


    @Data    public class ConverterData {      /**       * 我自定义 转换器,不管数据库传过来什么 。我给他加上“自定义:”       */      @ExcelProperty(converter = CustomStringStringConverter.class)      private String string;      /**       * 这里用string 去接日期才能格式化。我想接收年月日格式       */      @DateTimeFormat("yyyy年MM月dd日HH时mm分ss秒")      private String date;      /**       * 我想接收百分比的数字       */      @NumberFormat("#.##%")      private String doubleData;    }
复制代码


注意 :如果使用该类的对象去装载 Excel 中的数据,那么读取时就只能读取以下样式的 Excel 数据模型,否则数据部分丢失或者全部丢失

实战案例

读取 Excel 实现 Demo

数据模型 DemoModel


@Datapublic class DemoModel {    private String attribute1;    private Date attribute2;    private Double attribute3;}
复制代码


读取回调监听器


注意:DemoModelAnalysisListener 不要是单例模式或者全局共享,要每次读取 excel 都要 new,否则会出现复用之前读取的过程数据!


@Slf4jpublic class DemoModelAnalysisListener extends AnalysisEventListener<DemoData> {    /**     * 每隔500条存储数据库,然后清理list ,方便内存回收     */    static final int BATCH_COUNT = 500;    List<DemoModel> list = new ArrayList<>();    /**     * 这个也可以是一个service。当然如果不用存储这个对象没用。     */    private DemoService demoService;    public DemoModelAnalysisListener(DemoService demoService) {        this.demoService = demoService;    }    /**     * 这个每一条数据解析都会来调用     * @param data     *            one row value. Is is same as {@link AnalysisContext#readRowHolder()}     * @param context     */    @Override    public void invoke(DemoData data, AnalysisContext context) {        log.info("解析到一条数据:{}", JSON.toJSONString(data));        list.add(data);        // 达到BATCH_COUNT了,需要去存储一次数据库,    // 防止数据几万条数据在内存,容易OOM        if (list.size() >= BATCH_COUNT) {            saveData();        }    }
/** * 所有数据解析完成了 都会来调用 * @param context */ @Override public void doAfterAllAnalysed(AnalysisContext context) { // 这里也要保存数据,确保最后遗留的数据也存储到数据库 saveData(); log.info("所有数据解析完成!"); }
/** * 加上存储数据库 */ private void saveData() { log.info("{}条数据,开始存储数据库!", list.size()); demoService.insert(list); // 存储完成清理 list list.clear();//解析结束销毁不用的资源 log.info("存储数据库成功!"); }}
复制代码


读取操作测试类


    @Test    public void readExcelTest() {        // 写法1:    DemoService demoService = getService(); //获取service业务实现类        String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";        // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭        EasyExcel.read(fileName, DemoData.class, new DemoModelAnalysisListener(demoService)).sheet().doRead();
// 写法2: fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx"; ExcelReader excelReader = null; try { excelReader = EasyExcel.read(fileName, DemoData.class, new DemoModelAnalysisListener(demoService)).build(); ReadSheet readSheet = EasyExcel.readSheet(0).build(); excelReader.read(readSheet); } finally { if (excelReader != null) { // 这里千万别忘记关闭,读的时候会创建临时文件,到时磁盘会崩的 excelReader.finish(); } } }
复制代码
写入 Excel 实现 Demo

写入数据模型


@Datapublic class DemoData {    @ExcelProperty("字符串标题")    private String string;    @ExcelProperty("日期标题")    private Date date;    @ExcelProperty("数字标题")    private Double doubleData;    /**     * 忽略这个字段     */    @ExcelIgnore    private String ignore;}
复制代码


写入操作代码


    @Test    public void demoWriteTest() {        // 写法1        String fileName = TestFileUtil.getPath() + "simpleWrite" + System.currentTimeMillis() + ".xlsx";        // 这里 需要指定写用哪个class去写,然后写到第一个sheet,名字为模板 然后文件流会自动关闭        // 如果这里想使用03 则 传入excelType参数即可        EasyExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());    // 写法2        fileName = TestFileUtil.getPath() + "simpleWrite" + System.currentTimeMillis() + ".xlsx";        // 这里 需要指定写用哪个class去写        ExcelWriter excelWriter = null;        try {            excelWriter = EasyExcel.write(fileName, DemoData.class).build();            WriteSheet writeSheet = EasyExcel.writerSheet("模板").build();            excelWriter.write(data(), writeSheet);        } finally {            // 千万别忘记finish 会帮忙关闭流            if (excelWriter != null) {                excelWriter.finish();            }        }    }
复制代码
复杂头写入
@Datapublic class MultiHeaderEntity implements Serializable {
@ExcelProperty(value = {"一层信息","二层1"}) private Integer id;
@ExcelProperty(value = {"一层信息","二层2"}) private String name;
@ExcelProperty(value = {"一层信息","二层4"}) private String description;
@ExcelProperty(value = {"一层信息","二层3"}) private Date birthday; }
复制代码
复杂头的写入操作
    @Test    public void complexHeadWrite() {        String fileName = TestFileUtil.getPath() + "complexHeadWrite" + System.currentTimeMillis() + ".xlsx";        // 这里 需要指定写用哪个class去写,然后写到第一个sheet,名字为模板 然后文件流会自动关闭        EasyExcel.write(fileName, MultiHeaderEntity.class).sheet("复杂").doWrite(datalist());    }
复制代码


发布于: 1 小时前阅读数: 4
用户头像

🏆 2021年InfoQ写作平台-签约作者 🏆 2020.03.25 加入

👑【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 “任何足够先进的技术都是魔法“

评论

发布
暂无评论
🏆【Alibaba工具型技术系列】「EasyExcel技术专题」摒除OOM!让你的Excel操作变得更加优雅和安全