ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebSocket聊天室实战:从HTTP轮询到全双工实时通信

WebSocket聊天室实战:从HTTP轮询到全双工实时通信 简介资源为一份基于WebSocket的多人聊天室项目源码面向Web开发初学者涵盖用户登录、在线列表、群聊、私聊及在线客服等完整实时交互场景使用JavaScript/jQuery实现前端交互Java处理后端连接与消息分发。项目可直观理解WebSocket如何替代HTTP完成双向低延迟通信以及jQuery简化DOM操作与Ajax请求的实践方式。压缩包共23个文件以xml配置、html页面、java源码及class编译文件为主包含properties、mf、jsdtscope等辅助文件附带.project/classpath等工程配置整体仅43KB便于快速解压查看。内容预览显示含Tomcat v7.0服务配置、WebContent与src目录结构能帮助厘清前端资源与后台逻辑的组织方式。借助这份极简项目读者可对照源码学习连接建立、消息广播、私聊路由、在线状态维护及异常重连等处理思路并能参考工程目录配置快速跑通部署。已有116人学习适合具备基础Web知识、希望系统梳理jQuery与Java协同开发思想的初学者参考。1. WebSocket聊天室从HTTP轮询到全双工实时通信的转变WebSocket这东西我第一次真正吃透它就是在这样一个聊天室项目上。项目用 JavaScript jQuery 做前端界面Java 跑在 Tomcat v7.0 上维护服务端核心是一套 WebSocket 实时通信机制。它解决的核心问题是传统 HTTP 做聊天室时的尴尬——客户端得反复轮询服务器才能知道有没有新消息而 WebSocket 建立一次连接后服务器能主动把消息推到浏览器延时低、开销小这才是实时聊天的正解。这个项目适合两类人来复现一类是 JavaScript 开发想弄清 WebSocket 事件流和 jQuery 怎么配合另一类是 Java 后端想知道 Session 管理、消息广播和在线状态维护的完整套路。规模不大但登录、群聊、私聊、在线客服全都有作为练手底子非常合适。2. 项目结构与技术链路Tomcat部署、握手原理与连接建立2.1 项目目录结构分析与部署前置条件拿到压缩包解压后先看清目录再动手否则容易在部署阶段就卡住。项目根目录下有几个关键组成Servers/Tomcat v7.0 Server at localhost-config是 Eclipse 里 Tomcat 7.0 服务器实例的配置src放 Java 源码WebContent是 Web 应用根目录JSP、HTML、JavaScript 和 CSS 都在这build是编译输出目录.settings、.project、.classpath是 Eclipse 工程文件不需要手动改。部署方式有两种我一般直接走命令行# 方式一直接把项目放到Tomcat的webapps目录 cp -r TestWebSocket $CATALINA_HOME/webapps/ # 启动Tomcat $CATALINA_HOME/bin/startup.sh # 验证部署是否成功 curl http://localhost:8080/TestWebSocket/命令不复杂但有一个前置条件经常被忽略Tomcat 7.0 对 WebSocket 的支持依赖内置的 JSR 356 实现从 7.0.27 版本才开始稳定支持。如果你的 Tomcat 版本低于 7.0.27项目启动时会直接报ClassNotFoundException: javax.websocket.Endpoint因为 WebSocket API 的 jar 根本不在 classpath 里。这个项目在 Tomcat v7.0 上开发建议直接用 7.0.27 以上版本省得踩版本坑。部署后第一件事是检查端口冲突。Tomcat 默认 8080如果本机有别的服务占用了这个端口启动日志会报SEVERE: Error initializing endpoint这时改conf/server.xml里Connector port8080的端口号就行。另外要确认WebContent/WEB-INF/web.xml存在Tomcat 7.0 下如果没有这个文件部署会直接失败。2.2 WebSocket握手原理与Java服务端端点WebSocket 和 HTTP 不是替代关系而是升级关系。建立 WebSocket 连接之前客户端先发一个带Upgrade: websocket头的 HTTP 请求服务器确认后返回101 Switching Protocols连接才真正变成全双工通道。握手阶段结束后服务器和客户端互相直接发送数据帧不再有 HTTP 请求-响应那个完整周期所以实时性和传输效率都高得多。Java 端用 JSR 356 的ServerEndpoint注解定义服务端点项目核心骨架如下package com.test.websocket; import java.io.IOException; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; ServerEndpoint(/chat) public class ChatEndpoint { private static final SetSession onlineSessions new CopyOnWriteArraySet(); private static final ConcurrentHashMapString, Session userSessionMap new ConcurrentHashMap(); OnOpen public void onOpen(Session session) { onlineSessions.add(session); System.out.println(新连接当前在线 onlineSessions.size()); } OnMessage public void onMessage(String message, Session session) { System.out.println(收到消息 message); // 业务逻辑解析JSON、区分群聊/私聊、路由转发 } OnClose public void onClose(Session session) { onlineSessions.remove(session); // 清理用户映射防止残留Session userSessionMap.entrySet().removeIf(entry - entry.getValue().equals(session)); } }逻辑说明onlineSessions用CopyOnWriteArraySet而不是HashSet因为广播消息时会频繁遍历这个集合并发写少读多这个选择比同步加锁更合适。userSessionMap用来保存用户名和 Session 的对应关系私聊路由全靠它。参数方面OnOpen在连接建立时触发OnMessage每次收到文本消息时触发OnClose处理断开连接这三个注解是 WebSocket 端点的核心回调点。这里有一个很多初学者会搞混的点ServerEndpoint端点实例的生命周期。默认每个连接对应服务端的一个端点实例不是单例所以实例字段不能用来存全局状态这就是为什么onlineSessions和userSessionMap必须是static且线程安全的集合。如果你把在线用户列表放到实例字段里每人一个实例广播就只剩自己收到了。2.3 前端WebSocket连接与事件处理浏览器原生 WebSocket API 本身不复杂关键是四个事件onopen、onmessage、onerror、onclose。项目里 JavaScript 的职责是监听用户输入、建立连接、接收消息后交给 jQuery 渲染到页面上。// 建立WebSocket连接 var ws null; var wsUrl ws:// window.location.host /TestWebSocket/chat; function connectWebSocket() { ws new WebSocket(wsUrl); ws.onopen function () { console.log(WebSocket连接已建立); // 连接成功后才允许发送消息 $(#sendBtn).prop(disabled, false); }; ws.onmessage function (event) { var data JSON.parse(event.data); renderMessage(data); }; ws.onerror function (error) { console.error(WebSocket出错, error); }; ws.onclose function () { console.log(连接已关闭); $(#sendBtn).prop(disabled, true); }; }逻辑说明ws声明为全局变量方便其他函数调用send()。wsUrl用window.location.host动态拼地址避免硬编码 IP 和端口换环境部署不用改前端代码。onclose里禁用发送按钮防止用户点击发送时连接已断开消息白丢。对比一下 HTTP 轮询不用 WebSocket 的聊天室前端每 2 秒发一个 Ajax 请求问服务器“有新消息吗”大量请求是无效的实时性还差。而 WebSocket 把每次通信的 Header 开销省掉服务器有新消息直接推延时从秒级降到毫秒级。下面这个表是我常用来给团队讲两者差异的对比项HTTP轮询WebSocket连接方式每次请求独立连接一次握手长连接复用实时性依赖轮询间隔秒级服务端主动推送毫秒级服务端负载大量无效请求低开销全双工通信代码复杂度逻辑简单但效率差需要管理连接生命周期3. 聊天室功能落地登录验证、消息广播与私聊路由3.1 用户登录验证Ajax请求与身份管理登录页面是聊天室的入口。用户输入用户名后页面用 jQuery 的 Ajax 请求把用户名发给服务器端验证服务器校验用户名是否合法、是否被占用再把结果返回给前端。这个模块不走 WebSocket但它是用户身份的基础没有这一层后面私聊和在线客服都无从谈起。$(#loginForm).on(submit, function (e) { e.preventDefault(); var username $(#username).val().trim(); if (!username) { alert(用户名不能为空); return; } $.ajax({ url: login, type: POST, data: { username: username }, dataType: json, success: function (result) { if (result.success) { localStorage.setItem(chat_username, username); $(#loginPanel).hide(); $(#chatPanel).show(); connectWebSocket(); } else { $(#loginError).text(result.message).show(); } }, error: function () { alert(登录请求失败请检查服务端是否启动); } }); });逻辑说明e.preventDefault()阻止表单默认提交否则页面会跳转刷新。用户名先trim()去掉首尾空格避免用户误输入空串。登录成功后把用户名写入localStorage后续刷新页面或断线重连时可以直接恢复身份不用再登录一次。connectWebSocket()只在登录成功后才调用保证 WebSocket 握手时能带上合法用户名。服务端处理登录时要注意幂等性用户刷新页面可能重复提交同一个用户名如果服务端不做判断会出现两个相同用户名的连接并存。常规做法是在 Java 端先查userSessionMap如果该用户名已在在线列表里就提示“该用户已在线”拒绝重复登录。3.2 群聊消息广播服务器端Session管理与消息推送群聊是聊天室最核心的功能。客户端发送消息时把消息封装成 JSON 格式带上类型、发送人和内容{ type: chat, from: 张三, content: 大家好我是张三, time: 2024-01-01 12:00:00 }服务端收到后遍历在线 Session 列表把消息推送给所有在线用户private void broadcastMessage(String jsonMessage) { for (Session session : onlineSessions) { if (session.isOpen()) { try { session.getBasicRemote().sendText(jsonMessage); } catch (IOException e) { System.err.println(发送到Session session.getId() 失败 e.getMessage()); } } } }参数说明session.isOpen()这层判断很关键客户端异常断开时 Session 对象可能还躺在集合里直接调用sendText会抛IllegalStateException。getBasicRemote().sendText()是同步发送如果未来消息量大要换成getAsyncRemote().sendText()避免单个慢客户端阻塞整个广播循环。广播逻辑有一个经典误区OnMessage方法里的session参数是发送者的连接不是所有连接。要广播给其他人必须遍历onlineSessions找到目标 Session用目标 Session 的 Remote 发送。我见过不少初学者在这翻车写出来变成只有发送者自己能看到消息其他客户端在浏览器 Network 面板里发现 WebSocket 一点动静没有。3.3 私聊与在线客服定向消息的路由逻辑私聊和群聊的差别是消息里多了to字段服务器要根据to找到对应的 Session 定向推送。登录成功后把用户名和 Session 绑定的代码可以放在OnOpen里OnOpen public void onOpen(Session session) { String username parseUsernameFromQuery(session.getQueryString()); if (username ! null) { userSessionMap.put(username, session); } onlineSessions.add(session); }私聊消息格式{ type: private, from: 张三, to: 李四, content: 李四周末一起打球吗, time: 2024-01-01 12:00:00 }服务端路由逻辑Session targetSession userSessionMap.get(targetUsername); if (targetSession ! null targetSession.isOpen()) { targetSession.getBasicRemote().sendText(jsonMessage); // 同时回发给发送者让发送者界面立即显示消息气泡 session.getBasicRemote().sendText(jsonMessage); } else { session.getBasicRemote().sendText({\type\:\error\,\message\:\对方已离线\}); }逻辑说明私聊时消息同时发给接收者和发送者是为了让发送者界面立即出气泡不用等服务器回确认消息。目标不在线时返回错误类型 JSON前端拿到后弹提示这比静默丢消息友好多了。注意parseUsernameFromQuery里要对 URL 中文做URLDecoder.decode(username, UTF-8)否则中文用户名在握手阶段就乱了。在线客服本质上就是私聊的特殊场景。页面可以把客服账号当成固定聊天对象用户向customer_service账号发消息客服端通过同一套私聊路由收到并回复。区别在于客服端要支持多个用户同时咨询消息里需要带clientId区分不同咨询者客服界面可以做一个待处理队列把新消息顶到列表顶部处理完再标记已回复。这个改造不涉及 WebSocket 链路只是业务消息多一个字段。3.4 前端消息渲染jQuery操作DOM与动态更新消息到达前端后JavaScript 把 JSON 解析成对象jQuery 负责把消息插入页面 DOM。这里有几个细节直接影响用户体验自动滚动到底部、按发送者区分消息气泡左右位置、对内容做 HTML 转义防 XSS。function renderMessage(data) { var currentUser localStorage.getItem(chat_username); var messageClass (data.from currentUser) ? message-right : message-left; var html div classmessage-item messageClass span classmessage-name escapeHtml(data.from) /span span classmessage-content escapeHtml(data.content) /span span classmessage-time data.time /span /div; $(#messageList).append(html); var list $(#messageList); list.scrollTop(list[0].scrollHeight); } function escapeHtml(text) { var div document.createElement(div); div.appendChild(document.createTextNode(text)); return div.innerHTML; }参数说明messageClass根据消息发送人判断气泡靠左还是靠右这是聊天界面最常见的布局方式。escapeHtml非常关键——用户输入不可信如果直接把内容拼进 HTML有人发一段script就能在你浏览器里执行脚本。用createTextNode转义是最稳妥的方案jQuery 的$(document.createElement(div)).text(text).html()也能达到同样效果。DOM 性能方面jQuery 的append在消息量小的时候没问题。如果聊天室消息刷得很快一次进来上百条就要考虑用DocumentFragment批量插入或做消息分页。项目默认场景是教学和中小规模使用简单追加完全够用。4. 避坑指南WebSocket实战中六个高频故障的排查记录4.1 连接建立后空闲不到一分钟就断开现象WebSocket 能正常连接但用户不说话时连接很快断掉后台日志出现类似Client timeout的提示。原因Tomcat 容器对空闲连接有超时回收机制WebSocket Session 在设定时间内没有数据交互会被容器主动清理。这是协议层面的空闲处理策略不是代码 Bug。解决调大 Session 空闲超时或者加心跳保活OnOpen public void onOpen(Session session) { session.setMaxIdleTimeout(120000); // 2分钟空闲超时 // 如果不想被回收传 -1 // session.setMaxIdleTimeout(-1); }实测建议设成 2 到 3 分钟配上前端心跳稳定性最好。设-1意味着永不超时但服务器内存里残留的死连接会越来越多生产环境不建议这么做。4.2 中文用户名和消息乱码现象连接握手时带的中文用户名变问号聊天消息也有乱码。原因Tomcat 7.0 下 WebSocket 消息默认按 UTF-8 解码但 URL 参数里的中文如果没编码再解码就会乱。ws://localhost:8080/chat?username张三这个 URL 里的“张三”如果不做 urlencode服务端拿到的是被浏览器按系统默认编码处理过的字节。解决前端用encodeURIComponent包裹参数服务端用URLDecoder.decode(username, UTF-8)解码。消息体本身统一 UTF-8不要混用 GBK。另外确认 Tomcat 的server.xml里 Connector 配置了URIEncodingUTF-8。4.3 页面在8081端口连不上8080的WebSocket现象前端页面部署在 8081 端口WebSocket 地址指向 8080浏览器控制台报跨域错误连接失败。原因WebSocket 不受同源策略限制但握手请求会带Origin头服务端可以校验来源并拒绝。Tomcat 7.0 默认放行所有 Origin但如果你在代码或反向代理层加了校验逻辑跨域页面就会被拒。解决在服务端写一个 Origin 白名单过滤器允许的域名才放行或者用 Nginx 反向代理时配置proxy_set_header Origin $http_origin统一转发。开发环境图省事可以全放行生产环境必须白名单制否则任何人都能往你的 WebSocket 服务发消息。4.4 私聊提示“对方已离线”但用户明明在线现象用户 A 给用户 B 发私聊系统提示“对方已离线”但 B 的页面上分明在线。原因B 登录时可能断线重连过旧 Session 残留在userSessionMap里B 重新登录后建立的新 Session 又被put进去但旧的没清掉。get()查到的可能是旧的、已经关闭的 SessiontargetSession.isOpen()判断为 false于是走了离线分支。解决所有清理逻辑必须同时维护onlineSessions和userSessionMap两处。前端断线重连后重新触发OnOpen服务端要先移除旧用户名对应的旧 Session再写新 Session。代码里统一的close方法来清两处不要在OnClose只处理一个集合。4.5 并发登录时在线用户列表数据错乱现象多个用户同时连接在线列表时多一个时少一个广播消息时部分用户收不到。原因如果集合用了普通HashSet或ArrayList并发调用add和remove时会出现数据竞争。HashSet并发写可能破坏内部结构ArrayList遍历中被修改会抛ConcurrentModificationException。解决用CopyOnWriteArraySet或ConcurrentHashMap.newKeySet()。读多写少的场景CopyOnWriteArraySet最合适写操作会复制底层数组代价高但读操作无锁广播恰好是遍历读多、连接变化写少。这个场景下它比Collections.synchronizedSet性能好得多。4.6 前端onclose事件没有触发但消息已经发不出现象网络闪断客户端onclose没触发页面上看连接“还在”但发送消息没反应服务器也收不到。原因TCP 连接被网络设备静默切断时客户端不知道连接已死要等下一次发送或系统 TCP 超时才感知。这个问题在做 WebSocket 时非常典型几乎每个上线的项目都会遇到。解决前端加心跳发送见下一章定时给服务端发 ping如果连续几次没收到 pong主动调用ws.close()走重连逻辑。服务端也做对端检测超过阈值收不到任何消息就主动清理 Session。一句话心跳不是可选项是生产环境的标配。提示以上六条是我在实际部署这种小型 Java WebSocket 项目时遇到频率最高的故障排查顺序建议先看服务端日志再开浏览器 Developer Tools 的 Network 面板里 WS 标签页检查帧收发情况定位是链接层还是业务层的问题。5. 工程化进阶心跳机制、断线重连与消息去重心跳机制是这个项目上线后最该补的一块。WebSocket 是长连接网络抖动、服务器重启、代理超时都会让连接在不知不觉中死掉前端onclose事件不一定立刻触发。心跳的作用是让连接保持活跃并尽早发现死连接然后走重连流程。var HEARTBEAT_INTERVAL 30000; var heartbeatTimer null; function startHeartbeat() { heartbeatTimer setInterval(function () { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, time: Date.now() })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }服务端收到ping类型消息时回一个pong即可或者直接忽略——因为消息本身已经让连接维持活跃了。高可靠的做法是服务端启动定时扫描任务连续 N 个心跳间隔没收到客户端消息就主动关闭该 Session 并清理映射表。断线重连采用指数退避避免所有客户端同时重连打爆服务器var reconnectCount 0; function connectWithRetry() { connectWebSocket(); ws.onclose function () { var delay Math.min(30000, 1000 * Math.pow(2, reconnectCount)); reconnectCount; setTimeout(connectWithRetry, delay); }; }这里注意区分“主动关闭”和“意外断开”用户退出聊天室时要设置标志位跳过重连否则手动关闭也会触发重连死循环。消息去重则是重连后最容易被忽略的环节断线期间服务端推给其他用户的消息重连后要不要补发如果要补发前端就必须能识别重复。简单方案是每条消息带上服务端生成的唯一 ID渲染前检查 ID 是否已存在存在就跳过。从那以后我每次写 WebSocket 项目都先搭心跳、断线重连、消息去重这三块公共逻辑再写业务功能顺序反了就会陷入“连接怎么又断了”这种玄学排查。这个聊天室项目把 WebSocket 从握手到广播的完整链路走了一遍前端 JavaScript 配 jQuery、后端 Java 跑在 Tomcat 7.0 上覆盖登录、群聊、私聊、在线客服拿来做复现改造、或者作为面试前把 WebSocket 全链路重新过一遍的载体都很合适。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进