大批量Excel文件导出实战(EasyPOI)

大批量Excel文件导出实战

    • 业务需求
    • 传统POI遇到的问题
    • 解决办法
    • 具体实现核心代码
    • 总结

业务需求

接触了杭州市执法信息平台历史案卷的导出功能,因为有个功能是要导出全部的案卷,10年的执法数据有100w+的数据量,怎么样快速导出成为了棘手的问题。

传统POI遇到的问题

  1. Excel写入过慢;
  2. 每个Sheet仅支持65536(2003版)条数据;
  3. 容易导致OOM。
  4. 容易引起页面奔溃
  5. 网络传输数据太多

解决办法

  1. 寻找合适的POI(集成easyExcel组件)框架减少内存消耗
  2. 尽量避免一次性加载所有的数据(分页查询)
  3. 采用多线程的方式
  4. 采用压缩文件打包的方式
  5. 采用进度条的交互方式

具体实现核心代码

依赖

 
        <dependency>
            <groupId>cn.afterturngroupId>
            <artifactId>easypoi-baseartifactId>
            <version>4.1.0version>
        dependency>

        <dependency>
            <groupId>cn.afterturngroupId>
            <artifactId>easypoi-webartifactId>
            <version>4.1.0version>
        dependency>

        <dependency>
            <groupId>cn.afterturngroupId>
            <artifactId>easypoi-annotationartifactId>
            <version>4.1.0version>
        dependency>

        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>easyexcelartifactId>
            <version>2.0.5version>
        dependency>

        
        <dependency>
            <groupId>net.lingala.zip4jgroupId>
            <artifactId>zip4jartifactId>
            <version>1.3.2version>
        dependency>

EasyExcelUtil

@Slf4j
public class EasyExcelUtil {
    private static Sheet initSheet;

    static {
        initSheet = new Sheet(1, 0);
        initSheet.setSheetName("sheet");
        //设置自适应宽度
        initSheet.setAutoWidth(Boolean.TRUE);
    }

    /**
     * 读取少于1000行数据
     * @param filePath 文件绝对路径
     * @return
     */
    public static List<Object> readLessThan1000Row(String filePath){
        return readLessThan1000RowBySheet(filePath,null);
    }

    /**
     * 读小于1000行数据, 带样式
     * filePath 文件绝对路径
     * initSheet :
     *      sheetNo: sheet页码,默认为1
     *      headLineMun: 从第几行开始读取数据,默认为0, 表示从第一行开始读取
     *      clazz: 返回数据List 中Object的类名
     */
    public static List<Object> readLessThan1000RowBySheet(String filePath, Sheet sheet){
        if(!StringUtils.hasText(filePath)){
            return null;
        }

        sheet = sheet != null ? sheet : initSheet;

        InputStream fileStream = null;
        try {
            fileStream = new FileInputStream(filePath);
            return EasyExcelFactory.read(fileStream, sheet);
        } catch (FileNotFoundException e) {
            log.info("找不到文件或文件路径错误, 文件:{}", filePath);
        }finally {
            try {
                if(fileStream != null){
                    fileStream.close();
                }
            } catch (IOException e) {
                log.info("excel文件读取失败, 失败原因:{}", e);
            }
        }
        return null;
    }

    /**
     * 读大于1000行数据
     * @param filePath 文件觉得路径
     * @return
     */
    public static List<Object> readMoreThan1000Row(String filePath){
        return readMoreThan1000RowBySheet(filePath,null);
    }

    /**
     * 读大于1000行数据, 带样式
     * @param filePath 文件觉得路径
     * @return
     */
    public static List<Object> readMoreThan1000RowBySheet(String filePath, Sheet sheet){
        if(!StringUtils.hasText(filePath)){
            return null;
        }

        sheet = sheet != null ? sheet : initSheet;

        InputStream fileStream = null;
        try {
            fileStream = new FileInputStream(filePath);
            ExcelListener excelListener = new ExcelListener();
            EasyExcelFactory.readBySax(fileStream, sheet, excelListener);
            return excelListener.getDatas();
        } catch (FileNotFoundException e) {
            log.error("找不到文件或文件路径错误, 文件:{}", filePath);
        }finally {
            try {
                if(fileStream != null){
                    fileStream.close();
                }
            } catch (IOException e) {
                log.error("excel文件读取失败, 失败原因:{}", e);
            }
        }
        return null;
    }

