Bootstrap

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》,强烈建议跳转到此篇文章,此篇文章提供一个通用的导出Excel工具,可以导出各种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》,文章也是基于此篇文章的教程,希望大家收藏关注点赞。

Excel实用教程集锦

以下是我写的关于Java操作Excel的所有教程,基本包含了所有场景。

1.如果简单导出推荐使用工具类的方式,这种配置最简单。

2.如果对导出样式要求极高的还原度,推荐使用Freemarker方式,FreeMarker模板引擎可以通吃所有Excel的导出,属于一劳永逸的方式,项目经常导出推荐使用这种方式。

3.Freemarker导出的Excel为xml格式,是通过重命名为xls后,每次会打开弹框问题,我在《Freemarker整合poi导出带有图片的Excel教程》也已经完美解决,本教程将直接导出真正的xls格式,完美适配新版office和wps。Freemarker是无法导出带有图片的Excel,通过其他技术手段,也在本教程中完美导出带有图片的Excel。

4.下列教程中的代码都经本人和网友多次验证,真实有效!

↓↓↓↓一揽子Excel解决方案,赶快收藏吧↓↓↓↓

《Java导入Excel工具类使用教程》

《Java之Excel导出工具类使用教程》

《Freemarker导出复杂Excel图文教程》

《Freemarker整合poi导出带有图片的Excel教程》

;