GX博客

分享个人 Full-Stack JavaScript 项目开发经验

基于puppeteer的图片生成服务技术要点分析

在国内软件应用中,海报等形式的图片分享是流量裂变的重要手段之一。对于前端而言,利用Canvas绘制并通过HTMLCanvasElement.toDataURL()等 API 最终获得图片数据是一种可选方法。但是这种方法存在很多令人不能容忍的缺点,比如:

  • 绘制在其中的图片需要遵循浏览器同源策略,否则生成图片数据时候会失败。
  • 绘制复杂的大图时,会耗费较多客户端性能。
  • 实现简单的布局效果,需要繁琐的API,并且不一能和 Web 端展示效果一样。

随着近些年来 Node.js 端在 Web 应用中发挥着重要的作用,通过 Node.js 运行Puppeteer来以 screenshot 方式生成界面图片已经不是什么新鲜事了。它可以完全避开上述的问题,使图片的生成就像编写 Html 一样简单。

在将应用此方案应用到实际生产时,我们将会考虑更多的问题。很可能下面就会有你关心的问题:

本文将会从容器化部署的角度,讲述整个图片生成服务的协作原理和工作过程,以及分享在生产应用中可能遇到的技术问题。


工作原理

要想完成服务器端图片生成的功能,我们可以部署两套 Node.js 服务。一套是基于服务器端渲染 Html 的模板页面服务,另一套是运行 puppeteer 和接收图片请求的服务。

服务器端渲染 Html(SSR)的模板页面服务

往往分享的图片都是按照既定的界面,然后附加用户数据作为展示。所以可以将某一业务分享图片制作成模板页面,根据注入的数据展示最终的界面。

运行 puppeteer 和接收图片请求的服务

客户端请求和接收图片是以 http 请求的方式进行,仅仅是这个 http 服务器它还运行了一个 puppeteer。当请求进入 http 服务时,Node.js 将会从预启动的 Chrome 中获得一个页面来加载注入用户数据的模板页面。最后以需要的方式返回截屏结果。

FAQ

Q: 在应对并发和性能的问题上,如何建立并发模型才能让它工作得更快?

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;
})();

Q:只用到截图功能,如何最小化运行 puppeteer?

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

Q: 对于截屏的模板 Web 页面来说,数据如何注入?以什么样的形式提供此页面最好?

对于服务器端渲染的模板,我们可以通过 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/jsonapplication/octet-streamimage/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-minifierhandlebars的最小化响应 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,并且可靠加载所有资源。


Q: 如何尽可能地避免模板页面资源加载失败的恶劣情况?

服务器端渲染的模板,几乎可以将所有资源以 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}}

Q: 在容器化服务流行的今天,如何更好的处理 Node.js、Puppeteer 和容器之间的问题?

官方文档Running Puppeteer in Docker部分,阐述了如何在镜像内安装 chrome 及相关依赖,还有配置用户权限等。在自定义镜像中只需ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true来告诉安装 puppeteer 依赖时候跳过 chrome 的下载即可。最终设置options.executablePath = 'google-chrome-unstable';来告诉 puppeteer 使用系统安装的 chrome。

对于容易内运行 Node.js 和 Puppeteer,可以作如下部署:

  • 使用PM2托管 Node.js,以多线程集群方式运行,以同享 TCP 链接和充分利用多核 CPU 性能。并且还能通过 PM2 优雅地处理容器启动和关闭信号。
  • 使用 puppeteer-cluster 作为 Puppeteer 集群方案,以最低成本方式处理并发请求。

Q: 如果解决网络字体和 emoji👿表情的问题?

在生成的图片中,我们往往需要使用到特殊字体。当信息中包含用户输入时候,还可能包含用户输入的 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的字体包即可。


总结

上面做的所有,都是为了图片生成服务能够高性能稳定工作。归纳实施步骤如下:

  1. 构建安装了 chrome 相关依赖的基础镜像,并且将图片所需的特殊字体、emoji字体安装到镜像系统中。
  2. 基于上述镜像,部署集成了 puppeteer-cluster 的 Node.js http 服务,并且用 PM2 作为托管和多线程集群工具。(由于 Chrome 高负荷工作比较消耗 CPU,如果容器是部署在 K8s 上,可以考虑基于 CPU 使用率水平扩容,以提高服务的可用性。)
  3. 部署和上述服务衔接的服务器端渲染模板 http 服务。

版权声明:

本文为博主原创文章,若需转载,须注明出处,添加原文链接。

https://leeguangxing.cn/blog_post_95.html