Serverless 與 Node-Canvas 實作動態圖表匯出

#無伺服器 #圖表匯出 #API Gateway #Serverless #Node Canvas #Highcharts #AWS #Lambda
Yusheng Li
技術文章
Serverless 與 Node-Canvas 實作動態圖表匯出

需求

在我們幫某個客戶維護的服務當中,需要提供大量即時的數據圖表 (主要是 K 線圖,a.k.a 陰燭圖) 供用戶即時監測數值的變化與走勢。一般來說我們都是使用 JavaScript 的 Charting Library 來幫我們完成圖表繪製與顯示的需求,在現有的網頁中,這早已不是什麼稀奇的需求,依據所需的圖表類型,隨便都能想到約數十種 Library 來達成需求,甚至土炮一點也能完全用 D3.js 從頭刻起。一般來說這些套件大多是透過操作 DOM 或是繪出 SVG 的方式來顯示圖表,比較厲害一點的甚至能透過 HTML5 Canvas API 來以相當有效率的方式畫出圖表。

某一天客戶突然提出了一個需求,在特定事件發生時,圖表的走勢可能會有急遽的變化,通常在這時候會引起使用者熱烈的討論。客戶希望我們能提供一個功能,讓使用者能將當下的圖表匯出,分享到 Social Media。

方案?

收到需求之後,我們立刻評估了幾個可能的解決方案,包含:

Screenshot API

大致上的概念是透過類似 HTMLIFrameElement.getScreenshot() 的方式,將使用者所要分享的圖表截圖、送到後端的 File Storage 儲存後,再返回結果。

即便 getScreenshot() API 在各瀏覽器中的支援仍然相當慘澹,但是透過一些第三方 Javascript 套件的協助,尚可視為一個可能的解決方案。

HTML2Canvas

HTML2Canvas 與透過 Screenshot API 來產生圖片的概念雷同,官方網站上面給這個套件的註解是「Screenshots with JavaScript」。不過他的原理是透過解析 DOM 裡面每個元件的屬性,依據讀到的屬性在 Canvas 中重新繪製出所需要的畫面。

HTML2Canvas 作為一個不算新的解決方案,算是一個穩定可信賴的套件,同時也擁有相當的使用者數目。相當值得一提的是,專案的 Browser Compatibility 列表中宣稱可兼容 IE9+ 以上版本(需要在擁有 Promise Polyfill 的前提下,另外有些特定的 CSS 屬性沒有辦法被正確的解析)。

Charting Library 內建匯出功能

在部分商業圖表套件之中,本身就內建有圖表匯出的功能,例如 HighCharts Exporting ModuleTradingView 的 Snapshot Module,甚至像是 D3.js 本身就支援匯出 SVG Stream、就可再進一步轉換成所需的圖片格式。

Backend Screenshot

除了上述前端的解決方案之外,我們也曾經一度考慮過透過後端操作 Chrome Web Driver 等 Headless Browser Driver,直接對我們所需要的畫面截圖、上傳。

選用方案

大致討論了可能的解決方案之後,我們再一次的與客戶討論需求,考量到了幾個限制:

  • 客戶希望所匯出圖表、必須與現在呈現在網站中的圖表風格一致,由於客戶用於圖表繪製的方案是商業授權的 HighCharts.js,所以 HighChart Exporting Module 成了我們的優先考量。
  • 目前圖表在前端的呈現會因為螢幕大小、裝置類型而有些微不一樣的呈現,理所當然的不希望會因為螢幕大小不同而獲得不一致的匯出結果,所以 Browser Screenshot 這類概念的解決方案較為不適合。
  • 除了圖表之外,客戶也希望可以在畫面上自訂想要顯示的其他元素,包含外框、Icon、浮水印、價格總結等元素,這部分若要使用 HighCharts SVG Renderer 恐怕有些綁手綁腳。

綜合上述考量:

圖表匯出 —— HighCharts Node.js Export Server

如上所述,由於前端目前所採用的解決方案是 HighChart,所以我們決定採用 Highcharts Node.js Export Server 來幫我們完成圖表繪製的部分,以確保能擁有一致的風格。

其他元素繪製 —— Node-Canvas

匯出結果的其他部分,我們決定要使用 Node-Canvas 來進行繪製與輸出。

Node-Canvas 是個以 Cario 作為基礎的後端 Canvas 實作,網路上可以找到一些使用 Node-Canvas 做驗證碼繪製、或是使用 Node-Canvas 動態在哏圖上面加上文字的範例。如果想要了解更多 Node-Canvas 與 HTML5 Canvas 的差異,可以官方文件中的 Compatibility Status有非常詳盡的解釋。

Infrastructure - AWS Lambda with API Gateway

