ThinkJS 和 Sprite.js 服務端渲染實踐

編者注:今天呢咱們請來了 @有馬 同窗爲咱們分享他在作某數據可視化大屏項目的時候使用服務端渲染大屏動畫的經驗。說到服務端渲染你們通常都想到 Vue 的 SSR 或者 React 的同構吧,不過動畫也是能夠在服務端渲染的哦!因此讓咱們趕快進入正文看看究竟是怎麼實現的吧~javascript


介紹

ThinkJS 是一個基於 koa@2.0 的企業級服務端開發框架,本項目中除基本的 HTTP 服務外,還使用了定時任務和 websocket 功能。前端

Sprite.js 是一個跨平臺的 2D 繪圖對象模型庫,它支持 Web、Node、桌面應用和微信小程序的等多端圖形繪製及動畫實現。Sprite.js 使用 node-canvas 進行服務端渲染,這意味着咱們能夠在 node 環境中使用 Sprite.js,並將繪製好的圖形保存成 png,或將動畫保存成 gif。在本文中的項目中主要使用瞭如下特性:java

  • Scene(場景):sprite.js 經過建立場景 scene 實現 layer管理;
  • Layer(圖層):每層 layer 是一個封裝過的 canvas 2D 對象;
  • Sprite(精靈):一個擁有盒模型的可渲染對象。sprite.js 默認支持的精靈類型有四種,分別是Sprite、Label、Path和Group,其中Sprite是最基礎的精靈;

🤔️ 疑問

爲何進行 canvas 服務端渲染呢?

本項目的需求是實現峯值每小時百萬級的實時數據的大屏展現,爲了能達到最好的展現效果,而且能回溯歷史態勢,咱們決定使用前端、服務端代碼同構,前端進行實時數據的動畫展現, 服務端同時渲染數據攻擊路徑,具體策略以下:node

  • 服務端做爲 websocket 客戶端,接收 websocket 上游的數據,使用 sprite.js 繪製圖像,經過 ThinkJS 定時任務拍快照,並將圖片上傳到 CDN 後保存 URL;
  • 同時,服務端也做爲 websocket 服務端,把上游的數據過濾後發送給前端,前端將接收到的數據經過sprite.js 實時繪製到 canvas 上。
  • 前端回溯歷史態勢時,需請求服務端取得歷史快照。服務端將請求時間內的快照合併爲一張,上傳到 CDN後將URL返回給前端,並由前端繪製到 canvas 上。

👀 開發前的爬坑之旅

在實現這套方案的過程當中爬了很多坑,其中最大的坑是 node-canvas 挖的😂,爬坑的路上,我一度弄掛了服務器(幸好只是個docker容器)。linux

安裝 node-canvas

node-canvas 是一個使用 Cairo 支持的 Node.js 環境的 canvas 實現,打開它的開發者列表頁面,你會看到一個熟悉的名字 TJ Holowaychuk。目前遇到的這幾個問題也是在屢次更換服務器的過程當中發現的,但願你們留意,省得之後被坑哭。web

缺乏預編譯的二進制文件

node-canvas 只有在 node 服務端纔會用到,因此 sprite.js 的依賴中沒有添加它,須要咱們手動執行 npm i canvas@next 安裝到項目中,默認會安裝最新版本,安裝時它會根據系統架構決定在預編譯項目中下載相應的二進制文件,若是你遇到了圖1所示錯誤,有兩種解決方法:算法

  1. 編譯安裝 node-canvas ,官方文檔上寫清楚了不一樣操做系統編譯須要的依賴;
  2. 安裝最近有預編譯二進制文件的版本,目前是 canvas@2.0.0-alpha.14;

圖1

缺乏 GLIBC_2.14

在解決完上個問題後你可能還會遇到這個問題docker

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操做系統所提供的系統服務外,它自己也提供了許多其它一些必要功能服務的實現。npm

看完這段介紹,你就應該明白你即將面對的是什麼級別的依賴缺失,去搜一下相關詞條,多少人由於它重裝了系統。

查看系統內核是否支持 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 結合的服務端渲染大屏項目,對咱們來講是一次新的嘗試,可是技術解決的是怎樣實現的問題,實現什麼樣的展現?以及爲何這麼展現?還是可視化展示過程當中須要先行思考的問題。

相關文章
相關標籤/搜索