Websocket 异常处理

5,400 阅读7分钟

背景

花两天时间重构 Websocket 模块,关于链接异常处理的细节比较多,而且错误处理中容易出现冲突。本文主要是整理下链接异常 && 消息异常的处理方案。
建议可以先看一下我之前关于 tcp消息丢失的文章

链接异常 && 异常处理

一、关于websocket长链接的异常,主要分几种异常情况:

1、创建连接异常;
测试在chrome下网络丢包频率高的话,大概40s-50s会报错,如果监听了 onerror 事件可以捕获到报错(WebSocket connection to 'wss://***' failed: Error in connection establishment: net::ERR_CONNECTION_TIMED_OUT);
如果需要统一/固定时长,可以在创建时通过定时器监听(通过 socket.readyState 判断)超过多长时间没有创建成功则重新连接。

但当自己来处理链接时长后,需要注意在重连的时候关闭连接且将前一个链接的事件绑定解绑。否则还可能会收到原链接的相关事件推送(e.g. 设置了30s重连,但40秒后链接超时触发 onerror);

// 我这里用了单例模式来实现
let instance
let onmessage, onopen, onerror, onclose
class Ws {
    static getInstance() {
        if(!instance) {
            instance = new Ws()
        }
        return instance
    }
    createSocket(url) {
        this.socketInstance = new WebSocket(url)
        this.socketInstance.binaryType = 'arraybuffer'
        // 因为事件执行的this指向的是socket实例,需要修改this指向
        onmessage = this.onmessage.bind(this)
        onopen = this.onopen.bind(this)
        onerror = this.onerror.bind(this)
        onclose = this.onclose.bind(this)
        // socket 事件绑定
        this.socketInstance.addEventListener('message', onmessage)
        this.socketInstance.addEventListener('open', onopen)
        this.socketInstance.addEventListener('error', onerror)
        this.socketInstance.addEventListener('close', onclose)
        // 设置30s重连
        this.timeoutEvent(30000, () => { // 创建连接超时重连
            if(this.socketInstance && this.socketInstance.readyState !== 1) {
                this.closeSocket()
                this.reconnection()
            }
        })
    }
    onmessage() {}
    onopen() {}
    onerror() {}
    onclose() {}
    closeSocket() {
        // 解除事件绑定后,关闭连接
        this.socketInstance.removeEventListener('open', onopen)
        this.socketInstance.removeEventListener('message', onmessage)
        this.socketInstance.removeEventListener('error', onerror)
        this.socketInstance.removeEventListener('close', onclose)
        this.socketInstance.close()
    }
    reconnection() {
        this.createSocket('ws://127.0.0.1:8891/ws')
    }
}
instance = Ws.getInstance()

export default instance

2、创建成功后的异常;
首先,正常情况下由服务端/客户端主动触发的断线,都会通知到对方链接已断开,如:

1、服务端/客户端 关闭进程/crash等;  
2、服务端/客户端 断网;  
3、服务端/客户端 主动断开连接;  

例如浏览器的 webScoket onclose事件可以监听到服务端通知链接断开(断开的信息CloseEvent code一般为 1000, 服务端可以自行设置code),如果需要发送消息则需要重新建立链接;

有一种情况是例外的,如果服务端和客户端之间的网络不稳定,丢包概率比较大,两端是无法感知的,所以需要通过心跳进行健康检查:

// 连接监听到 onopen 后,进行心跳检查, 5s 一次
onopen() {
    clearInterval(this.heartbeatTimmer)
    this.heartbeatTimmer = setInterval(() => {
        this.sendMessage('ping') // 自定义心跳消息
    }, 5000)
}

但因为tcp是无状态的,我们无法监听到心跳是否发送成功,所以需要通过另外一种方式来监听链接状态是否正常,可以通过定时器监听服务端返回的心跳包(设置超时时间,如果超过60s没收到心跳回应则重新连接):

onmessage(event) {
    if(isPong(event)) {
        this.listenPong()
    }
}
listenPong() {
    clearTimeout(this.pongTimmer)
    // 超过60s没收到服务端心跳则重连
    this.pongTimmer = setTimeout(() => {
        this.closeSocket()
        this.reconnection()     
    }, 60000)
}

3、关闭连接异常;
客户端和服务端都有自行关闭链接的机制(即使安排一样的超时机制也可能会不一致),两边的状态可能会不一致, e.g.

1、服务端仍正常连接,客户端已断开,并会发起重连;  
2、服务端已断开,客户端未断开(服务端断开的消息未收到,或已收到断开消息,但客户端未处理);  
3、客户端自动关闭连接(测试在chrome超过60s没收到消息的情况下会自动断开连接,CloseEvent.code是1006);  

对1来说,客户端断开连接发起重连后,按照最新的连接收发消息,不会出现异常。

对3来说,不同的client有不一样的处理逻辑,我们需要在关闭后进行重连处理。

onclose(event) {
    // 1006 客户端主动关闭
    if(event && event.code === 1006 && this.socketInstance.readyState !== 1) {
        this.closeSocket()
        this.reconnection()
    }
}

但2的情况,因为客户端记录仍是正常的,发送消息的时候会抛出异常:WebSocket is already in CLOSING or CLOSED state.我们需要做两个处理:

// 当收到服务端断开消息时,需要重连
onclose(event) {
    // 1006 客户端主动关闭
    // 1000 服务端关闭
    if(event && (event.code === 1006 || event.code === 1000) && this.socketInstance.readyState !== 1) {
        this.closeSocket()
        this.reconnection()
    }
}
// 当发送消息时需要检查链接状态,如果状态异常则需要重连
sendMessage(message) {
    if (!this.socketInstance || this.socketInstance.readyState !== 1) {
        this.closeSocket()
        this.reconnection()
        return
    }
}

消息异常 && 异常捕获

同样因为tcp无状态,所以我们无法直接判断消息是否发送成功,需要在业务层面实现 AcKnowledgements (发送消息给服务端后,服务端需要返回一条确认消息,收到确认消息才认为消息已发送成功)。

大部分业务只需要知道消息是否发送成功,所以通过 AcKnowledgements 已完成需求。但在一些稳定性要求较高的项目上,仍需要:
1、确认消息发送失败(实际上,AcKnowledgements 也可能丢失);
2、丢失的消息需要自动重发;
3、基于2的情况,收到的消息可能是重复的(因为存在可能发送成功的消息,丢失了 也就是实际发送成功了,但客户端未收到 AcKnowledgements),需要自行处理去重;

为了更好的数据管理,我设置了队列来进行消息管理,每次发送的消息时,将消息放入发送队列:

sendMessage(message) {
    if (!this.socketInstance || this.socketInstance.readyState !== 1) {
        this.closeSocket()
        this.reconnection()
        return
    }
    msgQueue.enqueue(message)
    this.socketInstance.send(message)
}

当收到系统回执时,需要将消息从发送队列中删除:

onmessage(message) {
    msgQueue.dequeue(message)
}

同时,队列管理需要进行定时的消息重发:

let instance

// 关键消息结构
// localMsgId, 本地消息id
// createTimestamp, 创建的时间戳(这里最好用服务器时间)
class MsgQueue{
    msgList = [] // 发送消息列表
    pending = false // 是否在队列检查中
    period = 5000 // 超时时间
    static getInstance() {
        if(!instance) {
            instance = new MsgQueue()
        }
        return instance
    }
    enqueue(msg) {
        this.msgList.push(msg) // 放入消息队列
        if(!this.pending) { // 如果消息队列没有执行任务则开启执行
            this.listen()
        }
    }
    dequeue(msg) {
        this.msgList = this.msgList.filter(item => item.localMsgId !== msg.localMsgId)
    }
    listen() {
        if(!this.msgList.length) {
            this.pending = false
            return 
        }
        let now = new Date().getTime() // 同理,最好用服务器时间
        let msg = this.msgList[0]
        let distance = now - msg.createTimestamp

        this.pending = true
        if(distance >= this.period) {
            socket.sendMessage(msg) // 重发
            this.dequeue(msg)
            this.listen()
        } else {
            setTimeout(() => {
                this.listen()
            }, this.period - distance)
        }
    }
}
instance = MsgQueue.getInstance()

export default instance

关于去重,我这里只需要在onmessage中进行检查,已有的消息直接忽略:

onmessage(event) {
    if(isDuplicated(event.data)) {
        return
    }
}

Socket readyState

0 (WebSocket.CONNECTING) 正在链接中 1 (WebSocket.OPEN) 已经链接并且可以通讯 2 (WebSocket.CLOSING) 连接正在关闭 3 (WebSocket.CLOSED) 连接已关闭或者没有链接成功

涉及到的测试工具

1、断网,使用 chrome network 工具;
2、客户端丢包, 使用macOS的NLC工具设置丢包率;
3、服务端丢包, ubuntu服务器,使用linux tc命令模拟丢包;

// 设置丢包率 10%
tc qdisc add dev eth0 root netem loss 10% 
// 查看配置
tc qdisc show // dev eth0 root refcnt 2 limit 1000 loss 10%
// 删除配置
tc qdisc del dev eth0 root

备注:丢包率会影响tcp发送时间,因为tcp丢包后会重传,不同系统重传及超时机制不一样(e.g. linux最小重传时间是200ms, 最大重传时间是120s, 重传次数为 15)

问题记录

1、为什么已经关闭的连接onclose仍会执行;
socketInstance设置了null,事件绑定未取消,对象仍在内存中。事件仍会执行,需要手动解除事件绑定。

2、为什么 new Socket() 的错误(WebSocket connection to 'wss://***' failed: Error in connection establishment: net::ERR_CONNECTION_TIMED_OUT)不能阻止错误冒泡?
根据javascript-doesnt-catch-error-in-websocket-instantiation,这个答案描述的是因为这个错误是异步抛出的,所以无法捕获。
尝试像settimeout一样用promise包裹也无法捕获到,但这个错误不会影响代码的执行,而且也传播到onerror上可以被捕获到。

3、如何通过命令行查看 websocket 状态?
通过top命令查看进程pid,通过losf -p pid查看对应的进程TCP链接。

参考文档

1、WebSockets: developer.mozilla.org/zh-CN/docs/…