记一次小程序的websocket开发经历
最近的项目中,需要为小程序添加一个实时邀请好友对战的游戏活动,初步评估开发方案使用webSocket进行。
由于之前没有正儿八经的webSocket开发经历,途中遇见了一些问题,包括最坑的“Android手机中wss协议不走wifi代理”,这里一并整理,用作回顾和总结。
参考
WebSocket概述
背景
常规的web网络请求是单向的,即客户端向服务端发送请求->服务端响应请求
。
由于HTTP请求是无状态的,为了记住客户端身份,需要借助Cookie、Session等机制,即便如此,服务端也无法主动向客户端推送消息。如果客户端需要即时获取服务端的某个数据状态,常规的处理方式就是轮询,即定时向服务端发送请求,在过去的项目中接触到的轮询场景有
- 二维码扫描登录,查询用户是否已扫描二维码
- 订单支付,查询用户是否已完成支付并自动跳转
由于轮询的机制是通过定时器实现的,效率比较底下,对于服务器的性能并不友好,且时效性不好(需要等待下一次请求才能获取结果)。在某些场景下(聊天、游戏对战等),由服务器主动向客户端推送消息显得更为合理。因此我们需要WebSocket
WebSockets 是一个可以创建和服务器间进行双向会话的高级技术。通过这个API你可以向服务器发送消息并接受基于事件驱动的响应
协议
WebSocket是一种基于ws协议的技术。使用它可以在客户端与服务器之间建立一段连续的、全双工的连接。
建立WebSocket链接赖于HTTP协议升级机制,简单来讲,HTTP协议提供了一种特殊的机制,ws连接可以以常用的协议启动(如HTTP/1.1),随后再升级到WebSockets。
当然,需要升级的请求得配置下面的header
Connection: Upgrade
,表示这是一个升级请求Upgrade: websocket
,表示升级后的协议为websocket
上面这两个请求头是建立ws连接必须的,除此之外,还可以对请求头进行扩展,更精细地控制websocket请求
Sec-WebSocket-Extensions: extensions
,从规范中选择,已逗号分隔的扩展名Sec-WebSocket-Key: key
,这个头部实际上是用来阻止那些不是故意建立ws客户端的用户,无法被xht.setRequestHeader()
主动添加Sec-WebSocket-Protocol: subprotocols
,建立websocket时可以指定子协议,Sec-WebSocket-Version: supportedVersions
,请求websocket的版本号
响应头中,会携带一个Sec-WebSocket-Accept: hash
的头部,这是根据请求头中的Sec-WebSocket-Key
加密生成的hash值,表示服务端能够与该客户端建立websocket请求了。
nginx配置
如果需要配置nginx转发,根据上面的协议,配置Connection
和Upgrade
就可以了
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
封装客户端和服务端
封装小程序中的webSocket
小程序中的webSocket由微信进行了封装,但是其用法基本与浏览器中的webSocket保持一致。
根据webSocket的向服务器发送消息并接受基于事件驱动的响应的特性,我们可以将其封装成事件系统形式,然后前后端约定对应的事件名称进行调用即可.
事件系统在jQuery和Vue等库中都有实现,其核心思想就是维护一个事件处理函数的字典,在on
时收集对应事件的事件处理函数,然后在emit
时触发对应事件
与后台进行消息体约定,由于webSocket是一个双向的过程,因此封装对于客户端和服务端均适用。只需要在传输的消息中指定对应的事件名和数据即可
{
"event": eventName, // 事件名称
"content": data // 消息数据内容
}
下面是一个小程序中webSocket的简单实现,
class WxWebSocket {
constructor(connectOpt) {
this.connectOpt = connectOpt
this.isConnect = false
this.eventList = []
this.reTry = 0
this.maxReTry = 3
}
connect(cb) {
let connectOpt = this.connectOpt
wx.connectSocket(connectOpt)
wx.onSocketOpen((res) => {
this.isConnect = true
this.listen()
cb(res)
})
wx.onSocketClose((res) => {
this.isConnect = false
})
wx.onSocketError((res) => {
this.isConnect = false
})
}
close(opt) {
if (this.isConnect) {
this.isConnect = false
wx.closeSocket()
}
}
/**
*监听事件
*/
listen() {
wx.onSocketMessage((res) => {
let data = JSON.parse(res.data)
let {event, content} = data
let subs = this.eventList[event]
if (!subs) {
console.log(`no ${event} listener`)
return
}
subs.forEach(sub => {
if (typeof sub === 'function') {
sub(content)
}
})
})
}
/**
* 注册对应响应事件回调
* @param eventName
* @param cb
*/
on(eventName, cb) {
if (!this.eventList[eventName]) {
this.eventList[eventName] = []
}
if (typeof cb === 'function') {
this.eventList[eventName].push(cb)
}
}
/**
* 发送消息
* @param eventName
* @param data
*/
emit(eventName, data) {
if (!this.isConnect) {
console.log('no connect for webSocket')
return
}
let params = {
event: eventName,
content: data
}
wx.sendSocketMessage({
data: JSON.stringify(params),
// todo success fail
})
}
}
在web中只需要将对应的api替换成open
、send
和onmessage
即可,有空应该去看一下socket.io
的客户端实现。
搭建webSocket服务端
使用nodejs可以很方便地搭建一个本地的socket服务器,比较常用的有socket.io和websocket。
这里使用websocket
进行演示
const http = require("http");
const WebSocketServer = require("websocket").server;
const fs = require("fs");
const httpServer = http.createServer((request, res) => {
fs.readFile(__dirname + "/index.html", function(err, data) {
if (err) {
res.writeHead(500);
return res.end("Error loading index.html");
}
res.writeHead(200);
res.end(data);
});
});
const wsServer = new WebSocketServer({
httpServer,
autoAcceptConnections: true,
fragmentOutgoingMessages: true
});
let connections = [];
// 这里可以监听客户端主动触发的一些事件名
let logic = {};
wsServer.on("connect", connection => {
connections.push(connection);
connection
.on("message", message => {
if (message.type === "utf8") {
let data = message.utf8Data;
data = JSON.parse(data);
let { event, content } = data;
let response
// 获取logic的响应数据
if (typeof logic[event] === 'function'){
response = logic[event](content);
}else {
response = {
event: 'default',
content: {
'msg': 'hello'
}
}
}
connections.forEach(function(destination) {
destination.sendUTF(JSON.stringify(response));
});
}
})
.on("close", (reasonCode, description) => {
var index = connections.indexOf(connection);
if (index !== -1) {
connections.splice(index, 1);
}
console.log(connection.remoteAddres + ' disconnected');
});
});
httpServer.listen(3001, () => {
console.log("[" + new Date() + "] Serveris listening on port 3001");
});
遇见的一些问题
Android手机wifi代理不转发wss请求
在测试环境下遇见的一个bug,本地是通过charles进行代理和抓包的,发现Android无法正常建立webSocket连接,也无法进行抓包。
在调试过程中发现,通过局域网IP连接本地服务器是没有问题的,通过域名连接测试环境的服务就会连接失败。
经过查询,发现部分Android手机的wss协议不走wifi代理,将websocket的请求直接发送到线上环境,然而本地代理连接的是测试环境,线上还没有进行部署,导致请求失败,无法正常建立连接。(这个bug排查了一两天....简直怀疑人生了)
知道了原因,解决问题的核心思想就是让Android手机的wss连接都测试环境的代理即可,有下面几个思路
- 直接修改手机的hosts文件,需要root,且需要对每部手机进行修改,比较麻烦
- 在手机上安装drony,强制所有请求走drony代理
我们采用的是第二种方案,drony的安装和使用可以参考这里。(PS:drony的操作界面是左右滑动的,不是点击上面tab栏的标签进行切换,有点蛋疼...)
心跳连接
使用webSocket时,长时间(60s,可设置)不进行通信,会出现下面错误
failed: Error during WebSocket handshake: Unexpected response code: 400
解决办法有两种,
- 配置nginx,修改长时间不通信断开连接的时间阈值
- 建立心跳连接,开启定时器,隔一段时间触发ping事件,服务端响应pong事件即可。
在项目中采用了心跳连接的方式进行处理。
setHeartBeat() {
let socket = this.data.socket
setInterval(() => {
// ping事件
socket.emit(SOCKET_EVENT.HEART_BEAT)
// 默认不处理服务器的pong响应,只需要维持连接即可
}, 9 * 1000)
},
真机上测试小程序中的ws请求
在真机上测试的时候,不管指定的协议是ws还是wss,貌似发送的都是wss请求,如果是连接本地的测试服务器,记得配置证书。
iOS手机息屏一段时间后倒计时不正常
参考
就是在屏幕休眠或者该程序切换到后台 的时候,ios系统倒计时会暂停,但是在使用中的时候这个绝对算是一个bug。
这个bug貌似跟webSocket没啥关系,不过在项目中遇见了,顺道记一下。
解决办法有两种
- 启动倒计时的时候,将过期时间戳保存在本地,从休眠恢复后判断本地保存的过期时间戳与当前时间戳进行比较,然后进行判断定时器是否已经失效
- 在服务端保存过期时间戳,恢复休眠时从服务端获取是否过期的状态
上面两种办法无非是在客户端或者服务端维持一个过期时间戳,基本思路是一致的。至于BUG产生的原因,这个应该跟iOS系统有关系~暂时没有深究。
总结
这是第一次在生产项目中使用webSocket,且是在小程序环境中运行,遇见了不少问题(十几个内测bug/捂脸),索性最后都得到了解决。
其中,Android手机不转发wss走代理的问题,花了一两天的时间进行调试,最终发现问题的原因简直羞愧难当~
此外这个项目的规模虽然不大,但是逻辑比较多,前期没有规划好,后台的调整和修复导致代码比较繁复,有不少优化的余地,开发时间较紧,留下了一些技术债务,后面都是要还的~
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。