Serverless + WebSocket 实时消息推送

无服务器的概述

无服务器(Serverless)不是表示没有服务器,而表示当您在使用 Serverless 时,您无需关心底层资源,也无需登录服务器和优化服务器,只需关注最核心的代码片段,即可跳过复杂的、繁琐的基本工作。核心的代码片段完全由事件或者请求触发,平台根据请求自动平行调整服务资源。Serverless 拥有近乎无限的扩容能力,空闲时,不运行任何资源。代码运行无状态,可以轻易实现快速迭代、极速部署。

什么是 Serverless

Serverless 圈内俗称为“无服务器架构”,Serverless 是一种软件系统架构思想和方法,它的核心思想是用户无须关注支撑应用服务运行的底层主机。所谓“无服务器”,并不是说基于 Serverless 架构的软件应用不需要服务器就可以运行,其指的是用户无须关心软件应用运行涉及的底层服务器的状态、资源(比如 CPU、内存、磁盘及网络)及数量。软件应用正常运行所需要的计算资源由底层的云计算平台动态提供。

特点:

  • 无状态:因为每次函数执行,可能使用的都是不同的容器,无法进行内存或数据共享。如果要共享数据,则只能通过第三方服务,比如 Redis,COS 等。
  • 无运维:使用 Serverless 我们不需要关心服务器,不需要关心运维。这也是 Serverless 思想的核心。
  • 事件驱动编程:Serverless 的运行才计算,便意味着他是事件驱动式计算。
  • 低成本:使用 Serverless 成本很低,因为我们只需要为每次函数的运行付费。函数不运行,则不花钱,也不会浪费服务器资源。
    • 在 Serverless 应用中,开发者只需要专注于业务,剩下的运维等工作都不需要操心
    • Serverless 是真正的按需使用,请求到来时才开始运行
    • Serverless 是按运行时间和内存来算钱的
    • Serverless 应用严重依赖于特定的云平台、第三方服务

Serverless 的优势

  • 降低启动成本
  • 减少运营成本
  • 降低开发成本
  • 实现快速上线
  • 更快的部署流水线
  • 更快的开发速度
  • 系统安全性更高
  • 适应微服务架构
  • 自动扩展能力

Serverless 的缺点

  • 不适合有状态的服务
  • 不适合长时间运行应用
  • 完全依赖于第三方服务
  • 冷启动时间较长
  • 缺乏调试和开发工具

Serverless 的适用场景

  • 发送通知
  • WebHook
  • 轻量级 API
  • 物联网
  • 数据统计分析
  • Trigger 及定时任务
  • 精益创业
  • Chat 机器人

什么是 Serverless Framework

Serverless Framework 是业界非常受欢迎的无服务器应用框架,开发者无需关心底层资源即可部署完整可用的 Serverless 应用架构。Serverless Framework 具有资源编排、自动伸缩、事件驱动等能力,覆盖编码-调试-测试-部署等全生命周期,帮助开发者通过联动云资源,迅速构建 Serverless 应用。

腾讯云云函数

腾讯云为应用开发者提供的一站式后端云服务。腾讯云云函数是腾讯云提供的 Serverless 执行环境。您只需编写简单的、目的单一的云函数即可将它与您的腾讯云基础设施及其他云服务产生的事件关联。

Serverless Framework 和云函数有什么区别?

云函数(Serverless Cloud Function,SCF)是腾讯云为企业和开发者们提供的无服务器执行环境,帮助开发者在无需购买和管理服务器的情况下运行代码 。
Serverless Framework 是无服务器应用框架,提供将云函数 SCF、API 网关、对象存储 COS、云数据库 DB 等资源组合的业务框架,开发者可以直接基于框架编写业务逻辑,而无需关注底层资源的配置和管理。

安装 serverless

npm install -g serverless

使用 Node.js10.0 及以上版本,否则 Component V2 部署有可能报错

Nodejs 开发调试

sls dev 开发时调试
sls deploy 发布

腾讯云 serverless 组件

  • 云函数 SCF 组件
  • API 网关组件
  • 对象存储 COS 组件
  • 内容分发网络 CDN 组件
  • 数据库 PostgreSQL 组件
  • 私有网络 VPC 组件

本篇,我们用 serverless+websocket 实现 IM,用到了云函数组件,API 网关组件,存储的话用 COS 组件或者数据库组件。

实现原理

