前言
做企业级系统的同学应该都有体会:文档模板渲染这个需求看似简单,实则暗坑满满。
比如一个房管局业务系统里,既有「不动产登记申请表」这样的简单表单------几行表格、填几个字段就能出 PDF;又有「商品房买卖合同」这种几十页的正式合同------页眉页脚、复杂表格、特殊排版,必须基于 Word 模板生成,一点格式都不能错。
如果你只选一套方案,大概率会踩坑:
- 用 FreeMarker + HTML?简单表单很好使,但遇到复杂合同就抓瞎------HTML 排版能力有限,页眉页脚、分栏、水印根本搞不定
- 用 Word + poi-tl?合同排版没问题了,但简单表单也得上 Word 模板 + Collabora CODE 外部服务,杀鸡用牛刀,部署和维护成本白白翻倍
所以我设计了双模板引擎 方案:简单模板用 HTML + FreeMarker + Flying Saucer,纯 Java 零依赖;复杂模板用 Word + poi-tl + Collabora CODE,高保真排版。两套引擎各司其职,业务侧按需选择,既不牺牲排版质量,也不浪费部署成本。
这个方案已经开源为 template-view ------ 基于 Spring Boot 3 + Vue 3 的模板管理与渲染平台。本文将详细介绍整体架构、两套引擎的技术选型思路和核心实现。
一、项目概览
功能特性
- 双模板引擎 ------ FreeMarker(简单模板)+ poi-tl(复杂模板)
- 多格式输出 ------ 支持 PDF 和 Word 文档输出
- 版本管理 ------ 完整的版本发布、回滚、切换,乐观锁并发控制 + 操作审计日志
- Collabora CODE 集成 ------ 高性能 Word → PDF 转换
- Word 在线编辑 ------ 配合 online-view 项目实现基于 WOPI 协议的在线编辑
- 中文字体支持 ------ 内置宋体、黑体、仿宋、楷体
- 变量管理 ------ 可视化定义模板变量,自动生成默认数据模型
预览效果
模板列表页面:

简单模板渲染(HTML → PDF):

