在后台管理系统中,Word 文档打印、导出是非常常见的业务需求,使用 docxtemplater 这类模板引擎配合标签渲染数据,也是前端 / 后端常用的实现方案。
同样一个打印需求,不同开发思路写出来的代码,可读性、可维护性、扩展性天差地别。有的同学实现功能时,只管跑通就行:逻辑全部堆在一起、模板硬写在代码里、数据处理混乱、无复用无结构,看似快速完成,实则为后续需求变更埋下巨大隐患,这是典型的低级开发思路。
而真正成熟的开发方式,会把模板、数据处理、渲染逻辑、异常处理、复用能力全部抽离封装,代码结构清晰、易于扩展、方便排查问题,这就是高级开发思路。
本文以我在实际业务开发中,亲眼见到两位开发者使用 docxtemplater 实现模板打印为例,对比他们截然不同的两种实现思路,让你直观感受低级写法与高级写法的真实差距,学会用更规范、更优雅的方式封装通用打印功能,写出可长期维护的生产级代码。
一、基础知识引入
1、XWPFTemplate(poi-tl)介绍
XWPFTemplate(poi-tl)是基于 Apache POI 的 Java Word 模板引擎,核心是 TDO 模式(Template + Data = Output):在 Word 里写 {{变量}} 占位符,Java 传入数据自动填充,完全保留原格式。
2、XWPFTemplate依赖(Maven)
<dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.12.2</version> <!-- 最新稳定版 --></dependency><!--统一poi版本(可选) --><dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>5.2.5</version></dependency><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.2.5</version></dependency>
3、5 种核心语法
(1)文本:{{name}} → String / 对象属性
Word 模板
标题:{{title}}姓名:{{user.name}}年龄:{{user.age}}
Java 代码
import com.deepoove.poi.XWPFTemplate;import java.util.HashMap;import java.util.Map;public class SimpleDemo {public static void main(String[] args) throws Exception {// 1. 编译模板XWPFTemplate template = XWPFTemplate.compile("template.docx");// 2. 准备数据Map<String, Object> data = new HashMap<>();data.put("title", "员工信息表");Map<String, Object> user = new HashMap<>();user.put("name", "张三");user.put("age", 28);data.put("user", user);// 3. 渲染 + 输出template.render(data).writeToFile("output.docx");template.close();}}
(2)图片:{{@logo}} → PictureRenderData
Word 模板
公司Logo:{{@logo}}Java 代码
import com.deepoove.poi.data.PictureRenderData;// 宽100px、高100px、本地图片PictureRenderData pic = new PictureRenderData(100, 100, "logo.png");// 或流:new PictureRenderData(100,100, new FileInputStream("logo.png"))data.put("logo", pic);
(3)循环表格:{{#list}} → List/TableRenderData
Word 模板
在表格第一行写:{{#products}}| 名称 | 单价 | 数量 | 小计 ||------|------|------|------|| {{name}} | {{price}} | {{num}} | {{total}} |{{/products}}
Java 代码
import com.deepoove.poi.policy.HackLoopTableRenderPolicy;import com.deepoove.poi.config.Configure;// 配置循环表格策略(必须)Configure config = Configure.newBuilder().bind("products", new HackLoopTableRenderPolicy()).build();XWPFTemplate template = XWPFTemplate.compile("tableTemplate.docx", config);List<Map<String, Object>> products = new ArrayList<>();products.add(Map.of("name", "电脑", "price", 5999, "num", 1, "total", 5999));products.add(Map.of("name", "鼠标", "price", 199, "num", 2, "total", 398));Map<String, Object> data = new HashMap<>();data.put("products", products);template.render(data).writeToFile("tableOutput.docx");
(4)有序列表:{{*num}} → NumbericRenderData
Word 模板
工作任务:{{*taskList}}
Java 代码
// 构造有序列表数据NumbericRenderData listData = NumbericRenderData.build(STNumberFormat.DECIMAL, // 1,2,3Arrays.asList("需求分析","接口开发","单元测试","上线部署"));// 也可以设置字体、缩进等样式listData.setParagraphStyle(ParagraphStyle.builder().spaceAfter(120) // 段后间距.build());data.put("taskList", listData);
(5)区块:{{#block}}...{{/block}}
Word 模板
产品清单:{{#items}}- {{name}}:{{desc}}{{/items}}
Java 代码
List<Map<String, String>> items = Arrays.asList(Map.of("name", "A", "desc", "产品A说明"),Map.of("name", "B", "desc", "产品B说明"));data.put("items", items);
(6)条件:{{?cond}}...{{/cond}}
Word 模板
{{?isVip}}尊敬的VIP客户,享9折优惠!{{/isVip}}
Java 代码
data.put("isVip", true); // 显示VIP内容4、面向过程(POP)和面向对象(OOP)
之所以会聊到这个话题,是因为今天我要讲的东西,来自两位开发者写的同类型代码,一位仍带着明显的面向过程思维,另一位则完全践行了面向对象的设计理念,两种思路的差异很直观,也让我觉得有必要和大家重新梳理下这两个核心知识点,帮大家分清两者的本质区别。
(1)面向过程(POP):按步骤做事,一步一步来
以函数 / 方法为核心 数据和操作是分开的 适合简单、线性的小任务 代码复用性差,改一步可能全乱
(2)面向对象(OOP):把世界看成一个个对象,让对象自己做事
封装:把数据和操作包在一起,对外只留接口 继承:子类可以复用父类的功能 多态:同一个方法,不同对象表现不同行为
(3)对比
对比 | 面向过程(POP) | 面向对象(OOP) |
核心 | 步骤 / 流程 | 对象 |
思维 | 怎么做 | 谁来做 |
复用性 | 差 | 好 |
维护性 | 难 | 易 |
适合 | 小功能 | 大型项目 |
二、实战核心代码(Demo)
1、示例源码和SQL下载
通过网盘分享的文件:exportDemo.zip链接: https://pan.baidu.com/s/1RDIHu_aWdLObqpyVPbRUHQ?pwd=xnek 提取码: xnek
2、Word模板打印高级实现
(1)入口方法
@Overridepublic void highExportWord() {//构建返回值String filKey="";System.out.printf("high export word");// 测试,模拟取数WordData wordData = new WordData();wordData.setWordDataOne(wordDataOneMapper.selectById(1));wordData.setWordDataTwo(wordDataTwoMapper.selectById(1));List<ExportDataMapping> exportDataMappings = exportDataMappingMapper.selectList(null);Map<String, String> exportDataMapping = new WordDataMappingProcessor().processDataMapping(exportDataMappings, wordData);// 简报生成String filePath = null;try{// 将 null 或空字符串替换为 "未知"Map<String, Object> filledMap = exportDataMapping.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,entry -> {String value = entry.getValue();return (value == null) ? "未知" : value;}));// 简报文件存盘filKey = fileSave(filledMap);log.info("文件已生成: {}", filKey);}catch(Exception e){e.printStackTrace();filKey="";log.info("出现错误");}}
(2)存储Word模版数据的实体类
/*** WordData 实体类*/@Datapublic classWordData{private WordDataOne wordDataOne;private WordDataTwo wordDataTwo;}
(3)数据映射实体类
/*** 数据映射实体类* @ description 数据映射实体类,用于定义导出数据的映射关系* @TableName export_data_mapping*/@TableName(value ="export_data_mapping")@Datapublic class ExportDataMapping {/****/@TableId(type = IdType.AUTO)private Long id;/*** 标签*/private String lable;/*** 数据描述*/private String datadescription;/*** 数据映射属性*/private String datamapping;/*** 数据获取方式:1-映射字段、2-特殊算法*/private Integer datatype;/*** 特殊算法名称*/private String datafunction;/*** 数据映射类名*/private String dataclass;}
(4)数据映射处理器类,用于处理数据映射配置
/*** @author rh* @date 2026/4/7 11:24* @ description 数据映射处理器类,用于处理数据映射配置*/public class WordDataMappingProcessor{// 缓存类名到获取方法的映射private static final Map<String, String> CLASS_GETTER_Data_MAP = new HashMap<>();static {CLASS_GETTER_Data_MAP.put("WordDataOne", "getWordDataOne");CLASS_GETTER_Data_MAP.put("WordDataTwo", "getWordDataTwo");}// 方法处理器映射private final Map<String, DataFunctionHandler> functionHandlers = new HashMap<>();public WordDataMappingProcessor() {initHandlers();}/*** 初始化所有方法处理器*/private void initHandlers() {// 1. 对一个值进行处理functionHandlers.put("getData1Column1", (vo, mapping, clazz) -> {//省略处理逻辑return vo.getWordDataOne().getColumn1();});// 2. 对多个值进行处理functionHandlers.put("getColumn1", (vo, mapping, clazz) -> {WordDataOne wordDataOne = vo.getWordDataOne();WordDataTwo wordDataTwo = vo.getWordDataTwo();if (wordDataOne != null && wordDataTwo != null) {return wordDataOne.getColumn1() + wordDataTwo.getColumn1();}return "";});// 3. 对不确定值进行处理的公共方法functionHandlers.put("getUnknownValue", (vo, mapping, clazz) -> {if (clazz == null || mapping == null) {return "";}Object dataObj = getDataObject(vo, clazz);if (dataObj == null) {return "";}try {String value = getFieldValue(dataObj, mapping);if (value == null || value.isEmpty()) {return "";}String val = String.valueOf(value)+"(被处理了)";return val;} catch (Exception e) {return "";}});}/*** 主处理方法** @param mappingList 数据映射配置列表* @param wordData 主数据对象* @return Map<lable, value>*/public Map<String, String> processDataMapping(List<ExportDataMapping> mappingList, WordDatawordData) {Map<String, String> result = new HashMap<>();if (mappingList == null || wordData == null) {return result;}for (ExportDataMapping mapping : mappingList) {String lable = mapping.getLable();Integer dataType = mapping.getDatatype();String dataClass = mapping.getDataclass();String dataMapping = mapping.getDatamapping();String dataFunction = mapping.getDatafunction();if (lable == null || lable.isEmpty()) {continue;}String value;if (dataType == null) {value = "";} else if (dataType == 1) {// 直接从对应类的对应属性中获取值value = getDirectValue(wordData, dataClass, dataMapping);} else if (dataType == 2) {// 调用data_function的方法获取值value = getFunctionValue(wordData, dataFunction, dataMapping, dataClass);} else {value = "";}result.put(lable, value != null ? value : "");}return result;}/*** 获取直接值 (data_type = 1)*/private String getDirectValue(WordData vo, String dataClass, String dataMapping) {if (dataClass == null || dataMapping == null) {return "";}Object dataObj = getDataObject(vo, dataClass);if (dataObj == null) {return "";}return getFieldValue(dataObj, dataMapping);}/*** 获取方法计算值 (data_type = 2)*/private String getFunctionValue(WordData vo, String dataFunction, String dataMapping, String dataClass) {if (dataFunction == null || dataFunction.isEmpty()) {return "";}DataFunctionHandler handler = functionHandlers.get(dataFunction);if (handler == null) {return "";}return handler.handle(vo, dataMapping, dataClass);}/*** 根据类名获取对应的数据对象*/private Object getDataObject(WordData vo, String className) {if (className == null || className.isEmpty()) {return null;}String getterName = CLASS_GETTER_Data_MAP.get(className);if (getterName == null) {return null;}try {Method method = WordData.class.getMethod(getterName);return method.invoke(vo);} catch (Exception e) {return null;}}/*** 通过反射获取对象字段值*/private String getFieldValue(Object obj, String fieldName) {if (obj == null || fieldName == null || fieldName.isEmpty()) {return "";}try {// 构建getter方法名String getterName = "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);Method method = obj.getClass().getMethod(getterName);Object result = method.invoke(obj);if (result == null) {return "";}return result.toString();} catch (Exception e) {return "";}}}
(5)生成Word文件方法
/*** 生成 Word 文件* @param data* @return fileKey* @throws Exception*/public String fileSave(Map<String, Object> data) throws Exception {// 存盘路径String TEMPLATE_PATH = templateFileConfig.getPath();String TEMP_PATH = templateFileConfig.getTemp();// 生成原始文件key(UUID)String originalFileKey = UUID.randomUUID().toString().replace("-", "");String tempfilename = originalFileKey + ".docx";TEMP_PATH = TEMP_PATH + tempfilename;// 确保目录存在ensureDirectoryExists(TEMP_PATH);// ========== 配置插件(修复:根据版本选择正确的类)==========ConfigureBuilder configureBuilder = Configure.builder().useSpringEL();Configure config = configureBuilder.build();File templateFile = ResourceUtils.getFile(TEMPLATE_PATH);// 关键修复:确保流正确关闭,文件完全写入XWPFTemplate template = null;FileOutputStream tempOut = null;try {FileInputStream fis = new FileInputStream(templateFile);template = XWPFTemplate.compile(fis, config).render(data);tempOut = new FileOutputStream(TEMP_PATH);template.write(tempOut);// 关键:强制刷新并关闭流tempOut.flush();tempOut.close();tempOut = null;template.close();template = null;fis.close();System.out.println("临时文件已生成: " + TEMP_PATH);// 验证文件是否存在File tempFile = new File(TEMP_PATH);if (!tempFile.exists() || tempFile.length() == 0) {throw new RuntimeException("临时文件生成失败或为空: " + TEMP_PATH);}} finally {// 确保资源释放if (tempOut != null) {try {tempOut.close();} catch (Exception e) {}}if (template != null) {try {template.close();} catch (Exception e) {}}}return originalFileKey;}/*** 确保文件所在目录存在*/private void ensureDirectoryExists(String filePath) {File file = new File(filePath);File parentDir = file.getParentFile();if (parentDir != null && !parentDir.exists()) {parentDir.mkdirs();}}
3、Word模板打印低级实现
@Overridepublic void lowExportWord() {System.out.printf("low export word");// 测试,模拟取数WordDataOne wordDataOne = wordDataOneMapper.selectById(1);WordDataTwo wordDataTwo = wordDataTwoMapper.selectById(1);Map<String, Object> exportDataMapping = new HashMap<>();exportDataMapping.put("data1", wordDataOne.getColumn1());exportDataMapping.put("data2", wordDataOne.getColumn2());exportDataMapping.put("data3", wordDataOne.getColumn3());exportDataMapping.put("data4", wordDataOne.getColumn4());exportDataMapping.put("data5", wordDataTwo.getColumn1());exportDataMapping.put("data6", wordDataTwo.getColumn2());exportDataMapping.put("data7", wordDataTwo.getColumn3());exportDataMapping.put("data8", wordDataTwo.getColumn4());try {// 这里我懒得在写一遍生成文件的过程了,就直接用高级的替代一下String filKey = fileSave(exportDataMapping);log.info("文件已生成: {}", filKey);} catch (Exception e) {throw new RuntimeException(e);}}
夜雨聆风