likes
comments
collection
share

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

作者站长头像
站长
· 阅读数 18

背景

在公司级日常稳定性运营过程中,我们时常需要将各个公司级基础设施系统的数据进行图形化统计和展示,以便通过图表趋势分析出运营活动的有效性,还需要将分析的结果和大家共享,以鼓励的方式激励大家共同参与到稳定性运营活动中来,一起提升稳定性运营活动的成效;另外,在稳定性失衡时,我们需要让业务相关负责人快速了解出现问题的服务指标异常表现,以及随着应急处理,服务指标的进一步变化情况,以及最后服务指标的恢复情况等等。当然,像这些稳定性运营活动还有很多...

在之前,为了实现或促进这些稳定性运营活动的正常运转,我们需要人肉定时的将已经弄好的图表页面截图并转发到各个群组。比如在日常稳定性运营活动中,相关负责人就需要定时将图表的截图一个一个转发到相关的群组,并附加上一定的文案;在应急稳定性问题的时候,更是需要找到对应的指标,定时将指标图表进行截图转发。

那这种重复的工作是不是可以自动化呢?那当然!

技术调研

在社区,前端实现截图已经有比较成熟的方案了,这些方案主要分两个方向:交互式截图 和 声明式截图。

交互式截图 指的是:前端开发者通过事件监听的方式监听用户行为,在用户对页面交互的合适时机触发截图功能,然后引导用户下载或者上传文件或图片。该方向代表性的工具有 html2canvas,还有基于 dom-to-image 的 html-to-imagedom-to-image-more 等等。它们的主要技术原理是:解析 HTML + CSS,将解析结果渲染到 canvas 上,然后利用 canvas 的 toBlob() 和 toDataURL() 接口将内容进行转换和导出。当然,这个过程肯定会涉及到各种技术和浏览器限制,比如资源跨域渲染、标签渲染兼容性问题等!

声明式截图 指的是:开发者借助一些 headless 浏览器,借助代码逻辑一步步实现截图功能,这期间不会有用户的参与,是一个完全隔离的环境。该方向代表性的工具有 google 的 puppeteerselenium 等等。它们都是通过包装调用浏览器的能力,让开发者可以借助暴露出来的接口对需要处理的页面进行操作、实现截图等等。

html2canvas

html2canvas 使用方式非常简单,一个方法调用就可以简单搞定

// 需要截图的 DOM 结构
<div id="capture" style="padding: 10px; background: #f5da55">
    <h4 style="color: #000; ">Hello world!</h4>
</div>
  
// 截图代码
html2canvas(document.querySelector("#capture")).then(canvas => {
    document.body.appendChild(canvas)
});
  
// 该脚本会遍历它所加载的页面 DOM 结构,收集所有元素的信息,
// 然后使用这些信息在 canvas 中构建页面的表示形式。
// 换句话说,它实际上并不采用页面的屏幕快照,而是基于从 DOM 读取的属性构建页面的表示形式。因此,它只能正确地呈现它所理解的属性,这意味着有许多 CSS 属性不能工作。

html-to-image 和 dom-to-image-more

这两个工具都是 Fork 自 dom-to-image,所以它们有类似的接口:toPngtoSvgtoJpegtoBlobtoCanvas 和 toPixelData,分别将渲染的内容转换为 png\svg\jpeg\blob\canvas\pixelData 格式的结果。

var node = document.getElementById('my-node');
  
// toPng
htmlToImage.toPng(node)
  .then(function (dataUrl) {
    var img = new Image();
    img.src = dataUrl;
    document.body.appendChild(img);
  })
  .catch(function (error) {
    console.error('oops, something went wrong!', error);
  });
  
// toJpeg
htmlToImage.toJpeg(document.getElementById('my-node'), { quality: 0.95 })
  .then(function (dataUrl) {
    var link = document.createElement('a');
    link.download = 'my-image-name.jpeg';
    link.href = dataUrl;
    link.click();
  });
  
// ......

puppeteer

puppeteer 则和上面提到的工具大相径庭。它主要是借助浏览器的能力,创建页面自动化测试套件。基于这个套件,开发者可以通过声明式的方式调用若干接口对页面进行 load、交互等动作,然后针对每个动作做出判定和处理,以达到自动化测试的目的。实现截图只是它包含的很小的一个功能点。

const puppeteer = require('puppeteer');
  
(async () => {
  // 拉起一个浏览器
  const browser = await puppeteer.launch();
  // 创建一个 Tab 标签页
  const page = await browser.newPage();
  // 标签页跳转到某个页面地址
  await page.goto('https://example.com');
  // 实现页面截图
  await page.screenshot({path: 'example.png'});
  // 关闭浏览器
  await browser.close();
})();

关于 selenium,其实本人也不是很熟悉,所以这里就不做过多的介绍了!

