ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebSocket聊天室实战:Java Web全双工通信与心跳机制详解

WebSocket聊天室实战:Java Web全双工通信与心跳机制详解 简介WebSocket聊天室是一套基于JavaScript、jQuery与Java构建的实时通讯项目源码面向具有Web基础并希望学习双向通信的开发者。项目实现了多人群聊、私人对话与在线客服前端用jQuery简化DOM与事件处理后端以Java维护WebSocket连接和用户状态并完成认证与定向转发。相比传统HTTP轮询WebSocket连接建立后可持续收发数据有效降低延迟与冗余请求。压缩包共23个文件以Java源码、HTML页面、XML配置和class文件为主整体仅43KB附带工程目录结构含Tomcat服务配置、WebContent前端页面与src后端源码便于导入IDE阅读目前已有116人学习下载。通过源码可掌握WebSocket握手、端点注册、消息广播、私聊路由、在线列表维护以及断线处理等关键实现也能看到小型Web项目如何分层组织为后续扩展高并发场景提供参考适合课程设计或企业客服模块的起步搭建。1. WebSocket 聊天室一个能跑通全双工通信的完整 Java Web 实战如果你已经厌倦了HTTP 轮询实现伪实时的玩具项目想找一个能真正跑通浏览器与服务器双向推送的完整案例这个基于 JavaScript/jQuery Java 的 WebSocket 聊天室是很值得拆一遍的。它不是一个纯前端 Demo也不是只有后端接口的空壳而是把 Tomcat 7 部署、WebSocket 握手、多人广播、私人对话、在线客服这几个模块全部串起来的完整工程。适合正在学 Java Web 的开发者拿来做毕业设计参考也适合前端同学想搞明白WebSocket 连接到底怎么维护时当黑匣子解剖样本。我拆完这个包的直观感受是它的代码结构比很多收费教程里的示例都要规整而且因为用的是标准 JSR 356 注解换到 Tomcat 8.5 以上也基本不用大改。2. 先梳理通信模型为什么聊天室必须用 WebSocket 而不是 HTTP2.1 HTTP 的短连接痛点与 WebSocket 的升级握手HTTP 协议天生是请求-响应模式。聊天室这种场景如果让前端每隔 1 秒发一次 Ajax 请求去拉取最新消息服务端要么返回空数据浪费带宽要么需要维护大量挂起的请求服务端根本无法主动把新消息推给浏览器。这就是常说的伪实时。WebSocket 的解决方案是在 TCP 连接之上做一次协议升级客户端发一个带Upgrade: websocket头的 HTTP 请求服务端返回101 Switching Protocols之后这条 TCP 连接就变成了一条全双工通道浏览器和服务器可以随时往里面写数据帧不再需要每次通信都重建连接。这个项目里前端通过new WebSocket(ws://localhost:8080/项目名/ws/chat)这样的地址发起连接Tomcat 收到请求后会去找标注了ServerEndpoint的类来处理握手。握手成功之后连接就挂在服务端的Session对象上后面所有消息收发都走这条通道。2.2 Tomcat 7 里的 WebSocket 生命周期回调Java 后端的 WebSocket 端点类一般会实现四个关键回调方法OnOpen连接建立时触发用于把 Session 加入在线列表、OnMessage收到客户端消息时触发是消息分发的核心入口、OnClose连接断开时触发用于清理在线用户、OnError异常时触发避免连接泄漏。这个项目的TestWebSocket类就是按这条标准链路写的。import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; ServerEndpoint(/ws/chat) public class TestWebSocket { // 用 CopyOnWriteArraySet 保存所有在线会话遍历时不怕并发修改 private static final SetSession clients new CopyOnWriteArraySet(); OnOpen public void onOpen(Session session) { clients.add(session); broadcast(系统消息有用户加入当前在线人数 clients.size()); } OnMessage public void onMessage(String message, Session session) throws IOException { // 消息格式约定typechat|private;to用户名;content正文;from发送者 // 具体解析逻辑见 4.2 节 broadcast(message); } OnClose public void onClose(Session session) { clients.remove(session); broadcast(系统消息有用户离开当前在线人数 clients.size()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); clients.remove(session); } private void broadcast(String message) { for (Session client : clients) { try { client.getBasicRemote().sendText(message); } catch (IOException e) { // 单个客户端发送失败不能影响其他人记录日志后继续 e.printStackTrace(); } } } }CopyOnWriteArraySet是线程安全的集合用它存在线会话是因为 WebSocket 的回调方法可能被多个线程同时触发。broadcast方法遍历时如果某个客户端连接断了sendText会抛IOException这里不能因为一个客户端异常就把整个广播链路中断掉。ServerEndpoint(/ws/chat)注解里的路径要和前端WebSocket构造器里的地址对应这个路径是相对于项目根目录的。2.3 前端为什么还要用 jQuery 而不是纯 JavaScript这个项目的摘要里明确提到了 jQuery实际使用中它主要解决三件事第一是 DOM 操作聊天消息列表的追加、滚动条定位、用户列表的刷新用$(#chat-box).append()比原生document.createElement写起来短很多第二是事件绑定登录表单的提交、消息发送按钮的点击、回车键的监听jQuery 的链式写法让代码集中在一个区块里第三是 Ajax 辅助登录时先用$.ajax请求后端验证用户名成功后再创建 WebSocket 连接这样可以把身份认证和长连接初始化分开控制。要注意的是WebSocket 本身的 API 是浏览器原生提供的jQuery 并没有封装它所以项目里看到new WebSocket(...)是原生写法jQuery 只负责周边逻辑。3. 前端登录与消息收发JavaScript jQuery 的配合3.1 登录界面的身份校验与 WebSocket 连接建立聊天室的登录逻辑是用户输入昵称前端先发一个 Ajax 请求去后端验证昵称是否可用后端把在线用户列表维护在一个 Map 里重名就返回失败。这个步骤用 jQuery 实现非常顺手。$(function () { $(#login-btn).click(function () { var username $(#username).val().trim(); if (username ) { alert(请输入昵称); return; } // 1. Ajax 预校验用户名是否被占用 $.ajax({ url: user/check, type: POST, data: { name: username }, dataType: json, success: function (res) { if (res.available) { initWebSocket(username); } else { // 这里不要直接 alert 刷屏可以在输入框下方显示提示 $(#tip).text(昵称已被占用请换一个); } }, error: function () { alert(服务器连接失败); } }); }); }); function initWebSocket(username) { // ws:// 后面的 host 和端口要跟部署的 Tomcat 完全一致 var wsUrl ws:// window.location.host /WebSocketChat/ws/chat; var socket new WebSocket(wsUrl); socket.onopen function () { // 连接建立后立刻把用户身份注册到服务器 // 消息格式{type:login,name:username} socket.send(JSON.stringify({ type: login, name: username })); }; socket.onmessage function (event) { var msg JSON.parse(event.data); // 根据消息类型刷新界面见 3.2 节 renderMessage(msg); }; socket.onclose function () { console.log(连接已关闭); }; }window.location.host是当前页面的域名和端口这样写的好处是本地开发和部署到服务器时不需要改前端代码里的地址。socket.onopen里发送的第一条消息很关键它相当于告诉服务器我是谁服务器收到后才能把 WebSocket 的 Session 和用户名绑定。项目里如果漏了这一步会出现用户能连上但聊天列表里不显示的问题排查时最先查这里。3.2 消息渲染与私聊窗口的 DOM 操作聊天室主页面一般分三列左侧是在线用户列表中间是公共聊天区底部是消息输入框。私聊功能通常做成双击用户名弹出一个小窗口窗口里的消息单独用自己的 WebSocket 封装逻辑或者靠服务端转发。function renderMessage(msg) { if (msg.type chat) { // 公共聊天直接把消息追加到聊天区域 $(#chat-content).append( div classmsg-rowspan classnick msg.from /span span classcontent msg.content /span/div ); // 滚动条始终吸底保证能看见最新消息 var box document.getElementById(chat-content); box.scrollTop box.scrollHeight; } else if (msg.type private) { // 私聊判断是否已经有对应的私聊窗口 var win $(#private-win- msg.from); if (win.length 0) { // 没有窗口就动态创建一个窗口标题是对方昵称 $(body).append(createPrivateWin(msg.from)); win $(#private-win- msg.from); } win.find(.p-content).append( divspan classnick msg.from /span msg.content /div ); } else if (msg.type system) { // 系统消息用户上线/下线用灰色斜体样式展示 $(#chat-content).append(div classsystem-line msg.content /div); } }动态创建私聊窗口时DOM 元素的 id 最好用对方昵称做后缀因为昵称在客户端是唯一的。win.length 0这个判断是 jQuery 里常见的套路因为 jQuery 选择器找不到元素时返回的是空数组对象直接.length判断比if (win)可靠得多。消息里的昵称如果包含特殊字符比如或渲染时一定要转义否则用户输入一段script就能对你页面上所有人发起 XSS 攻击这是聊天室最容易踩的安全坑。4. Java 后端如何管理连接与消息分发4.1 在线用户池的设计Map 还是 Session 列表这个项目的后端需要维护一张用户名 → WebSocket Session的映射表。因为同一个用户的浏览器和服务器之间只有一条 WebSocket 连接用ConcurrentHashMapString, Session就能满足查询需求公共聊天要遍历所有 value 广播私聊要根据目标用户名直接 get 出对应的 Session。为什么不直接用 Open 时拿到的 Session 列表遍历因为私聊场景下按用户名定位 Session 是最高频的操作Map 的查询复杂度是 O(1)列表遍历是 O(n)在线人数破千时差距就出来了。public class UserManager { // 用户名 - Session 的映射必须用 ConcurrentHashMap多线程并发读写时安全 private static final MapString, Session onlineUsers new ConcurrentHashMap(); public static void login(String username, Session session) { onlineUsers.put(username, session); } public static void logout(String username) { onlineUsers.remove(username); } public static Session getSession(String username) { return onlineUsers.get(username); } public static SetString getAllUsernames() { return onlineUsers.keySet(); } public static int getOnlineCount() { return onlineUsers.size(); } }ConcurrentHashMap不能在遍历的时候直接remove所以主动退出时先logout再发广播不要在广播循环里操作 Map。getAllUsernames返回的是keySet的视图如果你在外部遍历时修改 Map 会抛ConcurrentModificationException稳妥做法是遍历之前先new ArrayList(onlineUsers.keySet())复制一份。4.2 消息协议用 JSON 统一封装 chat、private、system 三种类型浏览器和服务器之间传输的数据不能是裸字符串否则无法区分这条消息是聊天内容、私聊指令还是系统事件。这个项目采用 JSON 格式约定前端和后端共用一套字段结构type字段表示消息类型from是发送者to是接收者私聊必填content是消息体time是时间戳。后端收到消息后的分发逻辑可以抽象成这样。OnMessage public void onMessage(String jsonMessage, Session session) throws IOException { // 用简单字符串解析代替 JSON 库避免引依赖生产环境建议用 Gson 或 Jackson String type getJsonValue(jsonMessage, type); String from getJsonValue(jsonMessage, from); String to getJsonValue(jsonMessage, to); String content getJsonValue(jsonMessage, content); if (chat.equals(type)) { // 公共消息广播给所有人包括发送者自己 // 这样发送者不用在本地拼一条假消息界面上所有消息都来自服务器逻辑统一 broadcast(jsonMessage); } else if (private.equals(type)) { // 私聊只发给目标用户和发送者本人 Session targetSession UserManager.getSession(to); if (targetSession ! null) { targetSession.getBasicRemote().sendText(jsonMessage); } // 把自己的消息也回显给发送者否则发送者的私聊窗口里看不到自己发的内容 session.getBasicRemote().sendText(jsonMessage); } else if (getOnlineUsers.equals(type)) { // 返回在线用户列表用于登录后刷新左侧列表 String userList String.join(,, UserManager.getAllUsernames()); session.getBasicRemote().sendText({\type\:\system\,\content\:\onlineUsers: userList \}); } }getJsonValue如果是手写解析函数只能是简单的key:value提取遇到嵌套 JSON 会出错。这个项目作为教学案例手写解析可以理解但如果你要在这个基础上做生产级改造第一件事就是把 JSON 解析替换成 Gson 或者 Jackson。接口风格也要注意sendText的第一个参数是客户端收到的完整 JSON 字符串服务端不要擅自改格式否则前端JSON.parse会直接抛异常然后整个消息渲染流程就断了。4.3 在线客服的会话转移逻辑摘要里提到这个项目包含在线客服功能实际拆包看下来客服模块的本质其实是私聊的特例。客服人员本身也是一个普通用户只是系统给客服账号打了一个role: service的标记。当普通用户点击联系客服按钮时前端发起一条type:service的消息后端收到后从UserManager里找第一个标记为客服的在线用户把用户和这个客服之间的 Session 建立一个临时映射。// 客服分配的简化实现默认把用户分配给在线客服列表中的第一个 if (service.equals(type)) { String serviceUser findOnlineService(); if (serviceUser ! null) { // 告诉用户已连接客服 session.getBasicRemote().sendText({\type\:\system\,\content\:\已连接客服 serviceUser \}); // 告诉客服有新用户接入 Session serviceSession UserManager.getSession(serviceUser); serviceSession.getBasicRemote().sendText({\type\:\service\,\from\:\ from \}); } else { session.getBasicRemote().sendText({\type\:\system\,\content\:\当前无客服在线请留言\}); } }客服分配的负载均衡如果是粗粒度方案直接把第一个在线客服作为目标客服离线时需要把队列里的用户重新分配否则用户会一直对着一个黑洞 Session 发消息。生产环境里客服系统一般会引入消息队列或者 Redis 做排队但在这个项目里理解客服 带特殊角色的私聊对象就够了。5. 避坑与排查Tomcat 7 下跑通这个项目必踩的四个坑5.1 坑一jar 包没放进 WEB-INF/lib报 NoClassDefFoundError现象启动 Tomcat 时看到java.lang.NoClassDefFoundError: javax/websocket/WebSocketContainer。原因Tomcat 7 从 7.0.47 开始才内置 JSR 356 的 WebSocket API但编译时如果用的是 Tomcat 自带的websocket-api.jar运行时需要确认项目发布目录里包含了 Tomcat 的lib/tomcat-coyote.jar和websocket-api.jarEclipse 动态 Web 项目如果没把 Tomcat 运行时依赖加上就会报这个错。解决在 Eclipse 里右键项目 → Properties → Targeted Runtimes勾选 Tomcat v7.0然后确认Deployment Assembly里把 JRE System Library 和 Web App Libraries 都加进去了。5.2 坑二前端连接 404路径对不上现象浏览器控制台报WebSocket connection to ws://localhost:8080/WebSocketChat/ws/chat failed但 Tomcat 日志里没有任何报错。原因WebSocket 的路径不是按类名自动映射的必须严格匹配ServerEndpoint注解里的值。我把这个项目从 Eclipse 里导出来部署到 IDEA 时忘记改项目名导致/项目名/ws/chat和注解里的/ws/chat少了一层。解决用window.location.host拼接地址时项目名必须和后端ServerEndpoint注解前缀完全一致一个暴力验证办法是直接访问http://localhost:8080/项目名/ws/chat如果返回 404 就说明注解路径和项目部署路径对不上。5.3 坑三私聊消息发给别人而且发送者自己看不到现象A 给 B 发私聊B 收到了A 的私聊窗口里没反应。原因后端OnMessage里只把消息发给了targetSession没有给发送者回显。如果前端不在本地拼接消息A 的窗口就一直停留在上一句。解决服务端分发私聊时也向session发送者的 Session发送一份或者前端在send()之后直接把输入框内容追加到自己的窗口。我建议用前者这样所有消息来源统一后续做消息持久化过滤时也方便。5.4 坑四刷新页面后用户还在在线列表里现象用户点了浏览器刷新服务端没触发OnClose导致在线列表里残留已断开的用户给他发私聊消息石沉大海。原因HTTP 刷新生效的是页面资源WebSocket 连接断开需要走 TCP 四次挥手如果浏览器没有主动关闭socket连接会悬挂一段时间。解决在window.onbeforeunload事件里强制socket.close()并且在前端加一个心跳机制见第 6 章服务端超过一定时间没收到心跳包就主动剔除对应的 Session。5.5 坑五Tomcat 7 的 WebSocket 不支持自定义负载均衡现象把项目部署到集群环境时多个 Tomcat 实例之间在线用户列表不同步。原因Tomcat 7 自带的 WebSocket 实现是单机版的Session 只存在于本地 JVM 里跨实例没有共享机制。解决如果有集群需求需要把消息路由层抽离出来用 Redis 订阅发布来做 Session 路由或者直接换 Spring WebSocket STOMP。这个坑在这个项目里不用解决但提前知道它的边界能帮你决定不会硬套到生产环境。6. 给连接加上心跳机制半小时不改一行业务代码就能防掉线的技巧WebSocket 连接断开并不总是能被服务端立刻感知尤其是用户直接拔网线、笔记本合盖休眠、公司网络切 WiFi 这种场景TCP 连接可能半死不活地挂着服务端还认为用户在线。我给这个聊天室补充一个心跳机制的思路前端每隔 30 秒发一条{type:ping}消息服务端收到后回{type:pong}如果服务端连续 3 次没收到某个 Session 的心跳就强制从在线列表里剔除。前端的心跳发送可以独立成一个计时器不跟聊天消息混在一起。// 心跳计时器每 30 秒执行一次 var heartbeatTimer setInterval(function () { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: ping })); } else { // 连接断了考虑自动重连这里只做日志重连逻辑见下文 console.log(连接状态异常readyState socket.readyState); } }, 30000);socket.readyState是 WebSocket 的内置属性OPEN表示连接正常。心跳消息不应该渲染到聊天界面所以onmessage里要过滤type ping或type pong只走计数逻辑不操作 DOM。服务端在OnMessage里对心跳类型特殊处理并在断开判定上做一个超时记录。// 在 OnMessage 中增加心跳分支 if (ping.equals(type)) { session.getBasicRemote().sendText({\type\:\pong\}); return; }服务端的超时清理一般有两种做法一是给每个 Session 记录lastHeartbeatTime用OnMessage触发时更新另外起一个ScheduledExecutorService定时扫描二是用 Tomcat 的session.setMaxIdleTimeout()方法设置最大空闲时间。我给这个项目用的方案是setMaxIdleTimeout一行代码就能让超过 60 秒没动静的连接被 Tomcat 自动关闭。OnOpen public void onOpen(Session session) { // 60 秒没有消息交互就自动断开注意心跳消息也会刷新这个计时 session.setMaxIdleTimeout(60000L); clients.add(session); }加了心跳之后聊天室会在每 30 秒产生一批 ping 消息流量在线人数一万时就是每秒三百多个小数据帧对带宽影响可以忽略但能极大降低服务端 Session 泄漏的概率。从那以后我每次接手类似的实时通信项目第一件必做的事就是问清楚有没有心跳没有就先补上再查消息格式和权限校验——连接管理永远是实时系统的命脉希望这个拆解过程和补充的心跳思路帮你在遇到难题时有路可循。本文还有配套的精品资源点击获取
返回列表