需求
在我們幫某個客戶維護的服務當中,需要提供大量即時的數據圖表 (主要是 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 Module、TradingView 的 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-nodejs, path 則是指定專案要放置的路徑。
有關於 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則是宣告你有一個helloFunction,當他被呼叫時去執行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-binary與serverless-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 還有更多較有價值的運用實例,以上僅以最近使用的案例與各位分享。