如何優雅的導出Excel

前言

公司項目最近有一個須要:報表導出。整個系統下來,起碼超過一百張報表須要導出。這個時候如何優雅的實現報表導出,釋放生產力就顯得很重要了。下面主要給你們分享一下該工具類的使用方法與實現思路。java

實現的功能點

對於每一個報表都相同的操做,咱們很天然的會抽離出來,這個很簡單。而最重要的是:如何把那些每一個報表不相同的操做進行良好的封裝,儘量的提升複用性;針對以上的原則,主要實現了一下關鍵功能點:git

  • 導出任意類型的數據
  • 自由設置表頭
  • 自由設置字段的導出格式

使用實例

上面說到了本工具類實現了三個功能點,天然在使用的時候設置好這三個要點便可:github

  • 設置數據列表
  • 設置表頭
  • 設置字段格式

下面的export函數能夠直接向客戶端返回一個excel數據,其中productInfoPos爲待導出的數據列表,ExcelHeaderInfo用來保存表頭信息,包括表頭名稱,表頭的首列,尾列,首行,尾行。由於默認導出的數據格式都是字符串型,因此還須要一個Map參數用來指定某個字段的格式化類型(例如數字類型,小數類型、日期類型)。這裏你們知道個大概怎麼使用就行了,下面會對這些參數進行詳細解釋。spring

@Override
    public void export(HttpServletResponse response, String fileName) {
        // 待導出數據
        List<TtlProductInfoPo> productInfoPos = this.multiThreadListProduct();
        ExcelUtils excelUtils = new ExcelUtils(productInfoPos, getHeaderInfo(), getFormatInfo());
        excelUtils.sendHttpResponse(response, fileName, excelUtils.getWorkbook());
    }

    // 獲取表頭信息
    private List<ExcelHeaderInfo> getHeaderInfo() {
        return Arrays.asList(
                new ExcelHeaderInfo(1, 1, 0, 0, "id"),
                new ExcelHeaderInfo(1, 1, 1, 1, "商品名稱"),

                new ExcelHeaderInfo(0, 0, 2, 3, "分類"),
                new ExcelHeaderInfo(1, 1, 2, 2, "類型ID"),
                new ExcelHeaderInfo(1, 1, 3, 3, "分類名稱"),

                new ExcelHeaderInfo(0, 0, 4, 5, "品牌"),
                new ExcelHeaderInfo(1, 1, 4, 4, "品牌ID"),
                new ExcelHeaderInfo(1, 1, 5, 5, "品牌名稱"),

                new ExcelHeaderInfo(0, 0, 6, 7, "商店"),
                new ExcelHeaderInfo(1, 1, 6, 6, "商店ID"),
                new ExcelHeaderInfo(1, 1, 7, 7, "商店名稱"),

                new ExcelHeaderInfo(1, 1, 8, 8, "價格"),
                new ExcelHeaderInfo(1, 1, 9, 9, "庫存"),
                new ExcelHeaderInfo(1, 1, 10, 10, "銷量"),
                new ExcelHeaderInfo(1, 1, 11, 11, "插入時間"),
                new ExcelHeaderInfo(1, 1, 12, 12, "更新時間"),
                new ExcelHeaderInfo(1, 1, 13, 13, "記錄是否已經刪除")
        );
    }

    // 獲取格式化信息
    private Map<String, ExcelFormat> getFormatInfo() {
        Map<String, ExcelFormat> format = new HashMap<>();
        format.put("id", ExcelFormat.FORMAT_INTEGER);
        format.put("categoryId", ExcelFormat.FORMAT_INTEGER);
        format.put("branchId", ExcelFormat.FORMAT_INTEGER);
        format.put("shopId", ExcelFormat.FORMAT_INTEGER);
        format.put("price", ExcelFormat.FORMAT_DOUBLE);
        format.put("stock", ExcelFormat.FORMAT_INTEGER);
        format.put("salesNum", ExcelFormat.FORMAT_INTEGER);
        format.put("isDel", ExcelFormat.FORMAT_INTEGER);
        return format;
    }
複製代碼

實現效果

源碼分析

哈哈,本身分析本身的代碼,有點意思。因爲不方便貼出太多的代碼,你們能夠先到github上clone源碼,再回來閱讀文章。✨源碼地址✨ LZ使用的poi 4.0.1版本的這個工具,想要實用海量數據的導出天然得使用SXSSFWorkbook這個組件。關於poi的具體用法在這裏我就很少說了,這裏主要是給你們講解如何對poi進行封裝使用。sql

成員變量

咱們重點看ExcelUtils這個類,這個類是實現導出的核心,先來看一下三個成員變量。編程

private List list;
    private List<ExcelHeaderInfo> excelHeaderInfos;
    private Map<String, ExcelFormat> formatInfo;
複製代碼
list

該成員變量用來保存待導出的數據。api

ExcelHeaderInfo

該成員變量主要用來保存表頭信息,由於咱們須要定義多個表頭信息,因此須要使用一個列表來保存,ExcelHeaderInfo構造函數以下 ExcelHeaderInfo(int firstRow, int lastRow, int firstCol, int lastCol, String title)數組

  • firstRow:該表頭所佔位置的首行
  • lastRow:該表頭所佔位置的尾行
  • firstCol:該表頭所佔位置的首列
  • lastCol:該表頭所佔位置的尾行
  • title:該表頭的名稱
ExcelFormat

該參數主要用來格式化字段,咱們須要預先約定好轉換成那種格式,不能隨用戶本身定。因此咱們定義了一個枚舉類型的變量,該枚舉類只有一個字符串類型成員變量,用來保存想要轉換的格式,例如FORMAT_INTEGER就是轉換成整型。由於咱們須要接受多個字段的轉換格式,因此定義了一個Map類型來接收,該參數能夠省略(默認格式爲字符串)。瀏覽器

public enum ExcelFormat {

    FORMAT_INTEGER("INTEGER"),
    FORMAT_DOUBLE("DOUBLE"),
    FORMAT_PERCENT("PERCENT"),
    FORMAT_DATE("DATE");

    private String value;

    ExcelFormat(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}
複製代碼

核心方法

1. 建立表頭

該方法用來初始化表頭,而建立表頭最關鍵的就是poi中Sheet類的addMergedRegion(CellRangeAddress var1)方法,該方法用於單元格融合。咱們會遍歷ExcelHeaderInfo列表,按照每一個ExcelHeaderInfo的座標信息進行單元格融合,而後在融合以後的每一個單元首行首列的位置建立單元格,而後爲單元格賦值便可,經過上面的步驟就完成了任意類型的表頭設置。bash

// 建立表頭
    private void createHeader(Sheet sheet, CellStyle style) {
        for (ExcelHeaderInfo excelHeaderInfo : excelHeaderInfos) {
            Integer lastRow = excelHeaderInfo.getLastRow();
            Integer firstRow = excelHeaderInfo.getFirstRow();
            Integer lastCol = excelHeaderInfo.getLastCol();
            Integer firstCol = excelHeaderInfo.getFirstCol();

            // 行距或者列距大於0才進行單元格融合
            if ((lastRow - firstRow) != 0 || (lastCol - firstCol) != 0) {
                sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol));
            }
            // 獲取當前表頭的首行位置
            Row row = sheet.getRow(firstRow);
            // 在表頭的首行與首列位置建立一個新的單元格
            Cell cell = row.createCell(firstCol);
            // 賦值單元格
            cell.setCellValue(excelHeaderInfo.getTitle());
            cell.setCellStyle(style);
            sheet.setColumnWidth(firstCol, sheet.getColumnWidth(firstCol) * 17 / 12);
        }
    }
複製代碼
2. 轉換數據

在進行正文賦值以前,咱們先要對原始數據列表轉換成字符串的二維數組,之因此轉成字符串格式是由於能夠統一的處理各類類型,以後有須要咱們再轉換回來便可。