小结

因为我们这次的需求是希望使用相对灵活的方式实现页面的动态渲染和截图功能,同时还能满足被其他系统调用、返回结果的需求。很显然,纯前端的工具是无法满足本次需求的,所以我们需要借助 headless browser 工具帮助我们进行页面的渲染和截图。所以最终会使用 puppeteer!

早期实现

在早期,我们面临的需求是:在线上应用稳定性失衡时,能够第一时间将有问题服务的指标图表给整合推送到应急群。所以我们只需要针对特定的单一系统做处理,于是设计非常简单

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

这个设计的流程图基本和前面给出的示例代码流程差不多。

  1. 每次请求到来时拉起一个浏览器实例
  2. 浏览器实例准备好后,new 一个 tab page
  3. 根据传入的 url 跳转到对应的页面
  4. 等待页面渲染完成
  5. 页面截图,并返回结果
  6. 关闭浏览器,销毁实例
  7. 等待新请求...

起初运行还可以,但是随着其他系统需求的接入,我们很快发现基于这个简单封装核心流程的上层实现定制化的逻辑越来越多,代码也是越来越臃肿。慢慢的,系统接入越来越困难了。再有随着接入系统越来越多,请求也越来越多,很明显的发现调用高峰期的时候内存 和 CPU 占用非常高。最严重的问题是每个场景的耗时等待、定时器处理都不统一,导致截图耗时不可预期,时常发生耗时过久,截图效果不好的情况!

最新设计

基于早期设计在实际运行中暴露的问题,我们对整个流程进行了进一步的细化处理

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

在原有流程的基础上

1、我们为 goto page、render page、screenshot 等阶段新增了前后钩子,方便在上层实现时可以做一些自定义的事情

2、旧版的设计,因为每个请求都会拉起一个新的浏览器实例,导致内存和 CPU 占用过高。于是新增了 browser 实例的保活器。默认是没有 browser 实例可用的,当第一个截图请求到来时,会创建一个 browser 实例,然后这个实例不会被销毁,而是不断被复用

3、不同系统的自适应方式、页面的滚动方式(有的基于 body、有的基于自定义容器)可能有所不同,利用旧版的设计会导致截图效果不理想,这也是之前一直困扰我们的问题。于是,我们针对不同的场景封装了三种截图的模式

Snapshot 基础核心类

基于新的流程体系,我们的 Snapshot 基础核心类变得非常丰富

export class Snapshot {
  /** snapshot 启动器 */
  async run(options?: SnapshotOptions) {
    // 配置参数处理
    // do something....
 
    // 浏览器实例全局单例
    if (!Snapshot.browser) {
      await this.lanuchBrowser();
    }
 
    // 新打开 Tab 页,作为截图载体
    await this.newTabPage();
 
    // 页面跳转前
    await this.onBeforeGotoPage();
 
    // 页面跳转动作
    await this.gotoPage();
 
    // 页面跳转后
    await this.onAfterGotoPage();
 
    // 页面 snapshot 流程
    const result = await this.pageSnapshotPipeline();
 
    this.logger.info('快照任务执行结束!');
 
    return result;
  }
 
  // 页面 snapshot 流程
  async pageSnapshotPipeline() {
    // 等待页面重定向
    // 支持直接 return 有效值后中断整个流程
    const renderResult = await this.waitRenderPage();
 
    if (renderResult !== undefined) {
      await this.onBeforeCloseTabPage();
      await this.closeTabPage();
      return renderResult;
    }
 
    // 页面截图前
    await this.onBeforeScreenshot();
 
    // 页面截图
    this.screenshotBuffer = await this.screenshot();
 
    // 页面截图后
    const afterScreenshotResult = await this.onAfterScreenshot();
 
    if (afterScreenshotResult !== undefined) {
      // 关闭页面前
      await this.onBeforeCloseTabPage();
      // 销毁页面
      await this.closeTabPage();
      return afterScreenshotResult;
    }
 
    // 关闭页面前
    await this.onBeforeCloseTabPage();
    // 销毁页面
    await this.closeTabPage();
 
    return this.runOptions.wxToken
      // 支持上传到企信服务器
      ? await this.uploadFileToWx(this.screenshotBuffer)
      : await Promise.resolve(
          new ResponseBase({
            message: '获取快照成功!',
            data: arrayBufferToBase64Image(
              this.screenshotBuffer,
              this.runOptions.outputFormat,
            ),
          }),
        );
  }
}

Snapshot 基类用于包装整个 snapshot 流程。因为已经包含了大部分的流程处理,所以在特定系统实现截图功能时只需继承自该类,然后只需要实现生命周期钩子函数或者复写阶段处理函数就可以了。

