专栏首页Java艺术我的开源项目|可扩展、可自由玩耍的miniexcel,不需要考虑OOM

我的开源项目|可扩展、可自由玩耍的miniexcel,不需要考虑OOM

去年我也写过一篇文章介绍miniexcel,不过现在加了点新功能,并且现在可以通过依赖配置使用啦。

0

项目背景

之前遇到OOM问题的时候,同事推荐我使用阿里开源的easyexcel,但是我使用easyexcel也遇到了问题,并且从抛出的异常来看,easyexcel也是基于poi做的二次封装,也没什么特别的嘛。与其花费时间去研究easyexcelbug,还不如自己想办法解决,于是我的封装之作miniexcel就这样诞生了。

1

miniexcel简介

miniexcel主要目的是解决读写大数据量(上w条记录)时导致的OOM问题,解决办法就是基于事件模式读,使用SXSSFWorkbook写。

其次,miniexcel基于工厂模式,自动根据文件后缀名识别创建不同格式的读取器,只要文件后缀名规范就不需要自己根据文件格式创建读取器,但这点显然不够严格。

miniexcel正如其名,简单且小巧,源码也简单,只是加了点设计模式。操作excel无非就是读和写,所以miniexcel有了读取器和写入器的概念,同时为了更加通用,不管是读取器还是写入器,在开始读或写时,都必须要先创建一个监听器,这便是可玩性高的原因。

当读取数据时,监听器用于接收读取到的数据;当写入数据时,监听器要传给写入器当前要写入的数据,如果自己实现写入监听器,那么非常好的支持分页查询,每查一页自动写一页,并且支持设置每个sheet多少行记录,超过自动切换sheet写入。

miniexcel还引入了泛型和注解的使用,但目前我只是提供了一个注解,在写入(导出)数据时,可使用@ExcelCellTitle注解给bean的字段取一个标题名,还有设置标题的排序,新版本添加了是否忽略该字段的配置。默认情况下使用bean的字段名作为列标题,标题不排序。

2

添加依赖

maven中使用:

<!-- https://mvnrepository.com/artifact/com.github.wujiuye/miniexcel -->
<dependency>
    <groupId>com.github.wujiuye</groupId>
    <artifactId>miniexcel</artifactId>
    <version>1.1.0-RELEASE</version>
</dependency>

gradle中使用:

// https://mvnrepository.com/artifact/com.github.wujiuye/miniexcel
compile group: 'com.github.wujiuye', name: 'miniexcel', version: '1.1.0-RELEASE'

3

读,将excel文件中的记录读取到内存中

使用AbstractExcelReader.getReader方法获取一个文件读取器,第一个参数是文件的绝对路径(包含后缀名),第二个参数是是否读取列标题。

public class TestMain{
    /**
     * 解决记录超过1000内存oom问题
     *
     * @param filePath 文件路径
     * @param cellName
     * @return
     */
    public static List<Object> readDataWithCellName(final String filePath, final String cellName) {
        final List<Object> list = Lists.newArrayList();
        AbstractExcelReader reader = AbstractExcelReader.getReader(filePath, true);
        DefaultExcelReaderListener listener = new DefaultExcelReaderListener(cellName);
        reader.read(listener);
        if (listener.getData() != null && listener.getData().size() > 0) {
            listener.getData().forEach(entry -> list.add(entry.get(cellName)));
        }
        return list;
    }
}

DefaultExcelReaderListener是我为满足项目需求实现的一个默认监听器。支持只读某些列,只需要在new时传入列名即可。DefaultExcelReaderListener的源码如下。

/**
 * @author wujiuye
 * @version 1.0 on 2019/4/13 {描述:}
 */
public class DefaultExcelReaderListener implements ExcelReaderListener {

    //sheetName -> {cellIndex->cellName}
    private Map<String, Map<Integer, String>> cellTitleMap = new HashMap<>();
    private String currentSheetName;
    //cellName -> value
    private List<Map<String, Object>> data = new ArrayList<>();
    private int currentRow = -1;
    private Map<String, Object> currentRowData = new HashMap<>();

    private boolean ydCell = false;//是否约定只获取某些列
    private List<String> readCells;

    public DefaultExcelReaderListener() {

    }

    // 是否只获取某些列的值
    public DefaultExcelReaderListener(String... cellName) {
        if (cellName != null && cellName.length > 0) {
            ydCell = true;
            readCells = Arrays.stream(cellName).collect(Collectors.toList());
        }
    }

    @Override
    public void onReadSheetStart(String sheetName) {
        this.currentSheetName = sheetName;
        if (!cellTitleMap.containsKey(this.currentSheetName)) {
            cellTitleMap.put(this.currentSheetName, new HashMap<>());
        }
    }

    @Override
    public void onReadSheetTitle(int cellNumber, String cellTitle) {
        this.cellTitleMap.get(this.currentSheetName).put(cellNumber, cellTitle);
    }

