EasyExcel 輕鬆靈活讀取Excel內容

發表於2019-10-22

take-it-easy-3842473_1920.jpg

寫在前面

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去匹配
  1. 如果讀取的 Excel 模板資訊列固定,這裡建議以 index 的形式使用,因為如果用名字去匹配,名字重複,會導致只有一個欄位讀取到資料,所以 index 是更穩妥的方式
  2. 如果 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 方法中:

  1. 滿足 Controller RESTful API 的簡潔性
  2. listener 更加通用和靈活,它更多是扮演了抽象類的角色,具體的邏輯交給抽象方法的實現來完成
  3. 業務邏輯可擴充套件性也更好,邏輯更加清晰

總結

到這裡,關於如何使用 EasyExcel 讀取 Excel 資訊的基本使用方式已經介紹完了,還有很多細節內容沒有講,大家可以自行查閱 EasyExcel Github 文件去發現更多內容。靈活使用 Java 8 的函式式介面,更容易讓你提高程式碼的複用性,同時看起來更簡潔規範

除了讀取 Excel 的讀取,還有 Excel 的寫入,如果需要將其寫入到指定位置,配合 HuTool 的工具類 FileWriter 的使用是非常方便的,針對 EasyExcel 的使用,如果大家有什麼問題,也歡迎到部落格下方探討

完整程式碼請在公眾號回覆「demo」,點開連結,檢視「easy-excel-demo」資料夾的內容即可,另外個人部落格由於特殊原因暫時關閉首頁,其他目錄訪問一切正常,更多文章可以從 https://dayarch.top/archives 入口檢視

感謝

非常感謝 EasyExcel 的作者 ??,讓 Excel 的讀寫更加方便

靈魂追問

  1. 除了 Consumer,如果需要返回值的業務邏輯,需要用到哪個函式式介面呢?
  2. 當出現複雜表頭的時候要如何處理呢?
  3. 將 DB 資料寫入到 Excel 並下載,如何實現呢?
  4. 從 EasyExcel 的設計上,你學到了什麼,歡迎部落格下方留言討論

提高效率工具


推薦閱讀

  1. 這次走進併發的世界,請不要錯過
  2. 學併發程式設計,透徹理解這三個核心是關鍵
  3. 併發Bug之源有三,請睜大眼睛看清它們
  4. 可見性有序性,Happens-before來搞定
  5. 解決原子性問題?你首先需要的是巨集觀理解
  6. 面試併發volatile關鍵字時,我們應該具備哪些談資?

歡迎關注我的公眾號 「日拱一兵」,趣味原創解析Java技術棧問題,將複雜問題簡單化,將抽象問題圖形化落地
如果對我的專題內容感興趣,或搶先看更多內容,歡迎訪問我的部落格 dayarch.top

相關文章