1月份的样子 CodeBuddy 做任务领了 3 个月的服务器今天电话通知到期了,因为是临时用上面没有部署什么重要的东西,主要是平时用来折腾折腾,看了下上面还有一些编排的 docker-compose 文件,简单记个备忘,万一有人能用上呢。

这个调研是前段时间项目上有个需求就是生成用户在试用期三个月填写的周报,导出成“pdf”文件然后以压缩包的方式提供下载,最终打印出来进行归档作为转正依据。这里有一点就是导出的格式需要和客户线下已经在线下使用的一个模板一致,模板内容存在合并和分页的情况。

类似于如下:

开源自部署 Html 转 PDF API 方案 - gotenberg

开源自部署 Html 转 PDF API 方案 - gotenberg

过程

因为使用 java 的技术栈,最开始肯定是找 pdfbox ,iText7 ,poi-tl等等主流的方式先生成 word 再转 pdf,大多数都是采用模板占位然后渲染。项目成员开发过程中大部分都能搞,但是要随着内容自动扩充高度还要进行合并行处理起来相对复杂了。

在以前的公司也做过类似的项目当时方案直接用到了生产,还记得是在第一家公司做教育的时候要动态生产机读卡答题卡,样式也是特别复杂,基本上后端实现不了。当时是使用 wkhtmltopdf,由前端生成纯静态内容然后给后端直接渲染生成,还有当时做学生单科报告的时候使用了 JasperReport。

给项目成员说了去试试 wkhtmltopdf,让前端直接拿到数据渲染成任何页面,给原始 html 代码我们后端转换 PDF 下载即可。项目成员试了之后说 wkhtml2pdf 针对于比较新的 css 样式支持度不够,自己又 研究引入了 Chromium playwright 进行html 转 pdf,这个算也算是网上主流方案的了,但是就一个字重,java 有类似于组件,但是第一次需要先下载很大的浏览器在本地,然后以无头 headless 的方式进行调用原生 html导出pdf。

这个方案成员采用了我一开始不知道,但是发测试环境环境都时候不是很顺利,因为网络环境都原因吧,还有就是我们目前是容器化的,容器里面很多依赖没得导致疯狂报错,成员在本地 windows 开发机测试是没问题。

于是利用周末的时候研究了一下还有没有其他方案,既然要支持样式更多,那必然 playwright 利用浏览器是最好的了,就朝这个方向去看了。

为什么要进行替换?性能低大批量转换因为和 java 在一个容器进程里容易整体拉低服务可用性,甚至内存占用高了容器直接重启导致服务不可用了,所以即便要用也得单独抽一个公共文件转换服务出来进行通过 API 调用,即便死了也不会影响业务使用。

在业界 html 转 pdf 还有很多组件,当然也有商业版支持比如 aspose.pdf,IronPDF,iText 7/8,PD4ML。

但是我最终选择了开源的 gotenberg ,极低内存占用go 高性能提供 API 直接使用,可以当做一个中间件直接使用。

部署

进入本文正文,部署 docker-compose.yml 直接给出。

services:
  gotenberg:
    image: gotenberg/gotenberg:8
    container_name: gotenberg
    ports:
      - "3000:3000"
    restart: unless-stopped
    environment:
      - CHROMIUM_MAX_CONCURRENCY=6

    # ui 界面可以提供测试,如果不需要注释掉
  gotenbergui:
    image: techblog/gotenbergui
    container_name: gotenbergui
    restart: unless-stopped
    environment:
      - GOTENBERG_API_ADDRESS=http://你部署的gotenberg服务IP:3000
    ports:
      - "8080:8080"

更多配置参数可以见官方文档:https://gotenberg.dev/docs/configurationCHROMIUM_MAX_CONCURRENCY 并发配置官方只最大支持6

转换API以及参数可以见:https://gotenberg.dev/docs/convert-with-chromium/convert-html-to-pdf

使用

