簡介
使用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的思想和步驟
- 將Excel文件由
xls
或xlsx
格式,通過Excel另存爲的方式轉爲xml
格式。 - 對xml模板內的數據插值
- 用本文提供的Freemarker導出Excel工具類導出
核心步驟就這三個步驟,接下來將分步詳細講解。
一.Excel另存爲xml
格式
通過Excel中另存爲,將Excel由xls
格式保存爲xml
格式。
例:最新版的Office365另存爲中,選擇XML電子表格2003(*.xml)
。
遇到問題(坑):
導出的Excel中的模板上的中文亂碼,但是通過變量賦值進去的中文不會亂碼?
分析問題:
通過修改程序的編碼是解決不了的,因爲發現通過Freemarker插值的中文數據時不亂碼的,通過修改Freemarker寫入編碼,只能解決插值中的亂碼,但不能解決模板本身的亂碼。
Xml模板用Excel打開是不亂碼的。這個問題困擾了好久,最終換了臺電腦轉換xml模板,發現模板是不亂碼的,所以,定位問題:另存爲Excel的編碼出現問題。
解決問題:
另存爲時,選擇更多選項,在點擊工具,選擇web選項,在編碼選項卡中選擇UTF-8
編碼。
解決問題心法:
遇事不慌,要尋找不同維度的信息,進行交叉驗證。我花費了很長的時間在同一臺電腦上折騰,從《信息論》角度來看,只是用同樣的信息重負勞動。後來換了臺電腦,則是用不同的信息,進行交叉驗證,才最終定位了問題。定位問題花費的時間往往是解決問題時間的數倍,所以思考問題要從不同角度去思考,視角很重要。
二.對xml模板內的數據進行插值
插值思路
- 保證xml模板中至少有2條假數據(新手尤其重要,因爲你不熟悉Excel的XML結構,尤其是複雜Excel,結構也很複雜)。
- 找出Excel模板中第一行數據在xml模板中對應的位置。
- 定義相關變量,解析數據源並插值。
案例講解
以一個複雜案例,講解複雜的Excel模板結構,以及如何插值,如下圖:
案例分析:
- 這個Excel,包含了合併行,以及合計計算等等,如果用poi操作,工作量實在很大,但Freemarker卻減少了很多的工作量。
- 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是按行導出的。尤其是黃框的部分,這是最困難的地方。
5.根據Excel列,創建對象
大家注意看下,Excel是如何對應到實體類對象的。看明白了這裏,才知道在XML模板中怎麼取值。
將紅框中的內容,定義一個整體對象:
@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" + " " + "010-8866396");
bill.setBankAndAccount("中國銀行 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》,文章也是基於此篇文章的教程,希望大家收藏關注點贊。