poi-tl 实现 Java 按户合并导出 Word 模板实战

发布时间:2026/8/27 5:34:27
poi-tl 实现 Java 按户合并导出 Word 模板实战 在政务、银行、社区服务这类 Java 后台中“数据填充到固定文档”是很常见的一类需求而比填充更麻烦的往往是“按户合并”数据库里一个家庭有多条成员记录导出的 Word 模板却要求以户为单位把户主信息和所有家庭成员放到同一个文档里。如果把数据查询出来直接逐行填充文档会出现一户多页、成员分散的问题如果只在模板里做循环又很难处理户信息和成员列表之间的关联。这篇文章用一个完整的 Java 示例基于 poi-tl 实现从平铺明细到按户合并再到固定 Word 模板导出的全过程同时把模板占位符设计、数据模型、验证方法和常见坑一起说明。1. 先理解“固定文档填充”与“按户合并”的关系1.1 固定文档指的是什么固定文档不是程序自由生成的报告而是业务方已经设计好版式、只是等待数据填充的 Word 模板。典型的场景包括“一户一档”家庭信息表、居民信息采集确认书、住户收入证明、合同附件等。这些文档的特点是版式确定占位符确定数据不确定。程序要做的事情不是从零画一个文档而是把数据库里的数据准确填到模板对应的位置上。固定文档模板通常包含三类内容普通占位符如户编号、户主姓名、家庭住址。循环区块如家庭成员列表户下有多少人就要渲染多少行或多少段。静态描述如标题、单位、说明文字、落款不随数据变化。理解这一点很关键因为“按户合并”不是模板引擎要解决的问题而是数据准备阶段要解决的问题。模板引擎只知道把传入的数据对象渲染到指定位置它不会替你判断哪几行数据属于同一户。所以文章先讨论数据层面怎么按户合并再讨论模板怎么渲染。1.2 按户合并解决的是什么问题数据库查询结果通常是一行一个人。例如家庭成员明细表里一个户编号“H001”下有三条记录户主张伟、配偶李静、儿子张子轩。如果直接把这三条记录传给模板模板只能看到“三行独立数据”无法表达“这是一户户主信息是什么下面三个家庭成员分别是谁”。按户合并的目标就是把这种平铺明细转换成聚合结构H001 ├── 户主信息张伟、身份证号、住址 ├── 成员1李静配偶 ├── 成员2张子轩子在 Java 对象层面合并前是ListMemberRow合并后是ListHousehold其中每个Household内部还有一个ListFamilyMember。模板渲染时只需要拿到一个Household对象就能同时说清楚“户主是谁”和“有哪些家庭成员”。这就是“按户合并”的核心把数据库里一行行的明细按业务主键“户编号”分组再组装成适合模板渲染的树状模型。分组的业务标识可以是户编号、房屋编号、档案编号、客户编号规则是一致的。1.3 为什么选择 poi-tl 这类模板引擎在 Java 生态里生成 Word 文档有几种常见路线方案模板可维护性开发成本适用场景原生 Apache POI 创建文档低高文档完全由程序动态生成poi-tl 模板引擎高低固定 docx 模板占位符填充docx4j中高需要动态操作文档结构、复杂 XMLPDF 模板套打中中打印类、票据类固定版式原生 Apache POI 也可以实现“按户合并填充”但需要在代码里反复创建段落、复制表格行、设置单元格内容模板一旦调整代码就要跟着改。docx4j 能力很强但学习成本偏高。poi-tl 的优点是允许直接在 Word 里写{{name}}、{{?members}}这类占位符模板可以由业务人员或产品同学维护开发只负责传数据。选型时需要特别注意poi-tl 基于 Apache POI只支持.docx格式不支持旧版.doc。如果业务交付的是.doc模板需要先另存为.docx否则后面会遇到解析失败的问题。2. 环境准备与工程依赖2.1 技术选型和版本本文示例采用 Java 8 兼容写法实际使用 JDK 8 或 17 都可以。项目使用 Maven 管理依赖。核心组件是 poi-tl它负责解析模板、渲染占位符、循环列表并基于 Apache POI 完成 Word 文件输出。组件版本建议说明JDK8 或 17示例代码使用 Java 8 兼容语法Maven3.6管理依赖和打包poi-tl1.12.x具体版本以 Maven 仓库发布为准Apache POI由 poi-tl 传递引入需要关注其他依赖是否冲突因为不同版本的 poi-tl 可能传递引入不同版本的 Apache POI所以如果项目里还使用其他 POI 相关组件建议先统一版本。这里给出的版本号只用于示例落地前需要根据 Maven 仓库中的实际发布版本确认。2.2 Maven 依赖与配置在pom.xml中引入 poi-tl 和测试依赖。为了后续验证合并逻辑和生成文件内容再增加 JUnit 5。properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target poi-tl.version1.12.2/poi-tl.version /properties dependencies dependency groupIdcom.deepoove/groupId artifactIdpoi-tl/artifactId version${poi-tl.version}/version /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency /dependencies依赖引入后可以先执行一次依赖树查看 POI 版本情况mvn dependency:tree -Dincludesorg.apache.poi如果出现多个 POI 版本可以在其他依赖中通过exclusions排除不需要的版本也可以在dependencyManagement中显式锁定版本。这个检查点最容易忽略很多导出相关的问题最终都指向依赖版本冲突。2.3 准备示例数据表为了演示按户合并这里定义一张家庭成员明细表。表里每一行表示一个家庭成员同时冗余存储户主信息和家庭住址避免每次合并都要 JOIN 多张表。CREATE TABLE household_member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, household_no VARCHAR(32) NOT NULL COMMENT 户编号, householder_name VARCHAR(32) NOT NULL COMMENT 户主姓名, householder_id_card VARCHAR(32) COMMENT 户主身份证号, address VARCHAR(255) COMMENT 家庭住址, relation VARCHAR(16) COMMENT 与户主关系, member_name VARCHAR(32) COMMENT 成员姓名, member_id_card VARCHAR(32) COMMENT 成员身份证号, sort_no INT DEFAULT 0 COMMENT 排序号, KEY idx_household_no (household_no) ) COMMENT家庭成员明细表;插入两条测试数据覆盖“一户多人”的场景INSERT INTO household_member (household_no, householder_name, householder_id_card, address, relation, member_name, member_id_card, sort_no) VALUES (H001, 张伟, 110101199001011234, 北京市海淀区中关村大街1号, 本人, 张伟, 110101199001011234, 1), (H001, 张伟, 110101199001011234, 北京市海淀区中关村大街1号, 配偶, 李静, 110101199105059876, 2), (H001, 张伟, 110101199001011234, 北京市海淀区中关村大街1号, 子, 张子轩, 110101201803123456, 3), (H002, 王强, 110105198805057777, 北京市朝阳区建国路88号, 本人, 王强, 110105198805057777, 1), (H002, 王强, 110105198805057777, 北京市朝阳区建国路88号, 女, 王悦, 110105201506024321, 2);这里的sort_no用于稳定家庭成员顺序。按户合并时先按household_no, sort_no排序再分组这样合并后的成员顺序才可控。3. 从明细数据到按户合并模型3.1 定义原始明细 DTO原始明细行与数据库字段一一对应。这个类不需要包含业务逻辑只需要承载查询结果。public class MemberRow { private String householdNo; private String householderName; private String householderIdCard; private String address; private String relation; private String memberName; private String memberIdCard; private Integer sortNo; // 省略 getter/setter/toString }实际项目中这个 DTO 可以由 MyBatis、Spring Data JPA 或 MyBatis-Plus 直接映射。为了聚焦合并逻辑这里不引入具体 ORM。3.2 定义渲染模型渲染模型不是数据库表的直接映射而是为了配合 Word 模板而设计的对象结构。Household对应一个家庭FamilyMember对应一个家庭成员。public class FamilyMember { private String name; private String relation; private String idCard; // 省略 getter/setter }public class Household { private String householdNo; private String householderName; private String householderIdCard; private String address; private ListFamilyMember members; public void addMember(FamilyMember member) { if (members null) { members new ArrayList(); } members.add(member); } // 省略 getter/setter }这里把“户主信息”和“成员信息”放到同一个对象里是因为 Word 模板需要同时渲染两者。模板里用{{householderName}}输出户主姓名用{{?members}}...{{/members}}循环输出成员列表。如果只传原始明细模板引擎无法知道哪条是户主、哪些是成员。3.3 编写按户合并逻辑合并逻辑的核心是MapString, Household按户编号分组。示例代码使用LinkedHashMap保证第一次出现户编号的顺序被保留批量导出时户的顺序不会乱。public ListHousehold mergeByHousehold(ListMemberRow rows) { MapString, Household householdMap new LinkedHashMap(); for (MemberRow row : rows) { Household household householdMap.get(row.getHouseholdNo()); if (household null) { household new Household(); household.setHouseholdNo(row.getHouseholdNo()); household.setHouseholderName(row.getHouseholderName()); household.setHouseholderIdCard(row.getHouseholderIdCard()); household.setAddress(row.getAddress()); household.setMembers(new ArrayList()); householdMap.put(row.getHouseholdNo(), household); } FamilyMember member new FamilyMember(); member.setName(row.getMemberName()); member.setRelation(row.getRelation()); member.setIdCard(row.getMemberIdCard()); household.getMembers().add(member); } return new ArrayList(householdMap.values()); }这段代码有一个隐含约定第一行数据负责设置户主信息。如果数据在查询时没有按户号排序且第一行不是“本人”户主信息可能被其他成员行覆盖但最终效果通常还是一样因为同一户的户主字段冗余值相同。不过更严谨的做法是在分组内查找relation 本人的记录来设置户主字段。检查合并结果时重点看两点返回列表长度是否等于 SQL 查询出的household_no去重数量。每个Household下的members.size()是否等于该户在明细表中的记录数。3.4 成员顺序与户主识别成员顺序不应该在合并逻辑里临时处理而应该在 SQL 查询阶段解决。推荐查询时直接排序SELECT household_no, householder_name, householder_id_card, address, relation, member_name, member_id_card, sort_no FROM household_member ORDER BY household_no, sort_no;如果原始数据没有sort_no可以按relation字段排序把“本人”放在最前其他按姓名或创建时间排序。Java 侧也可以补充排序但要放在合并之前rows.sort(Comparator.comparing(MemberRow::getSortNo, Comparator.nullsLast(Integer::compareTo)));再执行mergeByHousehold。这样既保证成员顺序稳定也让合并逻辑保持简单。4. 设计 Word 模板并用 poi-tl 渲染4.1 模板语法速查poi-tl 的模板本质是 Word 文档里的普通文本。最小可用的语法如下语法作用示例{{field}}输出文本字段{{householderName}}{{?list}}...{{/list}}循环输出列表遍历家庭成员列表{{?members}}和{{/members}}之间是一个区块对。poi-tl 会遍历members列表对列表中的每一个元素渲染区块内部的模板文本。如果members为空区块内部内容不会输出。注意占位符花括号是英文半角标签内部不要加多余空格。{{?members}}、{{/members}}必须成对出现否则模板解析或渲染结果会不符合预期。4.2 制作一个最小 docx 模板在 Word 中新建household_template.docx输入以下内容户编号{{householdNo}} 户主姓名{{householderName}} 户主身份证号{{householderIdCard}} 家庭住址{{address}} 家庭成员 {{?members}} {{name}}{{relation}} {{idCard}} {{/members}}这个模板没有复杂表格但已经能表达“一户一档”的完整结构。{{?members}}到{{/members}}之间的内容会为每个家庭成员生成一段文字。例如张伟家庭会输出三行成员信息。如果业务要求成员信息按表格展示可以在 Word 中插入一个两列表格在单元格里放置同样的区块对。区块对可以作用于段落也可以作用于表格中的多行。这里用段落式模板先跑通最小闭环后续再调整版式。制作模板时不要从 PDF 或网页里复制占位符尽量手工输入避免 Word 自动把{{name}}拆分成多个 run导致 poi-tl 无法识别。4.3 渲染核心代码将合并后的Household对象转成模板需要的数据 Map再调用 poi-tl 渲染。public void exportHousehold(Household household, String templatePath, String outputPath) throws IOException { MapString, Object data new HashMap(); data.put(householdNo, household.getHouseholdNo()); data.put(householderName, household.getHouseholderName()); data.put(householderIdCard, household.getHouseholderIdCard()); data.put(address, household.getAddress()); data.put(members, household.getMembers()); try (XWPFTemplate template XWPFTemplate.compile(templatePath)) { template.render(data); template.writeAndClose(new FileOutputStream(outputPath)); } }这里每次都重新compile模板简单可靠。compile会加载模板文件并解析占位符render会把数据渲染到模板中writeAndClose将结果写入输出文件并关闭资源。批量导出时如果数据量很大可以考虑模板复用但需要结合 poi-tl 当前版本 API 仔细评估不必一上来就做性能优化。4.4 批量按户导出批量导出的核心是遍历合并后的Household列表逐户生成文档。文件名直接使用户编号可能不安全因为户编号可能包含/、\等特殊字符。输出之前先清洗文件名。public void batchExport(ListHousehold households, String templatePath, String outputDir) throws IOException { Files.createDirectories(Paths.get(outputDir)); for (Household household : households) { String safeName household.getHouseholdNo() .replaceAll([^a-zA