gotenberg开源相关有很多封装的SDK可以直接使用,下面给出直接通过原生API方式调用。成员的核心代码脱敏分享:

    /**
     * Gotenberg服务地址
     */
    @Value("${gotenberg-url:http://127.0.0.1:3000}")
    private String gotenbergUrl;
    /**
     * 并发控制
     */
    private final Semaphore semaphore = new Semaphore(6);

        /**
     * Gotenberg生成PDF
     *
     * @param htmlContent HTML内容
     * @param base64Encode 是否base64编码
     * @param watermarkText 水印文字
     * @param landscape 是否横向打印
     * @return PDF字节数组
     */
    private byte[] generatePdf(String htmlContent, boolean base64Encode, String watermarkText, boolean landscape) {
        try {
            // 对base64编码的HTML内容进行解码
            if (base64Encode) {
                htmlContent = new String(Base64.getDecoder().decode(htmlContent), StandardCharsets.UTF_8);
            }
            // 设置页面大小和生成水印
            htmlContent = addPageNumbersAndWatermark(htmlContent, watermarkText);
            // 根据横向/纵向设置A4纸张尺寸
            double paperWidth = landscape ? 8.27 : 11.7;
            double paperHeight = landscape ? 11.7 : 8.27;
            // 构建MultipartBody
            MultipartBody.Builder multipartBuilder = new MultipartBody.Builder()
                .setType(MultipartBody.FORM)
                .addFormDataPart("paperWidth", String.valueOf(paperWidth))
                .addFormDataPart("paperHeight", String.valueOf(paperHeight))
                .addFormDataPart("marginTop", "20.0mm")
                .addFormDataPart("marginBottom", "20.0mm")
                .addFormDataPart("marginLeft", "15.0mm")
                .addFormDataPart("marginRight", "15.0mm")
                .addFormDataPart("printBackground", "true")
                .addFormDataPart("landscape", String.valueOf(landscape));
            // 添加HTML文件
            RequestBody fileBody = RequestBody.create(
                MediaType.parse("text/html; charset=utf-8"),
                htmlContent.getBytes(StandardCharsets.UTF_8)
            );
            multipartBuilder.addFormDataPart("files", "index.html", fileBody);
            // 构建请求
            Request request = new Request.Builder()
                .url(gotenbergUrl + "/forms/chromium/convert/html")
                .post(multipartBuilder.build())
                .build();
            // 执行请求
            try (Response response = httpClient.newCall(request).execute()) {
                // 处理返回结果
                if (!response.isSuccessful()) {
                    String errorMsg = "Gotenberg转换失败,状态码:" + response.code();
                    try {
                        String responseBody = response.body() != null ? response.body().string() : "";
                        if (!responseBody.isEmpty()) {
                            errorMsg += ",响应内容:" + responseBody;
                        }
                    } catch (Exception e) {
                        errorMsg += ",读取响应内容失败:" + e.getMessage();
                    }
                    throw new RuntimeException(errorMsg);
                }
                // 返回PDF字节数组
                byte[] pdfBytes = response.body() != null ? response.body().bytes() : null;
                if (ArrayUtil.isEmpty(pdfBytes)) {
                    throw new RuntimeException("Gotenberg返回的PDF数据为空");
                }
                return pdfBytes;
            }
        } catch (Exception e) {
            throw new RuntimeException("生成PDF失败: " + e.getMessage(), e);
        }
    }

说下最终的效果吧,6 个线程同时转换 700 个 pdf 控制在 10s 以内,转换内存占用 800MB 不到,CPU 转换时飙高。

如果能够接受系统异构以及 docker 容器部署的有类似需求可以参考参考。但是仅限对于生产 PDF 的场景,生成 Word 复杂样式确实没什么实质性的方案。建议从产品层面来做优化,数据导出 EXCEL,用户端使用不允许更改的导出 PDF,如果数据有误通过系统修正再导出,如果确实不能接受不要有太多的样式可以实现 PDF 反转会 Word 支持编辑。

今天就分享到这里,如果你有软件定制开发、网站搭建、服务器运维、AI 大模型方案咨询可以评论区留言或发送邮件到( i#51it.wang将#改成@)联系我们,团队成员均来自国内外中大厂的技术实战经验,涵盖移动端开发、后端架构设计、云原生技术、人工智能等多个领域,我们深知技术选型与架构设计对企业长期发展的重要性,因此始终坚持从业务本质出发,为客户提供务实、可靠的技术解决方案。

文章目录