決定好了要以何種技術實作之後,最後的問題就是我們要將上述的服務運行在哪一種架構之上。由於目前我們的網站服務主要是運行在 Ruby on Rails 之上,而上述的服務很明顯的必須要在 Node.js 的環境下執行。決定要採用何種架構來運行這項服務前,我們考量到了幾個重點:

  • 由於 HighChart Export Server 背後是使用 Phantom.js 實作圖片生成、Canvas 是使用 Cario 等技術實作,為了確保圖片生成的速度在合理的範圍內,對於基礎資源有一定的要求。
  • 我們不希望在原有運行 Rails 的伺服器上面部署這個服務。期望可以將這個服務獨立部署,讓環境單一化不要相互影響。
  • 在這個功能需求被提出之時,我們無法確認實際被使用的頻率會是如何。若在某些熱門事件發生時,可能會有平常幾倍的使用量、但若平時較少人使用時,我也不希望有一台昂貴的伺服器在那邊空待命著。

綜合上述考量,我們決定採用 AWS Lambda 結合 API Gateway 來運行這個服務。在決定之初,其實有點擔憂諸如 Phantom.js 或是 Node-Canvas 都有一些系統層級的 Dependencies,不知道 Lambda 是否有辦法順利的佈建這些環境。

所幸的我並不是第一個遇到這些問題的人,在 Node-Canvas 官方的 Wiki 中,有 Installation: AWS Lambda的篇目,詳細的講述了如何打包 Custom Build。在 Node-Canvas 1.6 和 2.x 之後的版本,甚至有了可以直接根據不同 O.S. 打包 Prebuilt 的功能。同樣的 HighCharts Export Server 也是採用了 Prebuilt 的機制來應對 Dependencies2 的問題,因此只要在 Linux (或是 Linux Docker Container) 中完成打包,就能夠順利的在 AWS Lambda 環境中運行 HighCharts Export Server 以及 Node-Canvas 等套件了。

Get to know Serverless

碎念了前面這麼多,現在就來看看要怎麼利用 Serverless 架構一步步打造出我們所想要的結果吧,首先請先確認你準備好以下事項:

Prerequisite

  • Node.js 8.10 AWS Lambda 中 Node.js Runtime 包含 6.10 及 8.10,為了有較好的 Native API Support,我通常偏好使用 8.10 的 Runtime。由於大家偏好管理 Node.js 的方法各異,這邊就不多贅述安裝程序,如果有相關困擾的人可以參考 NVM 或是 asdf
  • AWS Account 為了將服務運行在 Amazon Web Services 上面,你需要 AWS 的帳戶。如果你還沒有 AWS 帳戶,可以透過 AWS 免費方案 獲得相關優惠,其中包含了每月 1 百萬個 AWS Lambda 呼叫次數 (依據所配置的記憶體大小、運算資源有所不同,收費細節請參照官方說明)。
  • AWS Credentials 有了一組 AWS 帳戶,你還需要 AWS Credentials 來部署及設定 AWS。如果不知道如何取得的話,可以參照 AWS 官方 AWS Security Credentials 的篇目;或是參閱 Serverless 官方文件中關於 AWS Credentials 的條目或是影片。由於大家偏好管理 Access Keys 的方法各異,加上這關係到帳戶安全性,建議大家務必要閱讀清楚相關文件,同樣不多加贅述如何取得及設定 AWS Access Keys。

Serverless Installation

這邊我偏好使用 Serverless.js 框架來打造要在 AWS Lambda 當中執行的服務,之前其實也使用過 Apex.js 或是 Claudia.js 等作為解決方案,這些不同的 Serverless Tools 試圖想要解決的問題層級也不太相同。不過因為 Serverless.js 在 Serverless 的領域中使用者眾多、更新與維護的狀況也都相當活躍,而且背後是使用 AWS CloudFormation來完成整個 Serverless Application 架構的管理,因此最後還是選擇使用 Serverless.js。

透過 Yarn 安裝 Serverless.js

$ yarn add global serverless

透過 NPM 安裝 Serverless

$ npm install -g serverless

檢查版本

好了之後讓我們透過以下指令檢查 Serverless 是否安裝成功、安裝的版本是多少

$ serverless -v
# => 1.32.0

在我所撰寫這篇文章時,最新可用的 Serverless.js 版本是 1.32.0。

Serverless Create

接著讓我們透過 serverless create 指令來初始化 Serverless 專案

$ serverless create --template aws-nodejs --path Canvas

由於我們打算使用 AWS Lambda 運行 NodeJS,這邊的 --template 選項設定為 aws-nodejspath 則是指定專案要放置的路徑。

有關於 serverless create 指令可用參數的詳細解釋,可以使用以下指令,或參閱詳盡的官方文件

$ serverless create -h

執行完上述的 serverless create 指令,讓我們進到目錄中看看 serverless cli 幫我們建立了些什麼:

$ cd Canvas
$ ls
#=> handler.js     serverless.yml

其中 handler.js 是你的第一個 Lambda Function,serverless.yml 裡面則是你的 Serverless 專案的設定檔。

handler.js

'use strict';

module.exports.hello = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless v1.0! Your function executed successfully!',
      input: event,
    }),
  };
};

打開 handler.js,可以看到裡面就是一個單純的 Node.js Function 回傳了一個 Object,裡面包含了 Response statusCode 以及 body

