如何將傳統 Web 框架部署到 Serverless
背景
因爲 Serverless 的 “無服務器架構” 應用相比於傳統應用有很多優點,比如:無需關心服務器、免運維、彈性伸縮、按需付費、開發可以更加關注業務邏輯等等,所以現在 Serverless 應用已經逐漸廣泛起來。
但是目前原生的 Serverless 開發框架還比較少,也沒有那麼成熟,另外主流的 Web 框架還不支持直接 Serverless 部署,但好在是現在國內各大雲廠商比如阿里雲、騰訊雲已經提供能力能夠將我們的傳統框架以簡單、快速、科學的方式部署到 Serverless 上,下面讓我們一起研究看看它們是怎麼做的吧。
我們以 Node.js 的 Express 應用爲例,看看如何通過阿里雲函數計算,實現不用按照傳統部署方式購買雲主機去部署,不用自己運維,快速部署到 Serverless 平臺上。
傳統應用與函數計算的入口差異
傳統應用的入口文件
首先看下傳統 Express 應用的入口文件:
const express = require('express')
const app = express()
const port = 3000
// 監聽 / 路由,處理請求
app.get('/', (req, res) => {
res.send('Hello World!')
})
// 監聽 3000 端口,啓動 HTTP 服務
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
可以看到傳統 Express 應用是:
-
通過 app.listen() 啓動了 HTTP 服務,其本質上是調用的 Node.js http 模塊的 createServer() 方法創建了一個 HTTP Server
-
監聽了
/
路由,由回調函數function(request, response)
處理請求
函數計算的入口函數
Serverless 應用中, FaaS 是基於事件觸發的,觸發器是觸發函數執行的方式, 其中 API 網關觸發器與 HTTP 觸發器與均可應用於 Web 應用的創建。函數計算會從指定的入口函數開始執行,其中 API 網關觸發器對應的入口函數叫事件函數,HTTP 觸發器對應的入口函數叫 HTTP 函數,它們的入口函數形式不同。
API 網關觸發器的入口函數形式
API 網關觸發器的入口函數形式如下,函數入參包括 event、context、callback,以 Node.js 爲例,如下:
/*
* handler: 函數名 handler 需要與創建函數時的 handler 字段相對應。例如創建函數時指定的 handler 爲 index.handler,那麼函數計算會去加載 index.js 文件中定義的 handler 函數
* event: 您調用函數時傳入的數據,其類型是 Buffer,是函數的輸入參數。您在函數中可以根據實際情況對 event 進行轉換。如果輸入數據是一個 JSON 字符串 ,您可以把它轉換成一個 Object。
* context: 包含一些函數的運行信息,例如 request Id、 臨時 AK 等。您在代碼中可以使用這些信息
* callback: 由系統定義的函數,作爲入口函數的入參用於返回調用函數的結果,標識函數執行結束。與 Node.js 中使用的 callback 一樣,它的第一個參數是 error,第二個參數 data。
*/
module.exports.handler = (event, context, callback) => {
// 處理業務邏輯
callback(null, data);
};
HTTP 觸發器的入口函數形式
一個簡單的 Node.js HTTP 函數示例如下所示:
module.exports.handler = function(request, response, context) {
response.send("hello world");
}
差異對比
對比可以看出,在傳統應用中,是啓動一個服務監聽端口號去處理 HTTP 請求,服務處理的是 HTTP 的請求和響應參數;而在 Serverless 應用中, Faas 是基於事件觸發的,觸發器類型不同,參數映射和處理不同:
-
若是 API 網關觸發器
-
當有請求到達後端服務設置爲函數計算的 API 網關時,API 網關會觸發函數的執行,觸發器會將事件信息生成 event 參數,然後 FaaS 以 event 爲參數執行入口函數,最後將執行結果返回給 API 網關。所以傳統應用和 Serverless 應用在請求響應方式和參數的數據結構上都有很大差異,要想辦法讓函數計算的入口方法適配 express。
-
若是 HTTP 觸發器
-
相對 API 網關觸發器參數處理會簡單些。因爲 HTTP 觸發器通過發送 HTTP 請求觸發函數執行,會把真實的 HTTP 請求直接傳遞給 FaaS 平臺,不需要編碼或解碼成 JSON 格式,不用增加轉換邏輯,性能也更優。
適配層
下面我們通過解讀阿里雲 FC 提供的將函數計算的請求轉發給 express 應用的 npm 包 @webserverless/fc-express 源碼,看看函數計算的入口方法是如何適配 express 的,如何適配 API 網關 和 HTTP 觸發器這兩種類型。
根據上述分析,Web 應用若想 Serverless 化需要開發一個適配層,將函數計算接收到的請求轉發給 express 應用處理,最後再返回給函數計算。
API 網關觸發的適配層
實現原理
API 網關觸發的情況下,通過適配層將 FaaS 函數接收到的 API 網關事件參數 event 先轉化爲標準的 HTTP 請求,再去讓傳統 Web 服務去處理請求和響應,最後再將 HTTP 響應轉換爲函數返回值。整體工作原理如下圖所示:
適配層核心就是:把 event 映射到 express 的 request 對象上, 再把 express 的 response 對象映射到 callback 的數據參數上。
API 網關調用函數計算的事件函數時,會將 API 的相關數據轉換爲 Map 形式傳給函數計算服務。函數計算服務處理後,按照下圖中 Output Format 的格式返回 statusCode、headers、body 等相關數據。API 網關再將函數計算返回的內容映射到 statusCode、header、body 等位置返回給客戶端。
核心過程
通過分析 @webserverless/fc-express 源碼,我們可以抽取核心過程實現一個簡易版的適配層。
1. 創建一個自定義 HTTP Server,通過監聽 Unix Domain Socket,啓動服務
(友情鏈接:不清楚 Unix Domain Socket 的小夥伴可以先看下這篇文章: Unix domain socket 簡介 (https://www.cnblogs.com/sparkdev/p/8359028.html))
第一步我們若想把函數計算接收的 event 參數映射到 Express.js 的 request 對象上,就需要創建並啓動一個自定義的 HTTP 服務來代替 Express.js 的 app.listen,然後接下來就可以將函數的事件參數 event 轉換爲 Express.js 的 request 請求參數。
首先創建一個 server.js 文件如下:
// server.js
const http = require('http');
const ApiGatewayProxy = require('./api-gateway-proxy');// api-gateway-proxy.js 文件下一步會說明其內容
/*
* requestListener:被代理的 express 應用
* serverListenCallback:http 代理服務開始監聽的回調函數
* binaryTypes: 當 express 應用的響應頭 content-type 符合 binaryTypes 中定義的任意規則,則返回給 API 網關的 isBase64Encoded 屬性爲 true
*/
function Server(requestListener,serverListenCallback,binaryTypes) {
this.apiGatewayProxy = new ApiGatewayProxy(this); // ApiGatewayProxy 核心過程 2 會介紹
this.server = http.createServer(requestListener);// 1.1 創建一個自定義 HTTP Server
this.socketPathSuffix = getRandomString(); // 隨機生成一個字符串,作爲 Unix Domain Socket 使用
this.binaryTypes = binaryTypes ? binaryTypes.slice() : [];// 當 express 應用響應的 content-type 符合 Server 構造函數參數 binaryTypes 中定義的任意規則時,則函數的返回值的 isBase64Encoded 爲 true,從而告訴 API 網關如何解析函數返回值的 body 參數
this.server.on("listening", () => {
this.isListening = true;
if (serverListenCallback) serverListenCallback();
});
this.server.on("close", () => {
this.isListening = false;
}).on("error", (error) => {
// 異常處理
});
}
// 暴露給函數計算入口函數 handler 調用的方法
Server.prototype.proxy = function (event, context, callback) {
const e = JSON.parse(event);
this.apiGatewayProxy.handle({
event: e,
context,
callback
});
}
// 1.2 啓動服務
Server.prototype.startServer = function () {
return this.server.listen(this.getSocketPath()); // 採用監聽 Unix Domain Socket 方式啓動服務,減少函數執行時間,節約成本
}
Server.prototype.getSocketPath = function () {
/* istanbul ignore if */
/* only running tests on Linux; Window support is for local dev only */
if (/^win/.test(process.platform)) {
const path = require('path');
return path.join('\\\\?\\pipe', process.cwd(), `server-${this.socketPathSuffix}`);
} else {
return `/tmp/server-${this.socketPathSuffix}.sock`;
}
}
function getRandomString() {
return Math.random().toString(36).substring(2, 15);
}
module.exports = Server;
在 server.js 中,我們定義了一個構造函數 Server 並導出。在 Server 中,我們創建了一個自定義的 HTTP 服務,然後隨機生成了一個 Unix Domain Socket,採用監聽該 Socket 方式啓動服務來代替 Express.js 的 app.listen
。
2. 將函數計算參數 event 轉換爲 Express.js 的 HTTP request
下面開始第 2 步,創建一個 api-gateway-proxy.js 文件,將函數計算參數 event 轉換爲 Express.js 的 HTTP request。
//api-gateway-proxy.js
const http = require('http');
const isType = require('type-is');
function ApiGatewayProxy(server) {
this.server = server;
}
ApiGatewayProxy.prototype.handle = function ({
event,
context,
callback
}) {
this.server.startServer()
.on('listening', () => {
this.forwardRequestToNodeServer({
event,
context,
callback
});
});
}
ApiGatewayProxy.prototype.forwardRequestToNodeServer = function ({
event,
context,
callback
}) {
const resolver = data => callback(null, data);
try {
// 2.1將 API 網關事件轉換爲 HTTP request
const requestOptions = this.mapContextToHttpRequest({
event,
context,
callback
});
// 2.2 通過 http.request() 將 HTTP request 轉發給 Node.js Server 處理,發起 HTTP 請求
const req = http.request(requestOptions, response => this.forwardResponse(response, resolver));
req.on('error', error => {
//...
});
req.end();
} catch (error) {
// ...
}
}
ApiGatewayProxy.prototype.mapContextToHttpRequest = function ({
event,
context,
callback
}) {
const headers = Object.assign({}, event.headers);
return {
method: event.httpMethod,
path: event.path,
headers,
socketPath: this.server.getSocketPath()
// protocol: `${headers['X-Forwarded-Proto']}:`,
// host: headers.Host,
// hostname: headers.Host, // Alias for host
// port: headers['X-Forwarded-Port']
};
}
// 核心過程 3 會介紹
ApiGatewayProxy.prototype.forwardResponse = function (response, resolver) {
const buf = [];
response
.on('data', chunk => buf.push(chunk))
.on('end', () => {
const bodyBuffer = Buffer.concat(buf);
const statusCode = response.statusCode;
const headers = response.headers;
const contentType = headers['content-type'] ? headers['content-type'].split(';')[0] : '';
const isBase64Encoded = this.server.binaryTypes && this.server.binaryTypes.length > 0 && !!isType.is(contentType, this.server.binaryTypes);
const body = bodyBuffer.toString(isBase64Encoded ? 'base64' : 'utf8');
const successResponse = {
statusCode,
body,
headers,
isBase64Encoded
};
resolver(successResponse);
});
}
module.exports = ApiGatewayProxy;
在 api-gateway-proxy.js 中,我們定義了一個構造函數 ApiGatewayProxy 並導出。在這裏我們會將 event 轉換爲 HTTP request,然後向 Node.js Server 發起請求,由 Node.js Server 再進行處理做出響應。
3. 將 HTTP response 轉換爲 API 網關標準數據結構,作爲 callback 的參數返回給 API 網關
接着繼續對 api-gateway-proxy.js 文件中的http.request(requestOptions, response => this.forwardResponse(response, resolver))
分析發出 HTTP 請求後的響應處理部分。
//api-gateway-proxy.js
ApiGatewayProxy.prototype.forwardRequestToNodeServer = function ({
event,
context,
callback
}) {
const resolver = data => callback(null, data); // 封裝 callback 爲 resolver
//...
// 請求、響應
const req = http.request(requestOptions, response => this.forwardResponse(response, resolver));
//...
}
//3.Node.js Server 對 HTTP 響應進行處理,將 HTTP response 轉換爲 API 網關標準數據結構,作爲函數計算返回值
ApiGatewayProxy.prototype.forwardResponse = function (response, resolver) {
const buf = [];
response
.on('data', chunk => buf.push(chunk))
.on('end', () => {
const bodyBuffer = Buffer.concat(buf);
const statusCode = response.statusCode;
const headers = response.headers;
const contentType = headers['content-type'] ? headers['content-type'].split(';')[0] : '';
const isBase64Encoded = this.server.binaryTypes && this.server.binaryTypes.length > 0 && !!isType.is(contentType, this.server.binaryTypes);
const body = bodyBuffer.toString(isBase64Encoded ? 'base64' : 'utf8');
// 函數返回值
const successResponse = {
statusCode,
body,
headers,
isBase64Encoded //當函數的 event.isBase64Encoded 是 true 時,會按照 base64 編碼來解析 event.body,並透傳給 express 應用,否則就按照默認的編碼方式來解析,默認是 utf8
};
// 將 API 網關標準數據結構作爲回調 callback 參數,執行 callback,返回給 API 網關
resolver(successResponse);
});
}
接着第 2 步,Node.js Server 對 http.request() 發出的 HTTP 請求做出響應處理,將 HTTP response 轉換爲 API 網關標準數據結構,把它作爲回調 callback 的參數,調用 callback 返回給 API 網關。
4. 在入口函數中引入適配層代碼並調用
以上 3 步就將適配層核心代碼完成了,整個過程就是:將 API 網關事件轉換成 HTTP 請求,通過本地 socket 和函數起 Node.js Server 進行通信。
最後我們在入口函數所在文件 index.js 中引入 server.js,先用 Server 構建一個 HTTP 代理服務,然後在入口函數 handler 中調用 server.proxy(event, context, callback);
即可將函數計算的請求轉發給 express 應用處理。
// index.js
const express = require('express');
const Server = require('./server.js');
const app = express();
app.all('*', (req, res) => {
res.send('express-app hello world!');
});
const server = new Server(app); // 創建一個自定義 HTTP Server
module.exports.handler = function(event, context, callback) {
server.proxy(event, context, callback); // server.proxy 將函數計算的請求轉發到 express 應用
};
我們將以上代碼在 FC 上部署、調用,執行成功結果如下:
HTTP 觸發的適配層
實現原理
HTTP 觸發的情況下,不用對請求參數做轉換,其它原理與 API 網關觸發器一致:通過適配層將 FaaS 函數接收到的請求參數直接轉發到自定義的 Web 服務內,最後再將 HTTP 響應包裝返回即可,整體工作原理如下圖所示:
核心過程
同樣我們抽取核心過程簡單實現一個適配層,與 API 網關觸發器原理相同的部分將不再贅述 。
1. 創建一個自定義 HTTP Server,通過監聽 Unix Domain Socket,啓動服務
server.js 代碼如下:
// server.js
const http = require('http');
const HttpTriggerProxy = require('./http-trigger-proxy');
function Server(requestListener,serverListenCallback) {
this.httpTriggerProxy = new HttpTriggerProxy(this);
this.server = http.createServer(requestListener); // 1.1 創建一個自定義 HTTP Server
this.socketPathSuffix = getRandomString();
this.server.on("listening", () => {
this.isListening = true;
if (serverListenCallback) serverListenCallback();
});
this.server.on("close", () => {
this.isListening = false;
}).on("error", (error) => {
// 異常處理,例如判讀 socket 是否已被監聽
});
}
// 暴露給函數計算入口函數 handler 調用的方法
Server.prototype.httpProxy = function (request, response, context) {
this.httpTriggerProxy.handle({ request, response, context });
}
// 1.2 啓動服務
Server.prototype.startServer = function () {
return this.server.listen(this.getSocketPath());
}
Server.prototype.getSocketPath = function () {
/* istanbul ignore if */
/* only running tests on Linux; Window support is for local dev only */
if (/^win/.test(process.platform)) {
const path = require('path');
return path.join('\\\\?\\pipe', process.cwd(), `server-${this.socketPathSuffix}`);
} else {
return `/tmp/server-${this.socketPathSuffix}.sock`;
}
}
function getRandomString() {
return Math.random().toString(36).substring(2, 15);
}
module.exports = Server;
2. 將 HTTP request 直接轉發給 Web Server,再將 HTTP response 包裝返回
創建一個 api-trigger-proxy.js 文件如下:
// api-trigger-proxy.js
const http = require('http');
const isType = require('type-is');
const url = require('url');
const getRawBody = require('raw-body');
function HttpTriggerProxy(server) {
this.server = server;
}
HttpTriggerProxy.prototype.handle = function ({
request,
response,
context
}) {
this.server.startServer()
.on('listening', () => {
this.forwardRequestToNodeServer({
request,
response,
context
});
});
}
HttpTriggerProxy.prototype.forwardRequestToNodeServer = function ({
request,
response,
context
}) {
// 封裝 resolver
const resolver = data => {
response.setStatusCode(data.statusCode);
for (const key in data.headers) {
if (data.headers.hasOwnProperty(key)) {
const value = data.headers[key];
response.setHeader(key, value);
}
}
response.send(data.body); // 返回 response body
};
try {
// 透傳 request
const requestOptions = this.mapContextToHttpRequest({
request,
context
});
// 2.將 HTTP request 直接轉發給 Web Server,再將 HTTP response 包裝返回
const req = http.request(requestOptions, response => this.forwardResponse(response, resolver));
req.on('error', error => {
// ...
});
// http 觸發器類型支持自定義 body:可以獲取自定義 body
if (request.body) {
req.write(request.body);
req.end();
} else {
// 若沒有自定義 body:http 觸發器觸發函數,會通過流的方式傳輸 body 信息,可以通過 npm 包 raw-body 來獲取
getRawBody(request, (err, body) => {
req.write(body);
req.end();
});
}
} catch (error) {
// ...
}
}
HttpTriggerProxy.prototype.mapContextToHttpRequest = function ({
request,
context
}) {
const headers = Object.assign({}, request.headers);
headers['x-fc-express-context'] = encodeURIComponent(JSON.stringify(context));
return {
method: request.method,
path: url.format({ pathname: request.path, query: request.queries }),
headers,
socketPath: this.server.getSocketPath()
// protocol: `${headers['X-Forwarded-Proto']}:`,
// host: headers.Host,
// hostname: headers.Host, // Alias for host
// port: headers['X-Forwarded-Port']
};
}
HttpTriggerProxy.prototype.forwardResponse = function (response, resolver) {
const buf = [];
response
.on('data', chunk => buf.push(chunk))
.on('end', () => {
const bodyBuffer = Buffer.concat(buf);
const statusCode = response.statusCode;
const headers = response.headers;
const contentType = headers['content-type'] ? headers['content-type'].split(';')[0] : '';
const isBase64Encoded = this.server.binaryTypes && this.server.binaryTypes.length > 0 && !!isType.is(contentType, this.server.binaryTypes);
const body = bodyBuffer.toString(isBase64Encoded ? 'base64' : 'utf8');
const successResponse = {
statusCode,
body,
headers,
isBase64Encoded
};
resolver(successResponse);
});
}
module.exports = HttpTriggerProxy;
3. 入口函數引入適配層代碼
// index.js
const express = require('express');
const Server = require('./server.js');
const app = express();
app.all('*', (req, res) => {
res.send('express-app-httpTrigger hello world!');
});
const server = new Server(app);
module.exports.handler = function (req, res, context) {
server.httpProxy(req, res, context);
};
同樣地,我們將以上代碼在 FC 上部署、調用,執行成功結果如下:
看到最後,大家會發現 API 網關觸發器和 HTTP 觸發器很多代碼邏輯是可以複用的,大家可以自行閱讀優秀的源碼是如何實現的~
其他部署到 Serverless 平臺的方案
將傳統 Web 框架部署到 Serverless 除了通過適配層轉換實現,還可以通過 Custom Runtime 或者 Custom Container Runtime (https://juejin.cn/post/6981921291980767269#heading-5) ,3 種方案總結如下:
-
通過引入適配層,將函數計算接收的事件參數轉換爲 HTTP 請求交給自定義的 Web Server 處理
-
通過 Custom Runtime
-
本質上也是一個 HTTP Server,接管了函數計算平臺的所有請求,包括事件調用或者 HTTP 函數調用等
-
開發者需要創建一個啓動目標 Server 的可執行文件 bootstrap
-
通過 Custom Container Runtime
-
工作原理與 Custom Runtime 基本相同
-
開發者需要把應用代碼和運行環境打包爲 Docker 鏡像
小結
本文介紹了傳統 Web 框架如何部署到 Serverless 平臺的方案:可以通過適配層和自定義(容器)運行時。其中主要以 Express.js 和阿里雲函數計算爲例講解了通過適配層實現的原理和核心過程,其它 Web 框架 Serverless 化的原理也基本一致,騰訊雲也提供了原理一樣的 tencent-serverless-http (https://github.com/serverless-plus/tencent-serverless-http) 方便大家直接使用(但騰訊雲不支持 HTTP 觸發器),大家可以將自己所使用的 Web 框架對照雲廠商函數計算的使用方法親自開發一個適配層實踐一下~
參考資料
Webserverless - FC Express extension (https://github.com/awesome-fc/webserverless/tree/master/packages/fc-express)
如何將 Web 框架遷移到 Serverless (https://zhuanlan.zhihu.com/p/152391799)
Serverless 工程實踐 | 傳統 Web 框架遷移 (https://developer.aliyun.com/article/790302)
阿里雲 - 觸發器簡介 (https://help.aliyun.com/document_detail/53102.html)
前端學 serverless 系列—— WebApplication 遷移實踐 (https://zhuanlan.zhihu.com/p/72076708)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/XvX6b3i9D197s3voXRcemQ