简介
使用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》,强烈建议跳转到此篇文章,此篇文章提供一个通用的导出Excel工具,可以导出各种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》,文章也是基于此篇文章的教程,希望大家收藏关注点赞。
Excel实用教程集锦
以下是我写的关于Java操作Excel的所有教程,基本包含了所有场景。
1.如果简单导出推荐使用工具类的方式,这种配置最简单。
2.如果对导出样式要求极高的还原度,推荐使用Freemarker方式,FreeMarker模板引擎可以通吃所有Excel的导出,属于一劳永逸的方式,项目经常导出推荐使用这种方式。
3.Freemarker导出的Excel为xml格式,是通过重命名为xls后,每次会打开弹框问题,我在《Freemarker整合poi导出带有图片的Excel教程》也已经完美解决,本教程将直接导出真正的xls格式,完美适配新版office和wps。Freemarker是无法导出带有图片的Excel,通过其他技术手段,也在本教程中完美导出带有图片的Excel。
4.下列教程中的代码都经本人和网友多次验证,真实有效!