前言

在开发小程序过程中经常会遇到的一个强需求:生成分享图发送给用户,这里往往存在着开发人员对复杂分享图的抵制与设计师的花里胡哨理念之间的冲突。以往渲染分享图都采用canvas绘制,但对复杂图形的绘制比较困难(比如曲线、图形起点终点的计算等),同时设计稿的某些效果难以实现(比如阴影等),而且往往同一个小程序需要在不同情况下产生不同的分享图效果(比如一个活动,当前参与人数为1,2,3人需要不同的分享图)。

某一天刚好大家一起在吐槽,然后冒出来一个想法,用 HTML 来绘制这些复杂图形并截图下来岂不妙哉?刚好之前看过谷歌开源的 puppeteer ,就赶紧趁热打铁整理了一下思路:

uml diagram

在 数据库中存储不同分享类型的模版,预留出数据填充位置,请求生成分享图时指定模版,服务端结合模版填充数据并使用 puppeteer 截图返回给小程序。

模版维护服务

表结构

表结构方面没那么严格,因为字段也不多,我这边设置如下:

const Sequelize = require('sequelize')

class Template extends Sequelize.Model {}

module.exports = (sequelize) => {
Template.init({
project: { // 表示当前模版服务的项目
type: Sequelize.STRING
},
title: { // 当前模版的标题
type: Sequelize.STRING
},
content: { // 模版内容
type: Sequelize.TEXT
},
demo_data: { // 测试数据
type: Sequelize.TEXT
}
}, { sequelize, modelName: 'share_killer_template' })
return Template
}

可以注意到我采用 Sequelize 来操作 mysql ,因为不是重点这里就不展开了。

模版维护接口

维护模版只有两个接口,一个获取列表,一个保存/更新模版,这里考虑到历史遗留的问题没有设置删除接口(而且代码也非常简单这里不赘述了)。

获取模版列表

// 获取模版列表
getTemplateList: async (ctx, next) => {
const { page = 1, pageSize = 10 } = ctx.query
const q = ctx.query
const where = {}
if (q.project) {
where.project = {
[Op.like]: '%' + q.project + '%'
}
}
if (q.title) {
where.title = {
[Op.like]: '%' + q.title + '%'
}
}
if (q.id) {
where.id = q.id
}
const offset = (page - 1) * pageSize

const list = await ctx.app.models.ShareKillerTemplate.findAndCountAll({
where,
limit: pageSize * 1,
offset,
order: [['id', 'desc']],
attributes: ['id', 'project', 'title', 'content', 'demo_data', 'createdAt', 'updatedAt']
})

ctx.body = list
return next()
},

保存/更新模版

// 保存/更新模版
save: async (ctx, next) => {
const body = ctx.request.body

const created = await ctx.app.models.ShareKillerTemplate.upsert({
project: body.project,
title: body.title,
content: body.content,
demo_data: body.demo_data,
id: body.id
})
ctx.body = { created }
return next()
},

截图生成服务

这里的服务编写只需指定模版并插入数据即可得到ejs渲染后的 html 结果,然后启动 puppeteer 塞入 html ,调用内置的截图接口拿到图片即可,后续对截图如何处理可以按需定制,要记得关闭 puppeteer ,这里还有一点需要注意,因为 puppeteer 渲染页面是不会自适应携带协议头,所以页面涉及到的所有外链资源都应该添加完整的 http/https 协议。

不过为了拓展性,可以传入一些额外的参数,我这边可以另外指定截图的尺寸和dpr。

// 生成模版渲染结果
draw: async (ctx, next) => {
// 获取参数的
...
const { tId, format, tData } = params
const q = params

// 模版
const templateRes = await ctx.app.models.ShareKillerTemplate.findOne({
where: { id: tId }
})

const template = templateRes.get({ plain: true })
// 截图文件名称
const filename = `${tId}_${Date.now() + Math.round(Math.random() * 1000)}.png`
// 保存到本地的截图临时路径
const localTmpPath = path.join(__dirname, `../share_killer_tmp/${filename}`)

// ejs生成的html字符串
const html = ejs.render(template.content, tData, { cache: false, debug: false })

// 一些参数
let width = 450
let height = 360
let dpr = 2

if (q.width && Number(q.width)) {
width = Number(q.width)
}
if (q.height && Number(q.height)) {
height = Number(q.height)
}
if (q.dpr && Number(q.dpr)) {
dpr = Number(q.dpr)
}

const browser = await puppeteer.launch({
defaultViewport: {
width,
height,
deviceScaleFactor: dpr,
isMobile: true
},
args: ['--no-sandbox', '--disable-setuid-sandbox'],
// executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
})

const page = await browser.newPage()
await page.setContent(html)
await page.screenshot({
path: localTmpPath,
omitBackground: true
})
await browser.close()

ctx.body = {
filename
}
// 后续一些操作,我这边做的是上传截图至阿里云OSS,所以下面才可以放心的删除该文件
...
// 清理任务,删除截图
const isTmpPathExists = fs.existsSync(localTmpPath)
if (isTmpPathExists) {
fs.unlinkSync(localTmpPath)
}
return next()
}

优化

后来使用过程中针对发现的一些问题也做了一定的优化。

  • 缓存

    针对相同数据的请求直接给出缓存结果,这块主要使用了 NodeCache,直接用参数作为 key 肯定不合适,我取了一下参数的 md5 值。

  • 解决 puppeteer 内存问题

    内存大户

    大家一定知道Chrome作为内存大户用在服务器上肯定也不是省油的灯,这 puppeteer 一开一关的遇到请求稍微多一点就会飙内存,所以在这块也去磕了一下文档找了找优化方案,还真让我找到了其他方法,改变了一下思路:保持浏览器常驻状态,截图只新建页面(即多开Tab思路),代码层面大概长这样:

    // app.js
    // 启动一个浏览器并挂在到全局
    puppeteer.launch({
    defaultViewport: {
    widt: 450,
    height: 360,
    deviceScaleFactor: 2,
    isMobile: true
    },
    args: ['--disable-gpu', '--disable-dev-shm-usage', '--disable-setuid-sandbox', '--no-sandbox', '--single-process'],
    // executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    }).then(async browser => {
    // 存储节点以便能重新连接到 Chromium
    app.browserWSEndpoint = browser.wsEndpoint()

    // 从 Chromium 断开和 puppeteer 的连接
    browser.disconnect()
    })

    // draw.js
    // Chromium 实例端点
    const browserWSEndpoint = ctx.app.browserWSEndpoint
    // 连接 Chromium 实例
    const browser = await puppeteer.connect({ browserWSEndpoint })
    const page = await browser.newPage()
    await page.setViewport({
    width,
    height,
    deviceScaleFactor: dpr,
    isMobile: true
    })
    await page.setContent(html)
    await page.screenshot({
    path: localTmpPath,
    omitBackground: true
    })
    // 关闭页面
    await page.close()
    browser.disconnect()

    另外,用PM2管理进程时设置了服务的最大内存 max_memory_restart 字段让它不至于将同一台服务器上的其他程序搞崩溃。

不过优化的方案不止上面两种,包括内存问题也不只一种解决思路,上面的也有个不足的地方就是很大程度上考验全局浏览器的稳定性,如果能在这基础上启动多个浏览器,形成一个浏览器池,然后对每个浏览器的请求数量做一个统计,服务指定指标后销毁该浏览器并启动一个新的,这样可以得到更好的稳定性。

一些局限性

  • 表情包&字体

    众所周知iOS和android的表情包展现方式是不一样的,但是在服务器上生成就取决于服务器安装的字体类型。

    字体方面也有相似的问题,不过字体方面稍微好解决一点,在小程序内设置指定的字体,并在服务器中安装相应字体即可(比如我这里统一使用 PingFangSC-Regular / PingFangSC );也可以通过 css 中加载外部字体,不过这里依然有个坑,比如 PingFangSC 中并不包含一些小语种比如韩语等,也需要另外安装字体才能显示 😅。