分享个人 Full-Stack JavaScript 项目开发经验
在国内软件应用中,海报等形式的图片分享是流量裂变的重要手段之一。对于前端而言,利用Canvas绘制并通过HTMLCanvasElement.toDataURL()
等 API 最终获得图片数据是一种可选方法。但是这种方法存在很多令人不能容忍的缺点,比如:
随着近些年来 Node.js 端在 Web 应用中发挥着重要的作用,通过 Node.js 运行Puppeteer来以 screenshot 方式生成界面图片已经不是什么新鲜事了。它可以完全避开上述的问题,使图片的生成就像编写 Html 一样简单。
在将应用此方案应用到实际生产时,我们将会考虑更多的问题。很可能下面就会有你关心的问题:
本文将会从容器化部署的角度,讲述整个图片生成服务的协作原理和工作过程,以及分享在生产应用中可能遇到的技术问题。
要想完成服务器端图片生成的功能,我们可以部署两套 Node.js 服务。一套是基于服务器端渲染 Html 的模板页面服务,另一套是运行 puppeteer 和接收图片请求的服务。
往往分享的图片都是按照既定的界面,然后附加用户数据作为展示。所以可以将某一业务分享图片制作成模板页面,根据注入的数据展示最终的界面。
客户端请求和接收图片是以 http 请求的方式进行,仅仅是这个 http 服务器它还运行了一个 puppeteer。当请求进入 http 服务时,Node.js 将会从预启动的 Chrome 中获得一个页面来加载注入用户数据的模板页面。最后以需要的方式返回截屏结果。
puppeteer-cluster可以轻松为我们创建一个 Chrome 池。它支持多种并发实现,其中包括“页面集群”,即每个 URL 一个页面。
在 Node.js 实例启动时全局创建 puppeteer 集群池:
const {Cluster} = require('puppeteer-cluster');
const puppeteer = require('puppeteer');
const {args} = require('./config/chrome');
// 全局创建 puppeteer 集群池
(async () => {
const options = {
args,
};
// 非开发环境使用外部 Chrome 程序,往往是容器内安装的 Chrome
if (process.env.NODE_ENV !== undefined) {
options.executablePath = 'google-chrome-unstable';
}
const cluster = await Cluster.launch({
concurrency: Cluster.CONCURRENCY_PAGE,
maxConcurrency: 16,
monitor: false,
retryLimit: 0,
// puppeteer 启动参数
puppeteerOptions: options,
puppeteer,
});
cluster.on('taskerror', (err, data, willRetry) => {
console.error(err);
});
global.cluster = cluster;
})();
puppeteer 允许我们通过参数配置 chromium 的细节功能。由于图片生成服务只用到简单的页面加载和截图功能,所以可以将不必要开启的功能禁用,又或者跳过某些不必要的检测等等。下面是一份参考配置:
const args = [
'--disable-gpu', // 禁用 GPU 硬件加速。 如果软件渲染器没有到位,那么 GPU 进程将不会启动。
'--disable-background-networking', // 禁用几个在后台运行网络请求的子系统。 这是在进行网络性能测试时使用,以避免测量中的噪声。
'--disable-breakpad', // 禁用崩溃报告。
'--disable-component-update', // 禁用组件更新?
'--disable-dev-shm-usage', // /dev/shm 分区在某些 VM 环境中太小,导致 Chrome 失败或崩溃(参见 http://crbug.com/715363)。 使用此标志来解决此问题(将始终使用临时目录来创建匿名共享内存文件)。
'--disable-domain-reliability', // 禁用域可靠性监控。
'--disable-extensions', // 禁用扩展
'--disable-features=AudioServiceOutOfProcess', // 禁用音频沙盒功能
'--disable-hang-monitor', // 禁止在渲染器进程中挂起监视器对话框。(选项卡允许直接关闭)
'--disable-ipc-flooding-protection', // 禁用 IPC 泛洪保护。 默认情况下已激活。 一些 javascript 函数可用于使用 IPC 淹没浏览器进程。 这种保护限制了它们的使用速率。
'--disable-notifications', // 禁用 Web 通知和推送 API。
'--disable-popup-blocking', // 禁用弹出窗口阻止。
'--disable-print-preview', // 禁用打印预览
'--disable-prompt-on-repost', // 禁用 prompt。此开关一般在自动化测试期间使用。
// '--disable-renderer-backgrounding', // 禁止后台渲染
'--disable-setuid-sandbox', // 禁用 setuid 沙箱(仅限 Linux)
'--disable-speech-api', // 禁用 Web Speech API(语音识别和合成)。
'--hide-scrollbars', // 从屏幕截图中隐藏滚动条。
'--metrics-recording-only', // 启用指标记录,但禁用报告。
'--mute-audio', // 静音音频
'--no-default-browser-check', // 禁用默认浏览器检查。
'--no-first-run', // 跳过首次运行任务
'--no-pings', // 不发送超链接审核 ping
'--no-sandbox', // 禁用沙箱
'--no-zygote', // 禁止使用 zygote 进程来派生子进程。 相反,子进程将被 fork 并直接执行。
'--password-store=basic', // 使用基础的密码保存
// '--use-gl=swiftshader', // 选择 GPU 进程应使用的 GL 实现。使用 SwiftShader 软件渲染器
'--use-mock-keychain', // 使用 mock-keychain 防止提示权限提示
];
了解更多 chromium 命令行标记说明,请点击这里。
一篇关于 puppeteer 快速截图建议的博文,8 Tips for Faster Puppeteer Screenshots。
对于服务器端渲染的模板,我们可以通过 URL 查询参数(注意 url 的最大长度限制)或者请求头的方式(注意服务器端对请求头的大小限制设置),带到模板 Web 页面服务器。
puppeteer 从页面实例中设置请求头信息(以下使用Koa2.js作为 http 服务器):
module.exports = async (ctx, next) => {
// 省略部分次要代码.....
try {
const image = await new Promise((resolve) => {
// 从全局 puppeteer 集群中获得页面实例
global.cluster.queue(null, async ({page}) => {
// 1、页面初始化
await page.setViewport({
width: Number(viewportWidth) * Number(scale),
height: Number(viewportHeight) * Number(scale),
});
// 亦可以为页面设置特定 ua
if (ua) {
await page.setUserAgent(ua);
}
// 2、以请求头方式注入数据
await page.setExtraHTTPHeaders({
'render-data': data ? encodeURIComponent(JSON.stringify(data)) : '',
});
// 这里考虑 window 的 onload 和 document 的 DOMContentLoaded 事件触发后,认为导航已经完成
await page.goto(url, { waitUntil: [
'load',
'domcontentloaded',
]});
// 3、截屏
let image;
const baseOptions = {
type,
fullPage: isFullPage,
// 允许 jpeg 格式设置图片质量,以优化图片大小
quality: type === 'jpeg' ? Number(quality) : undefined,
}
if (responseWithBase64) {
image = await page.screenshot({
encoding: 'base64',
...baseOptions,
});
} else {
const binary = await page.screenshot({
encoding: 'binary',
...baseOptions,
});
image = binary;
}
resolve(image);
});
});
if (responseWithBase64) {
// 以 base64 方式响应给客户端
ctx.body = {
...responseCodeMsg.SUCCESS,
data: {
base64: image,
},
};
} else {
// 设置禁用缓存的响应头
setNoCacheHeaders(ctx);
if (ctx.method === 'POST') {
ctx.type = `application/octet-stream`;
} else {
ctx.type = `image/${type}`;
}
ctx.body = image;
}
} catch (e) {
// 异步错误日志写入
ctx.logger.error(`${ctx.path} ${e.toString()}`);
ctx.status = 500;
ctx.body = {
...responseCodeMsg.BASE_ERROR,
msg: e.toString(),
};
}
};
上面例子中,展示了以包含 base64 的application/json
、application/octet-stream
和image/xxx
的形式响应给客户端。当然它还可以将文件上传到对象存储服务后,给客户端响应上传后的地址等。
使用page.setExtraHTTPHeaders
方式注入数据后,可以这样在模板页面服务接收数据(通过 Koa2.js 的中间件来处理请求头信息):
const Koa = require("koa");
const app = new Koa();
app.use((ctx, next) => {
ctx.state.data = {};
if (
ctx.request.headers &&
ctx.request.headers["render-data"] &&
ctx.request.headers["render-data"] !== 'undefined'
) {
ctx.state.data = JSON.parse(
decodeURIComponent(ctx.request.headers["render-data"])
);
}
return next();
})
模板页面服务接收到注入数据后,在服务器端渲染完整的最终的 html (包括二维码等内容)给 puppeteer(特殊情况下,如需要基于图片尺寸来修改界面样式,可给 img 标签添加 onload script)。这正是如此,我们可以确保page.goto
可以在 load 和 domcontentloaded 事件后开始截屏。
下面是 Koa2.js 结合html-minifier和handlebars的最小化响应 html 的中间件例子:
const Koa = require("koa");
const app = new Koa();
const views = require("koa-views");
const minify = require("html-minifier").minify;
const path = require("path");
app.use(
views(path.join(__dirname, "/views"), {
options: {
cache: process.env.NODE_ENV !== undefined,
settings: { views: path.join(__dirname, "views") },
partials: {
layout: path.join(__dirname, "views/layout.hbs"),
},
helpers: {
minify: (content, options) => {
return minify(options.fn(content), {
includeAutoGeneratedTags: true,
removeAttributeQuotes: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
sortClassName: true,
useShortDoctype: true,
collapseWhitespace: true,
minifyCSS: true,
});
},
},
},
map: { hbs: "handlebars" },
extension: "hbs",
})
)
上面模板将会对 html 和 css 进行压缩再响应给客户端。这里模板采用了 handlebars,相比 React 和 Vue 等框架的 JSX 模板,它会相对“笨拙”一些,因为它属于字符串模板。如果你有更好模板引擎的选择,可以一试。但是对于简单的模板页面来说,handlebars 已经几乎满足所有要求(部分能力需要自定义 helpers 来实现)。
整个过程下来,puppeteer 的页面加载时间已经可以去到 100-200 ms,并且可靠加载所有资源。
服务器端渲染的模板,几乎可以将所有资源以 base64、style 标签、script 标签集成到 html 中,以减少模板加载的网络请求次数。对于用户数据中的头像、自定义上传的图片则会在解析 html 时发起网络请求。对于极端的网络情况,可以使用 inline script 方式监听 img 的 onerror 事件,以显示默认的 base64 头像或占位图:
{{#if renderData.headUrl}}
<img src="{{ renderData.headUrl }}" class="avatar" onerror="javascript:this.src='{{ avatar }}';" />
{{else}}
<img src="{{ avatar }}" class="avatar" />
{{/if}}
官方文档Running Puppeteer in Docker部分,阐述了如何在镜像内安装 chrome 及相关依赖,还有配置用户权限等。在自定义镜像中只需ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
来告诉安装 puppeteer 依赖时候跳过 chrome 的下载即可。最终设置options.executablePath = 'google-chrome-unstable';
来告诉 puppeteer 使用系统安装的 chrome。
对于容易内运行 Node.js 和 Puppeteer,可以作如下部署:
在生成的图片中,我们往往需要使用到特殊字体。当信息中包含用户输入时候,还可能包含用户输入的 emoji 表情字符。
对于特殊字体,在 Web 端可以使用@font-face
定义 ttf 等字体源来引用网络字体。但是这种方式,每次页面都需要加载体积庞大的字体文件,影响服务性能。其实这些字体都可以通过 ttf 文件,直接安装到镜像系统中。
1、安装 fontconfig 工具:
apt-get install fontconfig
2、在 ttf 所在目录执行fc-cache命令重新构建字体信息缓存文件:
fc-cache -vf
3、通过 fc-list 命令查看系统字体列表:
fc-list
4、在 css 的font-family
中直接饮用该字体,如:
.div {
font-family: 'HYZhengYuan-75W';
}
而要在 Linux 的 chrome 中正常显示 emoji 表情,只需要再安装一个名为NotoColorEmoji.ttf的字体包即可。
上面做的所有,都是为了图片生成服务能够高性能稳定工作。归纳实施步骤如下: