前提概要
針對於後端開發者而言的,作為報表的匯入和匯出是一個很基礎且有很棘手的問題!之前常用的工具和方案大概有這麼幾種:
- JXL(Java Excel API 工具服務),此種只支援xls的檔案格式,而且對於記憶體的管理特別的差,現在基本不用了!
- 目前大多數會操作Excel工具服務或者解析都是利用Apache POI進行操作。
- 其他第三方的工具很多也是基於POI作為實現基礎!
存在隱患問題
因為當資料量特別大的時候,比如:說Excel匯出,如果資料量在百萬級,很有可能會出現倆點記憶體溢位的問題以及頁面極具卡頓。
解決方案
-
首先,在百萬級大資料量Excel檔案的匯出我們可以採用分批查詢資料來避免記憶體溢位的劇增!
-
此外,POI給出的方案是:使用SXSSFWorkbook方式快取資料到檔案上以解決下載大檔案EXCEL卡死頁面的問題。
- SXSSFWorkbook資料模型:主要可以解決在下載傳輸到瀏覽器的時候大Excel檔案轉換的輸出流記憶體溢位
- 此外其還可以通過其建構函式執指定在記憶體中快取的行數,剩餘的會自動快取在硬碟的臨時目錄上,同時,並不會存在頁面卡頓的情況。
- SXSSF機制而言還是需要手動進行封裝及定製化開發增加了一定的工作量!
- POI的操作方式仍然還是存在記憶體佔用過大的問題,仍會存在記憶體溢位的隱患。
- 存在空迴圈和整除的時候資料有缺陷的問題。
更優秀的選擇
對此阿里巴巴發明咯一個“萬金油”!EasyExcel(簡單Excel操作工具,讓Excel變得更簡單!),除此之外它可以將解析的Excel的記憶體佔用控制在KB級別,並且絕對不會記憶體溢位,還有就是速度極快, 不用想了就它了!
EasyExcel的介紹說明
EasyExcel是一個基於Java實現的、以節省記憶體為主要目標的的讀寫Excel檔案的開源專案。經過官方統計,在儘可能節約記憶體的情況下支援讀寫百M的Excel檔案的讀寫操作能力!
- 原始碼庫(github地址):https://github.com/alibaba/easyexcel
- 官方文件:https://alibaba-easyexcel.github.io/index.html
技術原理對比
POI
當利用POI去讀取Excel時,首先會將資料全部載入到記憶體中,然後返回給呼叫者,當資料量比較大時,及其容易發生OOM。以下是執行流程圖:
EasyExcel
與POI 不用的是,EasyExcel主要是採用sax模式一行一行解析,並將一行的解析結果以觀察者的模式通知處理,即使資料量較大時也不會發生OOM,以下是其執行流程圖
技術原理圖
借用官方圖:
節省記憶體的開銷
64M記憶體1分鐘內讀取75M(46W行25列)的ExcelExcel 記憶體開銷圖。當然還有急速模式能更快,但是記憶體佔用會在100M多一點。其實就是拿空間換時間!
Maven倉庫依賴
我用的版本是2.2.6
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.2.6</version>
</dependency>
注: 系統內部如果有POI的包一定讓poi和poi-ooxml的版本要保持一致。
注: 如果是springboot2.0,則不需要poi依賴,如果是1.0,則需要poi依賴,並且poi和poi-ooxml的版本要保持一致。
<!-- poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
</dependency>
基礎API介紹(參考官方文件)
- EasyExcel 入口類,用於構建開始各種操作,屬於典型的門面+工廠模式
public void testReadEntity() {
// 被讀取的檔案絕對路徑
String fileName = "path/testDemo.xlsx";
// 接收解析出的目標物件(Entity)指的是你的實體類
List<Entity> entityList = new ArrayList<>();
// 這裡需要指定讀用哪個class去讀,然後讀取第一個sheet檔案流會自動關閉
// excel中表的列要與物件的欄位相對應
EasyExcel.read(fileName, Entity.class, new AnalysisEventListener<Student>() {
// 每解析一條資料都會呼叫該方法
@Override
public void invoke(Entity entity, AnalysisContext analysisContext) {
System.out.println("解析一條Row行物件:" + JSON.toJSONString(entity));
entityList.add(student);
}
// 解析完畢的回撥方法
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
System.out.println("excel檔案讀取完畢!");
}
}).sheet().doRead();
}
// 通過Map作為整體的資料結構模型
public void readWithoutObj() {
// 被讀取的檔案絕對路徑
String fileName = "path/testDemo.xlsx";
// 接收結果集,為一個List列表,每個元素為一個map物件,key-value對為excel中每個列對應的值
List<Map<Integer,String>> resultList = new ArrayList<>();
EasyExcel.read(fileName, new AnalysisEventListener<Map<Integer,String>>() {
@Override
public void invoke(Map<Integer, String> map, AnalysisContext analysisContext) {
System.out.println("解析到一條資料:" + JSON.toJSONString(map));
resultList.add(map);
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
System.out.println("excel檔案解析完畢!" + JSON.toJSONString(resultList));
}
}).sheet().doRead();
}
-
invoke方法代表每解析一行就會呼叫一次,data資料表示解析出來一行的資料。
-
doAfterAllAnalysed 該方法表示將所有資料解析完畢以後才會去呼叫該方法。
-
內部實現機制:(可以理解成一個excel物件,一個excel只要構建一個)
-
ExcelReaderBuilder 構建出一個 ReadWorkbook
- excelType 當前excel的型別 預設會自動判斷
- inputStream 與file二選一。讀取檔案的流,如果接收到的是流就只用,不用流建議使用file引數。因為使用了inputStream easyexcel會幫忙建立臨時檔案,最終還是file
- file 與inputStream二選一。讀取檔案的檔案。
- autoCloseStream 自動關閉流。
- readCache 預設小於5M用 記憶體,超過5M會使用 EhCache,這裡不建議使用這個引數。
- useDefaultListener @since 2.1.4 預設會加入ModelBuildEventListener 來幫忙轉換成傳入class的物件,設定成false後將不會協助轉換物件,自定義的監聽器會接收到Map<Integer,CellData>物件,如果還想繼續接聽到class物件,請呼叫readListener方法,加入自定義的beforeListener、 ModelBuildEventListener、 自定義的afterListener即可。
- 方法體現:
- EasyExcel.read 該方法是用來建立ExcelReaderBuilder物件,該物件就是用來解析Excel文件
- read方法需要傳入三個引數:
- 第一個引數:需要解析檔案的路徑,當然除了傳入一個檔案路徑以外,還可以傳入InputStream
- 第二引數:資料型別的Class型別物件,可以不傳
- 第三個引數:事件監聽器,在之前介紹這款框架時說過,該框架是基於SAX的一種解析,載入一行資料到記憶體就會去解析一行,主要是為了節約記憶體。
-
ExcelWriterBuilder 構建出一個 WriteWorkbook
- excelType 當前excel的型別 預設xlsx
- outputStream 與file二選一。寫入檔案的流
- file 與outputStream二選一。寫入的檔案
- templateInputStream 模板的檔案流
- templateFile 模板檔案
- autoCloseStream 自動關閉流。
- password 寫的時候是否需要使用密碼
- useDefaultStyle 寫的時候是否是使用預設頭
-
WriteTable(就把excel的一個Sheet,一塊區域看一個table)引數
- tableNo 需要寫入的編碼。預設0
-
-
內部實現機制:(可以理解成excel裡面的一頁,每一頁都要構建一個)
- ExcelReaderSheetBuilder:構建出一個 ReadSheet物件。
- sheetNo 需要讀取Sheet的編碼,建議使用這個來指定讀取哪個Sheet。
- sheetName 根據名字去匹配Sheet,excel 2003不支援根據名字去匹配。
- ExcelWriterSheetBuilder 構建出一個 WriteSheet物件。
- 需要寫入的編碼。預設0
- sheetName 需要些的Sheet名稱,預設同sheetNo
- ExcelReaderSheetBuilder:構建出一個 ReadSheet物件。
-
內部實現機制:(可以理解成excel裡面的一頁,每一頁都要構建一個)
- ReadListener:每一行讀取完畢後都會呼叫ReadListener來處理資料
- WriteHandler :每一個操作包括建立單元格、建立表格等都會呼叫WriteHandler來處理資料
所有配置都是繼承的,Workbook的配置會被Sheet繼承,所以在用EasyExcel設定引數的時候,在EasyExcel...sheet()方法之前作用域是整個sheet,之後針對單個sheet。
- 相關使用註解
- ExcelProperty index 指定寫到第幾列,預設根據成員變數排序。value指定寫入的名稱,預設成員變數的名字,多個value可以參照快速開始中的複雜頭。
- ExcelIgnore 預設所有欄位都會寫入excel,這個註解會忽略這個欄位
- DateTimeFormat 日期轉換,將Date寫到excel會呼叫這個註解。裡面的value參照java.text.SimpleDateFormat
- NumberFormat 數字轉換,用Number寫excel會呼叫這個註解。裡面的value參照java.text.DecimalFormat
- ExcelIgnoreUnannotated 預設不加ExcelProperty的註解的都會參與讀寫,加了不會參與。
- 使用案例:
@Data public class TestEntity { /** * 從0開始,2代表強制讀取第三個, * 不建議 index 和 name 同時用 */ @ExcelProperty(index = 2) private Double doubleData; /** * 用名字去匹配,這裡需要注意, * 名字重複,會導致只有一個欄位讀取到資料 */ @ExcelProperty("字串標題") private String string; @ExcelProperty("日期標題") private Date date; }
@Data public class MultiHeaderEntity implements Serializable { @ExcelProperty(value = {"一層資訊","二層1"}) private Integer id; @ExcelProperty(value = {"一層資訊","二層2"}) private String name; @ExcelProperty(value = {"一層資訊","二層4"}) private String description; @ExcelProperty(value = {"一層資訊","二層3"}) private Date birthday; }
@Data public class ConverterData { /** * 我自定義 轉換器,不管資料庫傳過來什麼 。我給他加上“自定義:” */ @ExcelProperty(converter = CustomStringStringConverter.class) private String string; /** * 這裡用string 去接日期才能格式化。我想接收年月日格式 */ @DateTimeFormat("yyyy年MM月dd日HH時mm分ss秒") private String date; /** * 我想接收百分比的數字 */ @NumberFormat("#.##%") private String doubleData; }
注意 :如果使用該類的物件去裝載Excel中的資料,那麼讀取時就只能讀取以下樣式的Excel資料模型,否則資料部分丟失或者全部丟失
實戰案例
讀取Excel實現Demo
資料模型DemoModel
@Data
public class DemoModel {
private String attribute1;
private Date attribute2;
private Double attribute3;
}
讀取回撥監聽器
注意:DemoModelAnalysisListener 不要是單例模式或者全域性共享,要每次讀取excel都要new,否則會出現複用之前讀取的過程資料!
@Slf4j
public class DemoModelAnalysisListener extends AnalysisEventListener<DemoData> {
/**
* 每隔500條儲存資料庫,然後清理list ,方便記憶體回收
*/
static final int BATCH_COUNT = 500;
List<DemoModel> list = new ArrayList<>();
/**
* 這個也可以是一個service。當然如果不用儲存這個物件沒用。
*/
private DemoService demoService;
public DemoModelAnalysisListener(DemoService demoService) {
this.demoService = demoService;
}
/**
* 這個每一條資料解析都會來呼叫
* @param data
* one row value. Is is same as {@link AnalysisContext#readRowHolder()}
* @param context
*/
@Override
public void invoke(DemoData data, AnalysisContext context) {
log.info("解析到一條資料:{}", JSON.toJSONString(data));
list.add(data);
// 達到BATCH_COUNT了,需要去儲存一次資料庫,
// 防止資料幾萬條資料在記憶體,容易OOM
if (list.size() >= BATCH_COUNT) {
saveData();
}
}
/**
* 所有資料解析完成了 都會來呼叫
* @param context
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 這裡也要儲存資料,確保最後遺留的資料也儲存到資料庫
saveData();
log.info("所有資料解析完成!");
}
/**
* 加上儲存資料庫
*/
private void saveData() {
log.info("{}條資料,開始儲存資料庫!", list.size());
demoService.insert(list);
// 儲存完成清理 list
list.clear();//解析結束銷燬不用的資源
log.info("儲存資料庫成功!");
}
}
讀取操作測試類
@Test
public void readExcelTest() {
// 寫法1:
DemoService demoService = getService(); //獲取service業務實現類
String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
// 這裡 需要指定讀用哪個class去讀,然後讀取第一個sheet 檔案流會自動關閉
EasyExcel.read(fileName, DemoData.class, new DemoModelAnalysisListener(demoService)).sheet().doRead();
// 寫法2:
fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
ExcelReader excelReader = null;
try {
excelReader = EasyExcel.read(fileName, DemoData.class, new DemoModelAnalysisListener(demoService)).build();
ReadSheet readSheet = EasyExcel.readSheet(0).build();
excelReader.read(readSheet);
} finally {
if (excelReader != null) {
// 這裡千萬別忘記關閉,讀的時候會建立臨時檔案,到時磁碟會崩的
excelReader.finish();
}
}
}
寫入Excel實現Demo
寫入資料模型
@Data
public class DemoData {
@ExcelProperty("字串標題")
private String string;
@ExcelProperty("日期標題")
private Date date;
@ExcelProperty("數字標題")
private Double doubleData;
/**
* 忽略這個欄位
*/
@ExcelIgnore
private String ignore;
}
寫入操作程式碼
@Test
public void demoWriteTest() {
// 寫法1
String fileName = TestFileUtil.getPath() + "simpleWrite" + System.currentTimeMillis() + ".xlsx";
// 這裡 需要指定寫用哪個class去寫,然後寫到第一個sheet,名字為模板 然後檔案流會自動關閉
// 如果這裡想使用03 則 傳入excelType引數即可
EasyExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
// 寫法2
fileName = TestFileUtil.getPath() + "simpleWrite" + System.currentTimeMillis() + ".xlsx";
// 這裡 需要指定寫用哪個class去寫
ExcelWriter excelWriter = null;
try {
excelWriter = EasyExcel.write(fileName, DemoData.class).build();
WriteSheet writeSheet = EasyExcel.writerSheet("模板").build();
excelWriter.write(data(), writeSheet);
} finally {
// 千萬別忘記finish 會幫忙關閉流
if (excelWriter != null) {
excelWriter.finish();
}
}
}
複雜頭寫入
@Data
public class MultiHeaderEntity implements Serializable {
@ExcelProperty(value = {"一層資訊","二層1"})
private Integer id;
@ExcelProperty(value = {"一層資訊","二層2"})
private String name;
@ExcelProperty(value = {"一層資訊","二層4"})
private String description;
@ExcelProperty(value = {"一層資訊","二層3"})
private Date birthday;
}
複雜頭的寫入操作
@Test
public void complexHeadWrite() {
String fileName = TestFileUtil.getPath() + "complexHeadWrite" + System.currentTimeMillis() + ".xlsx";
// 這裡 需要指定寫用哪個class去寫,然後寫到第一個sheet,名字為模板 然後檔案流會自動關閉
EasyExcel.write(fileName, MultiHeaderEntity.class).sheet("複雜").doWrite(datalist());
}