
做 Java 办公自动化的人早晚会撞上一个需求手头有一份 Word 模板里面几个关键位置要动态换成数据库里的值比如客户名称、合同编号、签署日期。最常见的做法是读文件流把模板里的占位符字符串直接 replace 掉。这种路子早期看着省事等模板一复杂你会被格式错乱、表格错位、漏替换搞到崩溃。Word 本身其实有一套专门为这种场景设计的能力——文档变量Document Variable配合 DOCVARIABLE 域使用既能在正文里动态展示又能藏在文档属性里做元数据。用 Java 的 Apache POI 操作它比想象中要简单而且踩坑点明确、可控。这篇文章我会从底层结构讲起把变量存在哪里、正文怎么引用、用 POI 怎么增改、改完为什么还不显示这几个问题一次说透最后附上我在实际项目里的排查经验和批量生成模板的写法。无论你是刚接触 POI 的新手还是已经写过报表生成的老手这篇都能直接抄作业。1. 文档变量是什么先分清变量、域、书签和占位符1.1 变量在 Word 里的真实身份很多人以为文档变量是正文里的某种特殊字符串这是个误解。Word 的文档变量是一组挂在文档属性层级的键值对平时根本不出现在正文里它安静地躺在文档的设置区域中结构长这样w:variables w:variable w:namecustomerName w:val张三科技有限公司/ w:variable w:namecontractNo w:valHT-2024-018/ w:variable w:namesignDate w:val2024-08-20/ /w:variables每个变量就是w:name和w:val两个属性一个名字一个值。真正在正文里显示内容的是域Field它通过{ DOCVARIABLE customerName }这种指令去引用变量。所以你要记住一句话变量负责存数据域负责取数据。做自动化时你可以只把变量当元数据存着供程序读取也可以通过域让它展示在文档正文的任何位置。1.2 变量、域、书签、占位符四者完全不同我见过不少同事把文档变量和书签混为一谈。书签管的是位置变量管的是数据二者用途截然不同。这里列个对比表方便你理解边界机制本质上是什么存储位置典型用途文档变量键值对数据settings.xml元数据、模板动态值域 Field字段指令缓存结果document.xml显示变量的值、页码、日期等书签 Bookmark一段具名位置document.xml定位导航、超链接目标文本占位符普通字符串document.xml字符串替换不推荐用于正规方案书签适合做跳转锚点不适合拿来存业务数据纯文本占位符看着人畜无害但正文里一段文字经常被 Word 拆成多个 run字符串替换很容易漏掉或破坏格式后面我会专门讲。1.3 什么场景该用文档变量以我做合同生成的经验下面这几类需求特别适合用文档变量模板里的客户名、合同编号、签署日期这类数量有限但必须换的信息同一个值既要在正文显示又要作为元数据被其他程序索引不希望模板携带宏。DOCVARIABLE是域不是宏生成和打开时不会触发宏安全警告这对自动化交付给客户很重要。反过来如果需求是按数据动态增减表格行数根据条件插入整段内容那就别指望变量了那是结构化文档操作需要直接操作表格节点或段落节点。2. 环境准备POI 版本、依赖和一个总被坑的 schema 问题2.1 版本基线5.2.x 起步Apache POI 从 4.x 开始对 XWPFWord 2007 格式的支持已经很成熟到 5.x 把模块做了拆分。我这里以 5.2.5 为基线这也是当前主流稳定版。Maven 依赖很简单dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency /dependenciespoi-ooxml会传递依赖poi、poi-ooxml-lite和commons-compress大部分常见操作都能覆盖。2.2 一个隐藏的坑poi-ooxml-lite 不够用这里必须提前说一个很多人踩过的坑。POI 5.x 默认引入的是poi-ooxml-lite它为了减小体积只包含了大部分 OOXML schema 类。但文档变量操作涉及CTVariables、CTVariable这些 XMLBeans 生成的类在 lite 版里并不齐全。如果你写完代码一运行直接报NoClassDefFoundError: org/openxmlformats/schemas/wordprocessingml/x2006/main/CTVariable不用怀疑就是你缺了poi-ooxml-full。解决方式是在依赖里显式加上dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml-full/artifactId version5.2.5/version /dependency我的建议是只要你的项目打算正经操作文档变量、域这类底层功能就别省这一步两个依赖一起上省得写一半被环境问题打断。2.3 先说清楚适用范围本文所有操作针对.docxOOXML 格式用的是XWPFDocument。老的.doc二进制格式对应的是 HWPF文档变量支持很不完整而且版本兼容极差我的建议很直接先把旧文档批量转成.docx再走统一处理逻辑能省掉 80% 的兼容性麻烦。Java 版本方面POI 5.2.x 要求 Java 8 以上Java 8 到 Java 21 都能跑这不算瓶颈。3. 变量藏在 settings.xml 里先看物理结构再写代码3.1 把 docx 拆开看内脏写代码之前我强烈建议你先手动看一眼 docx 的内部结构。.docx本质是个 zip 压缩包把它改后缀名成.zip再解压能看到这些关键文件word/document.xml正文段落、表格、域都在这里word/settings.xml文档设置变量就存放在这里的w:variables节点下word/styles.xml样式定义。你可以创建一个空白文档随便写几个变量保存后解压查看。这样做的好处是出了 bug 你能直接对着 XML 排查而不是对着报错瞎猜。我调试 POI 生成结果时永远会保留一个这种解压查看的习惯。3.2 w:variable 的字段含义settings.xml里的变量节点字段很少就两个w:name变量名可以理解成 Map 里的 keyw:val变量值对应 Map 里的 value。有一点要注意变量名在同一文档里必须唯一。如果程序往文档里塞了两个同名的w:variable不同版本的 Word 行为不一致有的取第一个有的取最后一个这种不确定性在自动化里就是事故源。好在 POI 的 API 帮我们挡住了这个问题下面会说。3.3 POI 对应的入口XWPFDocument.getVariables()POI 把 settings.xml 里的变量集合封装成了XWPFDocument.Variables内部类通过getVariables()拿到。核心方法有这几个getVariable(String name)按名字取值不存在返回 nulladdVariable(String name, String val)新增或更新变量removeVariable(String name)删除变量getAll()返回全部变量对的 Map。这套 API 从 POI 4.1.1 开始就在用了到 5.2.x 依然稳定。真正用起来你会发现它比操作书签或者查找替换要清爽得多——你不用关心正文里的 run 被拆成几段只需要操作这个KV 仓库。4. 动态添加变量新文档、旧模板两种入口都写一遍4.1 从零创建文档并写入变量先看最简单的情况程序新建一个空文档往里塞三个变量然后落盘。完整代码如下import org.apache.poi.xwpf.usermodel.XWPFDocument; import java.io.FileOutputStream; public class AddVariablesDemo { public static void main(String[] args) throws Exception { try (XWPFDocument document new XWPFDocument()) { // 拿到变量的操作接口 XWPFDocument.Variables variables document.getVariables(); variables.addVariable(customerName, 张三科技有限公司); variables.addVariable(contractNo, HT-2024-018); variables.addVariable(signDate, 2024-08-20); try (FileOutputStream out new FileOutputStream(variables-demo.docx)) { document.write(out); } } } }保存后你把这个 docx 解压打开word/settings.xml应该能看到三个w:variable节点。到这一步变量是存进去了但正文里什么都看不到因为还没有域去引用它们。4.2 给已有模板补变量实际项目中更常见的是客户给你一份做好的模板里面可能已经有变量也可能一个都没有你需要把新数据补进去再另存为新文件。这时候只需要把构造函数换成new XWPFDocument(输入流)import org.apache.poi.xwpf.usermodel.XWPFDocument; import java.io.FileInputStream; import java.io.FileOutputStream; public class UpdateVariablesDemo { public static void main(String[] args) throws Exception { try (XWPFDocument document new XWPFDocument( new FileInputStream(contract-template.docx))) { XWPFDocument.Variables variables document.getVariables(); // 不存在就新增存在就覆盖语义后面细说 variables.addVariable(customerName, 李四贸易有限公司); variables.addVariable(signDate, 2024-10-11); try (FileOutputStream out new FileOutputStream(contract-out.docx)) { document.write(out); } } } }这里我特意强调新文件而不是覆盖原文件。自动化任务里模板是生产资产改坏了没人能帮你找回输出必须另存。4.3 addVariable 的有则改无则增语义很多第一次接触的人会问addVariable明明是 add为什么还能更新看 POI 源码后你就明白了。它的内部逻辑是先遍历已有变量如果找到同名变量就把值覆盖掉并返回旧值如果没找到就新建一个节点追加进去。所以addVariable其实是setVariable的语义。这个设计比让你先removeVariable再addVariable安全得多——至少不会出现重复变量名的坑。你写代码时只需要记住同一个XWPFDocument对象里同名变量只会有一个调用addVariable之后settings.xml 里的变量必然处于最新状态。这也是为什么我上面说POI 的 API 帮你挡住了重名问题。5. 让变量显示在正文插入 DOCVARIABLE 域的两种姿势5.1 先理解域的两种 XML 形态变量存进 settings.xml 后正文里要用域引用。OOXML 里域的存储有两种形态搞懂它们你才能既看懂模板又敢写生成代码。第一种是简单域用w:fldSimple一个节点搞定典型写法w:p w:rw:t客户名称/w:t/w:r w:fldSimple w:instr DOCVARIABLE customerName \* MERGEFORMAT w:rw:t张三科技有限公司/w:t/w:r /w:fldSimple /w:p第二种是复杂域用w:fldChar的 begin、separate、end 三个标记夹住指令和结果Word 界面里插入的域基本都是这种形态w:p w:rw:fldChar w:fldCharTypebegin//w:r w:rw:instrText DOCVARIABLE customerName /w:instrText/w:r w:rw:fldChar w:fldCharTypeseparate//w:r w:rw:t张三科技有限公司/w:t/w:r w:rw:fldChar w:fldCharTypeend//w:r /w:pfldSimple适合程序生成复杂域是 Word 亲儿子兼容性最好但操作稍微繁琐。两者表达同一个域Word 都能正确识别。5.2 用底层 API 插入简单域 DOCVARIABLEPOI 没有提供一键插入域的高层 API所以要直接操作 XMLBeans 生成的节点类。下面这段代码演示新建一个段落前面写个标签后面插入引用customerName变量的简单域。import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText; public class InsertFieldDemo { public static void main(String[] args) throws Exception { XWPFDocument document new XWPFDocument(); XWPFDocument.Variables variables document.getVariables(); variables.addVariable(customerName, 张三科技有限公司); XWPFParagraph paragraph document.createParagraph(); XWPFRun labelRun paragraph.createRun(); labelRun.setText(客户名称); // 在段落底层插入 fldSimple 节点 CTP ctp paragraph.getCTP(); CTSimpleField field ctp.addNewFldSimple(); field.setInstr(DOCVARIABLE customerName \\* MERGEFORMAT); // 域内至少要有一个 run 作为结果占位 CTR resultRun field.addNewR(); CTText text resultRun.addNewT(); text.setStringValue(张三科技有限公司); try (java.io.FileOutputStream out new java.io.FileOutputStream(field-demo.docx)) { document.write(out); } } }注意setInstr里的写法字符串中\\*是 Java 对反斜杠的转义最终写进 XML 的就是\* MERGEFORMAT这是 Word 域常见的格式开关表示保留原格式。占位符的值先随便填一个后面讲刷新域结果时再统一替换。5.3 在已有段落里插入域真实模板里通常不需要新建段落而是要在已有段落里的某个位置插上域。做法是定位XWPFParagraph然后调用insertNewRun(int index)插入新的 run再对新 run 做同样的底层节点操作。定位段落本身也有技巧遍历document.getParagraphs()按文本内容匹配如果要操作表格里的单元格先拿document.getTables()再table.getRow(i).getCell(j).getParagraphs()页眉页脚里的段落要单独从document.getHeaderList()、footerList()里取。我建议把定位段落和插入域拆成两个方法这样后面做批量生成时可以复用。5.4 复杂域怎么插兼容性更好的选择如果你希望生成的文档和 Word 菜单插入的域一模一样代码可以这样写import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STFldCharType; public void appendComplexVariableField(XWPFParagraph paragraph, String variableName) { XWPFRun beginRun paragraph.createRun(); beginRun.getCTR().addNewFldChar().setFldCharType(STFldCharType.BEGIN); XWPFRun instrRun paragraph.createRun(); instrRun.getCTR().addNewInstrText().setStringValue( DOCVARIABLE variableName ); XWPFRun separateRun paragraph.createRun(); separateRun.getCTR().addNewFldChar().setFldCharType(STFldCharType.SEPARATE); XWPFRun resultRun paragraph.createRun(); resultRun.setText(); XWPFRun endRun paragraph.createRun(); endRun.getCTR().addNewFldChar().setFldCharType(STFldCharType.END); }这段代码的关键是STFldCharType.BEGIN/SEPARATE/END三个枚举。复杂域的好处是用户按下 AltF9 看到的域代码结构和 Word 原生完全一致排查问题也更直观。我个人偏向用复杂域虽然代码啰嗦一点但客户拿回去二次编辑时不至于出幺蛾子。6. 修改已有变量改值只是第一步刷新域结果才是重点6.1 改变量值的核心三步修改已有文档的变量值从代码角度就三步读文件、addVariable覆盖值、另存新文件。前面代码已经演示过了这里不再重复。但我要强调的是这三步做完你在 Word 里打开新文件正文显示的很可能是旧值。这是让人最困惑的一步。原因在于Word 的域有一个缓存结果。正文里w:t张三科技有限公司/w:t是域的缓存显示值它不会因为你改了 settings.xml 里的变量值就自动变化。变量是货架上的货物域是贴在货架前的价格标签你改了仓库库存标签不会自己跟着变。6.2 方案一让 Word 打开时自动刷新域最省事的做法是设置文档属性让 Word 在打开文件时强制更新所有域。POI 支持一行搞定document.getSettings().setUpdateFields(true);加上这一行再保存用户用 Word 打开文件时会看到一个提示此文档包含可能引用其他文件的字段之类的话确认后所有域会重新计算结果正文立刻变成最新值。但注意这个方案有两个副作用每次打开都会弹提示自动化批量交付给客户时客户体验很糟糕如果你的文档还引用了外部数据源或链接Word 打开会更慢。所以这个方案适合内部使用数据变化频繁的场景。6.3 方案二程序里直接刷新域结果要彻底无感就得在生成阶段把域里的缓存结果也一并改掉。思路很简单扫描正文里的所有DOCVARIABLE域指令解析出变量名从变量仓库取值然后替换域结果里的文本。先看简单域的刷新因为 fldSimple 结构紧凑一个节点全搞定import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText; public static String extractVariableName(String instr) { if (instr null) return ; String value instr.trim().replaceFirst((?i)^DOCVARIABLE\\s, ); int mark value.indexOf(\\); if (mark 0) value value.substring(0, mark); return value.trim(); } public static void refreshSimpleFields(XWPFDocument document, XWPFDocument.Variables variables) { for (XWPFParagraph paragraph : document.getParagraphs()) { CTP ctp paragraph.getCTP(); for (CTSimpleField field : ctp.getFldSimpleList()) { String variableName extractVariableName(field.getInstr()); if (variableName.isEmpty()) continue; String value variables.getVariable(variableName); if (value null) value ; // 取域内第一个 run 作为结果载体 CTR resultRun field.getRList().isEmpty() ? field.addNewR() : field.getRList().get(0); if (resultRun.getTList().isEmpty()) { resultRun.addNewT().setStringValue(value); } else { // 第一个 t 放新值其余 t 清空 resultRun.getTList().get(0).setStringValue(value); for (int i 1; i resultRun.getTList().size(); i) { resultRun.getTList().get(i).setStringValue(); } } } } }复杂域稍微麻烦一点需要遍历段落里的所有 run找到fldCharTypebegin后往下收集instrText直到遇到separate再把separate和end之间的结果文本替换掉。核心查找逻辑我写出来import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STFldCharType; public static void refreshComplexFields(XWPFDocument document, XWPFDocument.Variables variables) { for (XWPFParagraph paragraph : document.getParagraphs()) { java.util.ListXWPFRun runs paragraph.getRuns(); for (int i 0; i runs.size(); i) { CTR ctr runs.get(i).getCTR(); if (!ctr.isSetFldChar()) continue; if (ctr.getFldChar().getFldCharType() ! STFldCharType.BEGIN) continue; // 收集 begin 之后的指令文本直到 separate StringBuilder instr new StringBuilder(); int separateIndex -1; for (int j i 1; j runs.size(); j) { CTR jCtr runs.get(j).getCTR(); if (jCtr.isSetFldChar()) { STFldCharType type jCtr.getFldChar().getFldCharType(); if (type STFldCharType.SEPARATE) { separateIndex j; break; } if (type STFldCharType.END) break; } if (jCtr.isSetInstrText()) { for (CTText text : jCtr.getInstrTextList()) { instr.append(text.getStringValue()); } } } String variableName extractVariableName(instr.toString()); if (variableName.isEmpty() || separateIndex 0) continue; String value variables.getVariable(variableName); if (value null) value ; // 覆盖 separate 与 end 之间的结果文本 boolean replaced false; for (int j separateIndex 1; j runs.size(); j) { CTR jCtr runs.get(j).getCTR(); if (jCtr.isSetFldChar() jCtr.getFldChar().getFldCharType() STFldCharType.END) { break; } if (jCtr.getTList().isEmpty()) continue; if (!replaced) { jCtr.getTList().get(0).setStringValue(value); for (int k 1; k jCtr.getTList().size(); k) { jCtr.getTList().get(k).setStringValue(); } replaced true; } else { for (CTText text : jCtr.getTList()) { text.setStringValue(); } } } } } }这段代码我故意保留了边界条件的处理有些域结果可能被拆成多个 run、多个 t直接塞一个文本进去会把别的内容冲掉所以先找到第一个 t 覆盖其余的全部清空。实际项目里你可以再优化把所有连续结果 run 合并成一个但大多数场景下上面这个版本够用。6.4 生成流程的最终组合拳把方案一和方案二想清楚后我的标准生成流程是这样的读模板getVariables().addVariable()把最新值写进 settings.xml调用刷新方法把正文所有域的缓存结果同步成最新值不设置updateFields保证用户打开无弹窗另存为带时间戳或业务编号的新文件。这套组合拳既保证了显示正确又避免了弹窗打扰是我在真实项目里最推荐的姿势。7. 实测踩坑记录六个常见的翻车现场7.1 一运行就 NoClassDefFoundError: CTVariable这个坑在第 2 章已经预告过。现象是代码编译没问题一跑就报找不到org/openxmlformats/schemas/wordprocessingml/x2006/main/CTVariable。原因就是poi-ooxml-lite里没有这个类。排查思路很直接先看依赖树里有没有poi-ooxml-full没有就加上加了还报八成是版本冲突强制统一到同一个 POI 版本。7.2 变量值明明改了正文却还是旧内容这是第 6 章说的缓存结果问题。遇到这个现象先别怀疑addVariable没生效解压输出文件看两处word/settings.xml里的w:variable是不是新值word/document.xml里域结果 run 的w:t是不是旧值。两处一对比你就知道问题出在只改了仓库没改标签还是仓库都没改对。用这种解压看 XML的方式排查比在 Office 里反复开关文件高效得多。7.3 Word 每次打开都弹更新域提示这个提示的来源就是setUpdateFields(true)。如果你交付给最终用户他们很可能会困惑以为文件有问题。我的处理习惯是给内部系统用保留更新标记给外部客户交付用程序刷新域结果并关掉这个开关。另外提醒一句如果一个文档里既有 DOCVARIABLE 域又有页码、目录这类自动域强制刷新会连目录页一起刷新时间戳可能变这在正式文件里反而可能是问题。7.4 修改数据后直接打不开 Word 文档这种情况十有八九不是 POI 的锅而是有人绕开 POI 直接对 zip 里的 XML 做字符串替换结果破坏了 XML 结构或者留下了非法字符。最常见的翻车点有两个替换时把标签边界弄坏或者把特殊字符原样塞进 XML。换行符、小于号、这些符号在 XML 里都要转义字符串替换根本照顾不过来。如果你必须手写 XML 片段记着用 POI 封装好的写入管线它保证生成合法的 XML。7.5 域在页面上显示成 { DOCVARIABLE xxx } 代码这不是程序错误是 Word 的显示域代码开关被打开了用户按 AltF9 就能切换。程序层面能做的就是保证域结果文本已经写入了最新值这样绝大多数用户打开看到的都是值而不是代码。另外如果模板是别人手动做的检查一下设置里有没有把域代码刻进结果文本那种情况需要直接清掉结果文本重新写。7.6 批量处理时文件句柄不释放批量生成几百份文档时最容易出现的是流不关导致的文件占用问题Windows 上表现尤其明显。我见过同事在循环里new XWPFDocument()之后忘了 close跑到一百多份时文件就写不动了。正确做法是用 try-with-resources每个文档对象用完即关写出的输出流也要关。POI 的document.write()不会自动关闭输出流这里别省。8. 从变量出发批量生成文档的现实玩法8.1 模板加变量批量产出合同文档变量的最大价值是配合模板做批量生成。模板里把客户名、合同编号、金额、日期都埋成 DOCVARIABLE 域Java 这边从数据库或 Excel 读数据循环调用生成逻辑一份一份产出。骨架代码大致是这样public void batchGenerate(ListContractData dataList) throws Exception { for (ContractData data : dataList) { try (XWPFDocument document new XWPFDocument( new FileInputStream(template.docx))) { XWPFDocument.Variables variables document.getVariables(); variables.addVariable(customerName, data.getCustomerName()); variables.addVariable(contractNo, data.getContractNo()); variables.addVariable(amount, data.getAmount()); variables.addVariable(signDate, data.getSignDate()); refreshSimpleFields(document, variables); refreshComplexFields(document, variables); String fileName output/ data.getContractNo() .docx; try (FileOutputStream out new FileOutputStream(fileName)) { document.write(out); } } } }这里有个性能点要说明每次循环都是读模板→改变量→刷新→写文件对一般几 MB 的模板来说开销可忽略如果模板特别大、单量特别多可以考虑用XWPFDocument的复制策略或者预构建缓存但那属于另一个话题了。8.2 变量和表格、图片、图表怎么配合有读者问过java poi word能生成图表吗。答案是能POI 的XWPFChart支持在 docx 里嵌入基础图表但说实话手写图表的代码量不小而且样式很难调。更稳妥的路线是模板里先用 Word 做好图表需要改数据时通过操作图表关联的数据引用去更新——这也是热词里通过修改模板中的图表数据修改 word 图表常见的生产做法。文档变量在这个场景里适合当图表标题单位名称这类元数据的载体和图表数据本身解耦。表格和图片配合变量的方式更直接表头单元格里放 DOCVARIABLE 域或者把变量值作为单元格文本写入。要注意的是表格单元格里的域刷新逻辑跟普通段落完全一样只是遍历时要从table.getRow().getCell().getParagraphs()里去取段落。8.3 为什么不建议全文档字符串替换再回到开头的痛点。字符串替换占位符看起来人畜无害但它有三个硬伤Word 会把一段文字拆成多个 run比如客户名称可能横跨三个节点替换时漏一半占位符跨 run 分布时简单 replace 根本无法命中同一个占位符在正文、表格、页眉页脚出现多次时普通字符串替换无法区分格式上下文。我曾经见过一个项目用字符串替换做合同模板模板一改版就崩一次最后所有逻辑推倒重来。用文档变量 域的方案值和展示位置是分离的模板怎么改都不影响变量的读取逻辑这才是办公自动化该有的解耦姿态。8.4 更省心的进阶选型poi-tl如果你的模板不仅有变量还涉及列表循环、条件段落、图片插入用原生 POI 手搓会很累。这种情况下可以考虑 poi-tl一个基于 POI 的模板引擎它在占位符渲染上做了非常成熟的封装支持 {{name}}、{{?list}} 这类标签语法内部本质上也是在文档结构上做渲染替换。怎么选我的判断标准很朴素只有几个固定位置的键值替换用 POI 原生文档变量就够毕竟零依赖、可控性强模板结构多变、还要跑列表和条件渲染直接上 poi-tl别重复造轮子。最后分享几个我长期使用的习惯。第一给变量起名统一用驼峰且有业务前缀比如contractNo、customerName因为文档变量没有层级或命名空间名字一乱后期维护就是灾难。第二把解压 zip 看 XML当成日常调试手段很多所谓玄学问题打开 XML 一眼就能定位。第三任何生成逻辑都保留一份原始模板备份输出永远写新文件这能让你在出问题时快速对比模板原貌和生成结果的差异。文档变量这套机制动起手来比想象中简单但真正踩过几个坑之后你才会理解它为什么值得用。希望这篇能帮你少走一段弯路。