    /**
     * 生成excle
     * @param filePath  绝对路径, 如:/home/chenmingjian/Downloads/aaa.xlsx
     * @param data 数据源
     * @param head 表头
     */
    public static void writeBySimple(String filePath, List<List<Object>> data, List<String> head){
        writeSimpleBySheet(filePath,data,head,null);
    }

    /**
     * 生成excle
     * @param filePath 绝对路径, 如:/home/chenmingjian/Downloads/aaa.xlsx
     * @param data 数据源
     * @param sheet excle页面样式
     * @param head 表头
     */
    public static void writeSimpleBySheet(String filePath, List<List<Object>> data, List<String> head, Sheet sheet){
        sheet = (sheet != null) ? sheet : initSheet;

        if(head != null){
            List<List<String>> list = new ArrayList<>();
            head.forEach(h -> list.add(Collections.singletonList(h)));
            sheet.setHead(list);
        }

        OutputStream outputStream = null;
        ExcelWriter writer = null;
        try {
            outputStream = new FileOutputStream(filePath);
            writer = EasyExcelFactory.getWriter(outputStream);
            writer.write1(data,sheet);
        } catch (FileNotFoundException e) {
            log.error("找不到文件或文件路径错误, 文件:{}", filePath);
        }finally {
            try {
                if(writer != null){
                    writer.finish();
                }

                if(outputStream != null){
                    outputStream.close();
                }

            } catch (IOException e) {
                log.error("excel文件导出失败, 失败原因:{}", e);
            }
        }

    }

    /**
     * 生成excle
     * @param filePath 绝对路径, 如:/home/chenmingjian/Downloads/aaa.xlsx
     * @param data 数据源
     */
    public static void writeWithTemplate(String filePath, List<? extends BaseRowModel> data){
        writeWithTemplateAndSheet(filePath,data,null);
    }

    /**
     * 生成excle
     * @param filePath 绝对路径, 如:/home/chenmingjian/Downloads/aaa.xlsx
     * @param data 数据源
     * @param sheet excle页面样式
     */
    public static void writeWithTemplateAndSheet(String filePath, List<? extends BaseRowModel> data, Sheet sheet){
        if(CollectionUtils.isEmpty(data)){
            return;
        }

        sheet = (sheet != null) ? sheet : initSheet;
        sheet.setClazz(data.get(0).getClass());

        OutputStream outputStream = null;
        ExcelWriter writer = null;
        try {
            outputStream = new FileOutputStream(filePath);
            writer = EasyExcelFactory.getWriter(outputStream);
            writer.write(data,sheet);
        } catch (FileNotFoundException e) {
            log.error("找不到文件或文件路径错误, 文件:{}", filePath);
        }finally {
            try {
                if(writer != null){
                    writer.finish();
                }

                if(outputStream != null){
                    outputStream.close();
                }
            } catch (IOException e) {
                log.error("excel文件导出失败, 失败原因:{}", e);
            }
        }

    }

    /**
     * 生成多Sheet的excle
     * @param filePath 绝对路径, 如:/home/chenmingjian/Downloads/aaa.xlsx
     * @param multipleSheelPropetys
     */
    public static void writeWithMultipleSheel(String filePath,List<MultipleSheelPropety> multipleSheelPropetys){
        if(CollectionUtils.isEmpty(multipleSheelPropetys)){
            return;
        }

        OutputStream outputStream = null;
        ExcelWriter writer = null;
        try {
            outputStream = new FileOutputStream(filePath);
            writer = EasyExcelFactory.getWriter(outputStream);
            for (MultipleSheelPropety multipleSheelPropety : multipleSheelPropetys) {
                Sheet sheet = multipleSheelPropety.getSheet() != null ? multipleSheelPropety.getSheet() : initSheet;
                if(!CollectionUtils.isEmpty(multipleSheelPropety.getData())){
                    sheet.setClazz(multipleSheelPropety.getData().get(0).getClass());
                }
                writer.write(multipleSheelPropety.getData(), sheet);
            }

        } catch (FileNotFoundException e) {
            log.error("找不到文件或文件路径错误, 文件:{}", filePath);
        }finally {
            try {
                if(writer != null){
                    writer.finish();
                }

                if(outputStream != null){
                    outputStream.close();
                }
            } catch (IOException e) {
                log.error("excel文件导出失败, 失败原因:{}", e);
            }
        }

    }


