Clojure 报表与文档生成

Clojure 报表与文档生成实战:PDFBox、OpenPDF 与 clj-pdf 的选型对比,Apache POI 与 docjure 导出 Excel,模板与数据绑定,大报表流式写出与内存控制,以及中文字体嵌入、自动换行与列宽等排版细节处理。

「导出个 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-pdfClojure 封装声明式 DSL,写起来最快复杂布局受限
Flying SaucerHTML → PDF复用 HTML/CSS 技能只支持 CSS 2.1 子集

选型经验:固定格式的票据、标签用 clj-pdf;复杂排版、动态表格用 OpenPDF;需要 HTML 模板复用用 Flying Saucer。

2.2 Excel 方案

方案定位内存模式适用
Apache POI XSSF全功能全内存中小报表、复杂样式
Apache POI SXSSF流式写滑动窗口大报表(10 万行+)
docjurePOI 封装依赖底层数据绑定、读取模板
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 往往是更优解:

维度CSVExcel
生成速度极快(纯文本流)慢(对象模型)
内存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! 返回全量游标 / 分页
转换中间 veclazy 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 报表生成的工程要点:

  1. 三层分离:取数、排版、交付各自独立,事故率大幅下降;
  2. 大报表全链路流式:游标取数 + lazy 转换 + SXSSF 写出,任一环 vec 就前功尽弃;
  3. 中文字体必须嵌入:IDENTITY_H + EMBEDDED,且确认商用许可;
  4. 样式对象循环外创建:避免触发 xlsx 的样式数量上限;
  5. 金额用 BigDecimal、日期明确时区:格式化的坑多半来自 Locale 与时区;
  6. 生成任务化:异步 + 状态可查 + 产物进对象存储 + 临时文件清理。

把报表当成一条数据管道而不是「一段导出代码」,它就会变得和普通数据处理一样可预测、可测试、可扩展。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JSON/EDN 序列化与数据格式互操作
  3. JVM 调优与容器化部署:GC、JFR 与 Docker