☰
用Freemarker模板引擎替代POI,轻松搞定复杂Excel报表导出
2026/10/17 4:41:42 网站建设 项目流程

做Java后端这几年,凡是带“报表”两个字的系统,最后基本都绕不开复杂Excel导出。客户的需求往往特别具体:第一行要合并大标题,中间带斜线表头,数据行要有边框、斑马纹,最后一列还得是“本月累计”公式。用POI一行一行写样式,代码能堆到三百行以上,改一次需求痛苦一次。后来我换了思路,用Freemarker直接把Excel当成模板来渲染,几万字的小工具就能扛住复杂的报表版本,后面所有调整都变成了改模板文件,不再动Java代码。这篇就把这套思路完整拆开讲,从最小示例到复杂版式,再到性能坑点,适合正在用POI写导出、被样式和合并单元格折磨的Java开发,也适合想给团队沉淀一套报表模板方案的兄弟。

1. 为什么用Freemarker写Excel:两条路线对比

1.1 路线一:POI硬凹的问题

Apache POI是Java操作Excel的标准库,功能强,但强在“底层”。你要导出一张带合并单元格、边框、列宽、统计公式的报表,需要在代码里不断调用createRow、createCell、setCellStyle、addMergedRegion这些方法,而且每个单元格的样式对象还要设置字体、边框、对齐方式、背景色,多个单元格共用一个样式还要注意对象的复用,否则内存直接膨胀。

更难受的是日常迭代。客户说“表头标题从一行改成两行”,你要改的是一整段创建表头的Java代码逻辑;客户说“把第三列改成百分比显示”,你又要去翻代码里设置数字格式的位置。报表模板经常是“改一次、崩一次、测半天”。

1.2 路线二:模板套数据的优势

Freemarker是Java生态里的模板引擎,原本最常用来生成HTML页面和邮件正文。它的核心模型很简单:先准备一份带占位符的模板文件,再准备一个数据Map,引擎负责把Map里的值填进占位符,输出成最终文本。

