Freemarker導出複雜Excel圖文教程

簡介

使用Freemarker導出Excel,比用poi操作Excel的方式要簡單的很多,尤其像那種首行是表頭,剩餘行是數據的Excel,Freemarker幾行代碼就可以搞定。可是如果出現合併單元格、合併行的複雜Excel導出時,Freemarker的模板的插值也會變得複雜,但還是要比poi簡單的多,用過Freemarker後,只要Freemarker能做到的,再也不想用poi導出Excel了。當然也不是說Freemarker能完全代替poi,有些特殊的情況還得需要poi來處理,比如說導出的Excel中帶有圖片等。本文將介紹一個使用Freemarker導出複雜Excel的實例,包含了合併行的情況,並通過圖文對照的方式,讓你快速上手Freemarker導出Excel。網上的文章大都在講解簡單的Excel導出,複雜的導出的講解確實很少,因爲寫一篇這樣的文章確實費時費力,爲了分享給大家有用的技術,所以花時間整理了兩篇文章,下一篇將講解《Freemarker結合POI導出帶有圖片的Excel》,請多關注。

Freemarker導出Excel的思想和步驟

  1. 將Excel文件由xlsxlsx格式,通過Excel另存爲的方式轉爲xml格式。
  2. 對xml模板內的數據插值
  3. 用本文提供的Freemarker導出Excel工具類導出

核心步驟就這三個步驟,接下來將分步詳細講解。

一.Excel另存爲xml格式

通過Excel中另存爲,將Excel由xls格式保存爲xml格式。

例:最新版的Office365另存爲中,選擇XML電子表格2003(*.xml)

遇到問題(坑):
導出的Excel中的模板上的中文亂碼,但是通過變量賦值進去的中文不會亂碼?

分析問題:
通過修改程序的編碼是解決不了的,因爲發現通過Freemarker插值的中文數據時不亂碼的,通過修改Freemarker寫入編碼,只能解決插值中的亂碼,但不能解決模板本身的亂碼。
Xml模板用Excel打開是不亂碼的。這個問題困擾了好久,最終換了臺電腦轉換xml模板,發現模板是不亂碼的,所以,定位問題:另存爲Excel的編碼出現問題。

解決問題:
另存爲時,選擇更多選項,在點擊工具,選擇web選項,在編碼選項卡中選擇UTF-8編碼。

解決問題心法:
遇事不慌,要尋找不同維度的信息,進行交叉驗證。我花費了很長的時間在同一臺電腦上折騰,從《信息論》角度來看,只是用同樣的信息重負勞動。後來換了臺電腦,則是用不同的信息,進行交叉驗證,才最終定位了問題。定位問題花費的時間往往是解決問題時間的數倍,所以思考問題要從不同角度去思考,視角很重要。

二.對xml模板內的數據進行插值

插值思路

  1. 保證xml模板中至少有2條假數據(新手尤其重要,因爲你不熟悉Excel的XML結構,尤其是複雜Excel,結構也很複雜)。
  2. 找出Excel模板中第一行數據在xml模板中對應的位置。
  3. 定義相關變量,解析數據源並插值。

案例講解

以一個複雜案例,講解複雜的Excel模板結構,以及如何插值,如下圖:
本文講解示例Excel結構圖

案例分析:

  1. 這個Excel,包含了合併行,以及合計計算等等,如果用poi操作,工作量實在很大,但Freemarker卻減少了很多的工作量。
  2. Freemarker是按行導出Excel的,所以要區分哪是一行數據,你可以打開xml模板,來看一下結構。

Freemarker插值講解(核心)

1.模板修改位置

打開模板xml文件,搜索Worksheet標籤,我們需要改動的內容都在Worksheet標籤內,每一個Worksheet對應Excel一個sheet頁面,模板Worksheet外的其他地方無需改動,保持原樣。

2.修改可擴展行數和列數