由于云函数是无状态且以触发式运行,即在有事件到来时才会被触发,因此,为了实现 WebSocket,云函数与 API 网关相结合,通过 API 网关承接及保持与客户端的连接。当客户端有消息发出时,会先传递给 API 网关,再由 API 网关触发云函数执行。当服务端云函数要向客户端发送消息时,会先由云函数将消息 POST 到 API 网关的反向推送链接,再由 API 网关向客户端完成消息的推送。

对于 WebSocket 整个生命周期的事件,云函数和 API 网关的处理过程如下:

  • 连接建立:客户端与 API 网关建立 WebSocket 连接,API 网关将连接建立事件发送给 SCF。
  • 数据上行:客户端通过 WebSocket 发送数据,API 网关将数据转发送给 SCF。
  • 数据下行:SCF 通过向 API 网关指定的推送地址发送请求,API 网关收到后会将数据通过 WebSocket 发送给客户端。
  • 客户端断开:客户端请求断开连接,API 网关将连接断开事件发送给 SCF。
  • 服务端断开:SCF 通过向 API 网关指定的推送地址发送断开请求,API 网关收到后断开 WebSocket 连接。

因此,API 网关与 SCF 之间的交互,因此我们需要创建上图中的三个云函数:

  • 注册函数:在客户端发起和 API 网关之间建立 WebSocket 连接时触发该函数,通知 SCF WebSocket 连接的 secConnectionID。通常会在该函数记录 secConnectionID 到持久存储中,用于后续数据的反向推送。
  • 清理函数:在客户端主动发起 WebSocket 连接中断请求时触发该函数,通知 SCF 准备断开连接的 secConnectionID。通常会在该函数清理持久存储中记录的该 secConnectionID。
  • 传输函数:在客户端通过 WebSocket 连接发送数据时触发该函数,告知 SCF 连接的 secConnectionID 以及发送的数据。通常会在该函数处理业务数据。例如,是否将数据推送给持久存储中的其他 secConnectionID。

具体实现

下面是一个最简单的 serverless+websocket 的实现。

1、腾讯云控制台:可以在腾讯云控制台根据教程一步步创建:https://cloud.tencent.com/document/product/583/32971

2、编辑器里代码实现:

创建云函数

创建 serverless 根目录,然后在目录控制台输入:serverless,按提示选择 SCF 模板,一路确定,就会生成了一个云函数的模板。
然后在同层级创建三个文件目录,分别为:wsRegister,wsTransmission,wsDelete。
如图:

修改云函数的功能实现代码块

wsRegister.index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const COS = require("cos-nodejs-sdk-v5");
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
exports.main_handler = async (event) => {
const connectionID = (event.websocket || {}).secConnectionID;
cos.putObject(
{
Bucket: process.env.Bucket /* 必须 */,
Region: process.env.Region /* 必须 */,
Key: connectionID /* 必须 */,
Body: "websocket",
},
function (err, data) {
// eslint-disable-next-line moka/lingui-mark
console.log("上传bucket成功", err, data);
}
);

return {
errNo: 0,
errMsg: "ok",
websocket: {
action: "connecting",
secConnectionID: connectionID,
},
};
};

wsTransmission.index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const request = require("request");
const COS = require("cos-nodejs-sdk-v5");
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
exports.main_handler = async (event) => {
const data = (event.websocket || {}).data;
const connectionID = (event.websocket || {}).secConnectionID;
let bucketObject = {};
cos.getBucket(
{
Bucket: process.env.Bucket /* 必须 */,
Region: process.env.Region /* 必须 */,
},
function (err, res) {
bucketObject = res;
for (let bucket of bucketObject.Contents || []) {
// eslint-disable-next-line no-constant-condition
if (1) {
const bodyData = {
websocket: {
action: "data send",
secConnectionID: bucket.Key,
dataType: "text",
data: data,
},
};
const params = {
url: process.env.SendbackHost,
method: "POST",
json: true,
body: bodyData,
headers: {
"Content-type": "application/json",
"Content-Length": Buffer.byteLength(JSON.stringify(bodyData)),
},
};
request.post(params, (err, _res, body) => {
// eslint-disable-next-line moka/lingui-mark
console.log("推送成功:", err, "body**", body);
});
}
}
}
);

return "send success";
};

