从轮询到全双工:第二周WebSocket通信架构与Socket.IO实战解析

第一周把环境搭好后,第二周我们开始搞项目里最核心的部分——实时通信。对“AI心理绘画游戏”来说,得让前端的Canvas画板和后端的AI模型实时交互,不然用户画完一笔要等很久才能收到反馈,体验会很差。这周我主要研究了WebSocket协议和Socket.IO库,解决了前后端通信的一些兼容性问题。

WebSocket协议基础:解决实时传输的问题

之前做网页都是用HTTP,得客户端主动发请求服务器才会回数据。但绘画游戏不一样,服务器得主动把AI的识别结果推给前端,不然用户画完画不知道结果,没法继续玩。WebSocket就解决了这个问题:

  • 握手阶段:WebSocket连接一开始是个HTTP请求,通过 Upgrade: websocket 这个头把协议升级成WebSocket,这样就能穿过大部分防火墙和代理。
  • 全双工通信:连接建立后,客户端和服务器可以同时发数据,不用等对方发完才能发。比如用户画一笔,前端马上把笔迹发给服务器,服务器也能同时把AI的识别结果推回来。
  • 帧格式:数据会分成很小的帧传输,不管是二进制还是文本,头部都很小,不会像HTTP那样带一堆没用的头信息,节省带宽。
Socket.IO:让WebSocket更稳定好用

原生WebSocket虽然能解决实时通信的问题,但在实际网络环境下很容易出问题,比如网络波动一下就会断开,而且有些老网络环境不支持WebSocket。Socket.IO在WebSocket基础上做了封装,帮我们解决了这些麻烦:

  • 自动重连:如果网络断了,原生WebSocket会直接报错,而Socket.IO会自动尝试重新连接,连上之后还能接着传数据,不会让用户画了一半的画丢失。
  • 降级策略:要是用户的网络不支持WebSocket,Socket.IO会自动切换成HTTP长轮询,这样就算在老网络环境下,游戏也能正常运行,不会出现连不上服务器的情况。
  • 房间功能:我们游戏里每个房间是一个独立的游戏局,Socket.IO的房间功能刚好能用来隔离不同房间的用户,比如房间A的用户画的画,只会推给房间A的其他人,不会干扰房间B。
前后端Socket.IO的事件匹配

刚开始对接的时候,我们遇到了一个大问题:前端发的事件,后端收不到;后端发的事件,前端也没反应。后来才发现是事件名没对上。

比如前端用 socket.emit('drawing_update', data) 发画笔数据,后端得用 @socketio.on('drawing_update') 来接收;后端用 emit('drawing_sync', result) 推绘画同步数据,前端得用 socket.on('drawing_sync', callback) 来监听。一开始我们前端写的事件名和后端不匹配,导致数据一直传不过去,调了半天才发现是这个问题。

后来我们定了个规则:所有事件名都用小写加下划线,比如 join_roomdrawing_updatesubmit_guess,前后端对照着写,再也没出过错。

用浏览器DevTools调试WebSocket

之前不知道怎么查看WebSocket的通信数据,后来发现浏览器的DevTools里有个Network面板,点开WS就能看到WebSocket的通信记录。

比如用户画一笔,前端会发一个 drawing_update 事件,里面带着笔迹的坐标数据,在DevTools里能看到这个事件的payload,是一个JSON对象,里面有笔触的点数据、颜色、粗细这些信息。后端收到后处理完,会发一个 drawing_sync 事件,payload里是同样的笔触数据,推给房间里的其他用户。

通过DevTools,我们能清楚看到数据有没有发出去,有没有收到,要是数据不对,还能直接看payload里的内容,比打印日志方便多了。

消息格式分析:数据到底长什么样

我们传输的数据都是JSON格式的,这样前后端都能方便解析。比如前端发的画笔数据是这样的:

{
    "room_id": "room1",
    "user_id": "user123",
    "stroke_data": {
        "points": [[100, 200], [105, 205]],
        "color": "#000000",
        "width": 2
    }
}

后端发的绘画同步数据是这样的:

{
    "user_id": "user123",
    "stroke_data": {
        "points": [[100, 200], [105, 205]],
        "color": "#000000",
        "width": 2
    }
}

前端发的完成绘画数据是这样的:

{
    "room_id": "room1",
    "user_id": "user123",
    "image_base64": "Base64编码的图片",
    "drawing_time_ms": 5000,
    "behavior_data": {
        "drawing_duration_ms": 5000,
        "stroke_count": 10,
        "eraser_usage": 0
    }
}

后端发的AI识别结果会在回合结束时通过 round_result 事件推送给所有用户:

{
    "round_number": 1,
    "target_word": "苹果",
    "result_type": "human_win",
    "ai_guess": "苹果",
    "ai_confidence": 0.9,
    "ai_correct": true,
    "human_correct": true,
    "human_winner_id": "user123",
    "guesses": [
        {
            "nickname": "玩家1",
            "guess": "苹果",
            "correct": true
        }
    ]
}

一开始我们在设计数据结构时考虑不够周全,导致后端解析的时候老是出错,后来仔细设计了JSON结构,解析起来就方便多了,而且数据结构清晰,不容易搞错。

遇到的难点和解决办法

这周最大的难点就是前后端通信不稳定,有时候能收到数据,有时候收不到。后来发现是因为Socket.IO的连接没处理好,前端页面刷新的时候,旧的连接没断开,新的连接又建立了,导致服务器同时收到两个连接的数据,就乱了。

解决办法是在前端页面卸载的时候,确保Socket连接正常关闭。另外,我们还遇到了一个关键问题:猜词阶段结束时的重复触发问题。当所有玩家都提交了猜测后,后端会自动结束猜词阶段,但同时前端的计时器也会触发结束事件,导致后端收到两次结束请求,引起逻辑混乱。

我们的解决办法是在后端的 _do_end_guessing 函数中添加了防重复触发机制,通过 round_ended 标记来确保每个回合只结束一次:

# 防止重复触发(submit_guess 和超时可能同时到达)
if state.get('round_ended'):
    return
state['round_ended'] = True

另外,我们还优化了AI猜词的处理逻辑,在结束猜词阶段时等待AI结果最多3秒,确保AI有足够的时间完成识别:

# 等待AI结果(最多等3秒,AI可能还在调用中)
round_info = None
for _ in range(15):  # 15 × 0.2s = 3s
    round_info = models.get_round(round_id)
    if round_info and round_info.get('ai_guess'):
        break
    time.sleep(0.2)

经过这周的调试,我们的实时通信功能终于稳定了,用户画一笔,后端能马上收到并同步给其他用户,AI识别结果也能及时推回来,游戏体验好了很多。下周我们打算开始深入研究AI识别模块,看看怎么把画板上的画传给模型进行更准确的识别。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