大批量Excel文件導出實戰
業務需求
接觸了杭州市執法信息平臺歷史案卷的導出功能,因爲有個功能是要導出全部的案卷,10年的執法數據有100w+的數據量,怎麼樣快速導出成爲了棘手的問題。
傳統POI遇到的問題
- Excel寫入過慢;
- 每個Sheet僅支持65536(2003版)條數據;
- 容易導致OOM。
- 容易引起頁面奔潰
- 網絡傳輸數據太多
解決辦法
- 尋找合適的POI(集成easyExcel組件)框架減少內存消耗
- 儘量避免一次性加載所有的數據(分頁查詢)
- 採用多線程的方式
- 採用壓縮文件打包的方式
- 採用進度條的交互方式
具體實現核心代碼
依賴
<!-- 集成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