ThinkJS 和 Sprite.js 服务端渲染实践

2,346 阅读9分钟

编者注:今天呢我们请来了 @有马 同学为我们分享他在做某数据可视化大屏项目的时候使用服务端渲染大屏动画的经验。说到服务端渲染大家一般都想到 Vue 的 SSR 或者 React 的同构吧,不过动画也是可以在服务端渲染的哦!所以让我们赶快进入正文看看到底是怎么实现的吧~


介绍

ThinkJS 是一个基于 koa@2.0 的企业级服务端开发框架,本项目中除基本的 HTTP 服务外,还使用了定时任务和 websocket 功能。

Sprite.js 是一个跨平台的 2D 绘图对象模型库,它支持 Web、Node、桌面应用和微信小程序的等多端图形绘制及动画实现。Sprite.js 使用 node-canvas 进行服务端渲染,这意味着我们可以在 node 环境中使用 Sprite.js,并将绘制好的图形保存成 png,或将动画保存成 gif。在本文中的项目中主要使用了以下特性:

  • Scene(场景):sprite.js 通过创建场景 scene 实现 layer管理;
  • Layer(图层):每层 layer 是一个封装过的 canvas 2D 对象;
  • Sprite(精灵):一个拥有盒模型的可渲染对象。sprite.js 默认支持的精灵类型有四种,分别是Sprite、Label、Path和Group,其中Sprite是最基础的精灵;

🤔️ 疑问

为什么进行 canvas 服务端渲染呢?

本项目的需求是实现峰值每小时百万级的实时数据的大屏展示,为了能达到最好的展示效果,并且能回溯历史态势,我们决定使用前端、服务端代码同构,前端进行实时数据的动画展示, 服务端同时渲染数据攻击路径,具体策略如下:

  • 服务端作为 websocket 客户端,接收 websocket 上游的数据,使用 sprite.js 绘制图像,通过 ThinkJS 定时任务拍快照,并将图片上传到 CDN 后保存 URL;
  • 同时,服务端也作为 websocket 服务端,把上游的数据过滤后发送给前端,前端将接收到的数据通过sprite.js 实时绘制到 canvas 上。
  • 前端回溯历史态势时,需请求服务端取得历史快照。服务端将请求时间内的快照合并为一张,上传到 CDN后将URL返回给前端,并由前端绘制到 canvas 上。

👀 开发前的爬坑之旅

在实现这套方案的过程中爬了不少坑,其中最大的坑是 node-canvas 挖的😂,爬坑的路上,我一度弄挂了服务器(幸亏只是个docker容器)。

安装 node-canvas

node-canvas 是一个使用 Cairo 支持的 Node.js 环境的 canvas 实现,打开它的开发者列表页面,你会看到一个熟悉的名字 TJ Holowaychuk。目前遇到的这几个问题也是在多次更换服务器的过程中发现的,希望大家留意,免得以后被坑哭。

缺少预编译的二进制文件

node-canvas 只有在 node 服务端才会用到,所以 sprite.js 的依赖中没有添加它,需要我们手动执行 npm i canvas@next 安装到项目中,默认会安装最新版本,安装时它会根据系统架构决定在预编译项目中下载相应的二进制文件,如果你遇到了图1所示错误,有两种解决方法:

  1. 编译安装 node-canvas ,官方文档上写清楚了不同操作系统编译需要的依赖;
  2. 安装最近有预编译二进制文件的版本,目前是 canvas@2.0.0-alpha.14;

图1

缺少 GLIBC_2.14

在解决完上个问题后你可能还会遇到这个问题

Error: /lib64/libc.so.6: version `GLIBC_2.14` not found

图2

这表示服务器操作系统上没有 GLIBC_2.14 的库,先了解下 GLIBC 是什么:

GLIBC是GNU发布的libc库,即C运行库。GLIBC是Linux系统中最底层的API,几乎其它任何运行库都会依赖于GLIBC。GLIBC除了封装Linux操作系统所提供的系统服务外,它本身也提供了许多其它一些必要功能服务的实现。

看完这段介绍,你就应该明白你即将面对的是什么级别的依赖缺失,去搜一下相关词条,多少人因为它重装了系统。