wsDelete.index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const request = require("request");
const COS = require("cos-nodejs-sdk-v5");
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
exports.main_handler = async (event) => {
const connectionID = (event.websocket || {}).secConnectionID;
cos.deleteObject(
{
Bucket: process.env.Bucket /* 必须 */,
Region: process.env.Region /* 必须 */,
Key: connectionID,
},
function (err, data) {
const bodyData = {
websocket: {
action: "closing",
secConnectionID: connectionID,
},
};
request.post(
{
url: process.env.SendbackHost,
method: "POST",
json: true,
body: bodyData,
headers: {
"Content-Type": "application/json",
"Content-Length": JSON.stringify(bodyData).length,
},
},
(err, res) => {
// eslint-disable-next-line moka/lingui-mark
console.log("删除bucket", err, res);
}
);
}
);
return event;
};

三个云函数的 yml 配置都差不多如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
component: scf
name: wsDelete # 每个函数要唯一

# 以下三个名称要一样,否则部署的时候会报不一致的错误
org: moka
app: video-interview-im
stage: dev

inputs:
src: ./
name: ${name}-${app}-${stage}
handler: index.main_handler # 入口,文件目录里的index.js文件里的main_handler方法
region: ap-beijing
runtime: Nodejs10.15
environment:
# 这是云函数里使用到的的环境变量,具体值定义在.env文件里,然后在这里读取,如果没有定义,云函数调用会报错
variables:
SecretId: ${env:TENCENT_SECRET_ID}
SecretKey: ${env:TENCENT_SECRET_KEY}
Bucket: ${env:BUCKET}
Region: ${env:REGION}
SendbackHost: ${env:SENDBACK_HOST}

项目根目录需要一个.env 文件,配置隐私信息。SendbackHost 是 API 网关反向推送地址,在腾讯云 API 网关控制台找:

API 网关配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
component: apigateway
name: websocket-gateway

org: moka
app: video-interview-im
stage: dev

inputs:
region: ap-beijing
protocols:
- http
- https
serviceName: videoInterviewSocket
description: websocket api for video interview
environment: release
netTypes:
- OUTER
- INNER
endpoints:
#前端类型: WEBSOCKET, 后端类型: SCF
- path: /vdInterviewWs
method: GET
enableCORS: TRUE
protocol: WEBSOCKET
serviceTimeout: 600
function:
# 这里对应了三个云函数
transportFunctionName: wsTransmission-video-interview-im-dev
registerFunctionName: wsRegister-video-interview-im-dev
cleanupFunctionName: wsDelete-video-interview-im-dev

完成了云函数、API 网关的创建,在项目根目录运行 sls deploy –all 就能把所有文件推到腾讯云服务器实现部署,接口就能被外部访问了。可以自行去腾讯云控制台查看推上去的内容。

最后一步,发起 WebSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 创建websocket并连接
const url = "ws://service-xxx.bj.apigw.tencentcs.com/release/websocket";
socket = new WebSocket(url);
socket.addEventListener("open", function (event) {
console.log("socket is open");
});
socket.onmessage = (event) => {
this.setState({ imMessages: [...this.state.imMessages, event.data] });
};

// 展示内容组件
import React from "react";
import { Button } from "moka-ui";

function submit(socket) {
console.log("submit");
const val = document.getElementById("textarea").value;
document.getElementById("textarea").value = "";
socket.send(val);
}
export default function ({ messages = [], socket = null }) {
return (
<div>
<div>serverless IM test</div>
<div style={{ display: "flex" }}>
<div>
<textarea id="textarea" height="200"></textarea>
<Button onClick={() => submit(socket)}>Submit</Button>
</div>
<div id="content">
{messages.map((message) => {
return <div>{message}</div>;
})}
</div>
</div>
</div>
);
}

通过以上操作,一个简单的、无需后端协助的实时消息发送、接收就能实现了。另外,开发过程中,看日志查询很重要,这能加快问题解决!

问题

WebSocket connection to ‘ws://service-xxx.bj.apigw.tencentcs.com/release/ws-test’ failed: Unexpected response code: 400

websocket 没有建立连接,一开始不知道咋回事,后面在腾讯云后台日志发现,线上依赖没有找到,需要在腾讯云后台在线安装依赖,问题就解决了。如果还是连接不上,一定要多看看云函数后台的日志查询,有可能就是函数报错了,或者是某些包没有安装上,导致无法连接

nodejs 发送 post 请求,报错: socket hang up

解决:在 post 的 headers 加入一下内容
headers: {
“Content-Type”: “application/json”,
“Content-Length”: params.length,
}

body: { errNo: -1, errMsg: ‘parse request body fail’ }

我在调试的过程中还碰到这个问题,这个问题一开始觉得参数传的都没有问题啊,调查了很久,发现’Content-Length’传都长度被截断了,长度计算正确就不会有问题
解决:”Content-Length”: JSON.stringify(params).length