使用 Puppeteer 搭建统一海报渲染服务
背景
有赞微商城包括了 PC 端、H5 端和小程序端,每个端都有绘制分享海报的需求。最早的时候我们是在每个端通过 canvas API
来绘制的,通过 canvas
绘制有很多痛点,与本文要讲的 海报渲染服务
做了一个对比:
对比项 | Canvas | Node 海报渲染服务 |
---|---|---|
上手门槛 | 需要掌握 canvas API | 了解 HTML、CSS 语法即可 |
代码体积 | 占用小程序包体积 | 代码存放在服务端,无需下载 |
代码可读性 | 较差,调试复杂 | 可读,易于调试 |
代码复用性 | 多端重复编码 | Node 端统一处理,无须重复编码 |
兼容性 | 小程序 canvas 存在兼容问题 | 无兼容问题 |
缓存策略 | 无缓存 | 基于 Redis 缓存 |
正是因为这些痛点问题,有同事就提出基于 Puppeteer
实现一个公共的海报渲染服务,使用方只需传入海报图片的 html
,海报渲染服务绘制一张对应的图片作为返回结果,解决了 canvas
绘制的各种痛点问题。
一、Puppeteer 是什么
Puppeteer
是谷歌官方团队开发的一个 Node 库,它提供了一些高级 API 来通过 DevTools
协议控制 HeadlessChrome
或 Chromium
。通俗的说就是提供了一些 API 用来控制浏览器的行为,比如打开网页、模拟输入、点击按钮、屏幕截图等操作,通过这些 API 可以完成很多有趣的事情,比如本文要讲的海报渲染服务,它用到的就是屏幕截图的功能。
二、Puppeteer 能做什么
Puppeteer
几乎能实现你能在浏览器上做的任何事情,比如:
- 生成页面的屏幕截图或 pdf
- 自动化提交表单、模拟键盘输入、自动化单元测试等
- 网站性能分析:可以抓取并跟踪网站的执行时间轴,帮助分析效率问题
- 抓取网页内容,也就是我们常说的爬虫
三、海报渲染服务
3.1 方案设计
首先我们来看一下海报渲染服务的流程图:
其实整个流程还是比较简单的,当有一个绘制请求时,首先看之前是否已经绘制过相同的海报了,如果绘制过,就直接从 Redis
里取出海报图片的 CDN 地址。如果海报未曾绘制过,则先调用 HeadlessChrome
来绘制海报,绘制完后上传到 CDN,最后 CDN 上传完后返回 CDN 地址。整个流程的大致代码实现如下:
复制代码
constcrypto = require('crypto'); constPuppeteerProvider = require('../../lib/PuppeteerProvider'); constoneDay =24*60*60; classSnapshotController{ /** * 截图接口 * * @param {Object} ctx 上下文 */ asyncpostSnapshotJson(ctx){ constresult =awaitthis.handleSnapshot(); ctx.json(0,'ok', result); } asynchandleSnapshot(){ const{ ctx } =this; const{ html } = ctx.request.body; // 根据 html 做 sha256 的哈希作为 Redis Key consthtmlRedisKey = crypto.createHash('sha256').update(html).digest('hex'); try{ // 首先看海报是否有绘制过的 letresult =awaitthis.findImageFromCache(htmlRedisKey); // 命中缓存失败 if(!result) { result =awaitthis.generateSnapshot(htmlRedisKey); } returnresult; }catch(error) { ctx.status =500; returnctx.throw(500, error.message); } } /** * 判断 kv 中是否有缓存 * * @param {String} htmlRedisKey kv 存储的 key */ asyncfindImageFromCache(htmlRedisKey){ } /** * 生成截图 * * @param {String} htmlRedisKey kv 存储的 key */ asyncgenerateSnapshot(htmlRedisKey){ const{ ctx } =this; const{ html, width =375, height =667, quality =80, ratio =2, type: imageType ='jpeg', } = ctx.request.body; this.validator .required(html,'缺少必要参数 html') .required(operatorId,'缺少必要参数 operatorId'); letimgBuffer; try{ imgBuffer =awaitPuppeteerProvider.snapshot({ html, width, height, quality, ratio, imageType }); }catch(err) { // logger } letimgUrl; try{ imgUrl =awaitthis.uploadImage(imgBuffer, operatorId); // 将海报图片存在 Redis 里 awaitctx.kvdsClient.setex(htmlRedisKey, oneDay, imgUrl); }catch(err) { } return{ img: imgUrl ||'', type: IMAGE_TYPE_MAP.CDN, }; } /** * 上传图片到七牛 * * @param {Buffer} imgBuffer 图片 buffer */ asyncuploadImage(imgBuffer){ // upload image to cdn and return cdn url } } module.exports = SnapshotController;
3.2 遇到的问题
2.3.1 Chromium 启动和执行流程
最开始一个版本我们是直接 Puppeteer.launch()
返回一个浏览器实例,每次绘制会用单独的一个浏览器实例,这个在使用过程中发现绘制海报会很慢,后面优化时找到了这篇文章:Puppeteer 性能优化与执行速度提升,这篇文章提到了两个优化点:1. 优化 Chromium
启动项;2. 优化 Chromium
执行流程。
先说优化 Chromium
启动项,这个就是为了我们启动一个最小化可用的浏览器实例,其他不需要的功能都禁用掉,这样会大大提升启动速度。
复制代码
constbrowser =await puppeteer.launch({ args: [ '–disable-gpu', '–disable-dev-shm-usage', '–disable-setuid-sandbox', '–no-first-run', '–no-sandbox', '–no-zygote', '–single-process' ] });
再来说说浏览器的执行流程,最开始我们是每次绘制都会用单独一个浏览器,也就是一对一,这个在压测的时候发现 CPU
和内存飙升,最后我们改用了复用浏览器标签的方式,每次绘制新建一个标签来绘制。
复制代码
constpage =awaitbrowser.newPage(); page.setContent(html, { waitUntil:'networkidle0' }); constimageBuffer =awaitpage.screeshot(options);
3.2.2 networkidle0
最开始我们的海报服务绘制海报时有时候会偶尔出现图片展示不出来的情况,我们排查后发现是因为我们 setContent
时,使用的是默认的 load
事件来判断设置内容成功,而我们期望的是所有网络请求成功后才算设置内容成功。
复制代码
page.setContent(html);
Puppeteer
在 setContent
和 goto
等方法里提供了一个 waitUntil
的参数,它就是用来配置这个判断成功的标准,它提供了四个可选值:
-
load
:默认值,load
事件触发就算成功 -
domcontentloaded
:domcontentloaded
事件触发就算成功 -
networkidle0
:在 500ms 内没有网络连接时就算成功 -
networkidle2
:在 500ms 内有不超过 2 个网络连接时就算成功
我们这里需要用到的就是 networkidle0
:
复制代码
page.setContent(html, { waitUntil:'networkidle0' });
当改成 networkidle0
后,使用方给我们反馈说整个绘制服务变慢了很多,随随便便都 2s 以上。变慢主要是因为加上 networkidle0
后,至少需要等待 500ms 以上,加上绘制的一些其他开销,基本上就需要 2s 了。所以我们期望这个 500ms 是可配置的,因为 500ms 实在太长了,我们的分享海报一般只有几张图片,不需要这么久。但是 Puppeteer
没有提供相关的参数,还好在 issue
中早已经有人提出了这个问题:Control networkidle wait time
复制代码
functionwaitForNetworkIdle(page,timeout,maxInflightRequests= 0){ page.on('request', onRequestStarted); page.on('requestfinished', onRequestFinished); page.on('requestfailed', onRequestFinished); letinflight =0; letfulfill; letpromise =newPromise(x=>fulfill=x); lettimeoutId = setTimeout(onTimeoutDone,timeout); return promise; functiononTimeoutDone(){ page.removeListener('request',onRequestStarted); page.removeListener('requestfinished',onRequestFinished); page.removeListener('requestfailed',onRequestFinished); fulfill(); } functiononRequestStarted(){ ++inflight; if(inflight > maxInflightRequests) clearTimeout(timeoutId); } functiononRequestFinished(){ if(inflight===0) return; --inflight; if(inflight===maxInflightRequests) timeoutId = setTimeout(onTimeoutDone,timeout); } } // Example awaitPromise.all([ page.goto('https://google.com'), waitForNetworkIdle(page,500,0),//equivalentto'networkidle0' ]);
3.2.3 Chromium 定时刷新机制
为什么需要定时刷新 Chromium
呢?总不可能一直用同一个 Chromium
实例吧,万一变卡或者 crash
了,就会影响海报的绘制。所以我们需要定时的去刷新当前的浏览器实例。
复制代码
classPuppeteerProvider{ constructor() { this.browserList = []; } /** * 初始化`puppeteer`实例 */ initBrowserInstance() { Array.from({ length: browserConcurrency }, () => { this.checkBrowserInstance(); }); // 每隔 30 分钟刷新一下浏览器 this.refreshTimer = setTimeout(() =>this.refreshOneBrowser(), thrityMinutes); } /** * 检查是否还需要浏览器实例 */ async checkBrowserInstance() { if(this.needBrowserInstance) { this.browserList.push(this.launchBrowser()); } } /** * 定时刷新浏览器 */ refreshOneBrowser() { clearTimeout(this.refreshTimer); constbrowserInstance =this.browserList.shift(); this.replaceBrowserInstance(browserInstance); this.checkBrowserInstance(); // 每隔 30 分钟刷新一下浏览器 this.refreshTimer = setTimeout(() =>this.refreshOneBrowser(), thrityMinutes); } /** * 替换单个浏览器实例 * *@param{String} browserInstance 浏览器 promise *@param{String} retries 重试次数,超过这个次数直接关闭浏览器 */ async replaceBrowserInstance(browserInstance, retries =2) { constbrowser = await browserInstance; constopenPages = await browser.pages(); // 因为浏览器会打开一个空白页,如果当前浏览器还有任务在执行,一分钟后再关闭 if(openPages && openPages.length >1&& retries >0) { constnextRetries = retries -1; setTimeout(() =>this.replaceBrowserInstance(browserInstance, nextRetries), oneMinute); return; } browser.close(); } launchBrowser(opts = {}, retries =1) { returnPuppeteerHelper.launchBrowser(opts).then(chrome => { returnchrome; }).catch(error => { if(retries >0) { constnextRetries = retries -1; returnthis.launchBrowser(opts, nextRetries); } throwerror; }); } }
这里还有一个点,我们给 replaceBrowserInstance
这个方法加了个重试次数的限制,当超出这个限制后不管有没有任务在进行都会关闭浏览器。这个是防止在某些特殊情况不能关闭掉浏览器,导致内存无法释放的情况。
四、展望
目前海报渲染服务的问题就是 qps
比较低,因为 Chromium
消耗最多的资源是 CPU
,当并发数变高时,CPU 也随之变高,就会导致后面的绘制变慢。在 4 核 8G
的情况,大概是 20qps
左右。后面的主要精力就是如何去提升单机的 qps
,应该还有比较大的空间。还有就是看看能不能增加定时任务,在凌晨机器比较闲的时候提前绘制好一些常用的海报,这样当需要海报时就是直接从 redis
里取出来了,充分利用了机器的性能,也可以减少海报服务白天的压力。也欢迎各位大牛加入有赞,一起来优化,简历直邮: zhangmin@youzan.com
。