当它遇到Excel时,做法其实是:把Excel改造成XML模板文件,里面的数据区域用Freemarker指令(如<#list>、<#if>)包起来,数据字段用${}占位,模板语法和普通HTML渲染完全一致。这样整个报表的“骨架”——标题、表头、合并单元格、边框、列宽——都固定死在模板XML里,Java端只负责把查询出来的数据塞进Map,然后调用渲染。

1.3 适用场景选择建议

我现在的习惯是这样:数据量在几千行以内、报表样式又特别“花”的导出,直接用Freemarker模板方案,尤其是需要频繁改版式的场景,模板改起来是真的快。反过来,如果单Sheet行数超过几万行甚至几十万行,模板方案的内存占用就偏高,POI的SXSSF流式模式更适合大数据量导出。另外,如果导出内容要在服务端做进一步数据处理,比如解析并依赖单元格值生成图表数据,POI也更合适。Freemarker方案的适合人群是“报表样式相对固定但数据变化多”的后台系统。

提示:Freemarker导出Excel走的不是POI的二进制文件格式,而是把Excel内容序列化成XML文本再输出。用户拿到的仍然是一个可以直接双击打开、功能完整的Excel文件,只是内部存储形式不一样,日常使用完全无感。

2. 把Excel变成模板:最小可运行Demo

2.1 环境准备与依赖

Freemarker本身是一个Java库,打包不到2MB,不依赖第三方额外组件。我在项目里用的版本是2.3.32(2.3.x系列现在还在更新),Spring Boot项目里直接加依赖:

<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.32</version> </dependency>

模板文件放在src/main/resources/templates/excel/目录下,文件名后缀我统一用.ftl。

2.2 理解XML Spreadsheet 2003模板结构

Freemarker不能直接“读懂”.xlsx文件,所以我们要先借助Excel的“另存为XML数据”能力。在Excel里新建一个文件,做好样式和表头布局,选择“文件-另存为-类型:XML表格2003”,就会得到一个.xml文件。这个XML就是模板的基础。

打开这个XML,开头长这样:

<?xml version="1.0"?> <?mso-application progid="Excel.Sheet"?> <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:html="http://www.w3.org/TR/REC-html40">

注意<?mso-application progid="Excel.Sheet"?>这一行不能删,Excel靠它识别这个文件是表格文档,否则双击打开的就是一个普通的XML文件,体验完全不同。

一个最简单的模板,包含一个Worksheet、一个Table、两行数据和两个字段的循环:

<#-- 开启XML转义,这样模板里出现&、<、>时不会破坏XML结构 --> <#escape x as x?xml> <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"> <Worksheet ss:Name="报表明细"> <Table> <Column ss:Width="120"/> <Column ss:Width="80"/> <Column ss:Width="100"/> <Row> <Cell><Data ss:Type="String">商品名称</Data></Cell> <Cell><Data ss:Type="String">销量</Data></Cell> <Cell><Data ss:Type="String">销售日期</Data></Cell> </Row> <#list rows as item> <Row> <Cell><Data ss:Type="String">${item.goodsName}</Data></Cell> <Cell><Data ss:Type="Number">${item.salesCount}</Data></Cell> <Cell><Data ss:Type="String">${item.salesDate}</Data></Cell> </Row> </#list> </Table> </Worksheet> </Workbook> </#escape>

这个模板渲染出来的最终效果,就是一张有三列的数据表,数据行数由rows这个List的长度决定。模板里出现了XML特殊字符,比如数据字段值里带了一个&符号,?xml转义会自动把它变成&amp;,保证XML解析不出错。

2.3 Java端渲染代码

渲染的核心逻辑非常少,两步走:创建Configuration、调用模板输出。

import freemarker.template.Configuration; import freemarker.template.Template; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; public class ExcelExportHelper { private static final Configuration CONFIG; static { CONFIG = new Configuration(Configuration.VERSION_2_3_32); // 模板目录,对应resources/templates/excel CONFIG.setClassForTemplateLoading(ExcelExportHelper.class, "/templates/excel"); CONFIG.setDefaultEncoding("UTF-8"); // 开发环境可以关掉缓存,方便改模板就生效 CONFIG.setTemplateUpdateDelayMilliseconds(0); } public static void export(String templateName, Map<String, Object> data, OutputStream out) throws Exception { Template template = CONFIG.getTemplate(templateName); try (Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { template.process(data, writer); writer.flush(); } } // 调用示例 public static void main(String[] args) throws Exception { Map<String, Object> data = new HashMap<>(); data.put("rows", List.of( Map.of("goodsName", "苹果", "salesCount", 120, "salesDate", "2024-06-01"), Map.of("goodsName", "香蕉", "salesCount", 88, "salesDate", "2024-06-02") )); try (var fos = new java.io.FileOutputStream("demo.xls")) { export("demo.ftl", data, fos); } } }

2.4 第一个复杂表格的成型过程

上面的Demo太薄,直接上一个稍微复杂的例子。假设要导出一张带合并大标题的季度销售汇总表:

  • 第一行:A1~F1合并,内容为大标题“二季度销售汇总表”
  • 第二行:每个季度小计一列,带背景色
  • 第三行:指标行,列比较多
  • 第四行开始:数据行,每行都有边框

在XML模板里,合并单元格用的是ss:MergeAcross和ss:MergeDown:

<Row> <Cell ss:MergeAcross="5"><Data ss:Type="String">二季度销售汇总表</Data></Cell> </Row>

整行的六个单元格就合并在了一起。ss:MergeAcross="5"表示当前单元格向右合并5格,加上自身就是6个单元格。同理ss:MergeDown="2"表示向下合并2行。

边框样式则是通过ss:StyleID引用<Styles>里的样式:

<Styles> <Style ss:ID="Default" ss:Name="Normal"/> <Style ss:ID="s_Header"> <Alignment ss:Horizontal="Center" ss:Vertical="Center"/> <Font ss:Bold="1"/> <Interior ss:Color="#DDEBF7" ss:Pattern="Solid"/> <Borders> <Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1"/> <Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1"/> <Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1"/> <Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1"/> </Borders> </Style> </Styles>

然后在Cell上引用:

<Cell ss:StyleID="s_Header"><Data ss:Type="String">商品类别</Data></Cell>

这个步骤是Freemarker模板方案里版式控制的核心,一定要学会自己手写或改造样式块。

3. 复杂版式逐个击破

3.1 表头合并与动态列

表头复杂的报表,最常见的是“国家-省份-城市”三级地区表头,或者“季度-月度-指标”多级指标表头。这类表头在POI里要逐个计算合并区域,代码逻辑很容易绕晕。模板方案里,你只需要在Excel里做出静态表头,另存为XML后,所有合并关系已经自动躺在XML的ss:MergeAcross和ss:MergeDown属性里了。

比如,四季度表的局部模板:

<Row> <Cell ss:MergeAcross="0"><Data ss:Type="String">一季度</Data></Cell> <Cell ss:MergeAcross="4"><Data ss:Type="String">二季度</Data></Cell> <Cell ss:MergeAcross="4"><Data ss:Type="String">三季度</Data></Cell> <Cell ss:MergeAcross="4"><Data ss:Type="String">四季度</Data></Cell> </Row> <Row> <Cell><Data ss:Type="String">1月</Data></Cell> <Cell><Data ss:Type="String">2月</Data></Cell> <Cell><Data ss:Type="String">3月</Data></Cell> <Cell><Data ss:Type="String">4月</Data></Cell> ... </Row>

动态列则是另一类常见需求。比如用户选择导出哪几列,模板里就要用<#if>和<#list>结合:

<#list columns as col> <Column ss:Width="${col.width}"/> </#list>

列头的动态生成:

<Row> <#list headers as header> <Cell ss:StyleID="s_Header"><Data ss:Type="String">${header}</Data></Cell> </#list> </Row>

这样Java端只需要往Map里传入columns和headers两个List,模板就能动态生成任意多列。注意动态输出列头时,合并单元格的数量也要跟列数对得上,否则会出现合并范围越界,Excel会报“文件已损坏”。

3.2 行高列宽与单元格样式

行高是在<Row>标签上配ss:Height属性:

<Row ss:Height="45"> <Cell ss:MergeAcross="5"><Data ss:Type="String">标题行</Data></Cell> </Row>

这里的单位是磅,跟Excel里设置行高的单位一致。如果标题字号比较大,45磅大概能容纳20号字。列宽则是<Column>上的ss:Width,单位是“字符宽度”,宽度80大约等于显示8个左右数字字符,中文字符更宽,一般中文列我会给到120~160。

单元格样式不止边框和背景色这一种。常用的还有:

  • ss:HAlign水平对齐:ss:Horizontal="Left/Center/Right"其实放在<Alignment>里
  • 垂直对齐:ss:Vertical="Top/Center/Bottom"
  • 自动换行:<Alignment ss:WrapText="1"/>,这个对长文本特别有用
  • 数字格式:<NumberFormat ss:Format="0.00%"/>

我建议把常用样式在<Styles>里定义成几个固定ID,比如s_Header、s_Title、s_DataLeft、s_DataRight、s_Total,模板里按需引用。很多人在模板里刚开始会担心样式太多,其实XML里的样式标签是可以复用的,只定义一次引用多次,体积增加很小。

3.3 数据填充、日期处理与公式

数据填充是最基础的部分,但有几个细节值得单独说。

日期类型的单元格,最好显式指定ss:Type="DateTime",并且值要传ISO格式:2024-06-01T00:00:00.000。如果你直接传字符串“2024-06-01”,Excel打开后是文本格式,无法参与日期排序和按月筛选。我在Java端通常先格式化好再放进去,复杂格式如“2024年6月1日”用String类型也完全可以,看诉求。

公式也是可以直接写进模板的。比如数据行最后要加一个“总计”:

<Row> <Cell><Data ss:Type="String">总计</Data></Cell> <Cell ss:Formula="=SUM(B4:B100)"><Data ss:Type="Number">0</Data></Cell> </Row>

这里把ss:Formula写在Cell上,Excel打开后会自动计算。注意单元格的<Data>里给的初始值只在公式不计算时显示,实际打开后Excel会用公式结果替换。这个方式非常适合“数据+合计+占比”型报表,合计行永远在数据最后,模板里用<#list>循环完数据后,紧跟一个静态合计行就行。

3.4 多工作表与页眉页脚

多个Sheet在XML里就是多个<Worksheet>。

<Workbook> <Worksheet ss:Name="销售汇总"> <Table>...</Table> </Worksheet> <Worksheet ss:Name="明细数据"> <Table>...</Table> </Worksheet> </Workbook>

每个Worksheet都可以独立设置列宽、行高和样式。动态数据的每个Sheet,可以用<#list>包住整个<Worksheet>:

<#list reportList as report> <Worksheet ss:Name="${report.name}"> <Table> ... </Table> </Worksheet> </#list>

这样就实现了一个Sheet一个业务分组的效果。比如按门店分组,每个门店生成一个Sheet,数据量在几百个门店以内没问题。

页眉页脚在XML里的写法:

<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"> <PageSetup> <Header><Data>报表生成时间:${generateTime}</Data></Header> <Footer><Data>第 &amp;P 页 共 &amp;N 页</Data></Footer> </PageSetup> </WorksheetOptions>

注意&amp;是XML转义后的&,如果直接写&P会被XML解析器当成实体引用报错。

4. 性能与稳定性:别让导出功能把服务拖垮

4.1 模板加载的缓存策略

Freemarker的Configuration本身自带模板缓存,默认会缓存模板对象,不需要每次都从磁盘读取。但前提是你要把Configuration做成全局单例,而不是每次导出都新建一个。很多人第一次写的时候图省事,把Configuration放在方法内部初始化,结果每次导出都重新加载模板、重新解析,接口QPS稍微一高,CPU和IO就飙了。

我的做法是在类加载时初始化一个静态Configuration,后面对外提供的方法只是调getTemplate和process。如果模板文件在运行期不会改,还可以把setTemplateUpdateDelayMilliseconds(3600000)调大一点,减少Freemarker每次检查模板文件的IO次数。开发阶段建议设成0,改模板立刻生效。

4.2 流式输出与内存控制

Freemarker渲染是一个字符串流过程,理论上可以用Writer流式输出,但实际使用中很多人习惯把模板渲染结果先放进一个超大String或ByteArrayOutputStream,再统一写响应。这在大数据量导出时很容易OOM。

正确姿势是直接把输出流交给Template.process。在Web接口里,就是拿到HttpServletResponse的OutputStream,直接传给导出方法,全程不落内存。

@RequestMapping("/export") public void export(HttpServletResponse response) throws Exception { response.setContentType("application/vnd.ms-excel;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=report_" + System.currentTimeMillis() + ".xls"); Map<String, Object> data = queryData(); ExcelExportHelper.export("report.ftl", data, response.getOutputStream()); }

如果业务确实要先拿到全部结果再判断导出内容,也可以分页查询后边查边往模板里喂数据。Freemarker的<#list>支持传入延迟List实现(Freemarker封装的LazySequence),但复杂度高,一般项目用不到。最现实的做法是控制一次导出的行数上限,比如单Sheet不超过5万行,超出就提示用户按条件拆分导出。

4.3 常见异常排查速查表

我把这些年遇到的Freemarker导出Excel异常整理成了一张表,每一条都踩过:

现象根因解决办法
打开文件报“文件格式和扩展名不匹配”文件不是真正的Excel二进制,或XML头缺少mso-application声明确认<?mso-application progid="Excel.Sheet"?>在第一行
数据里含中文变成乱码输出流编码没有用UTF-8OutputStreamWriter指定StandardCharsets.UTF_8
模板里的${被当成FreeMarker表达式报错XML里确实需要输出${...}字面量用<#-- -->注释或用${r"${"}输出原样
数据含&、<导致XML解析失败没有开启XML转义或字段没用?xml文件外层用<#escape x as x?xml>包住,或用${field?xml}
Excel提示“发现不可读取的内容”合并单元格越界,或Column数量和实际Cell数量不一致检查动态列场景下<#list>与<Column>数量对应关系
公式不自动计算单元格只有公式没有初始Data值补上<Data ss:Type="Number">0</Data>配合ss:Formula
大数据量导出时堆内存涨得厉害渲染结果被完整放入内存后再输出改用流式输出,直接连接HttpServletResponse的OutputStream
报表打开慢模板中有大量冗余样式定义收敛重复样式,统一用ss:StyleID复用,不要每行定义新样式

注意:Excel对XML模板的容错能力并不算强。一个</Row>标签配对错误,或一个属性值没加引号,Excel打开时可能直接报错,而且报错信息往往很笼统,不会告诉你具体哪一行出问题。我的习惯是模板改完后先用Excel打开一次看效果,再做数据渲染测试,两步分开排查。

5. 一些容易被忽略的细节

5.1 编码与Content-Type

导出接口的Content-Type我建议明确设置为application/vnd.ms-excel;charset=UTF-8,这个MIME类型对应的就是老版本的Excel文件。文件名后缀用.xls,这样Excel打开时不会对扩展名有疑问。如果你用XML原样输出,文件名后缀用.xml也是可以的,但为了用户体验统一,我倾向用.xls。

还有一个细节:设置Content-Disposition里的文件名时,直接用response.setHeader会存在中文文件名兼容问题,最好用URLEncoder.encode把文件名转成UTF-8再拼进去,或者用response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + URLEncoder.encode(fileName, "UTF-8"))。这是Web导出做过的人都踩过的坑。

5.2 特殊字符和换行

业务数据里出现&、<、>是非常常见的事,比如商品名称“A&B”或者“<特惠>”。模板外层加了<#escape x as x?xml>之后,这些字符都会被自动转义成XML合法字符,这是模板方案比POI好在哪里的一个重要体现,不需要在Java端写一套字符替换逻辑。

换行也是个小坑。如果数据值里带\n,在Excel的XML模板里不会自动换行,必须在<Data>标签前给单元格加上<Style ss:WrapText="1"/>,才能让\n生效。而且\n在XML里是可以直接使用的字符,不需要转义成&#10;。我在导出地址、备注这类字段时,会把\n先替换成<br>再配合自动换行处理,效果更稳定。

5.3 测试与回归

Freemarker模板最大的隐藏风险是模板和数据模型不匹配。比如模板里写了${item.salesCount},但数据Map里字段名拼成了salesCount,Freemarker默认会抛异常提示。为了避免线上导出失败,我在测试环境做了两层校验:

第一层是模板解析测试,写一个JUnit用例,用一个模拟数据Map去跑一次模板渲染,确认模板语法没有错误;第二层是数据模拟测试,构造边界数据,比如空列表、超长字符串、特殊字符,跑一遍完整导出。

这个习惯帮我挡掉过很多次“客户导出的Excel直接空白”的事故。另外建议在开发期打开setTemplateUpdateDelayMilliseconds(0),一边改模板一边刷新页面验证,效率高很多。

6. 一点个人体会

Freemarker导出Excel这套方案,最打动我的地方不在于技术有多新,而在于它把“报表结构”和“业务逻辑”真正拆开了。运营提个需求说“表头加一列,数据加一列”,我只需要在模板里复制一行XML再改个字段名,不需要重新编译部署。即使需求变更频繁,整个导出模块的维护成本也低到一个前端页面改样式差不多的程度。

不过也要清醒:这种方案只适合中低数据量的导出场景,大数据量还是老老实实走POI的SXSSF流式方案。工具没有好坏,只有合不合适。如果你现在正被Bean复制和单元格Style代码折腾得头疼,不妨把上面这套思路拿过去,把报表模板做成Freemarker模板,你会有种“早该这么干”的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询