公司有一块业务需要生成pdf,本文将讲述如何基于node和playwright来完成这一部分功能
技术选型
-
- 前端canvas绘制
-
- node+自动化UI工具(playwright、puppeteer以及Selenium)
首先,由于公司本身就存在一套基于网页的截图服务,只不过是基于wkhtmltopdf实现的,这个官方早已经停止维护,以及对css3、es6几乎没有兼容性,所以大大增加了开发需要截图的网页的难度,往往开发在本地调试的时候和截图出来的页面不一致,及其难排查问题。由于上述原因,所以要升级原本wkhtmltopdf截图,以及因为本身业务系统已经存在一套比较完善的任务队列、pdf处理流程,同时也没选择纯用前端canvas来对网页截图,而是只需要开发一个新的截图服务来替换旧的截图服务,其他逻辑不用改动。
至于playwright、puppeteer以及Selenium这三者的选型,都是ui自动化测试工具,都提供了网页截图功能,参考了这篇文章,真实环境下playwright的效率是最高的,以及playwright的功能更加强大,能够进行网络资源劫持以及更完善的等待机制。话不多说,撸起袖子就是酷酷干。
首先需要先安装一下node环境,安装一些依赖playwright、koa(用express也行),这些不是文本的重点,不在此做过多阐述。
我们的业务场景是这样的,以班级为单位,每个学生有10页或者更多档案,需要把这几页档案合成一个pdf。业务系统会以单个学生为单位,向截图服务发送请求,请求包含了这个学生的每页档案的网址,然后等待截图服务回传pdf文件流。因此,截图服务需要完成两个功能,首先并发将这些网页变成图片,然后合并成pdf回传。
截图
利用generic-pool创建一个资源池,以提高资源的利用率,减少频繁创建带来的开销。
因为playwright是利用浏览器的能力来截图的,一个浏览器可以开多个标签,所以在服务启动的时候,先初始化一个浏览器资源池,再给浏览器初始化一个page资源池来并发执行任务,考虑过再结合worker_threads再开启一个线程的资源池来进行多线程处理,这样处理效率应该更高。但是实际情况,我们同时需要处理的url一般只有10几个,在维护一个线程资源池会增加服务器的压力,出现不稳定性,所以就只考虑了资源池。
arduino
/**
* 资源池配置,取决于服务器配置
*/
const POOL_CONFIG = {
// 浏览器池配置
browser: {
min: 2, // 最小浏览器实例数
max: 4, // 最大浏览器实例数
idleTimeoutMillis: 1000*60*10, // 10分钟不用的资源会被移除
evictionRunIntervalMillis: 1000*60*60, // 每1小时检查一次(不要太频繁,避免无意义检查)
numTestsPerEvictionRun: 4, // 每次检查4个
testOnBorrow:true //借出时检查有效性(防止拿到失效资源)
},
// 页面池配置
page: {
min: 2, // 每个浏览器最小页面数
max: 4, // 每个浏览器最大页面数
idleTimeoutMillis: 1000*60*10, // 10分钟不用的资源会被移除
evictionRunIntervalMillis: 1000*60*60, // 每1小时检查一次(不要太频繁,避免无意义检查)
numTestsPerEvictionRun: 4, // 每次检查4个
testOnBorrow:true //借出时检查有效性(防止拿到失效资源)
}
};
/**
* 初始化全局资源池
*/
async function initializeResourcePool(config = {}) {
if (globalPagePoolManager) {
console.log('资源池已存在,跳过初始化');
return globalPagePoolManager;
}
console.log('初始化全局资源池...');
// 创建浏览器池和页面池管理器
const browserPool = createBrowserPool(config.browser);
globalPagePoolManager = createPagePoolManager(browserPool, config.page);
console.log('全局资源池初始化完成');
return globalPagePoolManager;
}
/**
* 创建浏览器实例池
*/
function createBrowserPool(config = {}) {
const finalConfig = { ...POOL_CONFIG.browser, ...config };
const factory = {
create: async () => {
const browser = await playwright.chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding'
]
});
return browser;
},
destroy: async (browser) => {
await browser.close();
},
};
return genericPool.createPool(factory, finalConfig);
}
/**
* 创建页面池管理器
*/
function createPagePoolManager(browserPool, config = {}) {
// 为每个浏览器实例创建页面池
const pagePools = new Map();
const finalConfig = { ...POOL_CONFIG.page, ...config };
return {
/**
* 获取页面
*/
acquirePage: async () => {
// 先获取浏览器实例
const browser = await browserPool.acquire();
// 如果这个浏览器还没有页面池,创建一个
if (!pagePools.has(browser)) {
const pagePool = createPagePool(browser, finalConfig);
pagePools.set(browser, pagePool);
}
const pagePool = pagePools.get(browser);
const page = await pagePool.acquire();
return { browser, page, pagePool };
},
/**
* 释放页面
*/
releasePage: async ({ browser, page, pagePool }) => {
await pagePool.release(page);
browserPool.release(browser);
},
/**
* 清理所有资源
*/
drain: async () => {
// 清理所有页面池
for (const [browser, pagePool] of pagePools) {
await pagePool.drain();
}
pagePools.clear();
// 清理浏览器池
await browserPool.drain();
},
};
}
接下来等业务系统的请求发送过来,就要开始进行截图合并pdf,可能是由于网络以及playwright自身等一切不可测因素(在真实业务中碰到的,发现会有截图白屏等现场),所以需要增加一些兜底策略和错误处理,譬如图片大小小于50kb进行重试,截图过程发生error进行重试,当然万一真有问题,不能无限重试,服务就死循环了
js
/**
* 带重试机制的截图页面
*/
async function captureScreenshotWithRetry(pagePoolManager, url, filename) {
let lastError = null;
let attempt = 0;
while (attempt <= RETRY_CONFIG.maxRetries) {
try {
console.log(`[${url}] 尝试截图 (第${attempt + 1}次)`);
const result = await captureScreenshot(pagePoolManager, url, filename);
// 检查图片大小
const imageSize = await checkImageSize(result);
if (imageSize < RETRY_CONFIG.minImageSize) {
throw new Error(`图片大小过小: ${imageSize} bytes (小于 ${RETRY_CONFIG.minImageSize} bytes)`);
}
console.log(`[${url}] 截图成功 (大小: ${Math.round(imageSize / 1024)}KB)`);
return result;
} catch (error) {
lastError = error;
console.warn(`[${url}] 第${attempt + 1}次尝试失败: ${error.message}`);
// 如果不是最后一次尝试,等待后重试
if (attempt < RETRY_CONFIG.maxRetries) {
console.log(`[${url}] 等待 ${RETRY_CONFIG.retryDelay}ms 后重试...`);
await delay(RETRY_CONFIG.retryDelay);
}
}
attempt++;
}
// 所有重试都失败了
throw new Error(`截图失败,已重试${RETRY_CONFIG.maxRetries}次: ${lastError.message}`);
}
csharp
/**
* 截图页面
*/
async function captureScreenshot(pagePoolManager, url, filename) {
const { browser, page, pagePool } = await pagePoolManager.acquirePage();
try {
const filePath = path.join(__dirname, 'screenshots', filename);
// 确保目录存在
const dir = path.dirname(filePath);
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
// 导航到页面
await page.goto(url, {
waitUntil: 'networkidle',
timeout: RETRY_CONFIG.timeout
});
// 截图
await page.screenshot({
path: filePath,
fullPage: true,
type: 'png'
});
return filePath;
} catch (error) {
throw error;
} finally {
// 释放页面和浏览器
await pagePoolManager.releasePage({ browser, page, pagePool });
}
}
最后当所有url截图完成之后,对图片进行合并成pdf
js
// 并发处理:使用Promise.all()实现异步并发
// 每个URL会从页面池获取一个页面进行截图
const screenshotTasks = urls.map((url, index) =>
captureScreenshotWithRetry(pagePoolManager, url, `screenshot-${index}.png`)
);
const screenshotFiles = await Promise.all(screenshotTasks);
// 合并为PDF
await mergePDFs(screenshotFiles, outputPdf);
// 清理临时文件
await cleanupTempFiles(screenshotFiles);
js
async function mergePDFs(imagePaths, outputPath) {
if (!imagePaths || imagePaths.length === 0) {
throw new Error('图片路径数组不能为空');
}
console.log(`开始合并 ${imagePaths.length} 个图片为PDF`);
// 创建PDF文档
const pdfDoc = await PDFDocument.create();
// 添加图片到PDF
for (const imagePath of imagePaths) {
try {
const imageBytes = await fs.readFile(imagePath);
const pngImage = PNG.sync.read(imageBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
const embeddedImage = await pdfDoc.embedPng(imageBytes);
page.drawImage(embeddedImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height
});
} catch (error) {
console.warn(`跳过无效图片: ${imagePath}`, error.message);
}
}
// 保存PDF
const pdfBytes = await pdfDoc.save();
}
目前使用的是无痕模式,没有很有效的利用浏览器的资源缓存来达到更加高效,后续会持续优化