天天看點

百萬級資料excel導出功能如何實作?

前言

最近我做過一個MySQL百萬級别資料的excel導出功能,已經正常上線使用了。

這個功能挺有意思的,裡面需要注意的細節還真不少,現在拿出來跟大家分享一下,希望對你會有所幫助。

原始需求:使用者在UI界面上點選全部導出按鈕,就能導出所有商品資料。

咋一看,這個需求挺簡單的。

但如果我告訴你,導出的記錄條數,可能有一百多萬,甚至兩百萬呢?

這時你可能會倒吸一口氣。

因為你可能會面臨如下問題:

  1. 如果同步導資料,接口很容易逾時。
  2. 如果把所有資料一次性裝載到記憶體,很容易引起OOM。
  3. 資料量太大sql語句必定很慢。
  4. 相同商品編号的資料要放到一起。
  5. 如果走異步,如何通知使用者導出結果?
  6. 如果excel檔案太大,目标使用者打不開怎麼辦?

我們要如何才能解決這些問題,實作一個百萬級别的excel資料快速導出功能呢?

百萬級資料excel導出功能如何實作?

1.異步處理

做一個MySQL百萬資料級别的excel導出功能,如果走接口同步導出,該接口肯定會非常容易逾時。

是以,我們在做系統設計的時候,第一選擇應該是接口走異步處理。

說起異步處理,其實有很多種,比如:使用開啟一個線程,或者使用線程池,或者使用job,或者使用mq等。

為了防止服務重新開機時資料的丢失問題,我們大多數情況下,會使用job或者mq來實作異步功能。

1.1 使用job

如果使用job的話,需要增加一張執行任務表,記錄每次的導出任務。

使用者點選全部導出按鈕,會調用一個後端接口,該接口會向表中寫入一條記錄,該記錄的狀态為:待執行。

有個job,每隔一段時間(比如:5分鐘),掃描一次執行任務表,查出所有狀态是待執行的記錄。

然後周遊這些記錄,挨個執行。

需要注意的是:如果用job的話,要避免重複執行的情況。比如job每隔5分鐘執行一次,但如果資料導出的功能所花費的時間超過了5分鐘,在一個job周期内執行不完,就會被下一個job執行周期執行。

是以使用job時可能會出現重複執行的情況。

為了防止job重複執行的情況,該執行任務需要增加一個執行中的狀态。

具體的狀态變化如下:

  1. 執行任務被剛記錄到執行任務表,是待執行狀态。
  2. 當job第一次執行該執行任務時,該記錄再資料庫中的狀态改為:執行中。
  3. 當job跑完了,該記錄的狀态變成:完成或失敗。

這樣導出資料的功能,在第一個job周期内執行不完,在第二次job執行時,查詢待處理狀态,并不會查詢出執行中狀态的資料,也就是說不會重複執行。

此外,使用job還有一個硬傷即:它不是立馬執行的,有一定的延遲。

如果對時間不太敏感的業務場景,可以考慮使用該方案。

1.2 使用mq

使用者點選全部導出按鈕,會調用一個後端接口,該接口會向mq服務端,發送一條mq消息。

有個專門的mq消費者,消費該消息,然後就可以實作excel的資料導出了。

相較于job方案,使用mq方案的話,實時性更好一些。

對于mq消費者處理失敗的情況,可以增加補償機制,自動發起重試。

RocketMQ自帶了失敗重試功能,如果失敗次數超過了一定的閥值,則會将該消息自動放入死信隊列。

2.使用easyexcel

我們知道在Java中解析和生成Excel,比較有名的架構有Apache POI和jxl。

但它們都存在一個嚴重的問題就是:非常耗記憶體,POI有一套SAX模式的API可以一定程度的解決一些記憶體溢出的問題,但POI還是有一些缺陷,比如07版Excel解壓縮以及解壓後存儲都是在記憶體中完成的,記憶體消耗依然很大。

百萬級别的excel資料導出功能,如果使用傳統的Apache POI架構去處理,可能會消耗很大的記憶體,容易引發OOM問題。

而easyexcel重寫了POI對07版Excel的解析,之前一個3M的excel用POI sax解析,需要100M左右記憶體,如果改用easyexcel可以降低到幾M,并且再大的Excel也不會出現記憶體溢出;03版依賴POI的sax模式,在上層做了模型轉換的封裝,讓使用者更加簡單友善。

