记一次小程序的websocket开发经历

最近的项目中,需要为小程序添加一个实时邀请好友对战的游戏活动,初步评估开发方案使用webSocket进行,之前没有正儿八经的webSocket开发经历,途中遇见了一些问题,包括最坑的“Android手机中wss协议不走wifi代理”,这里一并整理,用作回顾和总结。

<!--more-->

参考

1. WebSocket概述

1.1. 背景

常规的web网络请求是单向的,即客户端向服务端发送请求->服务端响应请求

由于HTTP请求是无状态的,为了记住客户端身份,需要借助Cookie、Session等机制,即便如此,服务端也无法主动向客户端推送消息。如果客户端需要即时获取服务端的某个数据状态,常规的处理方式就是轮询,即定时向服务端发送请求,在过去的项目中接触到的轮询场景有

  • 二维码扫描登录,查询用户是否已扫描二维码
  • 订单支付,查询用户是否已完成支付并自动跳转

由于轮询的机制是通过定时器实现的,效率比较底下,对于服务器的性能并不友好,且时效性不好(需要等待下一次请求才能获取结果)。在某些场景下(聊天、游戏对战等),由服务器主动向客户端推送消息显得更为合理。因此我们需要WebSocket

WebSockets 是一个可以创建和服务器间进行双向会话的高级技术。通过这个API你可以向服务器发送消息并接受基于事件驱动的响应

1.2. 协议

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请求了。

1.3. nginx配置

如果需要配置nginx转发,根据上面的协议,配置ConnectionUpgrade就可以了

location / {
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";  
}

2. 封装客户端和服务端

2.1. 封装小程序中的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替换成opensendonmessage即可,有空应该去看一下socket.io的客户端实现。

2.2. 搭建webSocket服务端

使用nodejs可以很方便地搭建一个本地的socket服务器,比较常用的有socket.iowebsocket

这里使用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");
});

3. 遇见的一些问题

3.1. Android手机wifi代理不转发wss请求

在测试环境下遇见的一个bug,本地是通过charles进行代理和抓包的,发现Android无法正常建立webSocket连接,也无法进行抓包。

在调试过程中发现,通过局域网IP连接本地服务器是没有问题的,通过域名连接测试环境的服务就会连接失败。

经过查询,发现部分Android手机的wss协议不走wifi代理,将websocket的请求直接发送到线上环境,然而本地代理连接的是测试环境,线上还没有进行部署,导致请求失败,无法正常建立连接。(这个bug排查了一两天....简直怀疑人生了)

知道了原因,解决问题的核心思想就是让Android手机的wss连接都测试环境的代理即可,有下面几个思路

  • 直接修改手机的hosts文件,需要root,且需要对每部手机进行修改,比较麻烦
  • 在手机上安装drony,强制所有请求走drony代理

我们采用的是第二种方案,drony的安装和使用可以参考这里。(PS:drony的操作界面是左右滑动的,不是点击上面tab栏的标签进行切换,有点蛋疼...)

3.2. 心跳连接

使用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)
},

3.3. 真机上测试小程序中的ws请求

在真机上测试的时候,不管指定的协议是ws还是wss,貌似发送的都是wss请求,如果是连接本地的测试服务器,记得配置证书。

3.4. iOS手机息屏一段时间后倒计时不正常

就是在屏幕休眠或者该程序切换到后台 的时候,ios系统倒计时会暂停,但是在使用中的时候这个绝对算是一个bug。

这个bug貌似跟webSocket没啥关系,不过在项目中遇见了,顺道记一下。

参考

解决办法有两种

  • 启动倒计时的时候,将过期时间戳保存在本地,从休眠恢复后判断本地保存的过期时间戳与当前时间戳进行比较,然后进行判断定时器是否已经失效
  • 在服务端保存过期时间戳,恢复休眠时从服务端获取是否过期的状态

上面两种办法无非是在客户端或者服务端维持一个过期时间戳,基本思路是一致的。至于BUG产生的原因,这个应该跟iOS系统有关系~暂时没有深究。

3.5. 小程序的生命周期

此前对于小程序的生命周期函数并没有做过多的了解,由于需要注意建立websocket连接及关闭连接的时机,因此重新翻阅了一下小程序的声明周期文档,发现之前的理解有一点问题。

  • onLoad,页面第一次加载完成
  • onReady,页面第一次渲染完成
  • onShow,显示页面时触发,这里指的是下面几种情形
    • 从其他页面进入到当前页面
    • 小程序从后台进入前台时
      • 从微信界面进入小程序
      • 按Home键返回桌面后,又重新进入微信小程序
  • onHide,跟onShow基本是对应的
    • 当navigateTo或底部tab切换时调用
    • 点击右上角的按钮,小程序进入后台时
  • onUnload
    • 当redirectTo或navigateBack的时候调用
    • 整个小程序被销毁时

更多可以参考

4. 总结

这是第一次在生产项目中使用webSocket,且是在小程序环境中运行,遇见了不少问题(十几个内测bug/捂脸),索性最后都得到了解决。

其中,Android手机不转发wss走代理的问题,花了一两天的时间进行调试,最终发现问题的原因简直羞愧难当~

此外这个项目的规模虽然不大,但是逻辑比较多,前期没有规划好,后台的调整和修复导致代码比较繁复,有不少优化的余地,开发时间较紧,留下了一些技术债务,后面都是要还的~