查看系统内核是否支持 GLIBC_2.14 可以用这条命令

strings /lib64/libc.so.6 | grep GLIBC

如果结果中确实没有 GLIBC_2.14 关键字,可以尝试以下两种方式解决这个问题:

  1. 在你使用的操作系统上添加 GLIBC 的源,然后安装对应版本的 GLIBC;
  2. 选择一个 GLIBC 版本 >= 2.14 的操作系统,如 CentOS 7。

如果没有找到服务器系统内核对应的源,也不要尝试编译安装这个库,运维的同事说有些老版本内核就不支持 GLIBC_2.14。然后请读一下下面这句话:

由于 GLIBC 囊括了几乎所有的 UNIX 通行的标准,可以想见其内容包罗万象。而就像其他的 UNIX 系统一样,其内含的档案群分散于系统的树状目录结构中,像一个支架一般撑起整个操作系统。

所以最好的方式还是直接用支持的操作系统。

缺少字体文件

在安装好 node-canvas 后,可以使用下面这段代码进行测试。如果输出图片上文字显示为下图所示的长方形,这表示你使用的系统缺少字体文件。碰巧你又有渲染文本的需求,就需要解决这个问题。

图3

一般 PC 上会有很多字体文件,但没有界面的服务器环境可能会缺少字体文件,因此需要至少安装一种字体,操作方法可以参考这篇文章。操作完后运行下面的代码,会生成一张图片,如果能正确显示文字说明成功安装了字体文件。

// label.js
const {Scene,Label} = require('spritejs');
const fs = require('fs');
const writeFileAsync = think.promisify(fs.writeFile, fs);

(function () {
  // 创建scene和layer
  const scene = new Scene('#paper', {resolution: [1200, 1200]});
  const fglayer = scene.layer('fglayer');
  
  // 创建label并设置属性
  const text1 = new Label('Hello World !');
  text1.attr({
    anchor: [0.5, 0.5],
    pos: [600, 600],
    font: 'bold 48px Arial',
    color: '#ffdc45',
  });
  
  // 将label添加到layer上,并将将canvas存为图片
  fglayer.append(text1);
  await fglayer.prepareRender();
  await writeFileAsync('spritejs.png', fglayer.canvas.toBuffer());
}());

🌵 服务端渲染

服务端渲染 canvas 的关键操作是图片的输出和合并,理解并灵活运用这两个过程,能够满足大部分 canvas 服务端渲染的场景。

图片输出

本文项目的方案,服务端得到新数据后创建 sprite 并添加到 layer 上,构造出的 sprite 只应该完成一个任务,就是在 layer 留下图像,然后就删除掉(如果不删掉内存会吃不消的)。要完成这个过程需要重写 layer 上的 clearContext 方法,这样才能保留 sprite 绘制的图案。

// 重写 clearContext 方法确保 sprite,label,path,等元素移除后保留图像
layer.clearContext = () => {}

// 通过数据生成新的 sprite 元素
const sprite = drawSomething(data);
// 绘制到 layer 上
layer.append(sprite);
// 确保 sprite 绘制到 layer 上后
await layer.prepareRender();
// 将 sprite 元素移除,因为重写了 clearContext 方法,移除后图像仍在 layer 上
sprite.remove();

// 如果要清空 layer
const {width, height} = layer.context.canvas;
layer.context.clearRect(0, 0, width, height);

sprite.js 的 scene 对象上是有快照方法的,它对当前的 scene 截屏并返回 canvas 对象,我们可以在这个 canvas 对象上调用 toBuffer 方法获得图像二进制数据,然后使用 node.js 中 fs 模块提供的同步写方法生成一张 png 图。

const canvas = await scene.snapshot()
await fs.writeFile('snap.png', canvas.toBuffer())

如果不想对整个 scene 拍快照,只想对特定的某个 layer 拍照快,可以通过 layer 获得 canvas 对象,然后使用同样的方式对layer进行拍摄快照。

async function snapshot(layer) {
  await layer.prepareRender();
  return layer.canvas.toBuffer();
};