比如下面基于 Snapshot 基类实现了一个完全动态(根据配置参数)的截图功能,只需要在 onAfterGotoPage 适配一下按系统实现登陆即可,大大降低了特定业务场景的截图功能实现

export class SnapshotDynamicImg extends Snapshot {
  async onAfterGotoPage() {
    const { pageType } = this.runOptions;
 
    if (pageType === 'tetris') {
      await loginTetris({ logger: this.logger, page: this.page });
    }
  }
 
  async onAfterScreenshot() {
    if (!this.screenshotBuffer.length) {
      const result = {
        code: CodeEnum.FAIL,
        message: `获取快照失败!`,
        data: null,
      };
 
      this.logger.error(JSON.stringify(result));
 
      return new ResponseBase(result);
    }
  }
}

Snapshot 基础核心类新特性

除此外,为了保证流程的顺利运行,Snapshot 基类还加入了一些其它特性

1、页面跳转重试机制。在以往的问题处理中,页面跳转失败的场景不在少数,所以为了保证页面能力在绝大多数情况下正常跳转,在新的流程中我们加入了重试机制

private async gotoPage(retryCount: number = 0) {
    const { url, ...restOptions } = this.runOptions;
 
    try {
      await this.page.goto(url, restOptions);
      this.logger.info(`页面跳转成功:${url}`);
    } catch (error) {
      if (retryCount < 3) {
        retryCount += 1;
 
        this.logger.error(
          `跳转页面失败,${retryCount}s 后重试:${url}, ${error.message}`,
        );
 
        this.logger.info(`重试页面跳转:第${retryCount}次...`);
 
        await sleep(retryCount * 1000);
 
        await this.gotoPage(retryCount);
      } else {
        await this.onBeforeCloseTabPage();
        await this.closeTabPage();
        throw new Error(`跳转页面最终失败:${url}, ${error.message}`);
      }
    }
  }

2、针对需要等待的场景进行统一处理

// 页面渲染默认实现为等待网络闲置 2s
async waitRenderPage<T = unknown>(): Promise<
  ResponseBase<T> | undefined | any
> {
  await this.waitForNetworkIdle();
}
 
/** 页面截图前 */
async onBeforeScreenshot() {
  await this.waitForNetworkIdle();
  await this.timeWait();
}
 
// 等待超时或失败重试截图过程
async waitForNetworkIdle(options?: { idleTime?: number; timeout?: number }) {
    try {
      await this.page.waitForNetworkIdle({
        idleTime: 2000,
        timeout: 60000,
        ...options,
      });
    } catch (error) {
      this.logger.error(`waitForNetworkIdle 等到超时:${error.message}`, error);
 
      if (!this.isRerun) {
        await this.rerun();
      }
 
      await this.onBeforeCloseTabPage();
      await this.closeTabPage();
      throw error;
    }
  }

三种截图模式

pageScreenshot

最普通的方式,直接根据传入的偏移量(x, y)、宽(width)、高(heigth)实现区域截图

async pageScreenshot({ clip, ...restOptions }: ScreenshotOptions = {}) {
    const { top, left } = this.runOptions;
    const { width, height } = this.page.viewport();
 
    const screenshotOptions: ScreenshotOptions = {
      clip: { x: left, y: top, width, height, ...clip },
      fullPage: false,
      ...restOptions,
      encoding: 'binary',
    };
 
    try {
      const snapshot = this.page.screenshot(
        screenshotOptions,
      ) as Promise<Buffer>;
      // 截图成功
      return snapshot;
    } catch (error) {
      // 页面截图失败
    }
  }

pageFullScreenshot

根据传入的容器(默认是 body)获取容器的实际高度(包含滚动条),然后将获取的高度设置给 viewport,相当于让视口能够平铺下整个页面,最后根据传入的偏移量(x,y)、宽(width)、高(heigth)实现整个视口的截图

async pageFullScreenshot() {
    const { top, left } = this.runOptions;
    const curViewport = this.page.viewport();
 
    if (this.fullHeight > curViewport.height) {
      this.viewport = {
        ...curViewport,
        height: this.fullHeight,
      };
 
      // 重新设置视口
      await this.page.setViewport(this.viewport);
 
      // 重载页面以使看板修改生效
      // 因为有些页面组件是懒加载的,直接设置视口可能不会生效
      await this.page.reload({ waitUntil: 'networkidle0' });
    }
 
    const screenshotOptions: ScreenshotOptions = {
      clip: {
        x: left,
        y: top,
        width: this.viewport.width,
        height: this.viewport.height,
      },
    };
 
    try {
      // 截图成功
      const snapshot = this.page.screenshot(screenshotOptions);
      return snapshot;
    } catch (error) {
      // 页面截图失败
    }
  }

pageScrollScreenshot

