html2pdf-chrome:一个 HTML 转 PDF 的 Go 库 / 服务,依旧是现阶段效果最佳的
html2pdf-chrome:一个 HTML 转 PDF 的 Go 库 / 服务
Go + Chrome Headless + CDP,支持连接池复用、可组合等待策略、Docker 部署。
约 3400 行 Go,无 Node 依赖。
仓库反正是开源的,写这个贴子主要分享一下技术方案和踩过的坑,给有类似需求的同学一个参考。
现阶段效果最佳的。
仓库:github.com/PiZhai/html2pdf-chrome
解决了什么问题
服务端 HTML 转 PDF 的几种常见方案:
| 方案 | 优势 | 短板 |
|---|---|---|
| Puppeteer/Playwright | 渲染保真,生态成熟 | 需要 Node 运行时,启动开销大 |
| wkhtmltopdf | 资源开销低 | 不支持现代 CSS(Grid/Flexbox)、Canvas |
| 纯 Go 库 (unidoc 等) | 纯 Go,静态二进制 | 不是真实浏览器,复杂页面易翻车 |
| Selenium WebDriver | 真实浏览器 | 重,速度慢 |
我们的需求场景:日均数万次转换,Go 技术栈,需要在 Kubernetes 上运行,页面含 Canvas 图表和 Web Font。
结论:必须用真实浏览器引擎,但不想引入 Node 运行时。方案是直接走 CDP 协议驱动 Chrome Headless。
实现原理
一句话:Go 程序通过 WebSocket 连接 Chrome 的调试端口,用 CDP 协议下发Page.navigate和Page.printToPDF命令,Chrome 负责渲染,Go 程序只做编排。
Request → Go → WebSocket → Chrome Headless → Page.printToPDF → PDF 文件CDP 通信层用了 chromedp,Go 生态里最成熟的选择。
连接池设计(重点)
v0.1 版每次调用启动一个 Chrome,用完就关。适合 CLI,不适合服务端——Chrome 冷启动 1-3 秒,内存开销几百 MB。
v0.2 核心改动:Chrome 实例池。
Pool { idle: []*Instance // 空闲队列 activeCount int // 使用中 totalCount int // 存活总数 mu: sync.Mutex cond: *sync.Cond // 阻塞等待 }Acquire(获取实例):
- 从 idle 队列 pop → 做健康检查(HTTP 探
/json/version) - 空闲 + 未达上限 → 新建实例
- 已达上限 →
cond.Wait()阻塞,等别人归还
Release(归还实例):
taskCount >= MaxTasksPerInstance(默认 100) → 销毁重建(防 Chrome 内存泄漏)- 不健康 → 销毁重建
- 正常 → 放回 idle 队列,
cond.Broadcast()唤醒等待者
后台 reaper:每 30 秒扫一次,空闲超过IdleTimeout(默认 5 分钟)的被回收,但至少保留MinInstances个热备。
关键细节:
- 新建实例前先
totalCount++预留槽位,防止并发冲破上限 - 每个请求用 chromedp 创建独立 Tab(
NewContext),Tab 之间不共享 cookie/storage - Tab 关闭后 Chrome 自己回收内存,不会累积
等待策略
静态页面简单——等readyState === "complete"。但实际页面经常需要等更多东西:
Navigate → WaitReady("body") → WaitDocumentReady(15s) // readyState === "complete" → WaitFontsReady(10s) // document.fonts.status === "loaded" → [可选] WaitNetworkIdle(idle, timeout) // 网络空闲 + 静默期 → [可选] WaitVisible(selector) // CSS 选择器可见 → [可选] WaitForExpression(expr, timeout) // 自定义 JS 条件 → printToPDF网络空闲检测的原理:Enable Network domain → 监听requestWillBeSent/loadingFinished/loadingFailed→ 维护 inflight counter → 归零后静默 500ms → 判定空闲。
忽略了 WebSocket、data:、blob:请求。如果页面有 SSE 长轮询,用-wait-expression替代:
-wait-expression"document.querySelector('#chart') !== null"CLI / HTTP / Go 库三种模式
三种模式复用同一条渲染链路:
CLI(本地 / 脚本):
html2pdf-chrome-urlhttps://example.com-papera4-landscape-outoutput.pdfHTTP 服务(微服务):
curl-XPOST localhost:8080/convert\-H"Content-Type: application/json"\-d'{"url":"https://example.com","paper":"a4","waitNetworkIdle":true}'\-ooutput.pdf服务内部用连接池,/health 端点返回{"idle":2,"active":1,"total":3}。
Go 库(嵌入式):
converter,_:=html2pdf.NewConverter(html2pdf.ConverterConfig{MaxInstances:4,MinInstances:2,})deferconverter.Close()converter.Convert(html2pdf.Request{URL:"https://example.com",OutputPath:"./out.pdf",})Docker 部署
docker build -t html2pdf-chrome . docker run --rm html2pdf-chrome -url https://example.com -out /app/output/out.pdf镜像特点:
- Noto CJK + STIX 数学 + DejaVu + Emoji → 中英数全覆盖
--no-sandbox默认开启(容器本身是隔离层,不需要 Chrome 再开沙箱)- 另有
Dockerfile.cn(Chromium + 阿里云镜像),给国内服务器用
一些踩坑记录
- Chrome headless 模式:用
--headless=new而不是旧版--headless,新版的渲染行为和 GUI 模式一致,Canvas/SVG 不会出现偏移。 - WebSocket 断开重连:chromedp 的
RemoteAllocator不负责断线重连,每次任务结束cancel()关闭 Tab,下次任务重新Connect建新 Tab,避免重连问题。 - PDF stream 模式:默认 base64 返回对大 PDF(50MB+)可能 OOM,stream 模式通过 IO 流分块读取,适合大文件场景。
- 进程退出信号:Chrome 有时不会正常退出,
Close()直接用Kill而不是Signal(os.Interrupt)确保清理。不优雅但可靠。 - 共享内存:Docker 里 Chrome 需要
/dev/shm,否则页面加载会 hang。K8s 里需要设shm-size或挂载emptyDir。
当前状态和规划
当前约 3400 行 Go,34 个文件。已经能跑生产,但还有几个明显短板:
- 错误处理是扁平 wrap,没分类。计划加
ErrorCode枚举,让调用方能做类型判断(启动失败 vs 渲染超时) - 缺可观测性:没 metrics,没结构化日志。需要加 Prometheus 指标和 slog
- 平台验证:路径查找逻辑写了 macOS/Linux/Windows 三套,但只在 macOS 测过
- 没有发布工程:缺交叉编译脚本、版本注入、自动发版
欢迎 issue 和 PR。
仓库:github.com/PiZhai/html2pdf-chrome
