做后端开发的同学,十有八九都写过“导出Word”这种需求。尤其是企业级系统里动不动就要导合同、导验收报告、导培训记录,你要是用原生POI一行行去画画样式,能把人画崩溃;你要是用Freemarker拼Word的XML结构,调试起来也够你喝一壶。后来我换成了基于模板的渲染方式,直接把项目里一个“根据Word模板动态生成Word”的需求做成了一套通用能力,实测下来方案又稳又省事,这个方案的主角就是Poi-tl。
Poi-tl是一款基于Apache POI的Word模板引擎,它允许你在docx文档里用{{变量}}、{{?条件}}、{{*循环}}这些标签提前占好位置,后端只需要把数据准备好交给它,它就能按模板原样生成一份新的Word文件。这个方案特别适合两类人:一类是正在做报表/合同/审批类系统、天天被导Word折腾的Java后端;另一类是准备接手老项目、发现代码里全是原生POI操作、想找机会重构的技术同学。这篇文章我会把Poi-tl的选型理由、模板制作规范、SpringBoot集成流程、常见坑全部分享出来,代码都是可以直接抄的。
1. 为什么选Poi-tl而不是直接操作POI
1.1 常见的Word导出方案对比
先说一个背景,Java生态里导出Word向来有几种路子,我把自己踩过的方案放在一起对比过:
| 方案 | 优点 | 坑点 |
|---|---|---|
| 原生Apache POI | 功能最全,能控制每一个单元格 | 代码量大,样式计算复杂,一个模板稍微改样式就得动代码 |
| Freemarker/Thymeleaf拼接XML | 利用模板语法,成本低 | 只能生成doc格式的XML,兼容性差,表格和图片处理麻烦 |
| Aspose.Words | 功能强大,效果接近Office原生 | 商业授权贵,公司预算紧的团队基本不会选 |
| Poi-tl | 模板思想接近Freemarker,但直接操作docx,支持图片/表格/条件/循环 | 需要学习模板标签;模板要求是docx而不能是doc |
这里说下为什么原生POI最让人头疼。你一旦用XWPFDocument手动建表格,就要重复做一遍“创建行→创建单元格→写段落→写run→设置边框→设置字体”这套动作,如果表格有十几行,代码量直接爆炸,等Word模板一调整单元格宽度,你可能得对着坐标改半天。而Poi-tl的核心思路是**“模板里画什么,导出来就是什么”**,边界单纯:样式归模板管,数据归程序管,两边互不干扰。这让我在后来改需求的时候轻松很多,产品说“加一行合计”“标题换个颜色”,都不用来回改Java代码。
1.2 Poi-tl的模板设计思想
如果你用过Freemarker渲染HTML,Poi-tl的上手难度会低很多。它的基础标签只有几类:
{{title}}普通文本标签,直接替换为字符串{{?flag}}内容{{/flag}}条件标签,flag为true才显示内容{{*rows}}循环标签,配合表格或段落循环渲染{{@image}}图片标签,后端传入图片对象{{#table}}表格引用标签,直接绑定一个表格模板
我举个最直观的例子。原来写POI导出,要实现一个“用户列表表格”,你得在代码里foreach用户,然后XWPFTableRow循环去添加行。现在用Poi-tl,只需要先在Word里画好一张一行两列的表格,表头写上“姓名”“部门”,第二行写{{*userList}}、{{name}}、{{dept}},后端拿着List对象往userList字段里一塞,渲染出来的就是从第二行开始动态扩展的完整表格。模板标签和Word表格的原生结构融合得很自然,英文单词虽然多,但学习成本主要在记忆标签含义上,半天就能上手。
1.3 选型时要考虑的额外因素
除了开发效率,选型还应该考虑项目运行环境和后续维护成本。Poi-tl底层还是Apache POI,所以你项目的SpringBoot版本、JDK版本基本不会成为阻碍;它渲染出来的文件就是标准docx,不会像XML拼接那样出现“文件损坏”的尴尬;而且它是一个纯Java库,Linux服务器上跑也不依赖Office软件,这一点很关键,因为很多开Word服务的公司服务器都是CentOS环境,总不能为了导出Word还去装办公套件。
我在实际项目中还关注了另一个点:可测试性。因为Poi-tl把模板渲染成InputStream/OutputStream流,我可以直接跑单元测试,断言生成的docx文件能正常打开、能包含指定关键词,而原生POI虽然也能测试,但动辄几百行的构建代码让测试用例变得很臃肿。所以我后来定的选型结论很简单:凡是文档样式要频繁改的项目,无脑选Poi-tl;如果你只是生成一个格式完全固定的纯文本doc,怎么省事怎么来。但实际业务里“格式固定”几乎是伪命题,所以Poi-tl基本是通用答案。
2. 环境准备与模板制作规范
2.1 SpringBoot工程依赖引入与版本选择
Poi-tl的接入方式很简单,SpringBoot项目只需要在pom.xml里加一个依赖。以我用的版本为例:
<dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.1</version> </dependency>这里提醒一下,1.12.1是基于POI 5.2.x的,如果你项目里本来就有POI,要留意版本冲突。最稳妥的做法是把Poi-tl依赖写在其他POI依赖之前,并且检查依赖树:
mvn dependency:tree -Dincludes=org.apache.poi如果发现apache.poi相关版本被冲掉了,尽量在SpringBoot的dependencyManagement里统一指定POI版本,但不能随便选过老的版本,否则会报一些NoClassDefFoundError。我用1.12.1的时候搭配SpringBoot 2.7.x,在Java 8和Java 11上都跑过,没出现什么问题;如果你用的是SpringBoot 3.x,建议尝试更高的Poi-tl版本,或者注意javax/xml相关依赖的兼容性。
2.2 制作Word模板时的标签规范
模板是整个方案的核心资产,模板做得规范,后端就省心;模板做乱了,后端代码再优雅也白搭。制作模板时我习惯用Microsoft Word或者WPS新建一个docx,在里面手动输入标签,而不是用代码去生成模板。
几个关键规范:
- 占位符写在Word正文里,直接输入
{{name}},不需要设置什么特殊格式,但要注意使用半角花括号,全角{{}}是识别不了的。 - 普通变量建议加“上下标”区分吗?不用,Poi-tl支持直接扫描整个Word文档,你只要保证标签完整就行。
- 表格里的循环标签,写在一行单元格内。比如要动态生成多行项目记录,先让表格有表头行和一行数据行,数据行的每一列写
{{*projList}}的引用方式按列绑定更常见:实际使用中我更喜欢把循环标签写在表格首列单元格,比如{{*list}},然后同一行其他列写{{name}}这样的字段标签,渲染时Poi-tl会自动复制这一行结构作为循环体。这个细节从1.10版本开始支持得很好。 - 图片占位符是
{{@image}},后面的代码要传入带Width和Height的图片对象,否则默认使用原始图片尺寸,很容易撑爆页面。 - 条件标签要成对出现:
{{?show}}和{{/show}}之间包住的内容,根据Boolean值决定是否展示。
另外还要注意,模板里不要出现颜色很花哨的“底纹”,因为部分复杂底纹渲染后可能丢失;如果模板里已经有一行空的普通文本,后端的标签变量,最好确保模板里待渲染的变量都有值。Poi-tl对null值默认是空字符串渲染,但要注意循环里的对象属性如果为null,取到的可能是“null”字符串,这需要后端转义。
2.3 模板文件放哪里以及资源加载方式
模板存放位置,我推荐放在项目的src/main/resources/templates/目录下,因为打包成jar之后仍然可以读取。比如路径:
src/main/resources/templates/contract/report_template.docx读取方式不要用new File(),因为jar包内路径不是普通文件路径。直接在代码里用ClassPathResource:
Resource resource = new ClassPathResource("templates/contract/report_template.docx"); InputStream inputStream = resource.getInputStream(); XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);这种方式在本地IDE里能跑,在Linux服务器上打包成jar之后也能跑,是我踩坑之后固定下来的标准写法。还有个小习惯:模板文件名不要用中文,避免部分服务器解压jar时出现编码问题;如果你一定要用中文,麻烦把文件编码和服务器系统编码都检查一遍,否则读不到文件非常难排查。
3. SpringBoot集成Poi-tl的核心实现
3.1 配置Poi-tl默认字体,避免中文乱码
Poi-tl渲染出来的Word默认字体可能跟模板原有字体不一致,尤其是中文场景,很容易变成宋体以外的字体,甚至出现乱码或字体不适配的问题。我后来直接在渲染前设置默认样式:
Configure config = Configure.builder() .defaultTextStyle(TextStyle.of().fontFamily("微软雅黑").fontSize(10.5f).build()) .build();这么做的好处是,模板里没显式设置字体的地方都按统一风格走,导出的文件不会一会儿宋体一会儿黑体。如果你模板里已经定义了每个标题的字体,那这个配置基本是兜底方案,优先级比模板内样式低,不会把Word里的既有样式冲掉。
3.2 普通变量与日期数据的渲染
最基础的做法就是构建一个Map,key对应模板里的变量名,value放实际值:
Map<String, Object> data = new HashMap<>(); data.put("title", "2025年上半年项目验收报告"); data.put("reportDate", formatDate(new Date())); data.put("projectName", "模拟跨平台业务系统项目");关于日期我多说一句,模板里如果直接放{{reportDate}},后端传的必须是字符串,所以提前用SimpleDateFormat格式化好,格式一般是yyyy年MM月dd日。我见过有同事直接把Date对象塞进去,结果渲染出来是一串英文时间戳,Word里看着非常突兀。遇到BigDecimal金额也是同理,后端先转成两位小数的字符串再放进Map,不要在模板层去做金额格式化。
3.3 循环表格与条件内容的数据组织
循环标签是这类方案里最容易出问题的部分。假设模板里有一个项目清单表格,表格结构是这样:
| 序号 | 项目名称 | 预算金额 |
|---|---|---|
| {{*itemList}} | {{name}} | {{budget}} |
后端的组织方式:
List<Map<String, Object>> itemList = new ArrayList<>(); Map<String, Object> item1 = new HashMap<>(); item1.put("name", "模块A建设"); item1.put("budget", "100000.00"); itemList.add(item1); Map<String, Object> item2 = new HashMap<>(); item2.put("name", "模块B升级"); item2.put("budget", "50000.00"); itemList.add(item2); data.put("itemList", itemList);这里的核心是:表格中写{{*itemList}}的那一行,会被当作循环模板行,循环体里引用的字段是{{name}}、{{budget}}这些List中Map的key。如果模板表格只有表头加一行数据行,那渲染出来的表格就是表头加两行数据,行数跟List大小一致。
条件内容类似:
{{?hasSign}} 验收日期:2025年06月30日 {{/hasSign}}后端只要data.put("hasSign", true),这段话就会出现在文档里;设为false就整段隐藏。这个特性很适合做“不同项目是否需要展示某些段落”的灵活性控制。
3.4 图片填充的三种方式
图片是Poi-tl里面最有价值的能力。做一个带签章、带证件照、带效果图的Word报告,用原生POI处理图片真的不是一般麻烦,而Poi-tl只需要传入图片对象。
模板里放{{@sign}},后端代码:
// 方式一:本地图片 Picture picture = Pictures.of("filePath/sign.png").size(120, 40).create(); data.put("sign", picture); // 方式二:输入流 InputStream imageStream = new FileInputStream("report.png"); Picture picture2 = Pictures.of(imageStream).size(120, 40).create(); data.put("sign", picture2); // 方式三:网络图片,需先下载成本地字节 byte[] bytes = downloadFromUrl("https://example.com/sign.png"); Picture picture3 = Pictures.ofBytes(bytes).size(120, 40).create(); data.put("sign", picture3);size方法的单位是磅,120磅大概是一张4厘米宽的图。如果不想失真,原图尺寸和设置尺寸比例要一致,不然会变形。我还遇到过一个问题:图片变量在循环表格里反复渲染时,每行的图片如果都用同一个文件InputStream,Poi-tl内部如果读完了没重置,可能第二行图片就渲染不出来了。我的解决办法是循环里每次new一个InputStream或者用byte[]构造图片对象。
3.5 模板整体渲染与输出文件
所有数据准备完成后,渲染的核心代码就三行:
XWPFTemplate template = XWPFTemplate.compile(inputStream, config).render(data); template.write(outputStream); template.close();在SpringBoot的接口里,通常用HttpServletResponse把输出流写回去,形成浏览器直接下载的效果;也可以先写入服务器磁盘,再把文件地址给前端。两种方式都有使用场景,如果是批量生成任务,建议先落盘再批量打包;如果是用户点一下按钮,直接用Response输出比较方便。
4. 实操过程与完整代码:导出项目验收报告
4.1 模拟场景与模板设计
为了把流程讲透,我虚构一个场景:某公司在做项目管理系统,系统里需要导出一份“项目验收报告”,内容包括项目基本信息、承建单位、项目清单表格、监理总结和验收签字区。需求特点是要动态生成清单行、根据项目情况显示“需要整改”的提示、并且右下角要放监理单位公章图片。
我的第一步一定是先做模板,而不是写代码。用Word新建模板,在每一处需要动态变化的位置放好Poi-tl标签,结构大致如下:
- 标题行:
{{projectName}}验收报告 - 基本信息区:项目编号
{{projectCode}}、承建单位{{contractor}}、验收日期{{acceptDate}} - 项目清单表格:表头为“序号/交付物名称/交付时间/状态”,数据行第一列写
{{*deliverables}},其余列写{{name}}、{{time}}、{{status}} - 整改提示:用
{{?needRectify}}包住一句“该项目仍存在需整改问题”的段落 - 签字区:左边填写单位名称
{{signUnit}},右边放图片标签{{@companySeal}}
模板里这些标签全部是普通文本,我甚至没有给它们单独设置字体颜色,因为我希望它们被渲染后跟正文风格一致,只需要在段落本身设置好常用字体即可。
4.2 完整代码结构
工程包结构我习惯这样划分:
com.example.report.controller.ReportExportController.java com.example.report.service.ReportExportService.java com.example.report.model.Deliverable.java com.example.report.util.PoiTlUtils.javaController只负责接收请求和输出文件:
@RestController @RequestMapping("/report") public class ReportExportController { @Resource private ReportExportService reportExportService; @GetMapping("/export") public void export(HttpServletResponse response) throws IOException { // 设置下载响应头 String fileName = URLEncoder.encode("项目验收报告.docx", "UTF-8"); response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\""); reportExportService.exportReport(response.getOutputStream()); } }Service是重点,里面组织数据并调用Poi-tl:
@Service public class ReportExportService { public void exportReport(OutputStream outputStream) throws IOException { // 从数据库或者业务接口取数,这里直接构造模拟数据 Map<String, Object> data = new HashMap<>(); data.put("projectName", "某跨平台业务系统"); data.put("projectCode", "PRJ-2025-0713"); data.put("contractor", "某某科技有限公司"); data.put("acceptDate", "2025年07月13日"); // 项目清单,对应{{*deliverables}} List<Deliverable> deliverableList = new ArrayList<>(); deliverableList.add(new Deliverable(1, "需求调研报告", "2025-05-10", "已完成")); deliverableList.add(new Deliverable(2, "系统设计文档", "2025-06-01", "已完成")); deliverableList.add(new Deliverable(3, "核心模块编码", "2025-06-20", "已完成")); deliverableList.add(new Deliverable(4, "用户培训材料", "2025-07-05", "待完善")); data.put("deliverables", deliverableList); // 是否显示整改提示 data.put("needRectify", true); // 签字区 data.put("signUnit", "某某科技有限公司"); // 使用byte[]方式构造印章图片,避免InputStream多次读问题 byte[] sealBytes = Files.readAllBytes(Paths.get("/data/seal.png")); data.put("companySeal", Pictures.ofBytes(sealBytes).size(90, 90).create()); // 加载模板并渲染 Resource resource = new ClassPathResource("templates/report_template.docx"); Configure config = Configure.builder() .defaultTextStyle(TextStyle.of().fontFamily("微软雅黑").fontSize(10.5f).build()) .build(); try (InputStream templateStream = resource.getInputStream(); XWPFTemplate template = XWPFTemplate.compile(templateStream, config).render(data)) { template.write(outputStream); } } }这里Deliverable就是一个普通POJO:
public class Deliverable { private int seq; private String name; private String time; private String status; public Deliverable(int seq, String name, String time, String status) { this.seq = seq; this.name = name; this.time = time; this.status = status; } // getter/setter省略 }有同学会问,为什么Deliverable是对象而模板循环里能用{{name}}直接取字段?因为Poi-tl对POJO的属性访问做了反射支持,字段名对应属性名就能直接读取。我实际项目里更多用Map,因为很多查询结果直接就是Map,但用POJO更利于代码维护和IDE提示,看团队习惯。
4.3 导出参数的动态计算与空值处理
上面代码只演示了固定数据,实际上数据往往是聚合出来的。比如“交付状态”可能是从数据库查询的字符串,也可能是需要根据日期算出来的“已完成/待完善”;“整改提示”往往需要根据某个规则来判断,比如存在status.equals("待完善")就让needRectify为true。
我写了一段常用的辅助逻辑:
boolean needRectify = deliverableList.stream() .anyMatch(item -> "待完善".equals(item.getStatus())); data.put("needRectify", needRectify);还有空值处理。模板渲染时如果value为null,默认变成空字符串,但有时候我们希望显示“/”或者“待补充”。这个可以用Poi-tl的哨兵值或者Lambda表达式配置,比如:
Configure config = Configure.builder() .bind("contractor", (model) -> null == model ? "待补充" : model.toString()) .build();这个写法适合针对个别字段做兜底,比每个字段单独判断要干净。如果整个Map里很多字段可能是null,也可以把配置写成一个方法复用,不然后期维护一段段if判断会让人头大。
4.4 前端调用与下载注意事项
前端只要一个普通下载请求即可:
window.location.href = '/report/export';或者使用axios的话要设置responseType为'blob',不然下载下来的文件可能是一串乱码:
axios.get('/report/export', { responseType: 'blob' }).then(response => { const blob = new Blob([response.data]); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '项目验收报告.docx'; link.click(); });一个容易踩的坑是Content-Disposition里的文件名,如果直接用中文且不回显,浏览器下载时可能变成一堆编码;Java后端用URLEncoder.encode(fileName, "UTF-8")处理过,前端再拿到的就正常了。此外,模板文件里的中文字体在Linux服务器上可能缺失,导致导出的Word在Windows上打开正常,在Linux上用LibreOffice预览却字体异常,这个不是Poi-tl的问题,是服务器字体库不全,别在这上面浪费时间排查。
5. 常见问题与排查技巧实录
5.1 中文乱码问题
乱码出现的原因,我排查下来基本是两种:一是模板本身就是doc而不是docx,Poi-tl不支持;二是渲染后字体缺失。处理方式很简单:模板统一另存为docx,配置里设置默认中文字体。另外要注意,如果模板里存在文本框内文字或页眉页脚里的变量,Poi-tl默认是不渲染的,需要针对Gramer或IncludeText做额外配置,但一般需求很少碰页眉页脚,遇到时再查官方文档就行。
5.2 图片无法显示或变成红叉
图片不显示的第一嫌疑是图片数据没传进去。检查点:
- 模板占位符是不是
{{@image}},少一个@就不认 - 图片构造是否调用
.create(),没创建就会空指针 - 图片路径如果是相对路径,在Linux上可能找不到,直接用字节流或绝对路径
遇到“红叉”一般是图片格式未被识别,比如ICO格式的图表,POI只支持常见png/jpg格式,转成png即可。我之前从某系统下载的印章图是webp格式,传进去直接整个表格渲染失败,转成png就好了。
5.3 模板标签被当成普通文本原样输出
如果你导出后发现Word正文里还留着{{name}},说明Poi-tl没有解析这个标签。最常见原因是模板里的花括号是全角,或者标签中间包含隐藏的换行符。解决办法是检查模板源,在Word里把{{name}}删掉重新输入一遍;如果是循环标签,还要确认标签是否位于表格单元格内,且没有跨单元格。
5.4 循环表格渲染结果不对
循环表格的坑集中在标签位置和数据结构上。比如你希望在表格里循环多行,模板的第二行第一列必须写{{*list}},该行的其他单元格才能写变量;如果你把{{*list}}写在表格外面的段落里,Poi-tl只会复制那段文字内容,不会扩展表格行。再比如List里的Map,key必须和模板变量完全一致,少一个字母就渲染不出值。这属于排错时最容易让人抓狂的一类问题,但定位到标签层级后就特别简单。
5.5 大文档导出内存溢出
Poi-tl底层还是POI的docx处理机制,如果模板里有一张大尺寸图片、循环几千行数据,JVM内存可能被打满。我的优化思路是:
- 循环数据如果巨大,优先输出流式接口,而不是把整个字节数组保留在内存里
- 图片压缩之后再传入,避免原图一个就好几MB
- 模板本身尽量精简,复杂样式越多,解压占用的临时内存越多
如果只是偶尔导出几百行表格,一般业务系统不会有问题。但如果做了批处理,比如凌晨把全年的报表全部导出成docx,建议分批执行,每生成完一个文件就及时关闭流,不要把所有XWPFTemplate对象堆在List里。
5.6 多线程并发渲染的线程安全问题
Poi-tl的模板编译对象是否可以复用?我的经验是:每次渲染都单独调用XWPFTemplate.compile,不要试图复用一个已经render过的template实例,因为render之后内部状态已经变化,再render会出问题。如果追求性能,可以把编译流程放在方法局部,配合线程池也没问题,因为compile本身并不会修改模板文件。
另外,数据Map中的对象在多线程下要避免共享修改。比如同一个List传给了多个导出任务,其中一个任务对List做了排序,另一个任务可能拿到错乱的数据。数据组装和渲染尽量保持无状态。
5.7 问题速查表
| 现象 | 排查方向 | 解决方案 |
|---|---|---|
| 导出的docx打开提示损坏 | 模板可能是doc格式,或响应头ContentType不对 | 模板统一转docx;接口返回正确ContentType |
| 中文全部乱码 | 字体配置缺失或模板本身编码问题 | Configure设置默认字体;重新另存docx模板 |
| {{name}}原样出现在文档里 | 标签写法或特殊字符问题 | 重新输入半角花括号标签 |
| 图片红叉 | 图片格式不被支持或路径不正确 | 转成png/jpg,使用InputStream或byte[] |
| 循环表格只渲染一行 | 循环标签位置不对 | 把{{*list}}移到表格数据行的首列单元格内 |
| 导出为空文件 | outputStream自行关闭或未write | 渲染后及时调用template.write |
| 模板读取不到 | ClassPathResource路径不对 | 确认模板在resources/templates目录且文件名正确 |
6. 实操心得与后续扩展建议
6.1 模板版本管理比代码版本管理更重要
整套方案跑起来后,你会发现真正的“需求变更入口”变成了Word模板,而不是Java代码。所以我建议把模板文件放进代码仓库的resources目录一起做版本管理,改模板和改代码走同样的评审和发布流程,不要直接在服务器上替换模板文件,否则哪天模板被改坏了,线上导出的文件会非常诡异。我们团队后来还约定:模板文件名末尾带版本号,比如report_template_v3.docx,每次大改就升版本,旧模板先不删,留一份回滚的余地。
6.2 水印、批注、加密与动态图表怎么扩展
Poi-tl发布版还提供了更丰富的插件能力,比如Word水印可以用WatermarkPlugin,批注可以用CommentPlugin,这些插件大多数人用不上,但其中水印确实是合同报告场景的常见需求。如果你也是导出合同,还可以顺便研究一下POI本身对docx加密的支持,但要注意加密后文件在某些老版本Office打开会提示兼容问题,所以上线前要拿目标用户的Office版本测一下。
动态图表是另一个方向,如果导出的Word里要伴随柱状图、饼图,纯Poi-tl实现不了。我遇到这种场景的做法是:先用Apache POI在模板里插入图表对象,或者渲染前用代码往docx的图表xml位置填充数据。这部分比较复杂,不是一句两句能说清,建议需求的优先级不高就别强行做,用截图+静态图片展示效果也是一种合理妥协。
6.3 导出Word后的二次加工与业务化扩展
导出Word通常不是终点,后面还跟着“转PDF”“加签章”“推送邮件”这些动作。我的经验是把导出和后续加工拆成独立服务,导出模块只负责生成标准的pptx/docx文件,转PDF可以再引入其他容器能力,推送交给消息服务。这样每个环节出错都能单独重试,而不是一个大流程串在一起,失败一次从头再来。
另外,我现在处理“导出模板动态生成Word”这类需求时,一定会在Service层写一个generateDocxData(model)方法,专门把业务对象转换成模板数据Map,这样将来接入其他模板引擎也能复用。这个小设计帮我避过很多“改了业务对象导致导出字段报错”的坑,某个字段找不到时,直接看Map的构建逻辑就清楚了。
最后再分享一个小技巧:Poi-tl的模板调试环不用重启整个SpringBoot。模板文件在resources里改完,有时IDEA不会主动把新文件复制到target目录,导致本地测了半天还是旧模板。遇到这种情况,先执行一下mvn clean compile强制刷新,或者直接把模板文件拷贝到target/classes对应的目录下,再刷新页面测试,能省下很多莫名其妙的时间。