报表导出与自动推送在大数据平台场景下的探索与实践

简述


在大数据分析平台,分析师可以通过多维交叉分析模型,生成许多能够直观反应现状的报表。这些报表可以动态更新,实时计算。我们可以通过不同的看板,把相关的报表进行聚合,聚焦。这些看板往往是对公司是非常有价值的智慧沉淀。越有价值的东西,我们就应该让它的价值最大化。我们可以把它们分享给内部同样关注和需要的人。分享一般有两种形式:

  • 系统内分享:把看板分享给同样具有系统账户和权限的其他同事。
  • 系统外分享:把看板信息通过其他载体分享出去。比如作为 PDF 或者图片在邮件,企业微信等渠道分享。

系统外分享也是非常有用和普遍的。一方面可以作为日报/周报,周期性的反应阶段性现状。另一方面,可以让其他同事,老板非常便捷的了解到这些信息。

对于推送方式,一般有两种:

  • 分析师可以把自己的看板导出为 PDF 或者图片,通过邮件或者内部沟通工具进行针对性分享。
  • 分析师可以创建规则(一般是推送时间,推送渠道,推送内容),将自己的看板定时的推送给相关人员。

工具


对于看板导出为 PDF,这在前端就可以实现。把 Html 页面生成PDF 的前端技术已经比较成熟,比如当下比较流行的两大利器:

  • html2canvas: 可以把 Html 转成 Canvas,进而生成图片。
  • jspdf: 可以把图片转成 PDF 文件。

对于看板定时推送,更多的需要借助后端的能力。比如定时任务,邮件,企业微信/钉钉/飞书等渠道的推送都需要后端来完成。报表的定时生成,需要用到无头浏览器来进行登录模拟,页面加载和图片生成(截屏)。

  • Node 环境下可以使用 Puppeteer
  • Java 环境下可以使用 ChromeDriver 工具。

前端需要配合完成推送规则,渠道和内容的配置。此外,前端还需要针对导出模式对原有页面做一系列特殊处理,让导出的报表更加符合预期。比如隐藏一些无用信息,额外显示一些信息(比如在页面上 hover 上去才会显示的有用信息)。同时还要保证不会影响常规模式下页面的使用效果。

场景

工具虽好,但在实际场景中应用的时候,很多问题是工具无法帮你解决的。在大数据平台下,以下场景就比较常见:

  • 懒加载
  • 巨量数据请求
  • 巨量数据渲染

你不能给用户导出一个数据不全,或者还没有渲染完成的报表。所以,我们必须解决懒加载的问题,并确保巨量数据已经请求并渲染完成。

懒加载

懒加载也叫按需加载,是一种被广泛应用的网页性能优化方式,它能极大的提升用户体验。比如页面很长,我们优先加载可视区域的内容,其他部分等用户滚动进入可视区域再加载。

比如在 React 技术栈项目中,我们可以使用 react-lazyload 组件实现懒加载:

import React from 'react';
import LazyLoad from 'react-lazyload';

const Demo = () => {
  return (
    <>
      <LazyLoad height={500} offset={100}>
    <>
  )
}

你可以简单粗暴的的把需要导出为 PDF 的页面去除懒加载。这种方法唯一的优点是尽早的开始请求和加载页面,让用户能进行尽早的导出,尽少的等待。缺点是为了实现功能而牺牲了性能。如果懒加载带来的优化微乎其微,也可以接受。

但是在很多场景,懒加载非常重要,直接去除的代价会非常大。比如大数据平台的报表看板页面。一方面,服务测的大数据计算队列是非常宝贵的资源,默认全量加载,计算队列的消耗会随着用户数几何式增长。另一方面,大数据报表的前端渲染也比较复杂耗时,如果看板的报表数量较多,一次习惯加载会导致页面卡顿。

那么,在这些懒加载必要的场景下,我们只能在用户进行导出的时候,再进行全量加载。这样页面导出 PDF 就分三步:

  • 页面加载中
  • 文件生成中
  • 文件可下载

在使用 react-lazyload 的情况下,可以通过重新设置 offset 参数,并触发容器 scroll 事件来进行全量加载:

import React, { useState, useEffect } from 'react';
import LazyLoad from 'react-lazyload';

const Demo = (height) => {
  const [offset, setOffset] = useState(100);

  useEffect(() => {
    const myEvent = new Event('scroll');
    document.body.dispatchEvent(myEvent);
  }, [offset])

  return (
    <>
      <LazyLoad height={height} offset={100}>
      <Button onClick={ () => { setOffset(height) } }>
        导出为PDF
      </Button>
    <>
  )
}

这种方案的缺点就是用户在点击导出以后,要等待页面的全量加载和PDF的生成,可能需要较长时间。我们可以通过以下两种方式来进行一些优化:

  • 前端侧:友好的提示用户导出的进展和进度。
  • 服务测:用户在点击导出 PDF 以后,服务端生成一个任务来进行 PDF 的导出。这样,用户可以进行其他操作,等文件生成以后通过异步回调来提示用户进行文件下载。

