背景
在移动端业务中,经常有裂变的需求,这时就需要动态的生成海报。一般海报需要包含分享人的授权信息还有落地页的二维码。
H5端生成海报网上一般传的比较多的是canvas动态画,还有html2canvas库生成,不过这两种基本都是基于canvas,canvas生成的图片对于色彩比较深的图片基本都模糊的不行。所以一般使用这个种方法来生成图片的时候,一般我都会和设计提前沟通,做一些比较浅的图片。
做过几次这样的需求之后,心里一直念念不忘想要找一个更好的办法。忽然有天在用手机截图的时候想到,能不能通过截图来生成海报呢,于是思路一发不可收拾,想到之前使用chrome截取页面长图的命令,就去搜索谷歌有没有提供相关的sdk。
这一搜索就遇到了今天的主角puppeteer,chrome开源的无头浏览器,加上之前写爬虫用过类似的无头浏览器,瞬间思路打开,开始搞起。
第一次尝试
很快看完puppeteer中文文档,思考了一下实现逻辑:
1. 写一个海报页面,可供浏览器截图
2. 写一个node服务,提供一个截图的接口
其中接口逻辑:
1. 创建浏览器实例
2. 创建一个page实例
3. 打开指定的海报页面
4. 等待加载完成的标记
5. 截图
6. 关闭浏览器实例
主要逻辑代码很简单,官方给的第一个例子就是,改写一下:
const puppeteer = require('puppeteer')
const router = express.Router();
const devices = require('puppeteer/DeviceDescriptors')
const iPhone = devices['iPhone 8']
const GetPoster = async (url, pageOptions, posterOptions) => {
// 创建浏览器实例
// args是配置一些浏览器性能的参数 官方文档都有解释
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox', '-–disable-gpu',
'-–disable-dev-shm-usage',
'-–no-first-run',
'-–no-zygote',
'-–single-process'
]
});
let screenshot = ''
try {
// 创建page实例
const page = await browser.newPage();
let path = 'example.jpg'
// 设置机型
await page.emulate(iPhone)
// 宽高
await page.setViewport({
isMobile: true,
width: 414,
height: 736,
...pageOptions
})
// 打开海报页面
await page.goto(url, {
waitUntil: ['load', 'domcontentloaded']
});
// 等待海报渲染完成的标记
await page.waitForSelector('.load-complete')
// 截图
screenshot = await page.screenshot({
path: path,
...posterOptions
});
// 关闭浏览器实例
await browser.close();
} catch (err) {
console.log(err)
await browser.close()
}
return screenshot
}
写完代码赶紧try一下,本地发现完美截图很OK,高清无瑕疵,于是赶紧兴冲冲的和运维大佬请求部署。
理想照进现实,影子往往惨不忍睹。
运维大佬拿到代码后,开了一台cenos6.x的服务器,结果安装依赖就出了问题。于是继续搜索解决方法,还真的找到了。
然后继续尝试,这次图是截出来了,结果图里的文字一片乱码,搜索一下,是由于服务器没有安装中文字体,安装之。
终于可以截图了,但是截的图和本地的不一样,有一层白色的蒙层。不过当时由于业务比较赶,就没有继续深究,就上线了。
然后悲剧就来了,倒不是白色蒙层的问题,而是上线没两天服务挂了,好在当时运维大佬帮忙做了守护脚本,服务还能重启。不过运维说内存老是暴涨,于是又review了一下代码,想象了一下浏览器机制。
发现是由于请求来的太多,每次都建一个浏览器实例,每个浏览器实例大概250M,并发一上来服务器就爆掉了,当时就想能不能就开一个或者几个浏览器实例,每次打开海报页面只是打开浏览器tab页来截图。
念念不忘,必有回响,当时当天就刷到了一篇”方凳雅集“的文章使用 generic-pool 优化 puppeteer 并发问题。 于是赶紧优化:
const pool = require('../bin/pool')
const browserPool = pool.InitPool({ // 全局只应该被初始化一次
puppeteerArgs: {
ignoreHTTPSErrors: true,
headless: true, // 是否启用无头模式页面
timeout: 0,
pipe: true, // 不使用 websocket
args: ['--no-sandbox',
'--disable-setuid-sandbox',
'-–disable-gpu',
'-–disable-dev-shm-usage',
'-–no-first-run',
'-–no-zygote',
'-–single-process'
]
}
})
const GetPoster = async (url) => {
let screenshot = ''
try {
const page = await browserPool.use(async (instance) => {
const page = await instance.newPage()
await page.emulate(iPhone)
await page.goto(url, {
waitUntil: ['load', 'domcontentloaded']
});
await page.waitForSelector('.load-complete')
return page
})
screenshot = await page.screenshot({
fullPage: true,
quality: 100,
type: 'jpeg'
});
page.close()
} catch (err) {
console.log(err)
}
return screenshot
}
问题解决
第二次尝试
隐患如果不解决总有爆发的一天,海报模糊的问题经过一年的发酵终于还是回到了我的手里,这次要做裂变2.0了,正好趁着这次机会彻底解决问题。
开始我怀疑是服务器问题,于是就用向运维又申请了一台测试服务器,部署代码,搭好环境,请求,结果发现截的图很清晰啊,没有灰白色的蒙层。
于是和运维大哥说要不再换一台服务器部署看看,运维大哥同意了,很快部署完成,请求,还是有白色的蒙层。
脑袋超大~这时忽然发现测试服务器截图请求一次1s,而服务器上截图只要200ms。于是好像找到了原因,是不是因为浏览器打开太快,还没来及展示完全就截图了,所有有白色蒙层。于是就写了一个sleep:
const sleep = (microSec) => new Promise((res, rej) => setTimeout(res, microSec));
await sleep(100)
在截图前停顿了100ms。然后部署,发现问题解决。
剩余问题
剩下的问题就是图片存储问题了,因为海报可以提前预生成,所以海报生成实际上对请求性能要求并不高,但也尽量控制在1s以内,保不准会遇到各种各样的场景。
我们的图片是传到七牛云存储的,这个图片上传实际上也会花费很大一部分时间,所以可以进行以下优化:
- 如果图片资源存储自己的服务器,那么请做一下cdn转发
- 如果你使用云服务器,请对服务的带宽做一下提升
- 代码层面,可以做缓存,因为海报的请求地址往往对应一个用户就一张,在不考虑用户信息频繁变更的情况下,可以对生成的海报路径做redis缓存,用户第一次生成海报之后,下次请求就直接返回上次的截图。当然这个也可以交给请求方来做,不用放在海报服务器
- 这个海报可以跨平台 各个端都可以用 app h5 小程序都可以 非常方便