<Table ss:ExpandedColumnCount="1000" ss:ExpandedRowCount="100" x:FullColumns="1"
   x:FullRows="1" ss:DefaultColumnWidth="47.25" ss:DefaultRowHeight="12">

ss:ExpandedColumnCount:可擴展列數,根據自己Excel,估算出一個列數上限。
ss:ExpandedRowCount:可擴展行數,根據自己Excel,估算出一個行數上限。

注意: 這兩個值不是改成越大越好,太大了影響渲染速度。自己估算下行列數的數量級即可。比如你的Excel最多有20列,那上限寫到100即可,就不要改成9999999,這麼大了。行數也是一樣,你可能只有幾百行,就不要寫成千萬數量級。

3.定位首行數據(非表頭)

搜索下表頭上的名稱,就可以找到模板中的表頭位置,表頭的內容我們是不需要修改的,除非你表頭都是動態的,那表頭的內容也要作爲數據行來處理了。在本例中,表頭Row標籤的結尾處,就是Excel第一行數據的開始。

4.理解模板中每一行數據,對應Excel位置(重要)

爲此我做了一張對照圖,方便你理解xml模板中的一行,對應Excel中的位置。
Excel行對照圖
仔細觀察上圖,你才能更好的理解爲什麼Excel是按行導出的。尤其是黃框的部分,這是最困難的地方。

5.根據Excel列,創建對象

大家注意看下,Excel是如何對應到實體類對象的。看明白了這裏,才知道在XML模板中怎麼取值。

將紅框中的內容,定義一個整體對象:
定義Java對象

@Data
public class SendBillOutput implements Serializable {

    // 客戶名稱
    private String customerName;
    // 是否一般納稅人
    private String isGeneralTaxpayer;
    // 稅號
    private String taxNumber;
    // 客戶公司地址及電話
    private String addressAndPhone;
    // 開戶銀行和賬號
    private String bankAndAccount;
    // 每個園區的信息列表
    private List<StationBillOutput> stationBillList;
    // 合計欄
    private StationAmountOutput stationAmount;

}

整體對象,包含兩個子對象:每個廠區對象:stationBillList;合計對象:stationAmount

定義下圖紅框中,奧迪一廠、二廠等每個廠區對象stationBillList
在這裏插入圖片描述

@Data
public class StationBillOutput implements Serializable {
    // 發票數量
    // private Integer invoiceCount;
    // 描述
    private String description;
    // 計費週期
    private String period;
    // 尖峯平谷
    private List<PeriodPowerOutput> periodPowerList;
    // 園區地址
    private String stationName;
    // 發票號碼
    private String invoiceNumber;
}

stationBillList 中包含一個用電量對象,即下圖紅框中的內容對象。
在這裏插入圖片描述

@Data
public class PeriodPowerOutput implements Serializable {
    // 尖、峯、平、谷
    private String powerName;
    // 電量(尖、峯、平、谷、合計)
    private BigDecimal power;
    // 含稅電價(尖、峯、平、谷、合計)
    private BigDecimal price;
    // 不含稅金額(尖、峯、平、谷、合計)
    private BigDecimal noTaxMoney;
    // 稅率(尖、峯、平、谷、合計)
    private Integer taxRate;
    // 稅額(尖、峯、平、谷、合計)
    private BigDecimal taxAmount;
    // 含稅金額(尖、峯、平、谷、合計)
    private BigDecimal taxmoney;
}

定義底部合計行對象:
在這裏插入圖片描述

@Data
public class StationAmountOutput implements Serializable {
    private BigDecimal power;
    private BigDecimal noTaxMoney;
    private BigDecimal taxAmount;
    private BigDecimal taxmoney;
}

這個就是Java面相對象的思想了,根據數據結構,將其抽象出對象,Excel中可以像這樣一步一步,定義出一個Excel模板對象。

6.根據對象,模擬數據。

Freemarker接收一個Map數據源,我們將Map中的鍵名定義爲bill,在模板中,將以bill進行取值。