    /*********************匿名内部类开始,可以提取出去******************************/

    @Data
    public static class MultipleSheelPropety{

        private List<? extends BaseRowModel> data;

        private Sheet sheet;
    }

    /**
     * 解析监听器,
     * 每解析一行会回调invoke()方法。
     * 整个excel解析结束会执行doAfterAllAnalysed()方法
     *
     */
    @Getter
    @Setter
    public static class ExcelListener extends AnalysisEventListener {

        private List<Object> datas = new ArrayList<>();

        /**
         * 逐行解析
         * object : 当前行的数据
         */
        @Override
        public void invoke(Object object, AnalysisContext context) {
            //当前行
            // context.getCurrentRowNum()
            if (object != null) {
                datas.add(object);
            }
        }


        /**
         * 解析完所有数据后会调用该方法
         */
        @Override
        public void doAfterAllAnalysed(AnalysisContext context) {
            //解析结束销毁不用的资源
        }

    }

    /************************匿名内部类结束,可以提取出去***************************/


 
  

工具类:ZIP压缩文件操作工具类

@Slf4j
public class CompressUtil {

    /**
     * 使用给定密码解压指定的ZIP压缩文件到指定目录
     * 

* 如果指定目录不存在,可以自动创建,不合法的路径将导致异常被抛出 * @param zip 指定的ZIP压缩文件 * @param dest 解压目录 * @param passwd ZIP文件的密码 * @return 解压后文件数组 * @throws ZipException 压缩文件有损坏或者解压缩失败抛出 */ public static File [] unzip(String zip, String dest, String passwd) throws ZipException { File zipFile = new File(zip); return unzip(zipFile, dest, passwd); } /** * 使用给定密码解压指定的ZIP压缩文件到当前目录 * @param zip 指定的ZIP压缩文件 * @param passwd ZIP文件的密码 * @return 解压后文件数组 * @throws ZipException 压缩文件有损坏或者解压缩失败抛出 */ public static File [] unzip(String zip, String passwd) throws ZipException { File zipFile = new File(zip); File parentDir = zipFile.getParentFile(); return unzip(zipFile, parentDir.getAbsolutePath(), passwd); } /** * 使用给定密码解压指定的ZIP压缩文件到指定目录 *

* 如果指定目录不存在,可以自动创建,不合法的路径将导致异常被抛出 * @param dest 解压目录 * @param passwd ZIP文件的密码 * @return 解压后文件数组 * @throws ZipException 压缩文件有损坏或者解压缩失败抛出 */ public static File [] unzip(File zipFile, String dest, String passwd) throws ZipException { ZipFile zFile = new ZipFile(zipFile); zFile.setFileNameCharset("GBK"); if (!zFile.isValidZipFile()) { throw new ZipException("压缩文件不合法,可能被损坏."); } File destDir = new File(dest); if (destDir.isDirectory() && !destDir.exists()) { destDir.mkdir(); } if (zFile.isEncrypted()) { zFile.setPassword(passwd.toCharArray()); } zFile.extractAll(dest); List<FileHeader> headerList = zFile.getFileHeaders(); List<File> extractedFileList = new ArrayList<File>(); for(FileHeader fileHeader : headerList) { if (!fileHeader.isDirectory()) { extractedFileList.add(new File(destDir,fileHeader.getFileName())); } } File [] extractedFiles = new File[extractedFileList.size()]; extractedFileList.toArray(extractedFiles); return extractedFiles; } /** * 压缩指定文件到当前文件夹 * @param src 要压缩的指定文件 * @return 最终的压缩文件存放的绝对路径,如果为null则说明压缩失败. */ public static String zip(String src) { return zip(src,null); } /** * 使用给定密码压缩指定文件或文件夹到当前目录 * @param src 要压缩的文件 * @param passwd 压缩使用的密码 * @return 最终的压缩文件存放的绝对路径,如果为null则说明压缩失败. */ // public static String zip(String src, String passwd) { // return zip(src, null, passwd); // } /** * 使用给定密码压缩指定文件或文件夹到当前目录 * @param src 要压缩的文件 * @param dest 压缩文件存放路径 * @return 最终的压缩文件存放的绝对路径,如果为null则说明压缩失败. */ public static String zip(String src, String dest) { return zip(src, dest, false, null); } /** * 使用给定密码压缩指定文件或文件夹到当前目录 * @param src 要压缩的文件 * @param dest 压缩文件存放路径 * @param passwd 压缩使用的密码 * @return 最终的压缩文件存放的绝对路径,如果为null则说明压缩失败. */ public static String zip(String src, String dest, String passwd) { return zip(src, dest, true, passwd); } /** * 使用给定密码压缩指定文件或文件夹到指定位置. *

* dest可传最终压缩文件存放的绝对路径,也可以传存放目录,也可以传null或者"".
* 如果传null或者""则将压缩文件存放在当前目录,即跟源文件同目录,压缩文件名取源文件名,以.zip为后缀;
* 如果以路径分隔符(File.separator)结尾,则视为目录,压缩文件名取源文件名,以.zip为后缀,否则视为文件名. * @param src 要压缩的文件或文件夹路径 * @param dest 压缩文件存放路径 * @param isCreateDir 是否在压缩文件里创建目录,仅在压缩文件为目录时有效.
* 如果为false,将直接压缩目录下文件到压缩文件. * @param passwd 压缩使用的密码 * @return 最终的压缩文件存放的绝对路径,如果为null则说明压缩失败. */ public static String zip(String src, String dest, boolean isCreateDir, String passwd) { if(Files.exists(Paths.get(dest))){ log.error("已经存在压缩文件[{}],不能执行压缩过程!", dest); return null; } File srcFile = new File(src); dest = buildDestinationZipFilePath(srcFile, dest); ZipParameters parameters = new ZipParameters(); parameters.setCompressionMethod(Zip4jConstants.COMP_DEFLATE); // 压缩方式 parameters.setCompressionLevel(Zip4jConstants.DEFLATE_LEVEL_NORMAL); // 压缩级别 if (!StringUtils.isEmpty(passwd)) { parameters.setEncryptFiles(true); parameters.setEncryptionMethod(Zip4jConstants.ENC_METHOD_STANDARD); // 加密方式 parameters.setPassword(passwd.toCharArray()); } try { ZipFile zipFile = new ZipFile(dest); if (srcFile.isDirectory()) { // 如果不创建目录的话,将直接把给定目录下的文件压缩到压缩文件,即没有目录结构 if (!isCreateDir) { File [] subFiles = srcFile.listFiles(); ArrayList<File> temp = new ArrayList<File>(); Collections.addAll(temp, subFiles); zipFile.addFiles(temp, parameters); return dest; } zipFile.addFolder(srcFile, parameters); } else { zipFile.addFile(srcFile, parameters); } return dest; } catch (ZipException e) { e.printStackTrace(); } return null; } /** * 构建压缩文件存放路径,如果不存在将会创建 * 传入的可能是文件名或者目录,也可能不传,此方法用以转换最终压缩文件的存放路径 * @param srcFile 源文件 * @param destParam 压缩目标路径 * @return 正确的压缩文件存放路径 */ private static String buildDestinationZipFilePath(File srcFile,String destParam) { if (StringUtils.isEmpty(destParam)) { if (srcFile.isDirectory()) { destParam = srcFile.getParent() + File.separator + srcFile.getName() + ".zip"; } else { String fileName = srcFile.getName().substring(0, srcFile.getName().lastIndexOf(".")); destParam = srcFile.getParent() + File.separator + fileName + ".zip"; } } else { createDestDirectoryIfNecessary(destParam); // 在指定路径不存在的情况下将其创建出来 if (destParam.endsWith(File.separator)) { String fileName = ""; if (srcFile.isDirectory()) { fileName = srcFile.getName(); } else { fileName = srcFile.getName().substring(0, srcFile.getName().lastIndexOf(".")); } destParam += fileName + ".zip"; } } return destParam; } /** * 在必要的情况下创建压缩文件存放目录,比如指定的存放路径并没有被创建 * @param destParam 指定的存放路径,有可能该路径并没有被创建 */ private static void createDestDirectoryIfNecessary(String destParam) { File destDir = null; if (destParam.endsWith(File.separator)) { destDir = new File(destParam); } else { destDir = new File(destParam.substring(0, destParam.lastIndexOf(File.separator))); } if (!destDir.exists()) { destDir.mkdirs(); } } public static void main(String[] args) { zip("D:\\tmp\\export\\82a7734ef75a4fda890320973fcda5c5", "D:\\tmp\\export\\82a7734ef75a4fda890320973fcda5c5\\export.zip"); // try { // File[] files = unzip("d:\\test\\汉字.zip", "aa"); // for (int i = 0; i < files.length; i++) { // System.out.println(files[i]); // } // } catch (ZipException e) { // e.printStackTrace(); // } } }

    /*
     * 执行excel导出的线程池
     */
    private ExecutorService execPool = new ThreadPoolExecutor(28, 56, 60L,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<Runnable>(256),
            new ThreadFactoryBuilder().setNameFormat("export-thread-%d").build(),
            new ThreadPoolExecutor.AbortPolicy());


  public void export(CaseInfoBo caseInfoBo, int pageSize, HttpServletResponse response) {
        log.debug("====> 开始导出excel,查询参数:[{}]", caseInfoBo);
        //查询最终记录数量
        Integer count = caseInfoMapper.countCaseList(caseInfoBo);
        //计算需要总共多少页
        int pageCount = calcPageCount(count, pageSize);
        long startTime = System.currentTimeMillis();
        //创建本次导出的临时目录
        String uuid =  UUID.randomUUID().toString().replaceAll("-","");
        String tmpDir = TEMP_ROOT_FOLDER +File.separator+ uuid;
        if(!ensureFolderExist(tmpDir)){
            log.error("创建临时目录[{}]失败!", tmpDir);
            return;
        }
        log.debug("需要导出[{}]条记录, 分为[{}]个线程分别导出excel!", count, pageCount);
        //按页数分配线程执行
        CountDownLatch latch = new CountDownLatch(pageCount);
        for(int i = 0; i < pageCount; i++) {
            execPool.execute(new tcase(i, pageSize, tmpDir, caseInfoBo, latch));
        }
        try {
            latch.await();
        } catch (InterruptedException e) {
            log.error("主线程同步等待线程完成失败!", e);
            //若出现异常,则清除临时文件
            clearTempDir(tmpDir);
            return;
        }
        //打压缩包
        String zipPath = CompressUtil.zip(tmpDir, getZipPath(tmpDir));
        if(zipPath == null) return;
        //发送文件流
        File file = new File(zipPath);
        if (file.exists()) {
            response.setHeader("content-type", "application/octet-stream");
            response.setContentType("application/octet-stream");
            response.setHeader("Content-Disposition", "attachment;filename=" + "HistoryCase"+uuid+".zip");

            //实现文件流输出
            byte[] buffer = new byte[1024];
            FileInputStream fis = null;
            BufferedInputStream bis = null;
            try {
                fis = new FileInputStream(file);
                bis = new BufferedInputStream(fis);
                OutputStream os = response.getOutputStream();
                int i = bis.read(buffer);
                while (i != -1) {
                    os.write(buffer, 0, i);
                    i = bis.read(buffer);
                }
                log.info("输出历史案件[{}]的文件流[{}]到客户端成功!", caseInfoBo, zipPath);
            }
            catch (Exception e) {
                log.error("输出历史案件[{}]的文件流[{}]到客户端出现异常!", caseInfoBo, zipPath, e);
            } finally {
                if (bis != null) {
                    try {
                        bis.close();
                    } catch (IOException e) {
                        //
                    }
                }
                if (fis != null) {
                    try {
                        fis.close();
                    } catch (IOException e) {
                        //
                    }
                }
            }
        }
        //清除文件
        clearTempDir(tmpDir);
        long endTime = System.currentTimeMillis();
        log.info("<====执行导出[{}]成功,总时长为:{}", caseInfoBo, (endTime - startTime)/1000);
    }

    /*
     * 临时目录中压缩文件路径名
     */
    private String getZipPath(String tmpDir){
        return tmpDir + File.separator + "archive.zip";
    }

    /*
     * 创建下级目录,已存在或创建成功为true,不存在且创建不成功为false
     */
    private boolean ensureFolderExist(String strFolder) {
        File file = new File(strFolder);
        //如果不存在,则创建文件夹
        if (!file.exists()) {
            if (file.mkdirs()) {
                // 创建成功
                return true;
            } else {
                //创建不成功
                return false;
            }
        }
        //目录已存在返回false
        return false;
    }

    /*
     * 清除临时目录
     */
    private void clearTempDir(String dirPath){
        execPool.execute(() -> {
            Logger log = LoggerFactory.getLogger(Thread.currentThread().getName());
            File localDir = new File(dirPath);
            try {
                // 确保存在空的project 文件夹
                if (!localDir.exists()) {
                    return;
                } else {
                    // 清空文件夹
                    // Files.walk - return all files/directories below rootPath including
                    // .sorted - sort the list in reverse order, so the directory itself comes after the including
                    // subdirectories and files
                    // .map - map the Path to File
                    // .peek - is there only to show which entry is processed
                    // .forEach - calls the .delete() method on every File object
                    log.debug("开始清空目录:{}", dirPath);
                    Files.walk(Paths.get(dirPath)).sorted(Comparator.reverseOrder()).map(Path::toFile)
                            .peek(f -> {log.debug(f.getAbsolutePath());}).forEach(File::delete);
                    log.debug("清空目录:{} 成功!", dirPath);
                }
            } catch (Exception e) {
                log.error("清空目录:{}时发生异常!", dirPath, e);
            }
        });
    }

    /**
     * 计算总页数
     */
    private int calcPageCount(int total, int row){
        return (total-1)/row+1;
    }

    /**
     * 执行查询记录和导出excel的方法体
     */
    private class tcase implements Runnable{

        private int page;

        private int size;

        private String dir;

        private CaseInfoBo model;

        private CountDownLatch latch;

        public tcase(int pageNum, int pageSize, String tmpDir, CaseInfoBo _model, CountDownLatch _latch){
            page = pageNum;
            size = pageSize;
            this.dir = tmpDir;
            model = _model;
            latch = _latch;
        }

        @Override
        public void run() {
            String threadName = Thread.currentThread().getName();
            log.debug("==> Thread:{} query and export page:[{}] start", threadName, page);
            Logger log = LoggerFactory.getLogger(threadName);
            try {
                CaseQueryParams finalModel = new CaseQueryParams();
                BeanUtils.copyProperties(finalModel, model);

                finalModel.setPageNo(page);
                finalModel.setPageSize(size);

                List<ExcelCaseInfo> pageList = caseInfoMapper.selectCasePage(finalModel);
                //excel
                String filePath = dir + File.separator + page + EXPORT_SUFFIX;
                EasyExcelUtil.writeWithTemplate(filePath,pageList);

            } catch (Exception e) {
                log.error("查询[{}]导出页[{}]出现异常!", model, page, e);
            } finally {
                latch.countDown();
            }
            log.debug("<== Thread:{} query and export page:[{}] end", threadName, page);
        }
    }

总结

选用解决了同时多sheet导入问题,分页查询获取每个sheet的内容,减少了内存处理的数据完美的解决了OOM问题,多文件压缩打包节省了前端等待的时间,进度条的交互方式完美的解决了用户体验感。

备注:不懂EasyExcel的参考https://blog.csdn.net/zhongzk69/article/details/92057268

你可能感兴趣的:(JAVA)