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