服务测的优化方式实现起来会比较复杂。如果服务端本身具备自动推送的功能,那么是可以复用自动推送的能力的。如果没有自动推送,前端侧优化性价比更高。

巨量数据请求&渲染

巨量数据下,数据的请求和渲染都比较耗时。那么在看板导出的场景下,我们需要进行导出时机的判断。我们在导出的时候需要确保:

  • 数据请求都已返回。
  • DOM 渲染已完成。
  • 图表(Canvas)绘制已完成。

在巨量数据的情况下,DOM 渲染 和 Canvas 绘制 不能简单的通过在请求完成后,预留一点时间的方式来判断。

那么,有没有一种全局的,与业务逻辑解耦的方式来来进行判断,比如监听方式。

请求监听

在前端侧,我没有找到全局解耦的进行请求监听的方式,服务测非常简单。比如 Node 环境的 Puppeteer就可以很方便的进行网络监听。

对于普通的的 Http 请求, 在 Puppeteer 中监听非常便利:

await page.goto('https://www.baidu.com', {
   timeout: 30 * 1000,
   waitUntil: [
       'load',                             //等待 “load” 事件触发
       'domcontentloaded',       //等待 “domcontentloaded” 事件触发
       'networkidle0',               //在 500ms 内没有任何网络连接
       'networkidle2'               //在 500ms 内网络连接个数不超过 2 个
   ]
});

