「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」
由于已经接触过不少
B/S结构项目,于是希望以C/S结构来构建一个项目,而socket通信就多用于此。本文章中会主要介绍客户端与服务端建立WebSocket连接的方式,消息的封装,消息的加密,及一些常见问题。
概念介绍
WebSocket
传统的Http请求是无状态的,每次请求只能由一方发起,另一方接收消息后响应即结束。对于需要频繁交互的,如聊天室,对于需要实时更新的项目,以往的实现采用轮询的方式执行,每隔一段极短时间向服务器发起请求,从而更新新的内容,每次建立握手连接与关闭是非常耗费时间与资源的。
WebSocket协议是在Http 1.1协议基础上产生,解决了Http的上述问题,能够进行全双工的TCP通信。只需要建立一次握手连接,之后便可以维持一个通信信道,借助监听消息事件来发送消息。
需要注意的是,WebSocket的消息传输以Frame帧的形式分段发送,获取的结果一般为字符串,因此若想传输对象,数组等结构,需要进行序列化与反序列化。
目标
个人实践将构建一个客户端与服务端的机器人通信项目。
服务端解析定制的DSL脚本,进行分词/解析AST;根据客户端的不同响应来进行不同的通信。主要借助后端ws模块来通信。
领域特定语言(Domain Specific Language,DSL)可以提供一种相对简单的文法,用于特定领域的业务流程定制。
客户端即为人机交互,用户与机器人进行交流。主要借助Html5原生的Websocket来进行通信,与ws模块的使用有所不同。
用法介绍
Html5 WebSocket和ws模块的用法有所不同,前者仅可在网页中使用,后者仅可在服务端使用;且对于获取到的数据也可能有所差异。
二者对于状态的划分与使用是相同的,通过ws.readyState获取状态码:
ws.CONNECTING === 0;
ws.OPEN === 1;
ws.CLOSING === 2;
ws.CLOSED === 3;
Html5 WebSocket
建立连接
字符串为ws协议的地址:ws://...
let ws = new WebSocket('ws://localhost:8080');
接收消息
ws.onmessage = function(event){
let msg = event.data;
// ...
}
这里需要强调对于数据的获取,需要间接通过event对象,且event.data可能有两种数据格式,一种为String,另一种为Blob。对于String的处理不必赘述,需要额外说明对于Blob的处理:借助FileReader来回调出其信息。
const reader = new FileReader();
// reader.readAsArrayBuffer(event.data);
reader.readAsText(event.data, 'utf8');
reader.onload = () => {
const message = reader.result;
this.receiveMsg(message);
};
发送消息
// typeof data === 'string'
ws.send(data);
关闭
ws.onclose = funtion(){
// ...
}
ws模块
建立服务
这里的WebSocket属于ws模块中的对象。
// 创建websocket服务端
let wss = new WebSocket.Server({ port:8080 });
// 创建一个新的连接
let ws = new WebSocket("ws://localhost:8080");
监听连接
每次有新的客户端建立连接后,服务端便可收到消息。
wss.on('connection', async (ws, req) => {
const { host } = req.headers;
// close event
ws.on("close", (code, reason) => {
console.log("ID:%d Server closed: %d %s", ClientId, code, reason);
});
// message event
ws.on("message", (message) => {
stdout.info("ID:%d Server receive: %s",message.toString());
}
})
回调函数中的ws便是对于客户端通信来说是一对一的,req附带响应TCP连接的信息,如上面是host是连接方的主机名。
心跳
对于每个ws连接,可以通过以下方式来进行心跳测试,每隔waitTime的时间内,监测是否收到回复。长时间未收到回复即会断开连接,以节省资源。
setTimeout(() => {
if (!reply) {
ws.close();
}
}, waitTime);
关于心跳的应用,在我的项目中有另外一处应用,即限时断线,作为机器人来说,是长时间保持在空闲状态的,否则会消耗资源,但是对于消息的监听是异步的,限时在某种意义上代表需要计算时间,需要阻塞,因此这里推荐一种时间片的做法:
时间片设置为500ms,计算循环轮数,每轮检测是否收到消息即可。这里的阻塞是必须的,否则在异步的情况下无法计算时间。
let INTERVAL = 500;
let loops = Math.ceil((seconds * 1000) / INTERVAL);
while (loops > 0) {
if (reply) {
console.log("Receive");
return;
}
await sleep(INTERVAL);
loops--;
}
if (!reply) {
console.log("Closed.");
return;
}
ws监听事件
需要说明,
ws.onmessage等只能够绑定一个回调函数,而ws.on("message",()=>{})能够绑定多个回调函数,不会进行覆盖。这点与网页中点击事件的绑定类似,即onclick和addListener('click',()=>{})。
接收消息,有以下两种方法,这里的回调参数message与前面的又不太一致,存在String和WebSocket.RawData类型,因此可统一使用toString来转换。
ws.onmessage = (message) => {}
ws.on("message", (message) => {
console.log("Receive %s", message.toString());
})
监听关闭:
ws.onclose = () => {}
ws.on("close",() => {})
通信封装
用到WebSocket的过程中势必会碰到一个问题,那是我们的需要不仅仅是收发消息,还需要对消息划分类型,传输类似于Http消息的数据,因此这里就可以用到序列化的知识。
封装
在我的项目中,构造了如下结构:
{
type: "init", // "message" or "close"
data: {
name: "xxx",
avatar: "xxx",
account: 100
}
}
type可以定义不同的消息类型,data中即为返回的数据,通过JSON.stringify()来序列化,接收消息后通过JSON.parse()来反序列化。
加密
在一些消息传递过程中,同时需要做到对信息的保密,如初始化时传递密码等,这时就需要用到RSA非对称加密(通过openssl生成公钥和私钥,详见相关中的另一篇文章),需要用到jsencrypt模块。
// const { JSEncrypt } = require('nodejs-encrypt');
const { JSEncrypt } = require('jsencrypt');
// 公钥加密
publicEncrypt(str) {
const encrypt = new JSEncrypt();
encrypt.setPublicKey(pubKey);
return encrypt.encryptLong(str);
},
// 私钥解密
privateDecrypt(str) {
const encrypt = new JSEncrypt();
encrypt.setPrivateKey(privKey);
return encrypt.decryptLong(str);
},
在使用上述模块加密过程中可能会碰到加密失败问题,这是由于字符串过长,可以借助
encryptlong模块来进行。另外,个人的另外一种思路是采用md5加密缩短字符串后再进行RSA加密。
最后
这篇文章的内容以WebSocket为主,讲述了它的一些基本使用和做项目过程中所碰到的一些问题。
这里推荐一下个人的项目,分为Electron客户端和Nodejs服务端,包含了DSL解析模块(Tokenize,AST),日志模块,测试模块等:MrPluto0/dslBot (github.com)