「导出个 Excel」听起来是 CRUD 里的边角料,直到你遇到十万行报表把 JVM 撑爆、中文在 PDF 里变成方框、单元格里的换行符把整个表格挤成一团。报表生成是数据工程 + 排版 + 内存管理的交叉地带,而 Clojure 恰好两头都占:上游能用数据处理管道把数据流式喂进来,下游能直接调 Java 的成熟排版库。本文从选型讲到中文排版,覆盖 PDF 与 Excel 两条主线,以及大报表的流式写出。
1. 报表生成的三个层次
| 层次 | 关注点 | 典型产出 |
|---|---|---|
| 数据层 | 取数、聚合、口径 | 一个 lazy seq 或游标 |
| 排版层 | 表格、分页、样式、字体 | PDF / Excel 文件 |
| 交付层 | 生成方式、存储、下载 | 异步任务 + 对象存储链接 |
多数事故发生在层次之间:数据层用 (vec ...) 把百万行拉进内存,交付层在 HTTP 请求里同步生成一个 200MB 的 Excel。把三层分开设计,问题自然消解。
2. 技术选型
2.1 PDF 方案
| 方案 | 定位 | 优势 | 代价 |
|---|---|---|---|
| Apache PDFBox | 底层库 | 完全控制、支持表单与签名 | API 繁琐,表格要手绘 |
| OpenPDF / iText | 中高层 | 表格、字体、模板成熟 | 版本与授权需注意 |
| clj-pdf | Clojure 封装 | 声明式 DSL,写起来最快 | 复杂布局受限 |
| Flying Saucer | HTML → PDF | 复用 HTML/CSS 技能 | 只支持 CSS 2.1 子集 |
选型经验:固定格式的票据、标签用 clj-pdf;复杂排版、动态表格用 OpenPDF;需要 HTML 模板复用用 Flying Saucer。
2.2 Excel 方案
| 方案 | 定位 | 内存模式 | 适用 |
|---|---|---|---|
| Apache POI XSSF | 全功能 | 全内存 | 中小报表、复杂样式 |
| Apache POI SXSSF | 流式写 | 滑动窗口 | 大报表(10 万行+) |
| docjure | POI 封装 | 依赖底层 | 数据绑定、读取模板 |
| CSV | 无格式 | 流式 | 只需数据、不要样式 |
关键认知:XSSF 每行都是对象,10 万行 × 20 列会占用数 GB;SXSSF 用滑动窗口只保留最近 N 行在内存,大报表必须用它。
2.3 模板引擎
;; Selmer:Django 风格模板,适合 HTML → PDF 路线
(require '[selmer.parser :as selmer])
(selmer/render "<h1>{{title}}</h1><p>共 {{count}} 条</p>"
{:title "月报" :count 42})
模板引擎的价值在于把排版从代码里分离——运营改文案不用改代码。但要注意模板注入风险:用户可控内容进模板前必须转义。
2.4 别忽视 CSV
如果用户只是要「把数据拿走做透视表」,CSV 往往是更优解:
| 维度 | CSV | Excel |
|---|---|---|
| 生成速度 | 极快(纯文本流) | 慢(对象模型) |
| 内存 | O(1) | O(行数) 或滑动窗口 |
| 样式/公式 | 无 | 有 |
| 大文件打开体验 | 差(无分页) | 好 |
决策原则:需要样式、公式、多 sheet 才上 Excel;纯数据导出优先 CSV。跨语言场景下文本模板生成报表的思路可参考 Go 文本模板生成报表
,与 Clojure 的 str/join 流式写出是同一套思路。
3. PDF 生成实战
3.1 用 clj-pdf 快速出报表
(require '[clj-pdf.core :refer [pdf]])
(pdf [{:title "销售月报" :size :a4 :font {:encoding :unicode}}
[:heading "2026 年 9 月"]
[:table {:widths [120 80 80] :header [{:style :bold} "商品" "数量" "金额"]}
["A 商品" "120" "¥ 12,000"]
["B 商品" "85" "¥ 8,500"]]
[:paragraph "制表:数据平台"]]
(java.io.FileOutputStream. "report.pdf"))
声明式 DSL 胜在直观,适合结构固定的报表。
3.2 用 OpenPDF 画表格与分页
(import '[com.lowagie.text Document Paragraph PageSize]
'[com.lowagie.text.pdf PdfWriter PdfPTable PdfPCell])
(defn write-pdf! [out rows]
(let [doc (Document. PageSize/A4)
writer (PdfWriter/getInstance doc out)]
(.open doc)
(let [tbl (PdfPTable. (into-array float [2.0 1.0 1.0]))]
(doseq [r rows]
(doseq [c r] (.addCell tbl (PdfPCell. (Paragraph. (str c))))))
(.add doc tbl))
(.close doc)))
分页是 PDF 的核心难点。OpenPDF 的 PdfPTable 会自动分页,但表头重复需要显式设置:
(.setHeaderRows tbl 1) ; 第一行作为表头,每页重复
(.setSplitLate tbl false) ; 行内尽量不拆分
3.3 中文与字体
PDF 里中文变方框是最常见的问题,根因是字体没有嵌入:
(import '[com.lowagie.text.pdf BaseFont])
(def cjk-font
(BaseFont/createFont "fonts/NotoSansSC-Regular.ttf"
BaseFont/IDENTITY_H ; 必须用 IDENTITY_H
BaseFont/EMBEDDED)) ; 必须嵌入
三个要点:
- 编码必须用
IDENTITY_H:中文是 CJK 双字节,用默认编码会丢字; - 字体必须
EMBEDDED:不嵌入则依赖阅读器本地字体,跨机器必翻车; - 字体文件要进镜像:把 ttf 放进
resources/或用-Djava.awt.fonts指向字体目录。
4. Excel 导出实战
4.1 POI 基础
(import '[org.apache.poi.xssf.usermodel XSSFWorkbook]
'[org.apache.poi.ss.usermodel CellType])
(defn write-xlsx! [out rows]
(with-open [wb (XSSFWorkbook.)]
(let [sheet (.createSheet wb "报表")]
(doseq [[i row] (map-indexed vector rows)]
(let [r (.createRow sheet i)]
(doseq [[j v] (map-indexed vector row)]
(let [c (.createCell r j)]
(cond
(number? v) (.setCellValue c (double v))
(inst? v) (do (.setCellValue c v)
(.setCellStyle c date-style))
:else (.setCellValue c (str v)))))))
(.write wb out))))
4.2 docjure 数据绑定
docjure 让你从模板读结构、往模板写数据,适合运营维护格式的场景:
(require '[dk.ative.docjure.spreadsheet :as xls])
(defn fill-template [tmpl rows out]
(let [wb (xls/load-workbook tmpl)
sheet (xls/select-sheet "报表" wb)]
(xls/add-rows! sheet (map #(vec (vals %)) rows))
(xls/save-workbook! out wb)))
注意:docjure 基于 XSSF,大报表会 OOM。它适合「格式由业务定义、数据量中等」的场景。
4.3 SXSSF 流式写出
(import '[org.apache.poi.xssf.streaming SXSSFWorkbook])
(defn stream-xlsx! [out row-source]
(with-open [wb (SXSSFWorkbook. 100)] ; 内存保留 100 行
(let [sheet (.createSheet wb "数据")]
(doseq [[i row] (map-indexed vector row-source)]
(let [r (.createRow sheet i)]
(doseq [[j v] (map-indexed vector row)]
(.setCellValue (.createCell r j) (str v))))
(when (zero? (mod i 1000))
(.flushRows wb))) ; 定期刷盘释放内存
(.write wb out))))
row-source 必须是 lazy 的:如果上游是 (vec (jdbc/execute! ...)),那流式写出就没意义了——数据早已全在内存。正确做法是用游标/分页流式读取,见 Clojure 数据库访问
。
4.4 样式、公式与数据校验
- 样式要复用:
CellStyle对象昂贵且有数量上限(xlsx 约 64000 个),应在循环外创建一次; - 公式:用
setCellFormula,注意 Excel 与 POI 的公式语法差异; - 数据校验:可给列加下拉限制,但 SXSSF 对流式写入的校验支持有限。
(defn- style! [wb fmt]
(let [s (.createCellStyle wb)]
(.setDataFormat s (.createDataFormat wb) fmt)
s))
;; 循环外创建,循环内复用
5. 大数据量报表
5.1 端到端流式
(defn export-large! [out]
(stream-xlsx! out
(->> (jdbc/plan ds ["SELECT * FROM orders ORDER BY id"]) ; 游标
(map row->vector)))) ; 逐行转换
三个环节都要流式:
| 环节 | 反例 | 正例 |
|---|---|---|
| 取数 | execute! 返回全量 | 游标 / 分页 |
| 转换 | 中间 vec | lazy map / transducer |
| 写出 | XSSF 全内存 | SXSSF 滑动窗口 |
任意一环断了,整条链路就退化成全内存。 数据转换的流式技巧可参考 Clojure 数据转换器深入 。
5.2 内存与 GC
即便流式,仍要关注:
- 行对象要及时释放:SXSSF 的
flushRows是唯一的释放手段,间隔太小影响性能、太大会占内存,100~1000 行是常见区间; - 避免字符串驻留:大量重复字符串(如状态名)可做 intern 或共享,减少内存;
- 堆外与堆内:POI 的临时文件默认在
java.io.tmpdir,容器里要确保该目录可写且有空间。
一个实操技巧是边写边压缩:SXSSF 写入的是一个 zip 容器,如果目标本身就是压缩包(如多文件导出),可以流式打包,避免先落盘再压缩的二次 IO。
5.3 分片与并行
超大数据集可以按维度分片成多个文件(按月、按地区),再打包成 zip:
(defn export-shards! [shards]
(doseq [{:keys [name query]} shards]
(stream-xlsx! (io/file tmp (str name ".xlsx"))
(->> (jdbc/plan ds query) (map row->vector))))
(zip! tmp "export.zip"))
分片还有额外好处:单个分片失败可重试,不必重跑整个导出。
6. 中文与排版细节
6.1 字体嵌入清单
| 场景 | 字体 | 说明 |
|---|---|---|
| 简体中文 | Noto Sans SC / 思源黑体 | 开源、可商用 |
| 繁体中文 | Noto Sans TC | 避免简繁混排 |
| 数字/金额 | 等宽或专用数字字体 | 对齐更整齐 |
| 报表标题 | 加粗黑体 | 层次清晰 |
许可要确认:商用系统必须用可商用字体,很多「免费」中文字体只允许个人使用。
6.2 换行与列宽
Excel 里长文本不会自动换行,需要显式设置:
(let [s (.createCellStyle wb)]
(.setWrapText s true)
(.setVerticalAlignment s org.apache.poi.ss.usermodel.VerticalAlignment/TOP))
PDF 里 PdfPCell 会自动换行,但要给足列宽,否则会把长单词硬切。中日韩文本没有空格,很多排版库的换行算法会失效,需要选择支持 CJK 断行的实现。
6.3 数字与日期格式
(defn money [v] (format "¥ %,.2f" (double v))) ; 千分位
(defn pct [v] (format "%.1f%%" (* 100 (double v))))
格式化的三个坑:
- 区域设置:
format用默认 Locale,容器里可能是en_US,金额与日期会不一致,应显式指定; - 时区:
java.util.Date无时区,写入 Excel 前要确认时区口径,否则跨时区差一天; - 精度:金额一律用
BigDecimal,不要用double累加。
6.4 图表与可视化
报表里的图表有两种实现路线:
| 路线 | 做法 | 适用 |
|---|---|---|
| 服务端绘图 | JFreeChart 等生成 PNG,插入 PDF/Excel | 需要静态归档 |
| 客户端渲染 | 报表输出数据,前端图表库渲染 | 交互式看板 |
静态归档场景(PDF 报告、邮件附件)必须服务端绘图,此时要注意图表里的中文同样需要嵌入字体,JFreeChart 默认字体不含 CJK,必须显式设置。图表类型的选择与配色原则属于通用的数据可视化范畴,与实现语言无关。
7. 模板与数据绑定
把「排版」与「数据」分离的三种模式:
| 模式 | 做法 | 适用 |
|---|---|---|
| 代码即模板 | DSL 直接写布局 | 格式稳定、开发维护 |
| 模板文件 | Selmer / docjure 读模板 | 业务方维护格式 |
| HTML 转 PDF | 复用前端技能 | 已有 HTML 报表 |
推荐:结构固定用代码即模板(可控、易测试);格式频繁变动用模板文件(改模板不改代码)。两条路线都要做数据绑定层的校验——模板期望的字段与传入的 map 不一致时,应该启动期就报错,而不是生成一份空白报表。
8. 异步生成与交付
大报表不能同步生成,标准做法是任务化:
(defn enqueue-export! [params]
(let [task-id (str (java.util.UUID/randomUUID))]
(future
(let [f (io/file tmp (str task-id ".xlsx"))]
(try
(stream-xlsx! f (fetch-rows params))
(upload! f (str "exports/" task-id ".xlsx"))
(mark-done! task-id)
(finally (io/delete-file f true))))) ; 本地临时文件必删
task-id))
要点:
- 状态可查:
pending / running / done / failed,前端轮询或推送; - 产物进对象存储:不要留在本地磁盘,容器重启就没了;
- 临时文件必删:否则磁盘会被慢慢吃满;
- 超时与取消:长时间任务要能取消,避免占住资源。
若报表需要定期产出,可以配合 Clojure 数据管道与流处理 的调度能力做成定时任务。
9. 小结
Clojure 报表生成的工程要点:
- 三层分离:取数、排版、交付各自独立,事故率大幅下降;
- 大报表全链路流式:游标取数 + lazy 转换 + SXSSF 写出,任一环
vec就前功尽弃; - 中文字体必须嵌入:
IDENTITY_H+EMBEDDED,且确认商用许可; - 样式对象循环外创建:避免触发 xlsx 的样式数量上限;
- 金额用 BigDecimal、日期明确时区:格式化的坑多半来自 Locale 与时区;
- 生成任务化:异步 + 状态可查 + 产物进对象存储 + 临时文件清理。
把报表当成一条数据管道而不是「一段导出代码」,它就会变得和普通数据处理一样可预测、可测试、可扩展。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。