serverless.yml

將註解移除後,產生出來的 serverless.yml 內容如下:

service: Canvas

provider:
  name: aws
  runtime: nodejs8.10

functions:
  hello:
    handler: handler.hello
  • 其中 provider 宣告了這個 Serverless Project 要部署的服務平台及 Runtime
  • functions 則是宣告你有一個 hello Function,當他被呼叫時去執行 handler.js 裡面 Export 出來的 Hello Function。

Serverless Invocation

我們可以試著使用 serverless invoke 指令,調用看看剛剛長出來的 hello Function:

$ serverless invoke local --function hello

Serverless: INVOKING INVOKE
{
    "statusCode": 200,
    "body": "{\"message\":\"Go Serverless v1.0! Your function executed successfully!\",\"input\":\"\"}"
}

Event

諸如 AWS Lambda 等 Serverless 服務,都是透過事件來做觸發 (Event-Trigger)。以 AWS Lambda 為例,常見的事件觸發有:

  • API Gateway
  • Kinesis Stream, DynamoDB Stream
  • S3 Trigger
  • Scheduling
  • AWS Simple Notification Service (SNS)
  • Amazon Simple Queue Service (SQS)
  • Alexa Skill / Alexa Smarthome
  • CloudWatch Event / CloudWatch Log 等

而我們最常會需要用到的 Event Trigger 類型就是 API Gateway 了,API Gateway 中的 lambda-proxy 讓你可以透過 HTTP 請求來觸發 Lambda Function。

透過 HTTP Request 觸發 Lambda Function

要設定 API Gateway —— Lambda Proxy 來觸發 Function 的方法非常簡單,只要打開 serverless.yml,設定對應的 HTTP Event 參數就行了。

# …
functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: hello
          method: get

設定完了之後,為了讓我們可以直接在 Local 測試結果而不需要將程式上傳到 AWS Lambda 才能測試,我們需要借助 Plugins 的幫忙。

Plugins

Serverless 透過 Plugins 的方式來讓大家貢獻插件,擴充 Serverless 套件本身。我本身比較常用到的套件有:

Serverless Offline

讓你在 Local 模擬 AWS Lambda 與 API Gateway 的行為,而不需要每次都把寫完的結果打包上傳到 AWS Lambda 才有辦法測試。

Serverless Webpack

既然選用了 Node.js 作為 Runtime,打包的時候當然希望可以使用 Webpack 來協助我們打包,這時侯 Serverless Webpack 就是個方便的插件了。

Change Project Structure

為了讓我們的 Serverless 專案有個比較好的結構方便管理,我們來整理一下專案結構本身。這邊採用的結構是參照自 serverless-webpack Babel Webpack 4 的 Example

由於這是個採用 Node.js 的專案,首先執行 yarn init / npm init

Install Required Dependencies

$ yarn init

跟著步驟一步一步將資訊填完了之後,在專案資料夾下應該會有 package.json 檔案產生,包含大致如下的內容:

{
  "name": "Canvas",
  "version": "1.0.0",
  "repository": "<YOUR_REPOSITORY_URL>",
  "author": "YOUR_NAME <YOUR_EMAIL>",
  "license": "MIT"
}

接著讓我們一一的把需要的 Node Packages, Serverless Plugins 放進來:

如果你使用 Yarn:

$ yarn add serverless webpack webpack-cli webpack-node-externals serverless-offline serverless-webpack babel-core babel-loader babel-preset-env webpack-node-externals —dev
$ yarn add source-map-support

如果你使用 NPM:

$ npm install serverless webpack webpack-cli webpack-node-externals serverless-offline serverless-webpack babel-core babel-loader babel-preset-env webpack-node-externals --save-dev
$ npm install source-map-support

webpack.config.js

在專案根目錄設定 webpack.config.js,作為 Webpack 打包時的設定

const path = require('path');
const slsw = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  entry: slsw.lib.entries,
  target: 'node',
  mode: slsw.lib.webpack.isLocal ? 'development': 'production',
  optimization: {
    // We no not want to minimize our code.
    minimize: false
  },
  performance: {
    // Turn off size warnings for entry points
    hints: false
  },
  devtool: 'nosources-source-map',
  externals: [nodeExternals()],
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader'
          }
        ],
      }
    ]
  },
  output: {
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, '.webpack'),
    filename: '[name].js',
    sourceMapFilename: '[file].map'
  }
};

在專案目錄設定 .babelrc,作為 Babel Presets 處理的基準

{
  "comments": false,
  "presets": [
    [ "env", { "node": "8.10" } ]
  ],
  "plugins": [
    "source-map-support"
  ]
}

Handlers

接著我們把原本的 handler.js 搬進 handler 資料夾,並重新命名為 hello.js

至此,你的整個專案目錄應該會長得大致像下面這樣:

$ tree -I node_modules                                                  
.
├── handlers
│   └── hello.js
├── package.json
├── serverless.yml
├── webpack.config.js
└── yarn.lock