// 將原始數據轉成二維數組
    private String[][] transformData() {
        int dataSize = this.list.size();
        String[][] datas = new String[dataSize][];
        // 獲取報表的列數
        Field[] fields = list.get(0).getClass().getDeclaredFields();
        // 獲取實體類的字段名稱數組
        List<String> columnNames = this.getBeanProperty(fields);
        for (int i = 0; i < dataSize; i++) {
            datas[i] = new String[fields.length];
            for (int j = 0; j < fields.length; j++) {
                try {
                    // 賦值
                    datas[i][j] = BeanUtils.getProperty(list.get(i), columnNames.get(j));
                } catch (Exception e) {
                    LOGGER.error("獲取對象屬性值失敗");
                    e.printStackTrace();
                }
            }
        }
        return datas;
    }
複製代碼

這個方法中咱們經過使用反射技術,很巧妙的實現了任意類型的數據導出(這裏的任意類型指的是任意的報表類型,不一樣的報表,導出的數據確定是不同的,那麼在Java實現中的實體類確定也是不同的)。要想將一個List轉換成相應的二維數組,咱們得知道以下的信息:

  • 二維數組的列數
  • 二維數組的行數
  • 二維數組每一個元素的值

若是獲取以上三個信息呢?

  • 經過反射中的Field[] getDeclaredFields()這個方法獲取實體類的全部字段,從而間接知道一共有多少列
  • List的大小不就是二維數組的行數了嘛
  • 雖然每一個實體類的字段名不同,那麼咱們就真的沒法獲取到實體類某個字段的值了嗎?不是的,你要知道,你擁有了反射,你就至關於擁有了全世界,那還有什麼作不到的呢。這裏咱們沒有直接使用反射,而是使用了一個叫作BeanUtils的工具,該工具能夠很方便的幫助咱們對一個實體類進行字段的賦值與字段值的獲取。很簡單,經過BeanUtils.getProperty(list.get(i), columnNames.get(j))這一行代碼,咱們就獲取了實體list.get(i)中名稱爲columnNames.get(j)這個字段的值。list.get(i)固然是咱們遍歷原始數據的實體類,而columnNames列表則是一個實體類全部字段名的數組,也是經過反射的方法獲取到的,具體實現能夠參考LZ的源代碼。
3. 賦值正文

這裏的正文指定是正式的表格數據內容,其實這一些沒有太多的奇淫技巧,主要的功能在上面已經實現了,這裏主要是進行單元格的賦值與導出格式的處理(主要是爲了導出excel後能夠進行方便的運算)。

// 建立正文
    private void createContent(Row row, CellStyle style, String[][] content, int i, Field[] fields) {
        List<String> columnNames = getBeanProperty(fields);
        for (int j = 0; j < columnNames.size(); j++) {
            if (formatInfo == null) {
                row.createCell(j).setCellValue(content[i][j]);
                continue;
            }
            if (formatInfo.containsKey(columnNames.get(j))) {
                switch (formatInfo.get(columnNames.get(j)).getValue()) {
                    case "DOUBLE":
                        row.createCell(j).setCellValue(Double.parseDouble(content[i][j]));
                        break;
                    case "INTEGER":
                        row.createCell(j).setCellValue(Integer.parseInt(content[i][j]));
                        break;
                    case "PERCENT":
                        style.setDataFormat(HSSFDataFormat.getBuiltinFormat("0.00%"));
                        Cell cell = row.createCell(j);
                        cell.setCellStyle(style);
                        cell.setCellValue(Double.parseDouble(content[i][j]));
                        break;
                    case "DATE":
                        row.createCell(j).setCellValue(this.parseDate(content[i][j]));
                }
            } else {
                row.createCell(j).setCellValue(content[i][j]);
            }
        }
    }
複製代碼

導出工具類的核心方法就差很少說完了,下面說一下關於多線程查詢的問題。

多扯兩點

1. 多線程查詢數據

理想很豐滿,現實仍是有點骨感的。LZ雖然對50w的數據分別建立20個線程去查詢,可是整體的效率並非50w/20,而是僅僅快了幾秒鐘,知道緣由的小夥伴能夠給我留個言一塊兒探討一下。

下面先說說具體思路:由於多個線程之間是同時執行的,你不可以保證哪一個線程先執行完畢,可是咱們卻得保證數據順序的一致性。在這裏咱們使用了Callable接口,經過實現Callable接口的線程能夠擁有返回值,咱們獲取到全部子線程的查詢結果,而後合併到一個結果集中便可。那麼如何保證合併的順序呢?咱們先建立了一個FutureTask類型的List,該FutureTask的類型就是返回的結果集。