    @Override
    public void onReadRow(Object data, int rowNumber, int cellNumber) {
        String cellName = cellTitleMap.get(currentSheetName).get(cellNumber);
        if (ydCell && !readCells.contains(cellName)) {
            return;
        }
        if (this.currentRow != rowNumber) {
            this.data.add(new HashMap<>(currentRowData));
            currentRowData.clear();
            this.currentRow = rowNumber;
        }
        this.currentRowData.put(cellName, data);
    }

    public List<Map<String, Object>> getData() {
        return this.data;
    }
}

在web项目中使用

1、在方法加上接收客户端上传的excel文件

public class XxxController{

    public void importBy(@RequestParam("file") CommonsMultipartFile excelFile){
    }

}

2、将文件临时存储,读取完成后删除

public class XxxController{

    public void importBy(){
        File tmpExcelFile = null;
        try {
            String suffix = excelFile.getOriginalFilename().substring(excelFile.getOriginalFilename().lastIndexOf("."));
            // 导数据上传的临时文件
            tmpExcelFile = File.createTempFile("export_data_upload_tmp" + System.currentTimeMillis() + CodeUtils.md5(excelFile.getOriginalFilename()), suffix);
            excelFile.transferTo(tmpExcelFile);
            //
            // 在此处读取文件内容
            //
        }finally{
            //删除临时文件
            if (tmpExcelFile != null) {
                try {
                   tmpExcelFile.delete();
                } catch (Exception e) {
                }
            }
        }
    }

}

4

写,将数据写入excel文件

1、首先创建一个bean,不支持Map类型哦。目前只支持简单类,不支持复杂类。

A:什么是复杂类?

Q:就是Object中有非java基本数据类型的字段。目前支持的非基本数据类型(包括其IntegerLong等)只有Date

/**
 * @author wujiuye
 * @version 1.0 on 2019/5/6 {描述:}
 */
@NoArgsConstructor
@Data
public class ExcelTestBean {
    @ExcelCellTitle(cellNumber = 1, alias = "这是id")
    private Long testId;
    @ExcelCellTitle(cellNumber = 2, alias = "这是名称")
    private String testName;
    @ExcelCellTitle(cellNumber = 3, alias = "这是日期")
    private Date createDate;
}

2、将List数据写入excel文件

Writer的几个方法:

a、setSheetSize(1_000):设置每个sheet的大小为1000行,当写满1000行时,自动创建一个新的sheet

b、setSheetNameFromat("export_{sn}")即给sheet设置命名规则,其中{sn}是必须的,会被替换为序号。

ExcelWriterListener的几个方法:

a:getDataObjectClass获取记录的真实类型

b:autoGenerateTitle是否需要在创建sheet时自动生成标题

c:getNetOutputDataRealSizegetOutputDataWithSheetNumber很重要,也是需要配合使用的。前者是表示接下来要写入的数据的大小,后者则需要返回接下来实际需要写入的数据。如果getNetOutputDataRealSize返回0则结束。limitStartlimitEnd是借鉴了分页查询的思想,实现分页写入,这样可以边查询边写入,避免一次将所有数据查询出来占用大量的内存。

 public class TestMain{
    /**
     * 测试导出
     */
    @Test
    public void testWrite() {
        final List<ExcelTestBean> records = new ArrayList<>();
        ExcelTestBean item1 = new ExcelTestBean();
        item1.setXXX(xxx);
        // ...
        for (int i = 0; i < 3_000; i++) {
            records.add(item1);
        }

        SXSSFWriter writer = (SXSSFWriter) AbstractExcelWriter.createExcelWriter("/Users/wjy/Desktop/test_export_excel_1", AbstractExcelWriter.ExportFormatType.XLS)
                .setSheetSize(1_000)
                .setSheetNameFromat("export_{sn}");
        writer.write(new ExcelWriterListener() {

            private int count = 0;

            @Override
            public Class<?> getDataObjectClass() {
                return ExcelTestBean.class;
            }

            @Override
            public boolean autoGenerateTitle() {
                return true;
            }

            @Override
            public int getNetOutputDataRealSize(int sn) {
                if (count >= records.size()) {
                    return 0;
                } else {
                    count += 100;
                    return 100;
                }
            }

            @Override
            public List getOutputDataWithSheetNumber(int sn, int limitStart, int limitEnd) {
                return records.subList(limitStart, limitEnd);
            }
        });
    }
}

在web项目中使用

public class XxxController{

    public void export(){
        File file = writer.write(new ExcelWriterListener() {});
    }

}

如,在基类Controller添加一个响应文件的方法