需要在maven的pom.xml檔案中引入easyexcel的jar包:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.0.2</version>
</dependency>
複制代碼           

之後,使用起來非常友善。

讀excel資料非常友善:

@Test
public void simpleRead() {
    String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
    // 這裡 需要指定讀用哪個class去讀,然後讀取第一個sheet 檔案流會自動關閉
    EasyExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
複制代碼           

寫excel資料也非常友善:

@Test
public void simpleWrite() {
    String fileName = TestFileUtil.getPath() + "write" + System.currentTimeMillis() + ".xlsx";
    // 這裡 需要指定寫用哪個class去讀,然後寫到第一個sheet,名字為模闆 然後檔案流會自動關閉
    // 如果這裡想使用03 則 傳入excelType參數即可
    EasyExcel.write(fileName, DemoData.class).sheet("模闆").doWrite(data());
}
複制代碼           

easyexcel能大大減少占用記憶體的主要原因是:在解析Excel時沒有将檔案資料一次性全部加載到記憶體中,而是從磁盤上一行行讀取資料,逐個解析。

3.分頁查詢

百萬級别的資料,從資料庫一次性查詢出來,是一件非常耗時的工作。

即使我們可以從資料庫中一次性查詢出所有資料,沒出現連接配接逾時問題,這麼多的資料全部加載到應用服務的記憶體中,也有可能會導緻應用服務出現OOM問題。

是以,我們從資料庫中查詢資料時,有必要使用分頁查詢。比如:每頁5000條記錄,分為200頁查詢。

public Page<User> searchUser(SearchModel searchModel) {
    List<User> userList = userMapper.searchUser(searchModel);
    Page<User> pageResponse = Page.create(userList, searchModel);
    pageResponse.setTotal(userMapper.searchUserCount(searchModel));
    return pageResponse;
}
複制代碼           

每頁大小pageSize和頁碼pageNo,是SearchModel類中的成員變量,在建立searchModel對象時,可以設定設定這兩個參數。

然後在Mybatis的sql檔案中,通過limit語句實作分頁功能:

limit #{pageStart}, #{pageSize}
複制代碼           

其中的pagetStart參數,是通過pageNo和pageSize動态計算出來的,比如:

pageStart = (pageNo - 1) * pageSize;
複制代碼           

4.多個sheet

我們知道,excel對一個sheet存放的最大資料量,是有做限制的,一個sheet最多可以儲存1048576行資料。否則在儲存資料時會直接報錯:

invalid row number (1048576) outside allowable range (0..1048575)
複制代碼           

如果你想導出一百萬以上的資料,excel的一個sheet肯定是存放不下的。

是以我們需要把資料儲存到多個sheet中。

5.計算limit的起始位置

我之前說過,我們一般是通過limit語句來實作分頁查詢功能的:

limit #{pageStart}, #{pageSize}
複制代碼           

其中的pagetStart參數,是通過pageNo和pageSize動态計算出來的,比如:

pageStart = (pageNo - 1) * pageSize;
複制代碼           

如果隻有一個sheet可以這麼玩,但如果有多個sheet就會有問題。是以,我們需要重新計算limit的起始位置。

例如:

ExcelWriter excelWriter = EasyExcelFactory.write(out).build();
int totalPage = searchUserTotalPage(searchModel);

if(totalPage > 0) {
   Page<User> page = Page.create(searchModel);
   int sheet = (totalPage % maxSheetCount == 0) ? totalPage / maxSheetCount: (totalPage / maxSheetCount) + 1;
   for(int i=0;i<sheet;i++) {
      WriterSheet writeSheet = buildSheet(i,"sheet"+i);
      int startPageNo = i*(maxSheetCount/pageSize)+1;
      int endPageNo = (i+1)*(maxSheetCount/pageSize);
      while(page.getPageNo()>=startPageNo && page.getPageNo()<=endPageNo) {
        page = searchUser(searchModel);
        if(CollectionUtils.isEmpty(page.getList())) {
            break;
        }
        
        excelWriter.write(page.getList(),writeSheet);
        page.setPageNo(page.getPageNo()+1);
     }
   }
}
複制代碼           

這樣就能實作分頁查詢,将資料導出到不同的excel的sheet當中。

6.檔案上傳到OSS

由于現在我們導出excel資料的方案改成了異步,是以沒法直接将excel檔案,同步傳回給使用者。

是以我們需要先将excel檔案存放到一個地方,當使用者有需要時,可以通路到。

這時,我們可以直接将檔案上傳到OSS檔案伺服器上。

通過OSS提供的上傳接口,将excel上傳成功後,會傳回檔案名稱和通路路徑。

我們可以将excel名稱和通路路徑儲存到表中,這樣的話,後面就可以直接通過浏覽器,通路遠端excel檔案了。

而如果将excel檔案儲存到應用伺服器,可能會占用比較多的磁盤空間。

一般建議将應用伺服器和檔案伺服器分開,應用伺服器需要更多的記憶體資源或者CPU資源,而檔案伺服器需要更多的磁盤資源。

7.通過WebSocket推送通知

通過上面的功能已經導出了excel檔案,并且上傳到了OSS檔案伺服器上。

接下來的任務是要本次excel導出結果,成功還是失敗,通知目标使用者。

有種做法是在頁面上提示:正在導出excel資料,請耐心等待。

然後使用者可以主動重新整理目前頁面,擷取本地導出excel的結果。

但這種使用者互動功能,不太友好。

還有一種方式是通過webSocket建立長連接配接,進行實時通知推送。

如果你使用了SpringBoot架構,可以直接引入webSocket的相關jar包:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
複制代碼           

使用起來挺友善的。

我們可以加一張專門的通知表,記錄通過webSocket推送的通知的标題、使用者、附件位址、閱讀狀态、類型等資訊。

能更好的追溯通知記錄。

webSocket給用戶端推送一個通知之後,使用者的右上角的收件箱上,實時出現了一個小視窗,提示本次導出excel功能是成功還是失敗,并且有檔案下載下傳連結。

目前通知的閱讀狀态是未讀。

使用者點選該視窗,可以看到通知的詳細内容,然後通知狀态變成已讀。

8.總條數可配置

我們在做導百萬級資料這個需求時,是給使用者用的,也有可能是給營運同學用的。

其實我們應該站在實際使用者的角度出發,去思考一下,這個需求是否合理。

使用者拿到這個百萬級别的excel檔案,到底有什麼用途,在他們的電腦上能否打開該excel檔案,電腦是否會出現太大的卡頓了,導緻檔案使用不了。

如果該功能上線之後,真的發生發生這些情況,那麼導出excel也沒有啥意義了。

是以,非常有必要把記錄的總條數,做成可配置的,可以根據使用者的實際情況調整這個配置。

比如:使用者發現excel中有50萬的資料,可以正常通路和操作excel,這時候我們可以将總條數調整成500000,把多餘的資料截取掉。

其實,在使用者的操作界面,增加更多的查詢條件,使用者通過修改查詢條件,多次導資料,可以實作将所有資料都導出的功能,這樣可能更合理一些。

此外,分頁查詢時,每頁的大小,也建議做成可配置的。

通過總條數和每頁大小,可以動态調整記錄數量和分頁查詢次數,有助于更好滿足使用者的需求。

9.order by商品編号

之前的需求是要将相同商品編号的資料放到一起。

例如:

編号 商品名稱 倉庫名稱 價格
1 筆記本 北京倉 7234
1 筆記本 上海倉 7235
1 筆記本 武漢倉 7236
2 平闆電腦 成都倉 7236
2 平闆電腦 大連倉 3339

但我們做了分頁查詢的功能,沒法将資料一次性查詢出來,直接在Java記憶體中分組或者排序。

是以,我們需要考慮在sql語句中使用order by 商品編号,先把資料排好順序,再查詢出資料,這樣就能将相同商品編号,倉庫不同的資料放到一起。

此外,還有一種情況需要考慮一下,通過配置的總記錄數将全部資料做了截取。

但如果最後一個商品編号在最後一頁中沒有查詢完,可能會導緻導出的最後一個商品的資料不完整。

是以,我們需要在程式中處理一下,将最後一個商品删除。

但加了order by關鍵字進行排序之後,如果查詢sql中join了很多張表,可能會導緻查詢性能變差。

那麼,該怎麼辦呢?

總結

最後用兩張圖,總結一下excel異步導資料的流程。

如果是使用mq導資料:

百萬級資料excel導出功能如何實作?

如果是使用job導資料:

百萬級資料excel導出功能如何實作?

這兩種方式都可以,可以根據實際情況選擇使用。

EasyExcel簡介

EasyExcel是一款阿裡開源的Excel導入導出工具,具有處理快速、占用記憶體小、使用友善的特點,在Github上已有22k+Star,可見其非常流行。

EasyExcel讀取75M(46W行25列)的Excel,僅需使用64M記憶體,耗時20s,極速模式還可以更快!

百萬級資料excel導出功能如何實作?

內建

在SpringBoot中內建EasyExcel非常簡單,僅需一個依賴即可。
<!--EasyExcel相關依賴-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.0.5</version>
</dependency>
複制代碼           

使用

EasyExcel和EasyPoi的使用非常類似,都是通過注解來控制導入導出。接下來我們以會員資訊和訂單資訊的導入導出為例,分别實作下簡單的單表導出和具有一對多關系的複雜導出。

簡單導出

我們以會員資訊的導出為例,來體驗下EasyExcel的導出功能。
  • 首先建立一個會員對象Member,封裝會員資訊,這裡使用了EasyExcel的注解;
/**
 * 購物會員
 * Created by macro on 2021/10/12.
 */
@Data
@EqualsAndHashCode(callSuper = false)
public class Member {
    @ExcelProperty("ID")
    @ColumnWidth(10)
    private Long id;
    @ExcelProperty("使用者名")
    @ColumnWidth(20)
    private String username;
    @ExcelIgnore
    private String password;
    @ExcelProperty("昵稱")
    @ColumnWidth(20)
    private String nickname;
    @ExcelProperty("出生日期")
    @ColumnWidth(20)
    @DateTimeFormat("yyyy-MM-dd")
    private Date birthday;
    @ExcelProperty("手機号")
    @ColumnWidth(20)
    private String phone;
    @ExcelIgnore
    private String icon;
    @ExcelProperty(value = "性别", converter = GenderConverter.class)
    @ColumnWidth(10)
    private Integer gender;
}
複制代碼           
  • 上面代碼使用到了EasyExcel的核心注解,我們分别來了解下: @ExcelProperty:核心注解,value屬性可用來設定表頭名稱,converter屬性可以用來設定類型轉換器; @ColumnWidth:用于設定表格列的寬度; @DateTimeFormat:用于設定日期轉換格式。
  • 在EasyExcel中,如果你想實作枚舉類型到字元串的轉換(比如gender屬性中,0->男,1->女),需要自定義轉換器,下面為自定義的GenderConverter代碼實作;
/**
 * excel性别轉換器
 * Created by macro on 2021/12/29.
 */
public class GenderConverter implements Converter<Integer> {
    @Override
    public Class<?> supportJavaTypeKey() {
        //對象屬性類型
        return Integer.class;
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        //CellData屬性類型
        return CellDataTypeEnum.STRING;
    }

    @Override
    public Integer convertToJavaData(ReadConverterContext<?> context) throws Exception {
        //CellData轉對象屬性
        String cellStr = context.getReadCellData().getStringValue();
        if (StrUtil.isEmpty(cellStr)) return null;
        if ("男".equals(cellStr)) {
            return 0;
        } else if ("女".equals(cellStr)) {
            return 1;
        } else {
            return null;
        }
    }

    @Override
    public WriteCellData<?> convertToExcelData(WriteConverterContext<Integer> context) throws Exception {
        //對象屬性轉CellData
        Integer cellValue = context.getValue();
        if (cellValue == null) {
            return new WriteCellData<>("");
        }
        if (cellValue == 0) {
            return new WriteCellData<>("男");
        } else if (cellValue == 1) {
            return new WriteCellData<>("女");
        } else {
            return new WriteCellData<>("");
        }
    }
}
複制代碼           
  • 接下來我們在Controller中添加一個接口,用于導出會員清單到Excel,還需給響應頭設定下載下傳excel的屬性,具體代碼如下;
/**
 * EasyExcel導入導出測試Controller
 * Created by macro on 2021/10/12.
 */
@Controller
@Api(tags = "EasyExcelController", description = "EasyExcel導入導出測試")
@RequestMapping("/easyExcel")
public class EasyExcelController {

    @SneakyThrows(IOException.class)
    @ApiOperation(value = "導出會員清單Excel")
    @RequestMapping(value = "/exportMemberList", method = RequestMethod.GET)
    public void exportMemberList(HttpServletResponse response) {
        setExcelRespProp(response, "會員清單");
        List<Member> memberList = LocalJsonUtil.getListFromJson("json/members.json", Member.class);
        EasyExcel.write(response.getOutputStream())
                .head(Member.class)
                .excelType(ExcelTypeEnum.XLSX)
                .sheet("會員清單")
                .doWrite(memberList);
    }
    
  /**
   * 設定excel下載下傳響應頭屬性
   */
  private void setExcelRespProp(HttpServletResponse response, String rawFileName) throws UnsupportedEncodingException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setCharacterEncoding("utf-8");
    String fileName = URLEncoder.encode(rawFileName, "UTF-8").replaceAll("\+", "%20");
    response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
  }
}
複制代碼           
  • 運作項目,通過Swagger測試接口,注意在Swagger中通路接口無法直接下載下傳,需要點選傳回結果中的下載下傳按鈕才行,通路位址:http://localhost:8088/swagger-ui/
