寫在前面
Java 后端程序員應該會遇到讀取 Excel 信息到 DB 等相關需求,腦海中可能突然間想起 Apache POI 這個技術解決方案,但是當 Excel 的數據量非常大的時候,你也許發現,POI 是將整個 Excel 的內容全部讀出來放入到內存中,所以內存消耗非常嚴重,如果同時進行包含大數據量的 Excel 讀操作,很容易造成內存溢出問題
但 EasyExcel 的出現很好的解決了 POI 相關問題,原本一個 3M 的 Excel 用 POI 需要100M左右內存, 而 EasyExcel 可以將其降低到幾 M,同時再大的 Excel 都不會出現內存溢出的情況,因為是逐行讀取 Excel 的內容 (老規矩,這里不用過分關心下圖,腦海中有個印象即可,看完下面的用例再回看這個圖,就很簡單了)
另外 EasyExcel 在上層做了模型轉換的封裝,不需要 cell 等相關操作,讓使用者更加簡單和方便,且看
簡單讀
假設我們 excel 中有以下內容:
我們需要新建 User 實體,同時為其添加成員變量
@Data
public class User {
/**
* 姓名
*/
@ExcelProperty(index = 0)
private String name;
/**
* 年齡
*/
@ExcelProperty(index = 1)
private Integer age;
}
你也許關注到了 @ExcelProperty
注解,同時使用了 index 屬性 (0 代表第一列,以此類推),該注解同時支持以「列名」name 的方式匹配,比如:
@ExcelProperty("姓名")
private String name;
按照 github 文檔的說明:
不建議 index 和 name 同時用,要么一個對象只用index,要么一個對象只用name去匹配
- 如果讀取的 Excel 模板信息列固定,這里建議以 index 的形式使用,因為如果用名字去匹配,名字重復,會導致只有一個字段讀取到數據,所以 index 是更穩妥的方式
- 如果 Excel 模板的列 index 經常有變化,那還是選擇 name 方式比較好,不用經常性修改實體的注解 index 數值
所以大家可以根據自己的情況自行選擇
編寫測試用例
EasyExcel 類中重載了很多個 read 方法,這里不一一列舉說明,請大家自行查看;同時 sheet 方法也可以指定 sheetNo,默認是第一個 sheet 的信息
上面代碼的 new UserExcelListener()
異常醒目,這也是 EasyExcel 逐行讀取 Excel 內容的關鍵所在,自定義 UserExcelListener
繼承 AnalysisEventListener
@Slf4j
public class UserExcelListener extends AnalysisEventListener<User> {
/**
* 批處理閾值
*/
private static final int BATCH_COUNT = 2;
List<User> list = new ArrayList<User>(BATCH_COUNT);
@Override
public void invoke(User user, AnalysisContext analysisContext) {
log.info("解析到一條數據:{}", JSON.toJSONString(user));
list.add(user);
if (list.size() >= BATCH_COUNT) {
saveData();
list.clear();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
saveData();
log.info("所有數據解析完成!");
}
private void saveData(){
log.info("{}條數據,開始存儲數據庫!", list.size());
log.info("存儲數據庫成功!");
}
}
到這里請回看文章開頭的 EasyExcel 原理圖,invoke 方法逐行讀取數據,對應的就是訂閱者 1;doAfterAllAnalysed 方法對應的就是訂閱者 2,這樣你理解了嗎?
打印結果:
從這里可以看出,雖然是逐行解析數據,但我們可以自定義閾值,完成數據的批處理操作,可見 EasyExcel 操作的靈活性
自定義轉換器
這是最基本的數據讀寫,我們的業務數據通常不可能這么簡單,有時甚至需要將其轉換為程序可讀的數據
性別信息轉換
比如 Excel 中新增「性別」列,其性別為男/女,我們需要將 Excel 中的性別信息轉換成程序信息: 「1: 男;2:女」
首先在 User 實體中添加成員變量 gender:
@ExcelProperty(index = 2)
private Integer gender;
EasyExcel 支持我們自定義 converter,將 excel 的內容轉換為我們程序需要的信息,這里新建 GenderConverter,用來轉換性別信息
public class GenderConverter implements Converter<Integer> {
public static final String MALE = "男";
public static final String FEMALE = "女";
@Override
public Class supportJavaTypeKey() {
return Integer.class;
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}
@Override
public Integer convertToJavaData(CellData cellData, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
String stringValue = cellData.getStringValue();
if (MALE.equals(stringValue)){
return 1;
}else {
return 2;
}
}
@Override
public CellData convertToExcelData(Integer integer, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
return null;
}
}
上面程序的 Converter 接口的泛型是指要轉換的 Java 數據類型,與 supportJavaTypeKey 方法中的返回值類型一致
打開注解 @ExcelProperty
查看,該注解是支持自定義 Converter 的,所以我們為 User 實體添加 gender 成員變量,並指定 converter
/**
* 性別 1:男;2:女
*/
@ExcelProperty(index = 2, converter = GenderConverter.class)
private Integer gender;
來看運行結果:
數據按照我們預期做出了轉換,從這里也可以看出,Converter 可以一次定義到處是用的便利性
日期信息轉換
日期信息也是我們常見的轉換數據,比如 Excel 中新增「出生年月」列,我們要解析成 yyyy-MM-dd 格式,我們需要將其進行格式化,EasyExcel 通過 @DateTimeFormat
注解進行格式化
在 User 實體中添加成員變量 birth,同時應用 @DateTimeFormat
注解,按照要求做格式化
/**
* 出生日期
*/
@ExcelProperty(index = 3)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private String birth;
來看運行結果:
如果這里你指定 birth 的類型為 Date,試試看,你得到的結果是什么?
到這里都是以測試的方式來編寫程序代碼,作為 Java Web 開發人員,尤其在目前主流 Spring Boot 的架構下,所以如何實現 Web 方式讀取 Excel 的信息呢?
web 讀
簡單 Web
很簡單,只是將測試用例的關鍵代碼移動到 Controller 中即可,我們新建一個 UserController
,在其添加 upload
方法
@RestController
@RequestMapping("/users")
@Slf4j
public class UserController {
@PostMapping("/upload")
public String upload(MultipartFile file) throws IOException {
EasyExcel.read(file.getInputStream(), User.class, new UserExcelListener()).sheet().doRead();
return "success";
}
}
其實在寫測試用例的時候你也許已經發現,listener 是以 new 的形式作為參數傳入到 EasyExcel.read 方法中的,這是不符合 Spring IoC 的規則的,我們通常讀取 Excel 數據之后都要針對讀取的數據編寫一些業務邏輯的,而業務邏輯通常又會寫在 Service 層中,我們如何在 listener 中調用到我們的 service 代碼呢?
**先不要向下看,你腦海中有哪些方案呢? **
匿名內部類方式
匿名內部類是最簡單的方式,我們需要先新建 Service 層的信息:
新建 IUser 接口:
public interface IUser {
public boolean saveData(List<User> users);
}
新建 IUser 接口實現類 UserServiceImpl:
@Service
@Slf4j
public class UserServiceImpl implements IUser {
@Override
public boolean saveData(List<User> users) {
log.info("UserService {}條數據,開始存儲數據庫!", users.size());
log.info(JSON.toJSONString(users));
log.info("UserService 存儲數據庫成功!");
return true;
}
}
接下來,在 Controller 中注入 IUser:
@Autowired
private IUser iUser;
修改 upload 方法,以匿名內部類重寫 listener 方法的形式來實現:
@PostMapping("/uploadWithAnonyInnerClass")
public String uploadWithAnonyInnerClass(MultipartFile file) throws IOException {
EasyExcel.read(file.getInputStream(), User.class, new AnalysisEventListener<User>(){
/**
* 批處理閾值
*/
private static final int BATCH_COUNT = 2;
List<User> list = new ArrayList<User>();
@Override
public void invoke(User user, AnalysisContext analysisContext) {
log.info("解析到一條數據:{}", JSON.toJSONString(user));
list.add(user);
if (list.size() >= BATCH_COUNT) {
saveData();
list.clear();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
saveData();
log.info("所有數據解析完成!");
}
private void saveData(){
iUser.saveData(list);
}
}).sheet().doRead();
return "success";
}
查看結果:
這種實現方式,其實這只是將 listener 中的內容全部重寫,並在 controller 中展現出來,當你看着這么臃腫的 controller 是不是非常難受?很顯然這種方式不是我們的最佳編碼實現
構造器傳參
在之前分析 SpringBoot 統一返回源碼時,不知道你是否發現,Spring 底層源碼多數以構造器的形式傳參,所以我們可以將為 listener 添加有參構造器,將 Controller 中依賴注入的 IUser 以構造器的形式傳入到 listener :
@Slf4j
public class UserExcelListener extends AnalysisEventListener<User> {
private IUser iUser;
public UserExcelListener(IUser iUser){
this.iUser = iUser;
}
// 省略相應代碼...
private void saveData(){
iUser.saveData(list); //調用 userService 中的 saveData 方法
}
更改 Controller 方法:
@PostMapping("/uploadWithConstructor")
public String uploadWithConstructor(MultipartFile file) throws IOException {
EasyExcel.read(file.getInputStream(), User.class, new UserExcelListener(iUser)).sheet().doRead();
return "success";
}
運行結果: 同上
這樣更改后,controller 代碼看着很清晰,但如果后續業務還有別的 Service 需要注入,我們難道要一直添加有參構造器嗎?很明顯,這種方式同樣不是很靈活。
其實在使用匿名內部類的時候,你也許會想到,我們可以通過 Java8 lambda 的方式來解決這個問題
Lambda 傳參
為了解決構造器傳參的痛點,同時我們又希望 listener 更具有通用性,沒必要為每個 Excel 業務都新建一個 listener,因為 listener 都是逐行讀取 Excel 數據,只需要將我們的業務邏輯代碼傳入給 listener 即可,所以我們需用到 Consumer<T>
,將其作為構造 listener 的參數。
新建一個工具類 ExcelDemoUtils,用來構造 listener:
我們看到,getListener 方法接收一個 Consumer<List<T>>
的參數,這樣下面代碼被調用時,我們的業務邏輯也就會被相應的執行了:
consumer.accept(linkedList);
繼續改造 Controller 方法:
運行結果: 同上
到這里,我們只需要將業務邏輯定制在 batchInsert
方法中:
- 滿足 Controller RESTful API 的簡潔性
- listener 更加通用和靈活,它更多是扮演了抽象類的角色,具體的邏輯交給抽象方法的實現來完成
- 業務邏輯可擴展性也更好,邏輯更加清晰
總結
到這里,關於如何使用 EasyExcel 讀取 Excel 信息的基本使用方式已經介紹完了,還有很多細節內容沒有講,大家可以自行查閱 EasyExcel Github 文檔去發現更多內容。靈活使用 Java 8 的函數式接口,更容易讓你提高代碼的復用性,同時看起來更簡潔規范
除了讀取 Excel 的讀取,還有 Excel 的寫入,如果需要將其寫入到指定位置,配合 HuTool 的工具類 FileWriter 的使用是非常方便的,針對 EasyExcel 的使用,如果大家有什么問題,也歡迎到博客下方探討
完整代碼請在公眾號回復「demo」,點開鏈接,查看「easy-excel-demo」文件夾的內容即可,另外個人博客由於特殊原因暫時關閉首頁,其他目錄訪問一切正常,更多文章可以從 https://dayarch.top/archives 入口查看
感謝
非常感謝 EasyExcel 的作者 🌹🌹,讓 Excel 的讀寫更加方便
靈魂追問
- 除了 Consumer,如果需要返回值的業務邏輯,需要用到哪個函數式接口呢?
- 當出現復雜表頭的時候要如何處理呢?
- 將 DB 數據寫入到 Excel 並下載,如何實現呢?
- 從 EasyExcel 的設計上,你學到了什么,歡迎博客下方留言討論
提高效率工具
推薦閱讀
- 這次走進並發的世界,請不要錯過
- 學並發編程,透徹理解這三個核心是關鍵
- 並發Bug之源有三,請睜大眼睛看清它們
- 可見性有序性,Happens-before來搞定
- 解決原子性問題?你首先需要的是宏觀理解
- 面試並發volatile關鍵字時,我們應該具備哪些談資?
歡迎持續關注公眾號:「日拱一兵」
- 前沿 Java 技術干貨分享
- 高效工具匯總 | 回復「工具」
- 面試問題分析與解答
- 技術資料領取 | 回復「資料」
以讀偵探小說思維輕松趣味學習 Java 技術棧相關知識,本着將復雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......