复杂模板渲染(Word → PDF):
二、整体架构
┌─────────────────────────────────────────────────────┐
│ Vue 3 前端 │
│ Element Plus + TypeScript + Axios │
└──────────────────────┬──────────────────────────────┘
│ HTTP / API
┌──────────────────────▼──────────────────────────────┐
│ Spring Boot 3 后端 │
│ ┌───────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ 模板管理 │ │ 版本管理 │ │ 变量/分类管理 │ │
│ └───────────┘ └────────────┘ └────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 模板渲染引擎 │ │
│ │ ┌─────────────────┐ ┌──────────────────────┐│ │
│ │ │ 简单模板引擎 │ │ 复杂模板引擎 ││ │
│ │ │ FreeMarker │ │ poi-tl ││ │
│ │ │ Flying Saucer │ │ Collabora CODE ││ │
│ │ │ OpenPDF │ │ ││ │
│ │ └─────────────────┘ └──────────────────────┘│ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
MySQL 8 本地存储 Collabora CODE
技术栈总览
| 层级 | 技术 | 版本 | 说明 |
|---|---|---|---|
| 后端框架 | Spring Boot | 3.4.0 | Java 17 |
| ORM | MyBatis-Plus | 3.5.9 | 内置分页,代码量少 |
| 连接池 | Druid | 1.2.23 | 阿里高性能连接池 |
| 数据库 | MySQL | 8.x | utf8mb4 字符集 |
| 简单模板引擎 | FreeMarker | 2.3.32 | HTML 模板变量替换 |
| HTML → PDF | Flying Saucer + OpenPDF | 9.1.22 | 纯 Java PDF 渲染 |
| 复杂模板引擎 | poi-tl | 1.12.2 | Word 模板变量替换 |
| Word → PDF | Collabora CODE | - | 高保真文档转换 |
| API 文档 | Knife4j | 4.5.0 | Swagger 增强 UI |
| 前端框架 | Vue | 3.4 | Composition API |
| UI 组件库 | Element Plus | 2.6 | 企业级 UI |
| 构建工具 | Vite | 5.2 | 极速构建 |
| 类型系统 | TypeScript | 5.4 | 严格模式 |
三、双模板引擎 ------ 技术选型的深度思考
这是本项目最核心的设计决策。我将从实际需求出发,详细解释为什么简单模板和复杂模板选择了截然不同的技术栈。
3.1 简单模板:为什么选择 FreeMarker + Flying Saucer + OpenPDF?
简单模板的场景特征:
- 模板内容是表单、申请表、简单合同、报告等结构化文档
- 排版要求不苛刻,表格、文字、基础样式即可满足
- 需要高频并发渲染(例如批量生成证明函)
- 希望系统轻量部署,不依赖外部服务
技术选型分析
为什么模板引擎选 FreeMarker?
在 Java 生态的模板引擎中,主流选择有 FreeMarker、Thymeleaf、Velocity。选择 FreeMarker 的理由:
- 语法直观 ------
${variable}、<#if>、<#list>对 HTML 模板非常友好,学习成本低 - 容错性好 ------ 内置
${variable!默认值}语法,变量缺失时不会报错而是输出默认值或空串,这在模板渲染场景中非常关键 - 轻量高效 ------ 编译型模板引擎,渲染性能优秀
- 与 Spring Boot 集成成熟 ------
spring-boot-starter-freemarker开箱即用
相比之下,Thymeleaf 的自然模板语法(th:text)虽然更适合 Web 页面渲染,但在纯文本替换场景反而过于繁琐;Velocity 已停止维护。
为什么 HTML → PDF 选 Flying Saucer + OpenPDF?
HTML 转 PDF 的 Java 方案非常多:iText、Flying Saucer、OpenPDF、wkhtmltopdf、Puppeteer 等。选择 Flying Saucer + OpenPDF 的理由:
- 纯 Java 实现 ------ 不需要安装任何外部服务(wkhtmltopdf、Puppeteer 都需要),部署成本为零
- Flying Saucer 的 XHTML 渲染能力 ------ 能将标准 XHTML + CSS 渲染为 PDF,对表格、边框、字号等基础排版支持良好
- OpenPDF(iText 的 LGPL 分支) ------ Flying Saucer 底层依赖 OpenPDF 进行 PDF 绘制,完全开源且无 AGPL 限制(iText 5+ 是 AGPL 协议,商业使用受限)
- 中文字体支持 ------ 可以通过
ITextRenderer.getFontResolver().addFont()注册系统字体或项目内置字体,完美支持中文渲染 - 并发友好 ------ 纯内存操作,无进程开销,天然支持高并发
为什么不用 iText 直接写 PDF?
iText 的编程式 PDF 生成(new Document() → add Paragraph)需要大量代码来描述布局,维护成本极高。模板引擎 + 渲染的方式让非技术人员也能通过修改 HTML 来调整模板样式,显著降低了维护门槛。
渲染流程:
HTML 模板文件
│
▼ FreeMarker 引擎(变量替换)
XHTML(数据已填充)
│
▼ Flying Saucer + OpenPDF
PDF 文件输出
核心代码片段:
java
// 1. FreeMarker 变量替换
Template template = freemarkerConfig.getTemplate(templateName);
String html = FreeMarkerTemplateUtils.processTemplateIntoString(template, dataModel);
// 2. 包装为完整的 XHTML 文档(补充 DOCTYPE、CSS 样式等)
String xhtml = wrapAsXhtml(html);
// 3. Flying Saucer 渲染为 PDF
ITextRenderer renderer = new ITextRenderer();
// 注册中文字体
renderer.getFontResolver().addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
renderer.setDocumentFromString(xhtml);
renderer.layout();
renderer.createPDF(outputStream);
3.2 复杂模板:为什么选择 poi-tl + Collabora CODE?
复杂模板的场景特征:
- 模板内容是正式合同、法律协议、招投标文件等
- 排版要求极其严格------页眉页脚、分页、复杂表格、特殊字体、水印
- 业务人员习惯用 Word 编辑模板,不允许改变原有格式
- 输出格式可能需要 PDF 和 Word 两种
技术选型分析
为什么模板引擎选 poi-tl?
Word 模板引擎的选择面比较窄,主要有 poi-tl 和 Apache POI 原生 API:
- 声明式语法 ------ 只需在 Word 中写
{``{variable}},poi-tl 自动完成替换,无需写代码 - 功能丰富 ------ 支持文本、图片、表格行循环(
LoopRowTableRenderPolicy)、列表、嵌套等 - 基于 Apache POI ------ 底层使用 poi-ooxml,兼容
.docx格式,稳定性有保障 - 轻量 ------ 核心 API 就一个
XWPFTemplate.compile().render(),上手极快
如果直接用 Apache POI 原生 API 操作 Word 文档,代码量会爆炸------替换一个表格需要遍历段落、运行、文本,处理合并单元格更是噩梦。poi-tl 将这一切封装成了优雅的声明式 API。
为什么 Word → PDF 选 Collabora CODE?
这是最关键的选择。Word 转 PDF 的方案有很多:
| 方案 | 保真度 | 性能 | 部署复杂度 | 并发能力 |
|---|---|---|---|---|
| Apache POI + iText | 低(丢失大量格式) | 中 | 低 | 高 |
| LibreOffice 命令行 | 中 | 低(每次启动进程) | 中 | 低 |
| Collabora CODE | 高(基于 LibreOffice Technology) | 高(长驻服务) | 中(Docker 部署) | 高 |
| Aspose.Words | 高 | 高 | 低 | 高(但商业授权昂贵) |
选择 Collabora CODE 的理由:
- 高保真转换 ------ Collabora CODE 基于 LibreOffice Technology 引擎,对 Word 文档的兼容性是开源方案中最好的,页眉页脚、分栏、复杂表格、中文字体等都能完美保留
- 服务化架构,进程复用 ------ 虽然底层同样是 LibreOffice,但调用方式有本质区别:LibreOffice 命令行每次调用都要
fork一个新的soffice进程,初始化运行环境耗时 2~5 秒,并发场景下极易资源耗尽;而 Collabora CODE 通过 LibreOfficeKit(LOKit) 将 LibreOffice 核心能力编译为动态链接库(.so/.dll),在自己的长驻进程内直接以函数调用的方式使用,无需每次启动新进程,相当于"Tomcat 处理请求"与"每次启动新 JVM"的区别 - 天然支持并发 ------ 内部维护 LibreOfficeKit 实例池,多线程安全地复用转换实例
- 一石二鸟 ------ 除了 PDF 转换,Collabora CODE 还支持 WOPI 协议的在线编辑功能,配合 online-view 项目可以直接在浏览器中编辑 Word 模板
- 开源免费 ------ 相比 Aspose.Words 动辄上万美元的商业授权,Collabora CODE 完全开源
LibreOffice CLI vs Collabora CODE 对比:
维度 LibreOffice 命令行 ( soffice --convert-to)Collabora CODE 调用方式 每次请求 fork 新进程 长驻服务,REST API LibreOffice 加载方式 进程级(每次重新加载) 库级(LibreOfficeKit,进程内复用) 单次转换启动耗时 2~5 秒 毫秒级(实例已预热) 并发处理 多进程并行,资源消耗大 实例池管理,资源可控 在线编辑 不支持 支持(WOPI 协议) 早期版本使用 LibreOffice 命令行做转换,不仅性能差,而且在并发场景下极不稳定。切换到 Collabora CODE 后,转换性能提升了一个数量级,同时还获得了在线编辑能力。
渲染流程:
Word 模板文件(.docx)
│
▼ poi-tl 引擎(变量替换)
渲染后的 Word 文档
│
├── 输出格式为 Word ──→ 直接返回 .docx
│
└── 输出格式为 PDF
│
▼ Collabora CODE REST API(/cool/convert-to)
PDF 文件输出
核心代码片段:
java
// 1. poi-tl 变量替换
Configure config = Configure.builder()
.bind("table", new LoopRowTableRenderPolicy()) // 表格行循环策略
.build();
try (InputStream is = new ByteArrayInputStream(templateBytes);
ByteArrayOutputStream os = new ByteArrayOutputStream()) {
XWPFTemplate template = XWPFTemplate.compile(is, config).render(dataModel);
template.write(os);
template.close();
byte[] renderedWord = os.toByteArray();
// 2. 如果需要 PDF,调用 Collabora CODE
if (outputFormat == OutputFormat.PDF) {
return collaboraPdfConverter.convertWordToPdf(renderedWord);
}
return renderedWord;
}
3.3 两种方案的对比总结
| 维度 | 简单模板 | 复杂模板 |
|---|---|---|
| 模板格式 | HTML (.html/.ftl) | Word (.docx) |
| 变量语法 | ${variable} |
{``{variable}} |
| 模板引擎 | FreeMarker | poi-tl |
| PDF 生成 | Flying Saucer + OpenPDF | Collabora CODE |
| 外部依赖 | 无 | Collabora CODE Docker 容器 |
| 部署复杂度 | 极低 | 中等 |
| 排版能力 | 表格、文字、基础 CSS | Word 全部功能 |
| 并发性能 | 优秀(纯内存) | 优秀(服务池化) |
| 适用场景 | 表单、申请表、报告 | 正式合同、法律文件 |
| 维护门槛 | 低(改 HTML) | 低(改 Word) |
四、版本管理设计
模板的版本管理是本平台的核心功能之一,参考了语义化版本规范(Semantic Versioning):
版本状态机
创建
│
▼
┌────────┐ 发布 ┌───────────┐
│ DRAFT │───────→│ PUBLISHED │◄──── 切换(SetActive)
│ 草稿 │ │ 已发布 │
└────────┘ └─────┬─────┘
│ │
│ 删除 │ 归档 / 新版本发布时旧版本自动归档
▼ ▼
删除 ┌──────────┐
│ ARCHIVED │
│ 已归档 │──── 回滚 → 创建新 PUBLISHED
└──────────┘
关键设计点
- 乐观锁 ------
lock_version字段防止并发修改冲突 - 非破坏性回滚 ------ 回滚不是覆盖,而是从归档版本创建一个新的已发布版本,保留完整历史
- 审计日志 ------ 每个版本操作(CREATE、PUBLISH、ARCHIVE、ROLLBACK、DELETE、SWITCH)都记录操作人、时间、状态变更
- 草稿上限 ------ 可配置每个模板的最大草稿数(默认 5),避免版本堆积
五、核心代码解析
5.1 统一渲染入口
TemplateRenderService 作为统一入口,根据模板类型分发到不同的渲染引擎:
java
public byte[] render(byte[] templateBytes,
TemplateType templateType,
OutputFormat outputFormat,
Map<String, Object> dataModel) throws IOException {
if (templateType == TemplateType.COMPLEX) {
// 复杂模板:poi-tl + Collabora CODE
return complexTemplateService.render(templateBytes, outputFormat, dataModel);
} else if (templateType == TemplateType.SIMPLE) {
// 简单模板:FreeMarker + Flying Saucer
String templateContent = new String(templateBytes, StandardCharsets.UTF_8);
String html = htmlTemplateProcessor.process(templateContent, dataModel);
String xhtml = htmlTemplateProcessor.wrapAsXhtml(html);
return pdfRenderer.renderToPdf(xhtml);
}
throw new IllegalArgumentException("不支持的模板类型: " + templateType);
}
5.2 中文 PDF 渲染
PdfRenderer 在启动时加载项目内置的中文字体,渲染时注册到 Flying Saucer:
java
// 启动时加载字体
@PostConstruct
public void loadFonts() {
// 从 resources/fonts/ 加载宋体、黑体、仿宋、楷体
// 也支持从系统字体目录加载
}
// 渲染时注册字体
ITextRenderer renderer = new ITextRenderer();
for (FontInfo font : loadedFonts) {
renderer.getFontResolver().addFont(
font.getPath(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED
);
}
5.3 Collabora CODE 集成
CollaboraPdfConverter 通过 REST API 与 Collabora CODE 通信:
java
// 构建 multipart 请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("data", new ByteArrayResource(wordBytes) {
@Override
public String getFilename() {
return "document.docx";
}
});
body.add("format", "pdf");
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
// POST /cool/convert-to
ResponseEntity<byte[]> response = restTemplate.exchange(
serverUrl + "/cool/convert-to", HttpMethod.POST, requestEntity, byte[].class
);
六、前端架构
前端基于 Vue 3 + TypeScript + Element Plus 构建,采用 Composition API 风格:
frontend/src/
├── api/ # API 接口层
│ ├── template.ts # 模板、版本、渲染相关 API
│ ├── category.ts # 分类 API
│ └── variable.ts # 变量 API
├── views/
│ ├── template/ # 模板管理(列表、变量定义)
│ ├── category/ # 分类管理
│ └── render/ # 模板渲染(数据填充、预览、下载)
├── layouts/ # 布局组件(侧边栏 + 顶栏)
├── router/ # 路由配置
├── utils/
│ └── request.ts # Axios 封装(Blob 错误解析、统一错误处理)
└── types/index.ts # TypeScript 类型定义
关键技术选择:
- Element Plus ------ 企业级 UI 组件库,表格、表单、对话框等开箱即用
- Vite ------ 开发时热更新极速,构建速度快
- TypeScript 严格模式 ------ 前后端类型统一,减少运行时错误
- Axios Blob 处理 ------ 模板渲染接口返回二进制流,前端需特殊处理错误(尝试将 Blob 解析为 JSON 错误信息)
七、数据库设计
共 7 张表,核心表关系如下:
template_category (分类)
│
│ 1:N
▼
template_info (模板信息)
│
├── 1:N ──→ template_version (模板版本,含 lock_version 乐观锁)
│ │
│ └── 1:N ──→ template_version_audit (版本审计日志)
│
├── 1:N ──→ template_variable (模板变量定义,支持层级嵌套)
│
└── N:N ──→ template_font (字体管理)
template_render_log (渲染日志,独立记录)
SQL 初始化脚本位于 sql/schema.sql,包含完整的建表语句和种子数据。
八、快速部署
1. 准备环境
- JDK 17+
- Maven 3.6.3+
- MySQL 8.0+
- Node.js 16+
- Docker(用于 Collabora CODE)
2. 初始化数据库
sql
CREATE DATABASE template_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
bash
mysql -u root -p template_platform < sql/schema.sql
3. 启动 Collabora CODE
部署文档:https://gitee.com/hhs_3/online-view#/hhs_3/online-view/blob/master/./doc/Collabora CODE部署文档.md
bash
docker-compose up -d
4. 配置环境变量
bash
DB_HOST=localhost
DB_PORT=3306
DB_NAME=template_platform
DB_USERNAME=root
DB_PASSWORD=your_password
COLLABORA_SERVER=http://localhost:9980
5. 启动后端
bash
mvn spring-boot:run
6. 启动前端
bash
cd frontend
npm install
npm run dev
7. 访问应用
- 前端页面:http://localhost:3000
- API 文档:http://localhost:8080/doc.html
九、API 使用示例
渲染模板
http
POST /api/template/render
Content-Type: application/json
{
"templateCode": "contract_001",
"outputFormat": "PDF",
"dataModel": {
"title": "房屋买卖合同",
"partyA": "张三",
"partyB": "李四",
"amount": "1000000",
"address": "北京市朝阳区XX路XX号"
}
}
返回值为 PDF 或 Word 二进制流,前端可直接用于预览或下载。
十、写在最后
设计理念
这个项目的核心设计理念是务实:
- 不追求万能方案 ------ 没有用一套引擎覆盖所有场景,而是根据场景复杂度选择最合适的技术栈
- 渐进式复杂度 ------ 简单模板零外部依赖即可运行,复杂模板按需引入 Collabora CODE
- 业务人员友好 ------ 模板编辑用 HTML 或 Word,不需要开发人员介入日常模板维护
- 生产级考量 ------ 版本管理、乐观锁、审计日志、中文字体,这些都是从实际生产环境中沉淀的需求
开源地址
- template-view :https://gitee.com/hhs_3/template-view
- online-view(在线编辑) :https://gitee.com/hhs_3/online-view
欢迎 Star、Fork、PR!