百萬級資料excel導出功能如何實作?
  • 下載下傳完成後,檢視下檔案,一個标準的Excel檔案已經被導出了。
百萬級資料excel導出功能如何實作?

簡單導入

接下來我們以會員資訊的導入為例,來體驗下EasyExcel的導入功能。
  • 在Controller中添加會員資訊導入的接口,這裡需要注意的是使用@RequestPart注解修飾檔案上傳參數,否則在Swagger中就沒法顯示上傳按鈕了;
/**
 * EasyExcel導入導出測試Controller
 * Created by macro on 2021/10/12.
 */
@Controller
@Api(tags = "EasyExcelController", description = "EasyExcel導入導出測試")
@RequestMapping("/easyExcel")
public class EasyExcelController {
    
    @SneakyThrows
    @ApiOperation("從Excel導入會員清單")
    @RequestMapping(value = "/importMemberList", method = RequestMethod.POST)
    @ResponseBody
    public CommonResult importMemberList(@RequestPart("file") MultipartFile file) {
        List<Member> memberList = EasyExcel.read(file.getInputStream())
                .head(Member.class)
                .sheet()
                .doReadSync();
        return CommonResult.success(memberList);
    }
}
複制代碼           
  • 然後在Swagger中測試接口,選擇之前導出的Excel檔案即可,導入成功後會傳回解析到的資料。
