05月23, 2023

说说截图

笔者最近负责一款大屏在线编辑器。可以通过拖拽进行大屏搭建。

该编辑器可以在每个大屏或模版保存时候,对该大屏或模版的展现进行截图,后续在列表展示的时候,用户就能对该大屏或模版的大致展现有初步的了解。

同时,我们还可以在编辑大屏时候,将大屏的截图以JPG的形式导出来。

随着项目日渐复杂,我们的截图功能有时会出现截图失败的情况。为此我们需要分析下截图失败的原因,并解决之。

经过分析我们发现,我们的截图方案,最初是使用脚本库 dom-to-image 实现的。

我们大致过一下源码,总结一下其实现的原理:svg本质时一段 xml 代码。我们将待截图的 Dom 及其子节点,通过 svg 的标签 foreignObject 递归地放入 svg 标签。这样我们就能把这个 svg 做成图片,之后再借助 Canvas 的 API toDataURL 把 svg 转化成对应的图片格式。

由于 svg 不能渲染跨域资源。我们需要把跨域的字体、图片、css 等资源内连到 svg 内部。而对于页面中的 canvas 元素,我们则直接将其用 toDataURL 渲染成图片。我们截图程序原来的失败点就在这里:

由于 canvas 也受同源策略限制。我们的出问题的组件从 mapbox 加载资源,因此触发同源策略限制。此时 canvas toDataURL 失效。

目前,市面上前端截图的方案,主要就是在 svg 和 canvas 上重新绘制 dom ,并借助 canvas API生成图片。比较著名的库包括:html2canvas、dom-to-image 和 rasterizehtml 等等。但这种重建,不是浏览器自身的机制,所以都有或多或少的问题。比如,无法截图 iframe、对跨域资源不友好、对脚本不友好等等。

其实前端还有一种方案,使用原生的 navigator.mediaDevices.getDisplayMedia()方法可以提炼出视频流,将它传递给 video 元素。并利用 video 和 canvas 配合,完成截图。这里是一个例子。由于安全限制,请在弹出的新页面里进行操作。

我们在调研时候,几乎就选定这种方法了。不过在最初的交互需要弹出用户交互的许可。这对于静默保存几乎是不可容忍的。因此我们放弃了这种做法。

接下来,我们的目光就转向了服务端截图。碰巧,NodeJS 服务端有一个比较完美的解决方案 puppeteer。它的原理就是在服务端启动一个浏览器,然后通过 devtools 协议指挥这个浏览器访问对应页面。最终调用自带的截图 API 进行截图,并返回 base64 图片内容。

我们的设想是,启动一个服务器。需要截图时候,向该服务发出请求。服务器接到请求,则启动截图。

我们需要解决几大问题:

  1. 用户身份问题。
  2. 非法身份不能干扰正常的截图。
  3. 因为是 Linux 系统,需要解决字体问题。
  4. 尽量快返回结果。

由于我们的系统是采用 cookie 进行身份验证,所以我们这里取了个巧。我们把截图请求在nginx 做一个代理,这时候,我们就相当于访问了当前域名下的服务,这时候,我们可以无缝地把 cookie 传过去:

location /workspaceshot {
    proxy_pass  http://xyzbalabala.com:3000;
    include     segments/proxy.conf;
} 

我们的截图服务端使用 NodeJS 完成,我们使用框架 fastify 搭建服务。接受到的 cookie 可以传给 puppeteer。我们在 puppeteer 中主动访问验证身份接口,如果验证失败,则直接返回错误,避免错误截图被保存。

为了防止截图中文乱码,我们把中文的字体,苹方和雅黑放置在源代码文件,并在 Dockerfile 中设置了复制这些文件到字体文件夹。

COPY ./fonts/* /usr/share/fonts/

为了截图程序尽快运行,我们在启动时,使用最小的启动参数。

  const browser = await puppeteer.launch({
    headless: true,
    timeout: 0,
    executablePath: process.env.CHROME_BIN || null,
    args: [
      '--disable-gpu', // GPU硬件加速
      '--disable-dev-shm-usage', // 创建临时文件共享内存
      '--disable-setuid-sandbox', // uid沙盒
      '--no-first-run', // 没有设置首页。在启动的时候,就会打开一个空白页面。
      '--no-sandbox', // 沙盒模式
      '--no-zygote', // 调试进程
      '--single-process', // 单进程运行
      '--incognito', // 无痕模式,
      '--autoplay-policy=user-gesture-required',
      '--disable-background-networking',
      '--disable-background-timer-throttling',
      '--disable-backgrounding-occluded-windows',
      '--disable-breakpad',
      '--disable-client-side-phishing-detection',
      '--disable-component-update',
      '--disable-default-apps',
      '--disable-domain-reliability',
      '--disable-extensions',
      '--disable-features=AudioServiceOutOfProcess',
      '--disable-hang-monitor',
      '--disable-ipc-flooding-protection',
      '--disable-notifications',
      '--disable-offer-store-unmasked-wallet-cards',
      '--disable-popup-blocking',
      '--disable-print-preview',
      '--disable-prompt-on-repost',
      '--disable-renderer-backgrounding',
      '--disable-speech-api',
      '--disable-sync',
      '--hide-scrollbars',
      '--ignore-gpu-blacklist',
      '--metrics-recording-only',
      '--mute-audio',
      '--no-default-browser-check',
      '--no-pings',
      '--password-store=basic',
      '--use-gl=swiftshader',
      '--use-mock-keychain'
    ]
  })

同时,我们在截图时,使用 JPEG 格式,使得返回更加迅速。

我们调用 puppeteer 基本就完成了服务端截图。平心而论,服务端截图质量较高,但是对服务器资源占用较大。而前端截图则更加轻量,但经常有截图失败的情况。

那么我们究竟选择哪种截图方案呢?

小孩子才做选择,我们都选择。我们的策略是,优先选择前端截图,如果前端失败,我们再用服务端截图作为兜底方案。

当然,这个方案还有优化的空间,比如在服务端截图时,把截图任务放入消息队列,服务请求只做队列的生产者,另起服务消费队列,进一步提高吞吐量。

本文链接:https://www.leon82.com/post/shuo-shuo-jie-tu.html

-- EOF --

Comments