乐于分享
好东西不私藏

业务场景实战:Word 模板打印两种实现,低级硬编码与高级可维护设计

业务场景实战:Word 模板打印两种实现,低级硬编码与高级可维护设计

在后台管理系统中,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<StringObject> data = new HashMap<>();        data.put("title""员工信息表");        Map<StringObject> 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(100100"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<StringObject>> 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<StringObject> 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,3    Arrays.asList(          "需求分析",          "接口开发",          "单元测试",          "上线部署"    ));// 也可以设置字体、缩进等样式listData.setParagraphStyle(    ParagraphStyle.builder()          .spaceAfter(120// 段后间距          .build());data.put("taskList", listData);

(5)区块:{{#block}}...{{/block}}

Word 模板

产品清单:{{#items}}- {{name}}:{{desc}}{{/items}}

Java 代码

List<Map<StringString>> 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<StringString> exportDataMapping = new WordDataMappingProcessor().processDataMapping(exportDataMappings, wordData);  // 简报生成  String filePath = null;  try{    // 将 null 或空字符串替换为 "未知"    Map<StringObject> 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, WordData    wordData) {    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(01).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<StringObject> 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);  }}

三、写在最后

看完这篇章,有没有人会想,高级的要写那么多代码,那么多方法,而低级的就一个方法。是的,同样一个Word 模板打印功能,用面向对象的思路去设计,会拆分成多个类和方法,结合反射来实现,结构清晰、职责明确;而用面向过程的写法,可能一个方法就能搞定,代码量看上去少了很多。
但这种“简洁” 只是假象,尤其在当前演示数据量不大的情况下才显得明显。一旦后续业务扩展、数据字段增多、需求不断迭代,差距就会非常明显:面向对象的设计,新增数据只需要扩展配置,原有逻辑几乎不用动,可维护性很强;而面向过程的写法,只能不断往原有方法里堆砌逻辑,代码只会越来越臃肿,最终变成难以维护的屎山。
PS:真有人,在controller控制层,直接写业务逻辑,并且一个方法691行,for里面有if,if里面又有for,然后又是for、if,不得不承认,虽然写这个方法的人没有好的代码思想,但逻辑能力还是挺强的。
所以大家也能看出来,面向对象并不是为了把代码写复杂,而是用更合理的设计思想,让系统在长期迭代中依然保持清晰、可扩展、可复用。这也是我们在实际开发中,要真正理解并运用好面向对象的原因。
如果本文对你有帮助,不妨点个赞,关注一下~欢迎在评论区留言交流,一起学习进步,共同成长!
注:本文为个人原创,AI 仅提供辅助支持。