1 directory, 5 files

更新設定

接著讓我們依據剛剛完成的調整,來更新我們在 serverless.yml 的設定如下:

service: Canvas

plugins:
  - serverless-webpack
  - serverless-offline

provider:
  name: aws
  runtime: nodejs8.10

custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true
    # 若使用 NPM 做套件管理,請將下面這行註解掉
    packager: yarn

package:
  individually: true

functions:
  hello:
    handler: handlers/hello.hello
    events:
      - http:
         path: hello
         method: get
  • plugins: 新增我們剛剛所加入專案中的 serverless-webpack、serverless-offline 插件
  • custom.webpack: 設定 serverless-webpack 所需要的設定值
  • package.individually: 選擇是否要分別打包每個 Function
  • functions.hello.handler: 由於我們剛剛把 handlers.js 重新命名並放進 handlers 資料夾,不要忘了更改這裡的設定

在 Local 測試用 HTTP 觸發 Hello Function

完成了上述的設定,我們接著使用剛剛加入專案的 serverless-offline 插件,在本機端模擬用 HTTP Request 觸發 Function 的動作。

$ serverless offline

Serverless: Bundling with Webpack...
Time: 517ms
Built at: 10/14/2018 12:09:00 AM
                Asset       Size          Chunks             Chunk Names
    handlers/hello.js   4.21 KiB  handlers/hello  [emitted]  handlers/hello
handlers/hello.js.map  982 bytes  handlers/hello  [emitted]  handlers/hello
Entrypoint handlers/hello = handlers/hello.js handlers/hello.js.map
[./handlers/hello.js] 408 bytes {handlers/hello} [built]
Serverless: Watching for changes...
Serverless: Starting Offline: dev/us-east-1.

Serverless: Routes for hello:
Serverless: GET /hello

Serverless: Offline listening on http://localhost:3000

接著就可以瀏覽 http://localhost:3000/hello 來觸發 Hello Function:

$ curl http://localhost:3000/hello

{"message":"Go Serverless v1.0! Your function executed successfully!","input":{"headers":{"Host":"localhost:3000","User-Agent":"curl/7.54.0","Accept":"*/*"},"path":"/hello","pathParameters":null,"requestContext":{"accountId":"offlineContext_accountId","resourceId":"offlineContext_resourceId","apiId":"offlineContext_apiId","stage":"dev","requestId":"offlineContext_requestId_3395068585679204","identity":{"cognitoIdentityPoolId":"offlineContext_cognitoIdentityPoolId","accountId":"offlineContext_accountId","cognitoIdentityId":"offlineContext_cognitoIdentityId","caller":"offlineContext_caller","apiKey":"offlineContext_apiKey","sourceIp":"127.0.0.1","cognitoAuthenticationType":"offlineContext_cognitoAuthenticationType","cognitoAuthenticationProvider":"offlineContext_cognitoAuthenticationProvider","userArn":"offlineContext_userArn","userAgent":"curl/7.54.0","user":"offlineContext_user"},"authorizer":{"principalId":"offlineContext_authorizer_principalId"},"protocol":"HTTP/1.1","resourcePath":"/hello","httpMethod":"GET"},"resource":"/hello","httpMethod":"GET","queryStringParameters":null,"stageVariables":null,"body":null,"isOffline":true}}%

部署第一個 Serverless Function

接著我們透過 serverless deploy 指令來實際將我們的第一個 Hello Function 部署到 AWS Lambda,serverless deploy 指令會透過 AWS CloudFormation Stack 來幫你把所需的服務都設定完成:

$ serverless deploy

Serverless: Bundling with Webpack...
Time: 446ms
Built at: 10/14/2018 12:48:16 AM
                Asset       Size  Chunks             Chunk Names
    handlers/hello.js   4.04 KiB       0  [emitted]  handlers/hello