百萬級資料excel導出功能如何實作?

複雜導出

當然EasyExcel也可以實作更加複雜的導出,比如導出一個嵌套了商品資訊的訂單清單,下面我們來實作下!

使用EasyPoi實作

之前我們使用過EasyPoi實作該功能,由于EasyPoi本來就支援嵌套對象的導出,直接使用内置的@ExcelCollection注解即可實作,非常友善也符合面向對象的思想。

百萬級資料excel導出功能如何實作?

尋找方案

由于EasyExcel本身并不支援這種一對多的資訊導出,是以我們得自行實作下,這裡分享一個我平時常用的快速查找解決方案的辦法。

我們可以直接從開源項目的issues裡面去搜尋,比如搜尋下一對多,會直接找到有無一對多導出比較優雅的方案這個issue。

百萬級資料excel導出功能如何實作?

從此issue的回複我們可以發現,項目維護者建議建立自定義合并政策來實作,有位回複的老哥已經給出了實作代碼,接下來我們就用這個方案來實作下。

百萬級資料excel導出功能如何實作?

解決思路

為什麼自定義單元格合并政策能實作一對多的清單資訊的導出呢?首先我們來看下将嵌套資料平鋪,不進行合并導出的Excel。

百萬級資料excel導出功能如何實作?

看完之後我們很容易了解解決思路,隻要把訂單ID相同的列中需要合并的列給合并了,就可以實作這種一對多嵌套資訊的導出了。