一种滚动截图实现方案。因为有些系统的自适应设计比较特殊(比如 Finebi)—— 无论设置多大的视口,它总是在视口只展示那几个图标,导致 pageScreenshot 和 pageFullScreenshot 截图出来的效果都不好,所以就有就有了这种方案

async pageScrollScreenshot() {
    const { top, left, outputFormat } = this.runOptions;
 
    // 页面宽度
    const { width } = this.page.viewport();
 
    let snapshotIndex = 0;
 
    // 按照页面高度循环截图
    for (let y = 0; y < this.fullHeight; y += this.clipHeight) {
      this.logger.info(`开始页面滚动截图:第 ${snapshotIndex} 张...`);
 
      // 计算当前截图的起始 Y 坐标和截图高度
      const scrollTop = Math.min(y, this.fullHeight - this.clipHeight);
      const height = Math.min(this.clipHeight, this.fullHeight - y);
 
      // 滚动页面到相应位置
      if (scrollTop > 0) {
        await this.page.evaluate(
          ({ scrollTop, container }) => {
            let scrollContainer: Element | (Window & typeof globalThis) =
              window;
 
            if (container) {
              const containerElement = document.querySelector(container);
              if (containerElement) {
                scrollContainer = containerElement;
              }
            }
 
            scrollContainer.scrollTo(0, scrollTop);
          },
          { scrollTop, container: this.runOptions.container },
        );
 
         
        await this.waitForNetworkIdle();
      }
 
      // 截取当前可见区域的屏幕快照
      const screenshot = await this.pageScreenshot({
        clip: {
          x: left,
          y: y <= scrollTop ? top : y - scrollTop,
          width,
          height,
        },
      });
 
      this.logger.info(`开始页面滚动截图:第 ${snapshotIndex} 张截图完成!`);
 
      // 将截图保存到磁盘,并将文件路径添加到数组中
      const snapshotBuffer = await sharp(screenshot).toBuffer();
      this.screenshotBuffers.push(snapshotBuffer);
 
      // debugger
      // await sharp(screenshot).toFile(`snapshot-${snapshotIndex}.png`);
 
      snapshotIndex += 1;
    }
 
    this.logger.info(`开始页面滚动截图完成,开始拼接图片...`);
 
    const output = sharp({
      create: {
        width,
        height: this.fullHeight,
        channels: 4,
        background: { r: 255, g: 255, b: 255, alpha: 1 },
      },
    });
 
    output.composite(
      this.screenshotBuffers.map((imageBuffer, index) => ({
        input: imageBuffer,
        left: 0,
        top: this.clipHeight * index,
      })),
    );
 
    // debugger
    // await output.toFile(`snapshot.png`);
 
    this.logger.info(`图片拼接完成!`);
 
    return output?.[outputFormat]().toBuffer();
  }

pageScrollScreenshot 主要包含两部分逻辑

1、根据视口高度将容器的真实高度切分为若干份,比如容器的真实高度是 1200,视口的高度为 800,整个页面就会被拆分为 800(scrollTop = 0,y = 0) 和 400(scrollTop = 400,y = 800 - scrollTop) 两个部分截图

2、利用 sharp 将多个截图拼接到一张高度为容器真实高度背景图上,使他们看上去是一张图片

pageScrollScreenshot 出了能够适配像 Finebi 一样的特殊自适应系统的截图外,也是可以用于任何有滚动容器页面截图的!

最终效果

经过一系列的改造,整个 Snapshot 变得更加清晰和丰富。目前已经接入了 3 个系统 5 个截图场景,每个实现类平均包含 50 行左右的纯代码,只需要按需重写和实现一些阶段和钩子函数就可以了。

由于流程更加清晰了,整个截图的耗时也缩短到原来的 1/3,内存 & CPU 占用也将为了原来的 1/3,服务的处理能力也提升了。

下面展示了在稳定性运营过程中截图的样例:

定期推送稳定性保障指标看板

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

每天晚上定时推送关键业务的关键指标看板,方便对应负责人了解趋势:

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

当某条业务链路发生故障时,推送整个链路以及故障点的截图,一目了然知道哪里出现了问题,帮助快速定位:

【前端技术】puppeeter截图推送助力酷家乐稳定性运营 【前端技术】puppeeter截图推送助力酷家乐稳定性运营

故障应急时,发现某些指标异常时,推送异常指标截图:

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

每周一早上定时推送研发效能改进指标截图,大家无需登录系统即可看到:

【前端技术】puppeeter截图推送助力酷家乐稳定性运营

后续计划

现阶段借助这个服务,我们已经实现了现有稳定性运营活动的自动化,原本投入的人力得到了释放。

后续我们有计划做大做强,将整个服务收拢、形成一个产品,有相关需求的业务方只需要在产品上简单的配置一个就可以做到自动化运营。最终产品等待后续的文章介绍。