如何將傳統 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 應用是:

  1. 通過 app.listen() 啓動了 HTTP 服務,其本質上是調用的 Node.js http 模塊的 createServer() 方法創建了一個 HTTP Server

  2. 監聽了 / 路由,由回調函數 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 是基於事件觸發的,觸發器類型不同,參數映射和處理不同:

適配層

下面我們通過解讀阿里雲 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 種方案總結如下:

小結

本文介紹了傳統 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