實作過程

  • 首先我們得把原來嵌套的訂單商品資訊給平鋪了,建立一個專門的導出對象OrderData,包含訂單和商品資訊,二級表頭可以通過設定@ExcelProperty的value為數組來實作;
/**
 * 訂單導出
 * Created by macro on 2021/12/30.
 */
@Data
@EqualsAndHashCode(callSuper = false)
public class OrderData {
    @ExcelProperty(value = "訂單ID")
    @ColumnWidth(10)
    @CustomMerge(needMerge = true, isPk = true)
    private String id;
    @ExcelProperty(value = "訂單編碼")
    @ColumnWidth(20)
    @CustomMerge(needMerge = true)
    private String orderSn;
    @ExcelProperty(value = "建立時間")
    @ColumnWidth(20)
    @DateTimeFormat("yyyy-MM-dd")
    @CustomMerge(needMerge = true)
    private Date createTime;
    @ExcelProperty(value = "收貨位址")
    @CustomMerge(needMerge = true)
    @ColumnWidth(20)
    private String receiverAddress;
    @ExcelProperty(value = {"商品資訊", "商品編碼"})
    @ColumnWidth(20)
    private String productSn;
    @ExcelProperty(value = {"商品資訊", "商品名稱"})
    @ColumnWidth(20)
    private String name;
    @ExcelProperty(value = {"商品資訊", "商品标題"})
    @ColumnWidth(30)
    private String subTitle;
    @ExcelProperty(value = {"商品資訊", "品牌名稱"})
    @ColumnWidth(20)
    private String brandName;
    @ExcelProperty(value = {"商品資訊", "商品價格"})
    @ColumnWidth(20)
    private BigDecimal price;
    @ExcelProperty(value = {"商品資訊", "商品數量"})
    @ColumnWidth(20)
    private Integer count;
}
複制代碼           
  • 然後将原來嵌套的Order對象清單轉換為OrderData對象清單;