public void export() {
        SendBillOutput bill = new SendBillOutput();
        bill.setCustomerName("奧迪公司");
        bill.setIsGeneralTaxpayer("是");
        bill.setTaxNumber("123456789");
        bill.setAddressAndPhone("北京市望京SOHO" + "&#10;" + "010-8866396");
        bill.setBankAndAccount("中國銀行&#10;123456");
        List<StationBillOutput> stationBillList = new ArrayList<StationBillOutput>();
        // 模擬n個電站
        for (int i = 0; i < 5; i++) {
            StationBillOutput stationBillOutput = new StationBillOutput();
            stationBillOutput.setDescription("奧迪公司3月份電費" + i);
            stationBillOutput.setPeriod("2020年03月01日_2020年03月31日");
            // 尖峯平谷時間段數據賦值
            List<PeriodPowerOutput> periodPowerList = new ArrayList<PeriodPowerOutput>();
            for (int j = 0; j < 5; j++) {
                PeriodPowerOutput periodPower = new PeriodPowerOutput();
                switch (j) {
                    case 0:
                        periodPower.setPowerName("尖");
                        break;
                    case 1:
                        periodPower.setPowerName("峯");
                        break;
                    case 2:
                        periodPower.setPowerName("平");
                        break;
                    case 3:
                        periodPower.setPowerName("谷");
                        break;
                    case 4:
                        periodPower.setPowerName("合計");
                        break;
                    default:
                        break;
                }
                periodPower.setPower(DecimalUtils.toBigDecimal(j + 1000));
                periodPower.setPrice(DecimalUtils.toBigDecimal(j + 0.1));
                // 若Excel公式自動計算,這幾個字段不用插值
                periodPower.setNoTaxMoney(DecimalUtils.toBigDecimal(j + 1002));
                periodPower.setTaxRate(13);
                periodPower.setTaxAmount(DecimalUtils.toBigDecimal(j + 1004));
                periodPower.setTaxmoney(DecimalUtils.toBigDecimal(j + 1005));
                periodPowerList.add(periodPower);
            }
            stationBillOutput.setPeriodPowerList(periodPowerList);
            stationBillOutput.setStationName("奧迪公司園區" + i+1);
            stationBillList.add(stationBillOutput);
        }
        bill.setStationBillList(stationBillList);
        StationAmountOutput stationAmountOutput = new StationAmountOutput();
        stationAmountOutput.setPower(DecimalUtils.toBigDecimal(123));
        stationAmountOutput.setNoTaxMoney(DecimalUtils.toBigDecimal(456));
        stationAmountOutput.setTaxAmount(DecimalUtils.toBigDecimal(789));
        stationAmountOutput.setTaxmoney(DecimalUtils.toBigDecimal(2324));
        bill.setStationAmount(stationAmountOutput);
        String templateName = "開票申請單.ftl";
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("bill", bill);
        //導出到項目所在目錄下,export文件夾中
        FreemarkerUtils.exportToFile(map, templateName, "", "export/導出Excel.xls");
    }

7.Freemarker工具類:

@Slf4j
public class FreemarkerUtils {
 /**
     * 導出Excel到指定文件
     * 
     * @param dataMap
     *            數據源
     * @param templateName
     *            模板名稱(包含文件後綴名.ftl)
     * @param templateFilePath
     *            模板所在路徑(不能爲空,當前路徑傳空字符:"")
     * @param fileFullPath
     *            文件完整路徑(如:usr/local/fileName.xls)
     * @author 大腦補丁 on 2020-04-05 11:51
     */
    @SuppressWarnings("rawtypes")
    public static void exportToFile(Map dataMap, String templateName, String templateFilePath, String fileFullPath) {
        try {
            File file = FileUtils.createFile(fileFullPath);
            FileOutputStream outputStream = new FileOutputStream(file);
            exportToStream(dataMap, templateName, templateFilePath, outputStream);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 導出Excel到輸出流
     * 
     * @param dataMap
     *            數據源
     * @param templateName
     *            模板名稱(包含文件後綴名.ftl)
     * @param templateFilePath
     *            模板所在路徑(不能爲空,當前路徑傳空字符:"")
     * @param outputStream
     *            輸出流
     * @author 大腦補丁 on 2020-04-05 11:52
     */
    @SuppressWarnings("rawtypes")
    public static void exportToStream(Map dataMap, String templateName, String templateFilePath,
        FileOutputStream outputStream) {
        try {
            Template template = getTemplate(templateName, templateFilePath);
            OutputStreamWriter outputWriter = new OutputStreamWriter(outputStream, "UTF-8");
            Writer writer = new BufferedWriter(outputWriter);
            template.process(dataMap, writer);
            writer.flush();
            writer.close();
            outputStream.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    }

8.對首行進行插值(最重要)

首行對應的位置:
在這裏插入圖片描述
插值對應的代碼示例:
在這裏插入圖片描述

插值步驟詳解:

①處理合並行:
num就是合併的行數。因爲首行的第二列到第六列,是需要合併的,合併的行數是動態的,所以要根據廠區的個數,計算出合併行數,一個廠區合併5行,我們可以通過Freemarker的語法進行計算。定義一個變量<#assign num = bill.stationBillList?size*5>。爲防止負數報錯,加個判斷,防止出現負數(也可以不加)。將合併行數num插入到第二列到第六列中:<Cell ss:MergeDown="${num}"

<#assign num = bill.stationBillList?size*5>
<#if num < 0>
 <#assign num = 0>
</#if>

②插入數值:
再將對應的數據通過${bill.customerName!}插入的單元格的Data標籤中。其中!表示在非空的情況下,才插入值,值爲空,則不插值,否則導出Excel會報錯。

取值語法解釋:
其中bill是我們在上文第6步模擬數據中定義的:map.put("bill", bill);我們將freemarker數據對象名定義爲bill。所以在模板中,freemarker語法可以直接使用bill來取值。

<#list bill.stationBillList as stationBill>含義,取出bill對象中的stationBillList對象,並定義變量爲stationBill,這個變量僅僅在當前<#list>循環中生效,在循環外是不可以使用的,要想再次使用,只能重新定義,這就類似於java循環體中的局部變量。

periodPower_index含義:判斷當前循環的指針位置,類似javafor循環中的指針i

關於Freemarker語法本文用到的基本就這些,要想了解更多,參考文檔:《Freemarker在線手冊》

③特殊地方(重要):
在這裏插入圖片描述
第一行數據中,包含一行沒有合併行的數據(見上圖),我們在對其插值時候,僅需要取出數據源中的第一條數據進行賦值,所以模板代碼中,我們通過循環,增加判斷<#if periodPower_index == 0>,取出數據源列表中的第一條數據,再進行復制,本行對應完整代碼如下:

<#list stationBill.periodPowerList as periodPower>
     <#if periodPower_index == 0>
		    <Cell ss:StyleID="s26"><Data ss:Type="String">${periodPower.powerName!}</Data></Cell>
		    <Cell ss:StyleID="s26"><Data ss:Type="Number">${periodPower.power!}</Data></Cell>
		    <Cell ss:StyleID="s27"><Data ss:Type="Number">${periodPower.price!}</Data></Cell>
		    <Cell ss:StyleID="s28"><Data ss:Type="Number">${periodPower.noTaxMoney!}</Data></Cell>
		    <Cell ss:StyleID="s129"><Data ss:Type="Number">${periodPower.taxRate!}</Data></Cell>
		    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.taxAmount!}</Data></Cell>
		    <Cell ss:StyleID="s31"><Data ss:Type="Number">${periodPower.taxmoney!}</Data></Cell>
		    <Cell ss:MergeDown="4" ss:StyleID="m327318864"><Data ss:Type="String">${stationBill.stationName!}</Data></Cell>
		    <Cell ss:StyleID="s37"/>
      </#if>
</#list>

上述的處理有點反直覺,我們可能會覺得,爲什麼要對第一行進行插值,而不是對這5行不合並行同時循環賦值?因爲Excel模板是按行生成的,不合並的第一行,才數據Excel的首行數據。要以模板上的一行爲準,不能以我們直覺(業務)上的一行爲準進行處理。接下來再對Excel不合並的行剩餘4行(下圖紅框)進行賦值。

處理不合並的行:
在這裏插入圖片描述

處理完了第一行,這幾行可以通過循環來插值,同樣,我們需要增加判斷<#if (stationBill.periodPowerList?size > 1 && periodPower_index > 0)>,含義是從數據源列表中第二行數據開始,循環賦值(因爲第一行,上面已經賦值)。這幾行處理的完整代碼如下:

<#list bill.stationBillList as stationBill>
    <#if stationBill_index == 0>
     <#list stationBill.periodPowerList as periodPower>
      	<#if (stationBill.periodPowerList?size > 1 && periodPower_index > 0)>
		   <Row ss:Height="12.75">
		    <Cell ss:Index="9" ss:StyleID="s26"><Data ss:Type="String">${periodPower.powerName!}</Data></Cell>
		    <Cell ss:StyleID="s26"><Data ss:Type="Number">${periodPower.power!}</Data></Cell>
		    <Cell ss:StyleID="s27"><Data ss:Type="Number">${periodPower.price!}</Data></Cell>
		    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.noTaxMoney!}</Data></Cell>
		    <Cell ss:StyleID="s129"><Data ss:Type="Number">${periodPower.taxRate!}</Data></Cell>
		    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.taxAmount!}</Data></Cell>
		    <Cell ss:StyleID="s31"><Data ss:Type="Number">${periodPower.taxmoney!}</Data></Cell>
		    <Cell ss:Index="17" ss:StyleID="s37"/>
		   </Row>
  	 </#if>
    </#list>
    </#if>
   </#list> 

注意: 條件語句中若使用<>時,要加括號,否則就會被XML解析,造成Excel導出報錯。

9.對第二行及其後續行進行插值

在這裏插入圖片描述
第二行即爲紅框部分,我們發現不在需要對第2-6合併行賦值了,因爲前面已經賦值過了。而且賦值方法和第一行基本一致。也是先對第一行進行賦值,後通過循環將剩餘行進行賦值,所以不再重複講解了,不明白請查看上一步。

<#if (bill.stationBillList?exists && bill.stationBillList?size >0) >
   	<#list bill.stationBillList as stationBill>
   	 <#if (stationBill_index >0) >
     <!-- 第二行數據及之後所有行 -->
	   <Row ss:Height="12.75">
	    <Cell ss:MergeDown="4" ss:StyleID="m327318576"><Data ss:Type="Number">1</Data></Cell>
	    <Cell ss:Index="7" ss:MergeDown="4" ss:StyleID="m327318736"><Data
	      ss:Type="String">${stationBill.description!}</Data></Cell>
	    <Cell ss:MergeDown="4" ss:StyleID="m327318796"><Data ss:Type="String">${stationBill.period!}</Data></Cell>
	  <!-- 非合併行,頂部行(尖)賦值-->  
     <#list stationBill.periodPowerList as periodPower>
     	<#if periodPower_index == 0>
		    <Cell ss:StyleID="s26"><Data ss:Type="String">${periodPower.powerName!}</Data></Cell>
		    <Cell ss:StyleID="s26"><Data ss:Type="Number">${periodPower.power!}</Data></Cell>
		    <Cell ss:StyleID="s27"><Data ss:Type="Number">${periodPower.price!}</Data></Cell>
		    <Cell ss:StyleID="s28"><Data ss:Type="Number">${periodPower.noTaxMoney!}</Data></Cell>
		    <Cell ss:StyleID="s129"><Data ss:Type="Number">${periodPower.taxRate!}</Data></Cell>
		    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.taxAmount!}</Data></Cell>
		    <Cell ss:StyleID="s31"><Data ss:Type="Number">${periodPower.taxmoney!}</Data></Cell>
		    <Cell ss:MergeDown="4" ss:StyleID="m327318884"><Data ss:Type="String">${stationBill.stationName!}</Data></Cell>
		    <Cell ss:StyleID="s37"/>
		</#if>
     	</#list>
	   </Row>
	   	
	   <!-- 非合併行,(峯、平、谷、合計)行賦值-->
	   <#list stationBill.periodPowerList as periodPower>
	     <#if (stationBill.periodPowerList?size > 1 && periodPower_index > 0)>
			<Row ss:Height="12.75">
			    <Cell ss:Index="9" ss:StyleID="s26"><Data ss:Type="String">${periodPower.powerName!}</Data></Cell>
			    <Cell ss:StyleID="s26"><Data ss:Type="Number">${periodPower.power!}</Data></Cell>
			    <Cell ss:StyleID="s27"><Data ss:Type="Number">${periodPower.price!}</Data></Cell>
			    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.noTaxMoney!}</Data></Cell>
			    <Cell ss:StyleID="s129"><Data ss:Type="Number">${periodPower.taxRate!}</Data></Cell>
			    <Cell ss:StyleID="s30"><Data ss:Type="Number">${periodPower.taxAmount!}</Data></Cell>
			    <Cell ss:StyleID="s31"><Data ss:Type="Number">${periodPower.taxmoney!}</Data></Cell>
			    <Cell ss:Index="17" ss:StyleID="s37"/>
			 </Row>   
	     </#if>
	   </#list>
    </#if>
  </#list>