快照图片量很大的时候,需要定时将快照上传到 cdn 或者单独的文件服务器,然后在数据库中保存图片的 url。这个过程用到了 ThinkJS 的定时任务,可以在 src/config/crontab.js 中添加如下配置,然后编写对应的处理方法。如果想确保在某个时间进行定时任务,例如在 5 分钟整数倍时执行任务,可以设一个更细粒度的定时器,然后在处理方法中判断,如果不是 5 分钟的倍数则不执行。

// src/config/crontab.js
module.exports = [
  {
    enable: true,
    interval: '1m', // 每1分钟执行一次
    handle: 'crontab/snapshot'
  }
];

// src/controller/crontab.js
module.exports = class extends think.Controller {
  async snapshotAction() {
    // 拒绝非定时任务启动
    if (!this.isCli) return;
    const now = new Date();
    // 如果不是 5 分钟的整数倍,则不执行任务
    if (now.Minutes() % 5) {
      return;
    }
    // 下面实现拍快照 -> 上传 cdn -> 存数据库的逻辑
    // ...
  }
}

图片处理

使用 sprite.js 可以在服务端组合,合并图片,添加滤镜等,这个方案中简单地将多张相同类型的图片合为一张。sprite.js 实现了前后端通用的预加载功能,可以预加载图片,然后在 sprite.js 中使用,下面的代码就实现了这个过程,具体可以参考 sprite.js 文档图片异步加载

const spritejs = require('spritejs');
const fs = require('fs');
const writeFileAsync = think.promisify(fs.writeFile, fs);

(async function() {
  const {Scene, Sprite} = spritejs;
  const scene = new Scene('#paper', {resolution: [1200, 1200]});
  // 预加载图片
  await scene.preload(
    'https://p3.ssl.qhimg.com/t01ccaee34d3f92a10c.png',
    'https://p2.ssl.qhimg.com/t01eb096408038e7496.png'
  );
  // 是否代理DOM 事件,如果这个参数设置为false,那么这个 Layer 将不处理DOM事件
  // 可以提升性能
  const layer = scene.layer('fglayer', {
    handleEvent: false
  });
  const sprite = new Sprite();
  // 在 sprite 元素上添加多个 texture
  // http://spritejs.org/#/zh-cn/doc/attribute?id=textures
  sprite.attr({
    textures: [
      {
        src: 'https://p3.ssl.qhimg.com/t01ccaee34d3f92a10c.png'
      },
      {
        src: 'https://p2.ssl.qhimg.com/t01eb096408038e7496.png'
      }
    ]
  });
  // 添加到 layer 上
  layer.append(sprite);
  const buffer = await snapshot(layer);
  await writeFileAsync('test.png', buffer);
  layer.remove(sprite);
})();

websocket

ThinkJS 使用 master/workers 多进程模型,如果项目没有用到 websocket,master 接收到请求后是以 Round Robin 算法交给 workers 处理,这样基本保证负载均衡。如过项目前后端需要 websocket 通信,在 ThinkJS 中需要配置 stickyCluster: true,添加这个配置后,master 会做 IP Hash,这样确保来自同一个客户端的请求会被相同的 worker 处理,从而成功建立起 websocket 通信,这样会牺牲一部分性能,详细了解多进程模型,请看《细谈ThinkJS多进程模型》

由于我们的项目是数据可视化大屏项目,一般没有什么访问量,因此在这个项目中只启动了一个 worker,将 stickyCluster 设置为 false,也能成功建立 websocket 通信。因为只有一个 worker 干活,所有的请求必然都交给了它。

虽然前端可以直接跟 websocket 上游服务建立通信,但是为什么没有这么做(目前是跟 ThinkJS 服务建立 websocket 通信),主要是考虑在 ThinkJS 服务中可以通过制定一套策略处理数据,决定服务端渲染以及前端实时数据展示,这样前端大屏页面就可以只关注绘图工作。

📓 总结

这是第一次做 ThinkJS 和 Sprite.js 结合的服务端渲染大屏项目,对我们来说是一次新的尝试,但是技术解决的是怎样实现的问题,实现什么样的展示?以及为什么这么展示?仍是可视化展现过程中需要先行思考的问题。