handlers/hello.js.map  977 bytes       0  [emitted]  handlers/hello
Entrypoint handlers/hello = handlers/hello.js handlers/hello.js.map
[0] ./handlers/hello.js 408 bytes {0} [built]
Serverless: No external modules needed
Serverless: Packaging service...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (2.08 KB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............................
Serverless: Stack update finished...
Service Information
service: Canvas
stage: dev
region: us-east-1
stack: Canvas-dev
api keys:
  None
endpoints:
  GET - https://s15hzbnz38.execute-api.us-east-1.amazonaws.com/dev/hello
functions:
  hello: Canvas-dev-hello

接著瀏覽上面部署 Log 中給你的 Endpoint 就可以看到結果了,以上方的部署結果為例,Endpoint 是 https://s15hzbnz38.execute-api.us-east-1.amazonaws.com/dev/hello

$ curl https://s15hzbnz38.execute-api.us-east-1.amazonaws.com/dev/hello

{"message":"Go Serverless v1.0! Your function executed successfully!","input":{"resource":"/hello","path":"/hello","httpMethod":"GET","headers":{"Accept":"*/*","CloudFront-Forwarded-Proto":"https","CloudFront-Is-Desktop-Viewer":"true","CloudFront-Is-Mobile-Viewer":"false","CloudFront-Is-SmartTV-Viewer":"false","CloudFront-Is-Tablet-Viewer":"false","CloudFront-Viewer-Country":"TW","Host":"s15hzbnz38.execute-api.us-east-1.amazonaws.com","User-Agent":"curl/7.54.0","Via":"2.0 568df8a696d1e36b703a9e99ac784f28.cloudfront.net (CloudFront)","X-Amz-Cf-Id":"zYoQOMV-qhJHpgQqXyk2a-g1cl_yElm35JZHC5pU7AQDxpg3eDwlMg==","X-Amzn-Trace-Id":"Root=1-5bc22487-421fbbbb343e8488cc258331","X-Forwarded-For":"114.136.140.108, 52.46.62.144","X-Forwarded-Port":"443","X-Forwarded-Proto":"https"},"multiValueHeaders":{"Accept":["*/*"],"CloudFront-Forwarded-Proto":["https"],"CloudFront-Is-Desktop-Viewer":["true"],"CloudFront-Is-Mobile-Viewer":["false"],"CloudFront-Is-SmartTV-Viewer":["false"],"CloudFront-Is-Tablet-Viewer":["false"],"CloudFront-Viewer-Country":["TW"],"Host":["s15hzbnz38.execute-api.us-east-1.amazonaws.com"],"User-Agent":["curl/7.54.0"],"Via":["2.0 568df8a696d1e36b703a9e99ac784f28.cloudfront.net (CloudFront)"],"X-Amz-Cf-Id":["zYoQOMV-qhJHpgQqXyk2a-g1cl_yElm35JZHC5pU7AQDxpg3eDwlMg=="],"X-Amzn-Trace-Id":["Root=1-5bc22487-421fbbbb343e8488cc258331"],"X-Forwarded-For":["114.136.140.108, 52.46.62.144"],"X-Forwarded-Port":["443"],"X-Forwarded-Proto":["https"]},"queryStringParameters":null,"multiValueQueryStringParameters":null,"pathParameters":null,"stageVariables":null,"requestContext":{"resourceId":"xh8fdj","resourcePath":"/hello","httpMethod":"GET","extendedRequestId":"OtqlOGFuIAMFWjw=","requestTime":"13/Oct/2018:16:59:51 +0000","path":"/dev/hello","accountId":"229235317867","protocol":"HTTP/1.1","stage":"dev","requestTimeEpoch":1539449991832,"requestId":"66a19205-cf09-11e8-80c4-cd77c7835cfc","identity":{"cognitoIdentityPoolId":null,"accountId":null,"cognitoIdentityId":null,"caller":null,"sourceIp":"114.136.140.108","accessKey":null,"cognitoAuthenticationType":null,"cognitoAuthenticationProvider":null,"userArn":null,"userAgent":"curl/7.54.0","user":null},"apiId":"s15hzbnz38"},"body":null,"isBase64Encoded":false}}%

Serverless Remove

由於這只是一個測試的 Function,測試沒問題過後讓我們透過 serverless remove 指令將不需要的 Function 移除。

$ serverless remove

Serverless: Getting all objects in S3 bucket...
Serverless: Removing objects in S3 bucket...
Serverless: Removing Stack...
Serverless: Checking Stack removal progress...
....................
Serverless: Stack removal finished...

使用 Node-Canvas 在 Serverless 中動態產生圖片

將 Node-Canvas 加入到專案中

Node-Canvas 目前分為穩定版本 1.6.x 與 Alpha 版本 2.x,由於即將迎來的 2.x 對於一些基本的 API 有很多不相容的變更,建議大家可以直上 2.x 版本,就我們目前使用的經驗上,基本的 API 都還算相當穩定,建議可以參閱 Node-Canvas 專案的 Changelog 來追蹤一下詳細的變動。

另外在這邊文章撰寫時,Node-Canvas 的最新 Alpha Release 是剛釋出甫四天 (Oct 10th, 2018) 的 v2.0.0-alpha.16,不過 Canvas-Prebuilt 實測可以在 AWS Lambda 中穩定執行的最新版是 v2.0.0-alpha.13,詳細請參閱 Canvas-Prebuilt Releases

$ yarn add canvas@v2.0.0-alpha.13

畫出圓角矩形

再來讓我們新增 handlers/roundedSquare.js 檔案,在裡面使用 Node-Canvas 畫出一個簡單的圓角矩形:

const { createCanvas, loadImage } = require('canvas')

module.exports.roundedSquare = async (event, cxt, callback) => {
  cxt.callbackWaitsForEmptyEventLoop = false;

  console.info(event);

  // 宣告一個 570 x 480 的畫布
  const canvas = createCanvas(570, 480)
  const context = canvas.getContext('2d')
  const radius = 5
  const padding = 10
  const squareWidth  = 570 - 2 * padding
  const squareHeight = 480 - 2 * padding

  context.beginPath()
  // 將線條顏色設定為 #aaa
  context.strokeStyle = "#aaa"
  context.moveTo(padding + radius, padding)

  // 計算四頂點座標
  const upperLeft =  { x: padding,               y: padding }
  const upperRight = { x: padding + squareWidth, y: padding }
  const lowerRight = { x: padding + squareWidth, y: padding + squareHeight }
  const lowerLeft =  { x: padding,               y: padding + squareHeight }

  context.arcTo(
    upperRight.x,
    upperRight.y,
    upperRight.x,
    upperRight.y + radius,
    radius
  )

  context.arcTo(
    lowerRight.x,
    lowerRight.y,
    lowerRight.x - radius,
    lowerRight.y,
    radius
  )

  context.arcTo(
    lowerLeft.x,
    lowerLeft.y,
    lowerLeft.x,
    lowerLeft.y - radius,
    radius
  )

  context.arcTo(
    upperLeft.x,
    upperLeft.y,
    upperLeft.x + radius,
    upperLeft.y,
    radius
  )

  context.lineWidth = 1
  context.stroke()

  callback(null, {
    statusCode: 200,
    headers: { 'Content-Type': 'image/png' },
    body: canvas.toBuffer().toString('base64'),
    isBase64Encoded: true
  });
};

更新 Functions 設定

接著在 serverless.yml 中為更新 functions 的設定:

functions:
    roundedSquare:
    handler: handlers/roundedSquare.roundedSquare
    events:
      - http:
         path: rounded_square
         method: get

在 Local 檢視結果

與先前一樣,讓我們先使用 serverless offline 在本地端預覽一下結果

serverless offline

接著打開瀏覽器,瀏覽 http://localhost:3000/rounded_square 來檢視我們剛剛畫出來的圖片:

試著部署到 AWS Lambda

Prerequisite

由於 AWS Lambda 實際執行的環境為 Amazon Linux (實際執行環境可參閱官方文件 — Lambda Execution Environment and Available Libraries),為了確保 Canvas 打包時使用正確的 Prebuilt Packages,建議在 Linux 環境中執行打包,或是使用 AMI 的 Docker Image 來進行打包。

serverless deploy

$ serverless deploy

  Serverless Error ---------------------------------------

  Serverless plugin "serverless-webpack" not found. Make sure it's installed and listed in the "plugins" section of your serverless config file.

  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Issues:        forum.serverless.com

  Your Environment Information -----------------------------
     OS:                     linux
     Node Version:           8.10.0
     Serverless Version:     1.32.0

[app-user@cc-bastion canvas_example]$ yarn install
yarn install v1.9.4
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.4: The platform "linux" is incompatible with this module.
info "fsevents@1.2.4" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
warning " > babel-loader@8.0.4" has unmet peer dependency "@babel/core@^7.0.0".
[4/4] Building fresh packages...
warning Your current version of Yarn is out of date. The latest version is "1.10.1", while you're on "1.9.4".
info To upgrade, run the following command:
$ curl --compressed -o- -L https://yarnpkg.com/install.sh | bash
Done in 11.71s.
[app-user@cc-bastion canvas_example]$ serverless deploy
Serverless: Bundling with Webpack...
Time: 321ms
Built at: 2018-10-14 06:04:42
                        Asset      Size  Chunks             Chunk Names
    handlers/roundedSquare.js  5.14 KiB       0  [emitted]  handlers/roundedSquare
handlers/roundedSquare.js.map  2.79 KiB       0  [emitted]  handlers/roundedSquare
Entrypoint handlers/roundedSquare = handlers/roundedSquare.js handlers/roundedSquare.js.map
[0] ./handlers/roundedSquare.js 1.39 KiB {0} [built]
[1] external "canvas" 42 bytes {0} [built]
Serverless: Package lock found - Using locked versions
Serverless: Packing external modules: canvas@2.0.0-alpha.14
Serverless: Packaging service...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (12.75 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............................
Serverless: Stack update finished...
Service Information
service: Canvas
stage: dev
region: us-east-1
stack: Canvas-dev
api keys:
  None
endpoints:
  GET - https://e0jf721pek.execute-api.us-east-1.amazonaws.com/dev/rounded_square
functions:
  roundedSquare: Canvas-dev-roundedSquare
[app-user@cc-bastion canvas_example]$ git pull -r
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (4/4), done.
Unpacking objects: 100% (7/7), done.
remote: Total 7 (delta 3), reused 7 (delta 3), pack-reused 0
From https://github.com/YushengLi/canvas_example
 + 00accff...ea4e0d8 master     -> origin/master  (forced update)
First, rewinding head to replay your work on top of it...
[app-user@cc-bastion canvas_example]$ serverless deploy
Serverless: Bundling with Webpack...
Time: 303ms
Built at: 2018-10-14 06:10:15
                        Asset      Size  Chunks             Chunk Names
    handlers/roundedSquare.js  5.14 KiB       0  [emitted]  handlers/roundedSquare
handlers/roundedSquare.js.map  2.79 KiB       0  [emitted]  handlers/roundedSquare
Entrypoint handlers/roundedSquare = handlers/roundedSquare.js handlers/roundedSquare.js.map
[0] ./handlers/roundedSquare.js 1.39 KiB {0} [built]
[1] external "canvas" 42 bytes {0} [built]
Serverless: Package lock found - Using locked versions
Serverless: Packing external modules: canvas@2.0.0-alpha.13
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (12.74 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: Canvas
stage: dev
region: us-east-1
stack: Canvas-dev
api keys:
  None
endpoints:
  GET - https://e0jf721pek.execute-api.us-east-1.amazonaws.com/dev/rounded_square
functions:
  roundedSquare: Canvas-dev-roundedSquare

檢查結果

接著使用瀏覽器瀏覽部署 Log 中的 Endpoint,以上面為例是 https://e0jf721pek.execute-api.us-east-1.amazonaws.com/dev/rounded_square

此時會發現回傳的結果並不是一張正確的圖片:

除錯

讓我們用 curl 來看一下實際回傳結果:

$ curl https://e0jf721pek.execute-api.us-east-1.amazonaws.com/dev/rounded_square
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  

這邊可以發現回傳的 Response Body 並不是一張 Binary Image,而是 Base64 Encoded 過的 Image。

這個原因是經過 Lambda 處理完的 Response 並未進行重新編碼成 Binary Response 的步驟,這個步驟需要在 API Gateway 進行設定,讓 API Gateway 將 Lambda 的 Response 重新編碼成 Binary 結果。

Encoded as Binary Media Types

在 API Gateway 當中可以透過手動設定 Content Type Conversions、Binary Media Types 的方式來達成 Binary Conversion,詳細可以參閱官方文件 — Enable Binary Support Using the API Gateway Console

不過我們若希望 serverless deploy 在建立 CloudFormation Stack 時就自動幫我們把設定調整好,可以透過以下兩個名字很相近的 Plugins 來達成:

  • serverless-apigw-binary:讓 API Gateway 可以根據 Headers 來決定要如何回應
  • serverless-apigwy-binary:幫助你在 API Gateway 加上 contentHandling: CONVERT_TO_BINARY 的設定。

加入需要的 Plugins

首先先將所需要的 Plugins 加到專案中:

$ yarn add serverless-apigwy-binary serverless-apigw-binary --dev

讓我們回到 serverless.yml 為我們新增的 Plugins 調整一些設定

service: Canvas

plugins:
  - serverless-webpack
  - serverless-offline
  - serverless-apigw-binary
  - serverless-apigwy-binary

provider:
  name: aws
  runtime: nodejs8.10

custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true
    packager: yarn
  apigwBinary:
    types:
      - 'image/png'
      - '*/*'

package:
  individually: true

functions:
  roundedSquare:
    handler: handlers/roundedSquare.roundedSquare
    events:
      - http:
         path: rounded_square
         method: get
         contentHandling: CONVERT_TO_BINARY
  • plugins: 加入 serverless-apigw-binaryserverless-apigwy-binary
  • custom.apigwBinary: 加入所需的 MIME-TYES 確保 API Gateway 可以正確的處理他們,這邊為了避免 Request Header 中的 Accept 沒有被正確設定成 image/png,加上這個 Function 除了回傳圖片之外沒有要處理多種不同的回傳檔案格式,所以直接新增 */* 以確保所有的 Request 都能被正確處理。
  • functions.roundedSquare.events: 加入 contentHandling: CONVERT_TO_BINARY 設定,確保 API Gateway 正確的將 Response 轉換為 Binary 的格式。

再次 Deploy

讓我們再次 Deploy 看看剛剛為了處理 Binary Encoding 所做的設定是不是可以正常運作了。

執行 serverless deploy 後,再次檢視回傳的 Endpoints 就可以看到圖片以 Binary 的形式正常回傳了。

小結

大致掌握了如何在 AWS Lambda 上面以 Node-Canvas 動態長出圖片後,你就可以用 Node-Canvas 十分接近 HTML Canvas 的 API,根據 Request 動態插入圖片、文字、線條等各式各樣的元素了。

由於 HTML Canvas 的 API 不是三言兩語介紹完的,若對怎麼操作 Canvas API 有興趣,可以參考 MDN 關於 Canvas API 的篇目。

HighCharts Export Server

接著讓我們來談談,如何透過 HighCharts Export Server 動態生成圖片。

Using as a Node.js Module

這邊由於我們要在 AWS Lambda 中運行的緣故,最好的做法是 Using as a Node.js Module,基本上只要把本來餵給 HighCharts.js 的設定傳入給 HighCharts Exporter 就行了。

不過官方 GitHub Repo README 的範例對於 Promise 並沒有直接的支援,所幸在 Repo Module Test 中可以找到能直接使用的 Sample Code,大致上如下:

// lib/HighchartPainter.js
import HighchartExporter from 'highcharts-export-server'

const exportCharts = (charts, exportOptions) => {
  exportOptions = exportOptions || {};

  let promises = [];
  let chartResults = [];

  exporter.initPool();

  charts.forEach((chart, i) => {
    promises.push(
      new Promise((resolve, reject) => {

        let exportData = Object.assign({}, exportOptions);
        exportData.options = chart;

        exporter.export(exportData, (err, res) => {
          if (err) return reject(err);
          chartResults.push(res.data);
          resolve();
        });
      })
    );
  });

  return Promise.all(promises)
    .then(() => {
      exporter.killPool();
      return Promise.resolve(chartResults);
    })
    .catch(e => {
      exporter.killPool();
      return Promise.reject(e);
    });
};

回傳的結果會是 Base64 編碼的圖片,可以直接使用 Node-Canvas Image 相關的 API 繪製在生成的圖片上。

HighChars Export Options

建議先透過 HighCharts 官方的 Export Server 調整完自己所需的參數及樣式。

因為我所需要產出的圖片較為單純,所以我直接定義了 HighChartsConfig 這個 Class 來幫我動態生成給 Export Server 需要的設定。

import javascriptStringify from 'javascript-stringify'

export class HighchartConfig {
  static generateFrom({
    dataset = [],
    precision = 2,
    width = 250,
    height = 150,
    } = {}) {
    return javascriptStringify({
      chart: {
        type: 'area',
        animation: false,
        height: height,
        width: width,
        marginTop: 0,
        marginLeft: 0,
        marginRight: 0,
        marginBottom: 25
      },
      navigation: { buttonOptions: { enabled: false } },
      title: { text: null },
      time: {
        timezoneOffset: -9 * 60
      },
      plotOptions: {
        series: {
          animation: false,
          dataGrouping: { enabled: false },
          marker: { enabled: false, symbol: 'circle', radius: 2 }
        },
        area: {
          threshold: false,
          lineColor: '#1a91d1',
          fillColor: 'rgba(111, 190, 247,0.3)',
          lineWidth: 4
        }
      },
      rangeSelector: { enabled: false },
      credits: { enabled: false },
      legend: { enabled: false },
      xAxis: {
        title: { text: null },
        tickAmount: 2,
        gridLineWidth: 0,
        type: 'datetime',
        dateTimeLabelFormats: {
          day: '%m/%d',
          week: '%Y/%m/%d',
          month: '%Y/%m',
          hour: '%H:%M'
        },
        minPadding: 0.011,
        labels: { style: { color: '#79808f', fontSize: '20px' }, y: 20 },
        tickInterval: 3600 * 8 * 1000
      },
      yAxis: {
        title: { text: null },
        tickAmount: 4,
        gridLineColor: '#e5e5e5',
        floor: 0,
        labels: {
          align: 'right',
          x: 0,
          y: -3,
          width: 40,
          step: 1,
          style: { color: '#79808f', fontSize: '24px' },
          precision: precision,
          convertOptions: convertOptions,
          formatter: function() {
            return Highcharts.numberFormat(
              parseFloat(price),
              this.axis.options.labels.precision, '.', ','
            )
          }
        },
        opposite: true,
        showLastLabel: false,
        startOnTick: true,
        endOnTick: false,
      },
      series: [{ data: dataset }]
    })
  }
}

需要特別一提的是,Config 回傳結果預設會經過 JSON Stringify,但在這個過程中會破壞 JavaScript Object 中所定義的 Function (如上例的 Formatter),這邊我透過 javascript-stringify 作為繞過這個問題的解法。

由於 HighCharts 依據所需圖表類型、樣式而有非常多樣的設定值,這邊無法一一解釋各種圖表與選項,可以自行參照詳盡的官方手冊進行調校。

安裝需要的 Dependencies

$ yarn add javascript-stringify highcharts-export-server  

部署到 Lambda 的注意事項

在部署到 Lambda 的過程中有幾件事情需要注意:

  • 設定 ACCEPT_HIGHCHARTS_LICENSE 環境變數:請確保在執行、部署的環境中都要設定環境變數 ACCEPT_HIGHCHARTS_LICENSE 為 1,以確保可以正常使用 HighCharts Export Server
  • HighCharts Export Server 當中使用 Phantom Prebuilt 來避免掉需要安裝繁雜的系統相依性套件,請確保在 Linux 或是 AMI 的 Docker 環境中打包。

serverless.yml 中加入 Environment 設定

provider:
  name: aws
  runtime: nodejs8.10
  environment:
    ACCEPT_HIGHCHARTS_LICENSE: 1

接著執行 serverless deploy 就完成佈署了。

結語

這個簡介主要是要分享我為何選用 AWS Lambda 搭建出動態圖表生成的服務,並簡介其中用到的主要技術與函式庫。結合 Node-Canvas 與 HighCharts 的圖表匯出功能後,可以輕易的根據所需要動態的即時生成不同的圖片。

Serverless 架構的確省卻了我不少維護的心力、同時又提供足夠強大的運算效能。其實 Serverless 還有更多較有價值的運用實例,以上僅以最近使用的案例與各位分享。