</#if> 

9.對底部的合計行進行插值

在這裏插入圖片描述
這是最後一步了,對於Excel中一些非重複的行,我們取出對象SendBillOutput中的stationAmount對象,分別取出其屬性賦值即可:

<!-- 合計 -->
   <Row ss:Height="12.75">
    <Cell ss:StyleID="s19"><Data ss:Type="Number">#{bill.stationBillList?size!}</Data></Cell>
    <Cell ss:Index="7" ss:StyleID="s23"><Data ss:Type="String">合計</Data></Cell>
    <Cell ss:StyleID="s23"/>
    <Cell ss:StyleID="s32"/>
    <Cell ss:StyleID="s32"><Data ss:Type="Number">#{bill.stationAmount.power!}</Data></Cell>
    <Cell ss:StyleID="s23"/>
    <Cell ss:StyleID="s32"><Data ss:Type="Number">#{bill.stationAmount.noTaxMoney!}</Data></Cell>
    <Cell ss:StyleID="s33"/>
    <Cell ss:StyleID="s32"><Data ss:Type="Number">#{bill.stationAmount.taxAmount!}</Data></Cell>
    <Cell ss:StyleID="s32"><Data ss:Type="Number">#{bill.stationAmount.taxmoney!}</Data></Cell>
    <Cell ss:StyleID="s34"/>
    <Cell ss:StyleID="s37"/>
   </Row>

10.本文所需要的Maven依賴

	    <dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>org.freemarker</groupId>
			<artifactId>freemarker</artifactId>
		</dependency>

三.總結

Freemarker導出步驟比較Poi來說還是簡單許多,如果是簡單循環類型的Excel,不需要合併行等,那就更簡單了,如果真的搞懂了本文,相信再遇到複雜Excel導出,也不會發愁了。由於學習這種工具,確實需要實際上手纔會更好的理解,所以將本文的代碼整理成一個SpringBoot項目,供大家研究:《下載本文源碼》
後續我將推出一篇用《Freemarker結合POI導出帶有圖片的Excel》,文章也是基於此篇文章的教程,希望大家收藏關注點贊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章