使用 Playwright 打造 HTML 轉 PDF 微服務
0 |
前陣子講到 .NET PDF 文件製作,讀者 fredli 提到 HTML 轉 PDF 缺乏 好用套件。在我心中,HTML 轉 PDF 首推微軟推出的 Playwright,可選擇 Chromium 引擎,不必擔心 HTML 支援不夠力或渲染邏輯不對(網頁用 Chrome 看不正常,錯的當然是網頁),而身為 E2E 測試解決方案,我直覺 Playwright 一定能放在伺服器端穩定執行(不然很難配合 CI 運轉)。這個議題挺有趣,於是有了這篇嘗試。
查了一下,Playwright 有 Docker Image,做成微服務快速部署機動管理不成問題,而將其封裝成 WebAPI 裝進容器,在高負載情境時可視需要擴充節點數,配合負載平衡機制,只要預算足,沒有達不到的 Throughput,豈不美哉?
Playwright Docker 內建基於 Node.js 的 npx playwright
CLI,評估後我決定直接用 Node.js 寫 WebAPI,比額外裝 Runtime 跑 ASP.NET Core 輕巧省事,雖然跟 Node.js 不熟,但有 Github Copiot 在,沒什麼好怕。如果是勇者欣梅爾的話,一定也會選擇用 Node.js 吧。
由於 Chromium 是以獨立 Process 形式運行,耗用記憶體與 CPU 資源遠比 In-Process 程式庫多,依過去寫 .NET 整合 Word 的經驗,最好要限定同時開啟 Chromuium 的數量,避免高請求量時啟動過多 Chromium 耗盡系統資源或因執行緒過多被 Context Switch 拖垮效能。
參考 Copilot 建議,我用 Express 網站框架跑 WebAPI,用 fastq Work Queue以佇列方式消化 HTML 轉 PDF 請求,使用 Worker 函式從佇列依序取出待轉網頁,呼叫 Playwright 產生 PDF 傳回。fastq 可限制最多同時執行的 Worker 上限,確保系統不會無限制地啟動 Chromium 程序。
WebAPI 範例如下,十分簡單,不到 50 行搞定:
const express = require('express');
const playwright = require('playwright');
const fastq = require('fastq');
const app = express();
const port = 3000;
const worker = async (task, callback) => {
const browser = await playwright.chromium.launch();
try {
const context = await browser.newContext();
const page = await context.newPage();
if (task.url) {
await page.goto(task.url);
} else if (task.html) {
await page.setContent(task.html);
}
const pdf = await page.pdf();
callback(null, pdf);
} catch (error) {
callback(error);
}
finally{
await browser.close();
}
};
const concurrency = parseInt(process.argv[2]) || 4;
const queue = fastq(worker, concurrency);
app.post('/pdf', express.json(), async (req, res) => {
const postData = req.body;
if (!postData.url && !postData.html) {
return res.status(400).send('Missing parameter');
}
queue.push(postData, (err, result) => {
if (err) {
return res.status(500).send(err.message);
}
res.send(result);
});
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
/pdf WebAPI 接受 JSON 傳入 url 或 html 參數,將網頁轉成 PDF 傳回。同時並行作業(Concurrent Task)數量上限可由參數控制,方便比較不同並行作業數量的效能表現。
接下來要實測效果。我請 ChatGPT 擬草稿,稍微加工做成中獎通知單範本網頁:
測試程式的原理如下,以 0.1 秒一發的頻率呼叫 WebAPI 將 HTML 內容轉成 PDF,總共執行 100 次,再測量生成 100 個 PDF 所需時間。
const fs = require('fs');
const { pipeline } = require('stream/promises');
const noProgress = process.argv.includes('--no-progress');
async function generatePDF(jobId, html, pdfPath) {
const start = process.hrtime.bigint();
try {
const response = await fetch('http://localhost:3000/pdf', {
method: 'POST',
body: JSON.stringify({ jobId, html }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Unexpected response ${response.statusText}`);
}
await pipeline(response.body, fs.createWriteStream(pdfPath));
} catch (err) {
console.error(err);
}
const end = process.hrtime.bigint();
if (!noProgress) console.log(`${pdfPath} in ${(end - start) / BigInt(1e6)}ms`);
}
if (!fs.existsSync('data')) fs.mkdirSync('data');
const promises = [];
const template = fs.readFileSync('template.html', 'utf8');
const overallStart = process.hrtime.bigint();
for (let i = 0; i < 100; i++) {
const promise = new Promise((resolve) => {
const seqNo = i;
setTimeout(() => {
const randomNum = Math.floor(Math.random() * 1000);
const htmlContent = template
.replace('#Name#', 'User ' + seqNo)
.replace('#Number#', randomNum);
resolve(generatePDF('Job' + seqNo, htmlContent, 'data/result-' + seqNo + '.pdf'));
}, 100 * i);
});
promises.push(promise);
}
Promise.all(promises).then(() => {
const overallEnd = process.hrtime.bigint();
console.log(`Overall time: ${(overallEnd - overallStart) / BigInt(1e6)}ms`);
});
同時執行 WebAPI 及客戶端,便能獲得不同並行作業數時的測試數據。
實測 Chromium 轉換一份 PDF 最快約 0.3 ~ 0.4s,但數量一多需排隊消化。而觀察效能瓶頸應卡在 CPU,連續處理時會同時開啟多個 chrome.exe 程序(Chromium 處理單一網頁原本就會啟動多個 Process),CPU 維持在 98% ~ 100%,此時再增加並行作業數也難以再加快。這個系統架構應該還有不少優化空間,像是不要每次轉 PDF 都重新啟動 Chromium,改為新建 Page 處理請求(但依經驗,長期執行需注意記憶體用量),應可減少資源消耗加快速度,這篇文章以 PoC 驗證為主,效能優化議題就此打住。
以下是在 i5 12500 隨興測試轉 100 個 PDF (每秒執行 10 筆) 的總耗時及平均值。(註:非嚴謹數據,未多次測試求平均,只約略看個趨勢)
並行作業數 | 產生 100 個 PDF 耗時(ms) | 每個 PDF 平均耗時(ms) |
---|---|---|
1 | 30,381 | 304ms |
2 | 19,528 | 195ms |
3 | 16,836 | 168ms |
4 | 15,592 | 156ms |
5 | 14,961 | 150ms |
6 | 14,388 | 144ms |
7 | 14,326 | 143ms |
8 | 14,603 | 146ms |
結論是並行數設成 4 應該就有很不錯效果(平均 150ms 即可轉完一個 PDF),再增加效果有限。
下一步我嘗試將它包成 Docker 當成微服務使用,首先小改程式改從環境變數決定並行作業上限:
const concurrency = parseInt(process.env.FASTQ_CONCURRENCY) || 4;
const queue = fastq(worker, concurrency);
Dockerfile 內容如下:
FROM mcr.microsoft.com/playwright:v1.42.1-jammy
RUN mkdir -p /home/app
WORKDIR /home/app
COPY package*.json ./
RUN npm install
COPY webapi.js .
EXPOSE 3000
CMD ["node", "webapi.js"]
用 docker build -t playwright-pdf-svc .
建立 Docker Image,再準備 docker-compose.yml下:
version: '3'
services:
app:
image: playwright-pdf-svc
ports:
- "3000:3000"
我找了一台 Azure Debian (B2 VM, 2 vCPU + 4G RAM) 實測,100 個 PDF 不用 20 秒,應付簡單應用沒什麼問題。
由這個結果來看,在普通等級硬體上,簡單網頁轉成 PDF 的時間不到 200ms,這個速度應足以滿足一般的 PDF 轉檔應用,且由於採用 Docker 方式部署,當需要更大 Throughput 可靠增加節點數量簡單實現,是值得考慮的解決方案,列入口袋名單。
程式範例我已放上 Github,有興趣的同學可以 Clone 回去玩看看並分享心得。
In this article, I demostrate how to use Playwright and node.js to create a HTML to PDF micro service running in a docker.
Comments
Be the first to post a comment