public class BaseController{
    /**
         * 响应文件给客户端请求
         *
         * @param response
         * @param excelFile
         * @param afterDeleteFile 响应完成后是否删除文件
         * @throws IOException
         */
        protected void responseExcelFile(HttpServletResponse response, File excelFile, boolean afterDeleteFile) throws IOException {
            response.setContentType("application/octet-stream; charset=utf-8");
            response.setHeader("Content-Disposition", "attachment; filename=" + excelFile.getPath());
            byte[] buf = new byte[2048];
            try (FileInputStream fin = new FileInputStream(excelFile)) {
                int len;
                while ((len = fin.read(buf, 0, buf.length)) > 0) {
                    response.getOutputStream().write(buf, 0, len);
                }
            } finally {
                if (afterDeleteFile) {
                    excelFile.delete();
                }
            }
        }
}

目前实现的功能很简单,同时miniexcel也只是为了解决简单数据的导入导出而设计的,并不想搞得太复杂。如果想实现复杂的需求,可通过扩展实现,这得益于miniexcel的可扩展性设计。

5

版本更新说明

版本1.1.0-RELEASE

日期:2019-10-10 版本号:1.1.0-RELEASE 更新说明:

  • 1、提供默认的写监听器,应付大部分小数据导出场景。
  • 2、提供导出列指示器,实现导出列指示器可以在不使用@ExcelCellTitle注解的情况下,设置导出忽略的字段,设置字段对应导excel的列标题。
/**
 * @author wujiuye
 * @version 1.0 on 2019/10/10 {描述:
 * 列指示器
 * }
 */
public interface ColumnsDesignator {

    /**
     * 是否忽略该列
     *
     * @param column data的数据类型的字段名
     * @return
     */
    boolean isIgnore(String column);

    /**
     * 是否需要重命名
     *
     * @param column data的数据类型的字段名
     * @return 不需要重命名则直接返回参数column,需要重命名则返回重命名后的列名(excel文件的标题名称)
     */
    String renameColumn(String column);

}
public class TestMain{
    public void testWrite(){
        File target = writer.write(new DefaultExcelWriteListenerAdapter(dataList, 1000), new ColumnsDesignator() {

            @Override
            public boolean isIgnore(String column) {
                if (CollectionUtils.isEmpty(columns)) {
                    return false;
                }
                return !columns.contains(column);
            }

            @Override
            public String renameColumn(String column) {
                if (CollectionUtils.isEmpty(columns)
                        || CollectionUtils.isEmpty(columnNames)) {
                    return column;
                }
                int index = columns.indexOf(column);
                return columnNames.get(index);
            }
        });
    }
}

版本1.1.1-RELEASE

日期:2020-03-24 版本号:1.1.1-RELEASE 更新说明:

  • 1、修复bug,去掉使用外部集合判断

版本1.1.2-RELEASE

日期:2020-03-25 版本号:1.1.2-RELEASE 更新说明:

  • 1、注解提供ignore配置
  • 2、修复注解声明的列名与ColumnsDesignator冲突问题
  • 3、调整代码结构
  • 4、把提供默认的默认读监听器DefaultExcelWriteListener改为DefaultExcelWriteListenerAdapter,默认抛出异常。

本文分享自微信公众号 - Java艺术(javaskill),作者:wujiuye

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-03-26

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 访问者模式在ASM框架中的使用

    访问者模式的定义是:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。

    wujiuye
  • 解析JSON数组正常,却在获取数组元素时抛出了类型转换异常

    下面这段代码会抛出类型转换异常(ClassCastException),JVM给出的解释是:不能将Double类型对象转换String类型 (java.lang...

    wujiuye
  • 我的开源项目|动态字节码技术实现的asyncframework

    这是ASM+动态字节码技术实现的一个异步框架,只需要在你的接口上添加一个@AsyncFunction注解就可让这个方法异步执行。

    wujiuye
  • Spring Boot使用Shiro实现登录授权认证

    1、Shiro是Apache下的一个开源项目,我们称之为Apache Shiro。它是一个很易用与Java项目的的安全框架,提供了认证、授权、加密、会话管理,与...

    朝雨忆轻尘
  • Spring boot整合shiro权限管理

    二十三年蝉
  • 聊聊NacosNamingService的selectOneHealthyInstance

    本文主要研究一下NacosNamingService的selectOneHealthyInstance

    codecraft
  • 聊聊NacosNamingService的selectOneHealthyInstance

    本文主要研究一下NacosNamingService的selectOneHealthyInstance

    codecraft
  • Java枚举类使用和总结

    别先生
  • Java微信公众平台开发(四)--回复消息的分类及实体的创建

    前面有说道对接收到微信服务器消息后对消息的分类,当时主要分为普通消息和事件消息,这里我们要讲述的是我们在给用户回复的消息类型,在这里也可以大致分为两类:一种为不...

    用户2417870
  • springboot集成redis实现消息发布订阅模式-跨多服务器

    基础配置参考 https://blog.csdn.net/llll234/article/details/80966952

    用户1518699

扫码关注云+社区

领取腾讯云代金券