写点什么

使用 Puppeteer 搭建统一海报渲染服务

  • 2019-06-11
  • 本文字数:4793 字

    阅读完需:约 16 分钟

使用 Puppeteer 搭建统一海报渲染服务

背景

有赞微商城包括了 PC 端、H5 端和小程序端,每个端都有绘制分享海报的需求。最早的时候我们是在每个端通过 canvas API 来绘制的,通过 canvas 绘制有很多痛点,与本文要讲的海报渲染服务做了一个对比:


对比项CanvasNode 海报渲染服务
上手门槛需要掌握 canvas API了解 HTML、CSS 语法即可
代码体积占用小程序包体积代码存放在服务端,无需下载
代码可读性较差,调试复杂可读,易于调试
代码复用性多端重复编码Node 端统一处理,无须重复编码
兼容性小程序 canvas 存在兼容问题无兼容问题
缓存策略无缓存基于 Redis 缓存


正是因为这些痛点问题,有同事就提出基于 Puppeteer 实现一个公共的海报渲染服务,使用方只需传入海报图片的 html,海报渲染服务绘制一张对应的图片作为返回结果,解决了 canvas 绘制的各种痛点问题。

一、Puppeteer 是什么

Puppeteer 是谷歌官方团队开发的一个 Node 库,它提供了一些高级 API 来通过 DevTools 协议控制 HeadlessChromeChromium。通俗的说就是提供了一些 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);
复制代码


PuppeteersetContentgoto 等方法里提供了一个 waitUntil 的参数,它就是用来配置这个判断成功的标准,它提供了四个可选值:


  • load:默认值, load 事件触发就算成功

  • domcontentloadeddomcontentloaded 事件触发就算成功

  • networkidle0:在 500ms 内没有网络连接时就算成功

  • networkidle2:在 500ms 内有不超过 2 个网络连接时就算成功


我们这里需要用到的就是 networkidle0


page.setContent(html, {  waitUntil: 'networkidle0'});
复制代码


当改成 networkidle0 后,使用方给我们反馈说整个绘制服务变慢了很多,随随便便都 2s 以上。变慢主要是因为加上 networkidle0 后,至少需要等待 500ms 以上,加上绘制的一些其他开销,基本上就需要 2s 了。所以我们期望这个 500ms 是可配置的,因为 500ms 实在太长了,我们的分享海报一般只有几张图片,不需要这么久。但是 Puppeteer 没有提供相关的参数,还好在 issue 中早已经有人提出了这个问题:Control networkidle wait time


function 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

相关链接:

  1. Puppeteer 性能优化与执行速度提升:https://blog.it2048.cn/article-puppeteer-speed-up/

  2. Control networkidle wait time:https://github.com/GoogleChrome/puppeteer/issues/1353


本文转载自公众号有赞 coder(ID:youzan_coder)


原文链接


https://mp.weixin.qq.com/s/fpdz1KK0kkogFxAmAq7VkA


2019-06-11 08:0018068

评论

发布
暂无评论
发现更多内容

aPaaS是什么(aPaaS与iPaaS的区别)

优秀

aPaaS ipaas

天翼云Serverless边缘容器下沉服务 促进企业聚焦业务创新

天翼云开发者社区

2023 重学 Angular

PingCode研发中心

前端框架

阿里云洛神云网络集中式网关丨技术解读与产品实践

云布道师

云网络

三年后端开发:拿下阿里/腾讯/美团等四个大厂的Offer后,总结如下

钟奕礼

Java Java 面试 程序员‘ java 编程

跟误告警说再见,Smart Metrics 帮你用算法配告警

阿里巴巴云原生

阿里云 云原生 Grafana

精彩回顾 | 云原生系统软件的产业应用

BoCloud博云

云原生

基于云原生技术的融合通信是如何实现的?

阿里云CloudImagine

阿里云 云通信

在结构效率不变情况下的降本增效

PMO实践

数字化转型 数字化 数智化 11月月更

互联网企业面试必问Spring源码?搞定Spring源码,看完这篇就够了

钟奕礼

Java java面试 java编程 程序员‘

腾讯云原生容器服务发布三大新能力,创新自研技术助力企业降本增效

科技热闻

SpringMVC常用注解

@下一站

软件开发 程序 Java‘’ 11月月更

焱融科技为国家重点实验室打造海量高性能存储

焱融科技

云计算 分布式系统 高性能 文件存储

瓴羊Quick BI在商业智能BI发展趋势方面如何?

对不起该用户已成仙‖

对话Neo4j首席科学家Jim Webber:图数据库江湖5年后将尘埃落定

Neo4j 图无处不在

neo4j 图数据库 知识图谱 非关系型数据库 图技术

嘉为科技吴文豪:重塑运维系统,跨越烟囱式建设的陷阱

嘉为蓝鲸

运维 #WeOps

企业数字营销和运营如何效果更好?瓴羊Quick BI成为了不错的选择

小偏执o

新时代冠军企业成功硬道理:人效管理与可组装式HCM SaaS

ToB行业头条

MegEngine Inference 卷积优化之 Im2col 和 winograd 优化

MegEngineBot

深度学习框架 卷积 MegEngine

天翼云混合云容灾技术解析

天翼云开发者社区

数字产业化的颠覆创新和生态打法

PMO实践

产业数字化 11月月更

企业想要高效运营,还需要选择瓴羊Quick BI软件

流量猫猫头

大数据

8年程序员年初被迫毕业,前后面试30家公司,如今终于上岸

Java永远的神

程序人生 后端 java程序员 java面试 面经分享

MyBatis resultMap元素的用途是什么呢?

@下一站

技术 mybatis java; 11月月更

老板让我在Linux中使用traceroute排查服务器网络问题,幸好我收藏了这篇文章!

wljslmz

Linux 网络故障 11月月更 traceroute

Neo4j CEO Emil Eifrem 解读图数据平台引领数据库未来十年的发展

Neo4j 图无处不在

neo4j 图数据库 知识图谱 图可视化引擎 图数据

嘉为科技宋蕴真:观测不止于监控,让运维不开盲盒

嘉为蓝鲸

运维 智能运维AIOps

10月&11月书单

图灵社区

书单推荐

制造业的敏捷分析,还需要使用瓴羊Quick BI

对不起该用户已成仙‖

图数据技术护航网络安全

Neo4j 图无处不在

网络安全 neo4j 图数据库 知识图谱 图算法

蓝鲸研运体系在腾讯内是如何应用实践的?

嘉为蓝鲸

运维 智能运维AIOps

使用 Puppeteer 搭建统一海报渲染服务_大前端_张敏_InfoQ精选文章