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

业务需求

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

传统POI遇到的问题

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

解决办法

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

具体实现核心代码

依赖

 <!-- 集成easypoi组件 .导出excel http://easypoi.mydoc.io/ -->
        <dependency>
            <groupId>cn.afterturn</groupId>
            <artifactId>easypoi-base</artifactId>
            <version>4.1.0</version>
        </dependency>

        <dependency>
            <groupId>cn.afterturn</groupId>
            <artifactId>easypoi-web</artifactId>
            <version>4.1.0</version>
        </dependency>

        <dependency>
            <groupId>cn.afterturn</groupId>
            <artifactId>easypoi-annotation</artifactId>
            <version>4.1.0</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>2.0.5</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/net.lingala.zip4j/zip4j -->
        <dependency>
            <groupId>net.lingala.zip4j</groupId>
            <artifactId>zip4j</artifactId>
            <version>1.3.2</version>
        </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> 中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压缩文件到指定目录
     * <p>
     * 如果指定目录不存在,可以自动创建,不合法的路径将导致异常被抛出
     * @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压缩文件到指定目录
     * <p>
     * 如果指定目录不存在,可以自动创建,不合法的路径将导致异常被抛出
     * @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);
    }

    /**
     * 使用给定密码压缩指定文件或文件夹到指定位置.
     * <p>
     * dest可传最终压缩文件存放的绝对路径,也可以传存放目录,也可以传null或者"".<br />
     * 如果传null或者""则将压缩文件存放在当前目录,即跟源文件同目录,压缩文件名取源文件名,以.zip为后缀;<br />
     * 如果以路径分隔符(File.separator)结尾,则视为目录,压缩文件名取源文件名,以.zip为后缀,否则视为文件名.
     * @param src 要压缩的文件或文件夹路径
     * @param dest 压缩文件存放路径
     * @param isCreateDir 是否在压缩文件里创建目录,仅在压缩文件为目录时有效.<br />
     * 如果为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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章