/**
 * EasyExcel導入導出測試Controller
 * Created by macro on 2021/10/12.
 */
@Controller
@Api(tags = "EasyExcelController", description = "EasyExcel導入導出測試")
@RequestMapping("/easyExcel")
public class EasyExcelController {
    private List<OrderData> convert(List<Order> orderList) {
        List<OrderData> result = new ArrayList<>();
        for (Order order : orderList) {
            List<Product> productList = order.getProductList();
            for (Product product : productList) {
                OrderData orderData = new OrderData();
                BeanUtil.copyProperties(product,orderData);
                BeanUtil.copyProperties(order,orderData);
                result.add(orderData);
            }
        }
        return result;
    }
}
複制代碼           
  • 再建立一個自定義注解CustomMerge,用于标記哪些屬性需要合并,哪個是主鍵;
/**
 * 自定義注解,用于判斷是否需要合并以及合并的主鍵
 */
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface CustomMerge {

    /**
     * 是否需要合并單元格
     */
    boolean needMerge() default false;

    /**
     * 是否是主鍵,即該字段相同的行合并
     */
    boolean isPk() default false;
}
複制代碼           
  • 再建立自定義單元格合并政策類CustomMergeStrategy,當Excel中兩列主鍵相同時,合并被标記需要合并的列;
/**
 * 自定義單元格合并政策
 */
public class CustomMergeStrategy implements RowWriteHandler {
    /**
     * 主鍵下标
     */
    private Integer pkIndex;

    /**
     * 需要合并的列的下标集合
     */
    private List<Integer> needMergeColumnIndex = new ArrayList<>();

    /**
     * DTO資料類型
     */
    private Class<?> elementType;

    public CustomMergeStrategy(Class<?> elementType) {
        this.elementType = elementType;
    }

    @Override
    public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {
        // 如果是标題,則直接傳回
        if (isHead) {
            return;
        }

        // 擷取目前sheet
        Sheet sheet = writeSheetHolder.getSheet();

        // 擷取标題行
        Row titleRow = sheet.getRow(0);

        if (null == pkIndex) {
            this.lazyInit(writeSheetHolder);
        }

        // 判斷是否需要和上一行進行合并
        // 不能和标題合并,隻能資料行之間合并
        if (row.getRowNum() <= 1) {
            return;
        }
        // 擷取上一行資料
        Row lastRow = sheet.getRow(row.getRowNum() - 1);
        // 将本行和上一行是同一類型的資料(通過主鍵字段進行判斷),則需要合并
        if (lastRow.getCell(pkIndex).getStringCellValue().equalsIgnoreCase(row.getCell(pkIndex).getStringCellValue())) {
            for (Integer needMerIndex : needMergeColumnIndex) {
                CellRangeAddress cellRangeAddress = new CellRangeAddress(row.getRowNum() - 1, row.getRowNum(),
                        needMerIndex, needMerIndex);
                sheet.addMergedRegionUnsafe(cellRangeAddress);
            }
        }
    }