但是上述方式不适用于 webSocket 请求。我们可以通过以下方式来监听 webSocket 请求的所有通信:

  const client = await page.target().createCDPSession();

  await client.send('Network.enable');

  client.on('Network.webSocketCreated', function (params) {   
    // console.log(`创建 WebSocket 连接:`)
  });
  client.on('Network.webSocketClosed',function (params) {
    // console.log(`关闭 WebSocket 连接`)
  });
  client.on('Network.webSocketWillSendHandshakeRequest',function (params) {
    // console.log(`发送 WebSocket 握手消息`)
  });

  client.on('Network.webSocketHandshakeResponseReceived',function (params) {
    // console.log(`收到 WebSocket 握手消息`)
  });

  client.on('Network.webSocketFrameSent', ( frame ) => {
    // console.log(`发送 WebSocket 请求`) 
  });

  client.on('Network.webSocketFrameReceived',function (frame) {
    // console.log(`收到 WebSocket 请求`) 
  );

我们只需最后两种监听,就可以判断是否还存在未完成的 websocket 请求。

前面有提到,在前端侧我们并没有找到一种全局解耦的方式进行请求监听。但是对于页面导出为 PDF 这样的纯前端功能,需要前端来判断所有请求已完成。无法通过请求监听的方式,那只能通过记录所有请求的发送和回调这种请求判断的方式。

DOM 监听

DOM 监听的运用和实践已经比较普遍,我们使用 MutationObserver 就可以。MutationObserver 主要用于监听 DOM 元素的一系列变化。如果一段时间内页面无任何DOM 变化,我们可以认为页面渲染已经完成。

/**
 * 监听 Dom 变化
 */
function domMutationObserver(resolve): void {
  observer = new MutationObserver(() => {
    // console.log('rendering...');
    observerHeadler(resolve);
  });

  observer.observe(document.body, {
    attributes: true,
    childList: true,
    subtree: true,
  });
}

Canvas监听

我们都知道,Canvas 是通过 JS 绘制,所以图形不会反应在 DOM 结构中,没法通过 DomMutationObserver 来进行监听。但是要进行 Canvas 绘制,必然会一直掉用 Canvas 的各种 API。我们可以通过 数据劫持 的方式来监听这些 API。如果短时间内无任何相关 API的调用,我们可以认为 Canvas 绘制已经完成。

/**
 * 监听 canvas 绘制
 */
function canvasMutationObserver(resolve): void {
  const canvasProto = CanvasRenderingContext2D.prototype;
  const canvasProps = Object.getOwnPropertyNames(canvasProto);

  canvasProps.forEach((prop) => {
    const property = Object.getOwnPropertyDescriptor(canvasProto, prop);
    const getter = property && property.get;

    /* 监听 canvas 属性(方法就不用不监听了)*/
    if (getter) {
      Object.defineProperty(canvasProto, prop, {
        get: function () {
          // console.log('drawing...');
          observerHeadler(resolve);
        },
      });
    }
  });
}

至此,我们可以通过导出时机的准确判断来导出一个懒加载的页面。

差异化导出

在报表导出的时候,用户可能希望导出的 PDF 文件中隐藏一些不必要的元素(比如操作功能区),也可能希望额外显示一些重要的信息(比如 hover 到某元素上去才显示)。这是非常普遍又合理的。

首先,我觉得在页面交互设计上,应该尽量保证页面和导出的一致性,对于个别无法保证的差异点再通过编码处理。对于处理方式,需要分 前端导出 和 自动推送 两种场景来分析。

前端导出

对于前端导出的场景,用户导出的同时,还是能够看到页面。如何做到用户无感知的差异化导出比较重要。我们可以在导出时先微调,导出后再还原。但是这种用户可感知的方案非常奇怪。新开一个不可见的窗口二次渲染又非常耗时耗能。

好在,html2canvas 提供了一个非常好用的 onclone 钩子函数作为配置参数。该函数会在 html2canvas 已经解析获取到页面 dom 副本后,在生成canvas 前调用。我们只需要给隐藏的元素增加一个 pdf_hidden 标记。在页面新增默认隐藏的需要额外显示的信息,然后增加 pdf_show 标记。然后在 onclone 钩子函数中移除或隐藏带 pdf_hidden 标记的元素;显示带 pdf_show 标记的元素即可。

html2canvas(element, {
  ...options,
  onclone: (html) => {
    const needHide = html.getElementsByClassName('pdf_hidden');
    const needShow = html.getElementsByClassName('pdf_show');
    if (needHide) {
      Array.from(needHide).forEach((item) => {
        item.remove();
      });
    }
    if (needShow) {
      Array.from(needShow).forEach((item) => {
        item.setAttribute('style', 'display: block');
      });
    }
  },
}).then((canvas) => { });

onclone 钩子函数能处理很多问题。比如你页面的自定义图标( SVG )是使用 <symbol>元素来全局定义,然后在具体的 <svg/> 元素中使用 <use> 来引用的,那么你生成的 canvas 是看不到svg图标的。因为 html2canvas 是单独解析遇到的 <svg/> 元素的。这个时候,你就需要通过 onclone 钩子函数来做一些特殊处理,比如:

/**
 * svg 处理
 */
function svgDealwith(element): void {
  const svgs: Document[] = Array.from(element.getElementsByTagName('svg'));
  svgs.forEach((svg) => {
    const use = svg.getElementsByTagName('use');
    if (use.length > 0) {
      const fontId = use[0].getAttribute('xlink:href');
      if (fontId) {
        const path = document.getElementById(fontId.replace('#', '')).cloneNode(true);
        svg.insertBefore(path, svg.firstChild);
        setTimeout(() => {
          svg.removeChild(svg.firstChild);
        }, 0);
      }
    }
  });
}

自动推送

前面有提到,自动推送是后端通过无头浏览器进行页面的渲染和截屏的,是所见即所得的。如果存在导出差异,新做一个绝大部分内容一致的页面进行承载显然是不可取的。我们可以通过在原有页面增加参数来区分导出模式,然后在页面中根据是否是 导出模式,做一些差异化处理。

这无疑增加了页面的逻辑复杂度。开发得确保满足导出模式的差异化需求的同时,不影响页面的原有逻辑。我们能做的就是对差异化处理进行更好的抽象,做到尽量隔离。虽然我们没法在 导出模式 下,使用 html2canvasonclone 钩子函数,但是可以复用前端导出时增加的 pdf_hiddenpdf_show 标记。导出模式是无头浏览器模式,不用考虑用户感知。我们只需要在页面加载以后,如果是导出模式就对带有 pdf_hiddenpdf_show 标记的元素进行相似处理即可。

比如在 React hooks 组件中,我们可以这样处理:

import React, { useEffect } from 'react';
// pageReady 页面渲染完成标识
const Demo = (pageReady) => {

  useEffect(() => {
    if (pageReady) {
      const needHide = html.getElementsByClassName('pdf_hidden');
      const needShow = html.getElementsByClassName('pdf_show');
      if (needHide) {
        Array.from(needHide).forEach((item) => {
          item.remove();
        });
      }
      if (needShow) {
        Array.from(needShow).forEach((item) => {
          item.setAttribute('style', 'display: block');
        });
      }
    }
  }, [pageReady])

  return (
    <div>
      <div className="pdf_hidden">hello<div>
       <div className="pdf_show" style={{display: 'none'}}>world<div>
    <div>
  )
}

jspdf问题

最后,给大家分享一个jspdf工具在使用中遇到的一个问题:页面报表非常多的情况下,导出的pdf文件丢失部分内容。原因是我们导出的 PDF 是单页的,这样效果较好。但是 PDF 单页有高度 14400 的限制。针对这种情况,我们对 PDF 的宽高进行等比压缩来处理。

while (h1 + h2 > 14400) {
  w = Math.floor(w * 0.95);
  h1 = Math.floor(h1 * 0.95);
  h2 = Math.floor(h2 * 0.95);
}
const h = Math.max(w, h1 + h2);

const pdf = new jsPDF({
  orientation: 'p',
  unit: 'pt',
  format: [w, h],
});

参考资料