List<FutureTask<List<TtlProductInfoPo>>> tasks = new ArrayList<>();
複製代碼

當咱們每啓動一個線程的時候,就將該線程的FutureTask添加到tasks列表中,這樣tasks列表中的元素順序就是咱們啓動線程的順序。

FutureTask<List<TtlProductInfoPo>> task = new FutureTask<>(new listThread(map));
            log.info("開始查詢第{}條開始的{}條記錄", i * THREAD_MAX_ROW, THREAD_MAX_ROW);
            new Thread(task).start();
            // 將任務添加到tasks列表中
            tasks.add(task);
複製代碼

接下來,就是順序塞值了,咱們按順序從tasks列表中取出FutureTask,而後執行FutureTaskget()方法,該方法會阻塞調用它的線程,知道拿到返回結果。這樣一套循環下來,就完成了全部數據的按順序存儲。

for (FutureTask<List<TtlProductInfoPo>> task : tasks) {
            try {
                productInfoPos.addAll(task.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
複製代碼

2. 如何解決接口超時

若是須要導出海量數據,可能會存在一個問題:接口超時,主要緣由就是整個導出過程的時間太長了。其實也很好解決,接口的響應時間太長,咱們縮短響應時間不就能夠了嘛。咱們使用異步編程解決方案,異步編程的實現方式有不少,這裏咱們使用最簡單的spring中的Async註解,加上了這個註解的方法能夠立馬返回響應結果。關於註解的使用方式,你們能夠本身查閱一下,下面講一下關鍵的實現步驟:

  1. 編寫異步接口,該接口負責接收客戶端的導出請求,而後開始執行導出(注意:這裏的導出不是直接向客戶端返回,而是下載到服務器本地),只要下達了導出指令,就能夠立刻給客戶端返回一個該excel文件的惟一標誌(用於之後查找該文件),接口結束。
  2. 編寫excel狀態接口,客戶端拿到excel文件的惟一標誌以後,開始每秒輪詢調用該接口檢查excel文件的導出狀態
  3. 編寫從服務器本地返回excel文件接口,若是客戶端檢查到excel已經成功下載到到服務器本地,這個時候就能夠請求該接口直接下載文件了。

這樣就能夠解決接口超時的問題了。

源碼地址

github.com/dearKundy/e…

源碼服用姿式

  1. 建表(數據本身插入哦)
CREATE TABLE `ttl_product_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '記錄惟一標識',
  `product_name` varchar(50) NOT NULL COMMENT '商品名稱',
  `category_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '類型ID',
  `category_name` varchar(50) NOT NULL COMMENT '冗餘分類名稱-避免跨表join',
  `branch_id` bigint(20) NOT NULL COMMENT '品牌ID',
  `branch_name` varchar(50) NOT NULL COMMENT '冗餘品牌名稱-避免跨表join',
  `shop_id` bigint(20) NOT NULL COMMENT '商品ID',
  `shop_name` varchar(50) NOT NULL COMMENT '冗餘商店名稱-避免跨表join',
  `price` decimal(10,2) NOT NULL COMMENT '商品當前價格-屬於熱點數據,並且價格變化須要記錄,須要價格詳情表',
  `stock` int(11) NOT NULL COMMENT '庫存-熱點數據',
  `sales_num` int(11) NOT NULL COMMENT '銷量',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '插入時間',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
  `is_del` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '記錄是否已經刪除',
  PRIMARY KEY (`id`),
  KEY `idx_shop_category_salesnum` (`shop_id`,`category_id`,`sales_num`),
  KEY `idx_category_branch_price` (`category_id`,`branch_id`,`price`),
  KEY `idx_productname` (`product_name`)
) ENGINE=InnoDB AUTO_INCREMENT=15000001 DEFAULT CHARSET=utf8 COMMENT='商品信息表';
複製代碼
  1. 運行程序
  2. 在瀏覽器的地址欄輸入:http://localhost:8080/api/excelUtils/export便可完成下載

拉票環節

本次文章就寫到這裏啦,喜歡的朋友能夠點贊、評論、加關注哦!

相關文章
相關標籤/搜索