|
文 | 张敏?on 前端背景有赞微商城包括了 PC 端、H5 端和小程序端,每个端都有绘制分享海报的需求。最早的时候我们是在每个端通过 canvas API?来绘制的,通过 canvas?绘制有很多痛点,与本文要讲的海报渲染服务做了一个对比:对比项CanvasNode 海报渲染服务上手门槛需要掌握 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 地址。整个流程的大致代码实现如下:const crypto = require('crypto');const PuppeteerProvider = require('../../lib/PuppeteerProvider');const oneDay = 24 * 60 * 60;class SnapshotController { /** * 截图接口 * * @param {Object} ctx 上下文 */ async postSnapshotJson(ctx) { const result = await this.handleSnapshot(); ctx.json(0, 'ok', result); } async handleSnapshot() { const { ctx } = this; const { html } = ctx.request.body; // 根据 html 做 sha256 的哈希作为 Redis Key const htmlRedisKey = crypto.createHash('sha256').update(html).digest('hex'); try { // 首先看海报是否有绘制过的 let result = await this.findImageFromCache(htmlRedisKey); // 命中缓存失败 if (!result) { result = await this.generateSnapshot(htmlRedisKey); } return result; } catch (error) { ctx.status = 500; return ctx.throw(500, error.message); } } /** * 判断kv中是否有缓存 * * @param {String} htmlRedisKey kv存储的key */ async findImageFromCache(htmlRedisKey) { } /** * 生成截图 * * @param {String} htmlRedisKey kv存储的key */ async generateSnapshot(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'); let imgBuffer; try { imgBuffer = await PuppeteerProvider.snapshot({ html, width, height, quality, ratio, imageType }); } catch (err) { // logger } let imgUrl; try { imgUrl = await this.uploadImage(imgBuffer, operatorId); // 将海报图片存在 Redis 里 await ctx.kvdsClient.setex(htmlRedisKey, oneDay, imgUrl); } catch (err) { } return { img: imgUrl || '', type: IMAGE_TYPE_MAP.CDN, }; } /** * 上传图片到七牛 * * @param {Buffer} imgBuffer 图片buffer */ async uploadImage(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?启动项,这个就是为了我们启动一个最小化可用的浏览器实例,其他不需要的功能都禁用掉,这样会大大提升启动速度。const browser = await puppeteer.launch({ args: [ '–disable-gpu', '–disable-dev-shm-usage', '–disable-setuid-sandbox', '–no-first-run', '–no-sandbox', '–no-zygote', '–single-process' ]});再来说说浏览器的执行流程,最开始我们是每次绘制都会用单独一个浏览器,也就是一对一,这个在压测的时候发现 CPU?和内存飙升,最后我们改用了复用浏览器标签的方式,每次绘制新建一个标签来绘制。const page = await browser.newPage();page.setContent(html, { waitUntil: 'networkidle0'});const imageBuffer = await page.screeshot(options);3.2.2 networkidle0最开始我们的海报服务绘制海报时有时候会偶尔出现图片展示不出来的情况,我们排查后发现是因为我们 setContent?时,使用的是默认的 load?事件来判断设置内容成功,而我们期望的是所有网络请求成功后才算设置内容成功。page.setContent(html)uppeteer?在 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 timefunction waitForNetworkIdle(page, timeout, maxInflightRequests = 0) { page.on('request', onRequestStarted); page.on('requestfinished', onRequestFinished); page.on('requestfailed', onRequestFinished); let inflight = 0; let fulfill; let promise = new Promise(x => fulfill = x); let timeoutId = setTimeout(onTimeoutDone, timeout); return promise; function onTimeoutDone() { page.removeListener('request', onRequestStarted); page.removeListener('requestfinished', onRequestFinished); page.removeListener('requestfailed', onRequestFinished); fulfill(); } function onRequestStarted() { ++inflight; if (inflight > maxInflightRequests) clearTimeout(timeoutId); } function onRequestFinished() { if (inflight === 0) return; --inflight; if (inflight === maxInflightRequests) timeoutId = setTimeout(onTimeoutDone, timeout); }}// Exampleawait Promise.all([ page.goto('https://google.com'), waitForNetworkIdle(page, 500, 0), // equivalent to 'networkidle0']);3.2.3 Chromium定时刷新机制为什么需要定时刷新 Chromium?呢?总不可能一直用同一个 Chromium?实例吧,万一变卡或者 crash?了,就会影响海报的绘制。所以我们需要定时的去刷新当前的浏览器实例。class PuppeteerProvider { 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); const browserInstance = 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) { const browser = await browserInstance; const openPages = await browser.pages(); // 因为浏览器会打开一个空白页,如果当前浏览器还有任务在执行,一分钟后再关闭 if (openPages & openPages.length > 1 & retries > 0) { const nextRetries = retries - 1; setTimeout(() => this.replaceBrowserInstance(browserInstance, nextRetries), oneMinute); return; } browser.close(); } launchBrowser(opts = {}, retries = 1) { return PuppeteerHelper.launchBrowser(opts).then(chrome => { return chrome; }).catch(error => { if (retries > 0) { const nextRetries = retries - 1; return this.launchBrowser(opts, nextRetries); } throw error; }); }}这里还有一个点,我们给 replaceBrowserInstance?这个方法加了个重试次数的限制,当超出这个限制后不管有没有任务在进行都会关闭浏览器。这个是防止在某些特殊情况不能关闭掉浏览器,导致内存无法释放的情况。四、展望目前海报渲染服务的问题就是 qps?比较低,因为 Chromium?消耗最多的资源是 CPU,当并发数变高时,CPU 也随之变高,就会导致后面的绘制变慢。在 4核8G?的情况,大概是 20qps?左右。后面的主要精力就是如何去提升单机的 qps,应该还有比较大的空间。还有就是看看能不能增加定时任务,在凌晨机器比较闲的时候提前绘制好一些常用的海报,这样当需要海报时就是直接从 redis?里取出来了,充分利用了机器的性能,也可以减少海报服务白天的压力。也欢迎各位大牛加入有赞,一起来优化,简历直邮: zhangmin@youzan.com。相关链接:Puppeteer 性能优化与执行速度提升:https://blog.it2048.cn/article-puppeteer-speed-up/Control networkidle wait time:https://github.com/GoogleChrome/puppeteer/issues/1353拓展阅读:浅谈 Android Dex 文件基于 weex 的有赞无线开发框架有赞微商城-Android 组件化方案有赞移动 App 一键切换网关实践-The End-Vol.190有赞技术团队为 442 万商家,150 个行业,330 亿电商交易额提供技术支持微商城|零售|美业 | 教育微信公众号:有赞coder ? ?微博:@有赞技术技术博客:tech.youzan.comThe bigger the dream,?the more important the team.
|
|