    /**
     * 初始化主鍵下标和需要合并字段的下标
     */
    private void lazyInit(WriteSheetHolder writeSheetHolder) {

        // 擷取目前sheet
        Sheet sheet = writeSheetHolder.getSheet();

        // 擷取标題行
        Row titleRow = sheet.getRow(0);
        // 擷取DTO的類型
        Class<?> eleType = this.elementType;

        // 擷取DTO所有的屬性
        Field[] fields = eleType.getDeclaredFields();

        // 周遊所有的字段,因為是基于DTO的字段來建構excel,是以字段數 >= excel的列數
        for (Field theField : fields) {
            // 擷取@ExcelProperty注解,用于擷取該字段對應在excel中的列的下标
            ExcelProperty easyExcelAnno = theField.getAnnotation(ExcelProperty.class);
            // 為空,則表示該字段不需要導入到excel,直接處理下一個字段
            if (null == easyExcelAnno) {
                continue;
            }
            // 擷取自定義的注解,用于合并單元格
            CustomMerge customMerge = theField.getAnnotation(CustomMerge.class);

            // 沒有@CustomMerge注解的預設不合并
            if (null == customMerge) {
                continue;
            }

            for (int index = 0; index < fields.length; index++) {
                Cell theCell = titleRow.getCell(index);
                // 當配置為不需要導出時,傳回的為null,這裡作一下判斷,防止NPE
                if (null == theCell) {
                    continue;
                }
                // 将字段和excel的表頭比對上
                if (easyExcelAnno.value()[0].equalsIgnoreCase(theCell.getStringCellValue())) {
                    if (customMerge.isPk()) {
                        pkIndex = index;
                    }

                    if (customMerge.needMerge()) {
                        needMergeColumnIndex.add(index);
                    }
                }
            }
        }

        // 沒有指定主鍵,則異常
        if (null == this.pkIndex) {
            throw new IllegalStateException("使用@CustomMerge注解必須指定主鍵");
        }

    }
}
複制代碼           
  • 接下來在Controller中添加導出訂單清單的接口,将我們自定義的合并政策CustomMergeStrategy給注冊上去;
/**
 * EasyExcel導入導出測試Controller
 * Created by macro on 2021/10/12.
 */
@Controller
@Api(tags = "EasyExcelController", description = "EasyExcel導入導出測試")
@RequestMapping("/easyExcel")
public class EasyExcelController {
    
    @SneakyThrows
    @ApiOperation(value = "導出訂單清單Excel")
    @RequestMapping(value = "/exportOrderList", method = RequestMethod.GET)
    public void exportOrderList(HttpServletResponse response) {
        List<Order> orderList = getOrderList();
        List<OrderData> orderDataList = convert(orderList);
        setExcelRespProp(response, "訂單清單");
        EasyExcel.write(response.getOutputStream())
                .head(OrderData.class)
                .registerWriteHandler(new CustomMergeStrategy(OrderData.class))
                .excelType(ExcelTypeEnum.XLSX)
                .sheet("訂單清單")
                .doWrite(orderDataList);
    }
}
複制代碼           
  • 在Swagger中通路接口測試,導出訂單清單對應Excel;
百萬級資料excel導出功能如何實作?
  • 下載下傳完成後,檢視下檔案,由于EasyExcel需要自己來實作,對比之前使用EasyPoi來實作麻煩了不少。
百萬級資料excel導出功能如何實作?

其他使用

由于EasyExcel的官方文檔介紹的比較簡單,如果你想要更深入地進行使用的話,建議大家看下官方Demo。

百萬級資料excel導出功能如何實作?

總結

作者:MacroZheng

連結:https://juejin.cn/post/7051751438715715620

來源:稀土掘金

著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

最後說一句(求關注,别白嫖我)

如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維碼關注一下,您的支援是我堅持寫作最大的動力。

求一鍵三連:點贊、轉發、在看。

繼續閱讀