前陣子講到 .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 吧。

thumbnail

由於 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)
130,381304ms
219,528195ms
316,836168ms
415,592156ms
514,961150ms
614,388144ms
714,326143ms
814,603146ms

結論是並行數設成 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

Post a comment