ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java+jQuery构建WebSocket聊天室:握手、广播与心跳保活实战

Java+jQuery构建WebSocket聊天室:握手、广播与心跳保活实战 简介基于JavaScript、jQuery与Java技术栈的WebSocket实时聊天室项目源码包聚焦多人聊天、私聊及在线客服场景适合正在学习Web前后端交互、实时通讯及Java服务端开发的读者参考。压缩包共23个文件以XML配置、HTML页面、Java源码、Class编译文件及Eclipse工程文件为主整体仅43KB目录结构精简便于快速定位与二次改造。目前已有116人学习浏览。包内完整覆盖了从客户端监听用户输入、借助jQuery简化DOM操作与事件绑定到Java后端管理WebSocket连接、维护在线用户状态并广播消息的完整链路同时涉及Tomcat v7.0服务端配置、登录验证、私聊消息定向发送及在线客服窗口等实现细节能够帮助读者直观理解TCP之上的全双工通信机制与真实项目文件组织方式适合作为课程设计或业务原型搭建的实用参考。对连接断开、网络异常等错误处理也有相应设计便于借鉴稳定性优化思路。1. 用 Java 和 jQuery 搭一个能上线的 WebSocket 聊天室后端工程师接到“做个聊天室”的需求时第一反应大多是 HTTP 轮询第二反应才是 WebSocket。轮询能跑通演示但消息延迟、请求风暴和服务器压力都会在在线人数上去后暴露出来。标题里的技术栈其实很直接Java 负责维护 WebSocket 长连接和消息广播JavaScript 加 jQuery 负责浏览器端的消息收发与页面渲染。这篇文章按“协议 → 后端 → 前端 → 部署坑位”的顺序把这个方案讲透适合准备做轻量 IM、客服系统或在线协作工具的开发者也适合想弄明白握手过程、1006 断连和心跳保活的人。读完你能自己写出一套可运行、可调试、能扛住基本并发的前后端联调代码。2. WebSocket 协议要点与聊天室模型拆解2.1 从 HTTP 升级到 WebSocket一次握手的完整链路WebSocket 不是凭空出现的协议它复用了 HTTP 的 80/443 端口靠一次“升级握手”完成协议切换。浏览器发起请求时带上Upgrade: websocket、Connection: Upgrade和Sec-WebSocket-Key服务端校验通过后返回101 Switching Protocols之后这条 TCP 连接就从 HTTP 换成了 WebSocket 帧。Java 容器和浏览器都把握手过程封装好了但排错时必须知道每一步发生了什么。用 curl 可以模拟这个握手过程验证服务端是否真的支持 WebSocketcurl -i -N \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ -H Sec-WebSocket-Version: 13 \ http://localhost:8080/chat服务端正常返回时响应行是HTTP/1.1 101 Switching Protocols同时带Sec-WebSocket-Accept头。浏览器端出现 400 或 404 就是握手路径或协议头有问题。Sec-WebSocket-Key是随机 Base64 字符串仅用于服务端计算Sec-WebSocket-Accept不能当作鉴权凭证真正的用户身份校验要在聊天消息里做。这里有个容易记错的细节浏览器开发者工具里握手请求显示在 Network 面板状态码是 101如果服务端返回 200 且没有升级说明服务端把 WebSocket 请求当成了普通 HTTP 请求处理通常是路径映射错误或经过了不支持 Upgrade 的中间层。2.2 聊天室的核心对象连接、消息、房间进入实现前先把模型拆开。一个聊天室运行过程中只有三类数据在流动连接、消息、房间或会话组。对象含义服务端对应物关键字段Connection一条客户端到服务端的长连接javax.websocket.Sessionid、open 状态、基础远程端点Message一次聊天内容或业务事件自定义 POJO / JSON 字符串type、userId、content、timestampRoom / Group一组连接的集合决定广播范围ConcurrentHashMapString, Set roomId、创建时间消息设计上我一般会加一个type字段而不是把所有内容都塞进content。type: chat是聊天消息type: system是某人上线和下线的系统提示type: ping/pong是心跳探测。这样前端可以按类型走不同的渲染分支后端也可以统一过滤非法消息。2.3 广播模型群聊与私聊的代价差异群聊的广播逻辑很简单遍历房间里的所有 Session逐个sendText。私聊则要先把 userId 映射到 Session再单独发送。难点从来不是发送本身而是遍历过程中连接可能已经关闭直接sendText会抛IOException。常见做法是发送前判断session.isOpen()发送时捕获异常并顺手从房间移除失效连接。不要用普通HashMap存 Session 集合多个用户同时上下线时会产生并发修改问题必须用ConcurrentHashMap配合ConcurrentHashMap.newKeySet()。这套模型是 websocket 使用中最容易踩坑的地方也是 java 面试题里经常追问的考点。单机场景下这个模型已经够用。Tomcat 默认配置维持几千条 WebSocket 连接问题不大瓶颈往往不在协议本身而在广播引发的线程开销一条消息进来服务端要循环对房间内每个 Session 调用 sendText假设一个房间 1000 人一条群聊消息就会触发 1000 次网络写入。所以广播之前先过滤掉未 open 的 Session比在发送之后处理异常更高效。3. Java 后端实现 WebSocket 端点3.1 选 javax.websocket 还是 Spring WebSocketJava 后端实现 WebSocket 有两条主路用 JSR 356 标准注解ServerEndpoint或者用 Spring 的WebSocketHandler。对标题这种“一个聊天室项目”的场景我建议直接用javax.websocket新项目里对应jakarta.websocket。原因有三个第一它只依赖 Servlet 容器自带的实现Tomcat、Jetty 开箱即用不用引额外框架第二ServerEndpoint把连接生命周期映射成onOpen/onMessage/onClose/onError四个回调心智负担小第三消息格式只要是自己定义的 JSONSpring 那套 STOMP 订阅模型反而是多余的复杂度。对比项ServerEndpointSpring WebSocket依赖容器内置零额外依赖需要 spring-websocket、spring-messaging消息模型自由收发字符串或字节支持 STOMP 子协议和 MessageMapping鉴权自己写握手拦截器通过 HandshakeInterceptor 实现适合场景轻量聊天室、消息推送与 Spring 生态深度集成的项目如果你已经在用 Spring Boot 写业务接口加一条独立路径给ServerEndpoint也完全没问题常见做法是显示声明一个ServerEndpointExporter的Bean。Java 基础掌握到集合和并发工具类这一层读下面的代码就不会有障碍。3.2 用 ServerEndpoint 写最小聊天端点下面是一个可以直接运行的 Java 类放在src/main/java下由容器启动时自动注册到/chat/{roomId}路径。新项目建议直接用jakarta.websocket命名空间旧项目把 import 换成javax.websocket即可注意同一份代码不要混用两个命名空间package com.example.chat; import jakarta.websocket.*; import jakarta.websocket.server.PathParam; import jakarta.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; ServerEndpoint(/chat/{roomId}) public class ChatEndpoint { // 房间 - 该房间的所有会话 private static final ConcurrentHashMapString, SetSession ROOMS new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { SetSession sessions ROOMS.computeIfAbsent( roomId, k - ConcurrentHashMap.newKeySet()); sessions.add(session); session.getUserProperties().put(roomId, roomId); broadcast(roomId, {\type\:\system\,\content\:\user joined\}); } OnMessage public void onMessage(String message, Session session) { String roomId (String) session.getUserProperties().get(roomId); broadcast(roomId, message); } OnClose public void onClose(Session session) { String roomId (String) session.getUserProperties().get(roomId); if (roomId ! null) { SetSession sessions ROOMS.get(roomId); if (sessions ! null) { sessions.remove(session); broadcast(roomId, {\type\:\system\,\content\:\user left\}); } } } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } private void broadcast(String roomId, String message) { SetSession sessions ROOMS.get(roomId); if (sessions null) return; for (Session s : sessions) { try { if (s.isOpen()) { s.getBasicRemote().sendText(message); } } catch (IOException e) { sessions.remove(s); } } } }这个文件里有三个关键点。ROOMS.computeIfAbsent是线程安全的建房间动作避免多人同时进入时重复创建集合session.getUserProperties()用来把 roomId 绑到当前连接上这样onMessage和onClose不需要从参数里重新推断房间getBasicRemote().sendText()是同步发送消息量小时比getAsyncRemote()更好排查问题因为异常会直接抛到当前线程。{roomId}路径参数让每个房间有独立 URL前端用/chat/roomA和/chat/roomB就能区分不同聊天室不用在消息体里额外传房间号。3.3 用户身份与消息协议上面的onMessage只是原样转发生产环境必须解析和校验。建议客户端发送的每条消息都带上 userId 和随机消息 id服务端校验格式后再广播并把时间戳替换成服务器时间{type:chat,userId:u_1001,content:hello,clientMsgId:m_123}服务端拿到这条消息后做三件事检查type是否在允许列表里检查content长度超出 2000 字直接丢弃用System.currentTimeMillis()生成serverTime字段再广播。clientMsgId是前端生成的消息唯一标识后端不必解析它但可以在广播时原样带回去前端收到后拿它去重避免重连重发导致消息重复展示。userId不应该只在消息体里出现还应该在握手阶段或登录接口里完成绑定否则任何人都可以冒充别人发言。简单的做法是登录成功后签发一个临时 token在OnOpen里校验 query 参数失败就直接关闭连接。3.4 线程安全与消息丢失的几个误区聊天室最常见的故障不是逻辑写错而是并发场景下的状态不一致。一个典型误用是直接在onOpen里给全体发消息没有判断对方会话是否已经关闭。另一个是用ArrayList存在线 Session遍历删除时抛ConcurrentModificationException。提示Java 后端线程模型里同一个 Session 的 sendText 是线程安全的但不同线程对同一个 Session 并发调用时可能交错生产环境最好用会话级别的发送队列或使用getAsyncRemote().sendText(message, result - {...})回调检查发送结果。参数层面也值得注意消息过大时容器会按maxTextMessageBufferSize限制解析不同容器默认值差异很大Tomcat 9 默认是 8K。部署后可以用一条 100KB 的测试消息跑一遍观察是否触发异常关闭。4. JavaScript/jQuery 前端接入与消息渲染4.1 原生 WebSocket API 和 jQuery 的分工浏览器端的 WebSocket 是原生能力不需要 jQuery 提供封装。jQuery 在整个前端代码里的角色是 DOM 操作绑定点击事件、把消息追加到列表、处理输入框内容。很多人误以为要用$.websocket插件其实原生new WebSocket(url)已经足够少引一层依赖就少一个排错点。前端和后端遵循同一个约定连接路径是ws://host:port/chat/roomId消息统一是 JSON 字符串。jQuery 帮我们省掉的是document.createElement、element.appendChild这类冗长写法让消息渲染代码短一半。这套做法也方便调试Chrome DevTools 的 Network 面板里 WS 过滤器能直接看到每条发送和接收的帧不需要额外加日志。4.2 建立连接和处理四个事件回调WebSocket 连接创建后所有状态变化都会走到四个回调onopen、onmessage、onclose、onerror。下面是一段完整的连接初始化代码const roomId lobby Date.now(); const ws new WebSocket( (location.protocol https: ? wss:// : ws://) location.host /chat/ roomId ); ws.onopen function () { log(连接已建立); sendMessage(大家好); }; ws.onmessage function (event) { renderMessage(JSON.parse(event.data)); }; ws.onclose function (event) { log(连接关闭, code event.code , reason event.reason); }; ws.onerror function (event) { console.error(WebSocket error, event); }; function sendMessage(content) { if (ws.readyState ! WebSocket.OPEN) { alert(连接未建立); return; } ws.send(JSON.stringify({ type: chat, userId: $(#username).val() || anonymous, content: content, clientMsgId: Date.now().toString(32) Math.random().toString(32).slice(2) })); }连接地址的拼法有几个细节。location.protocol https:时要用wss://否则浏览器会拦截混用内容location.host已经包含端口号直接拼路径即可。onmessage里收到的是字符串必须JSON.parse之后再交给渲染函数。发送前检查ws.readyState WebSocket.OPEN是必要的因为onopen之后连接也可能异步断开。回调触发时机常用操作onopen握手完成发送首条入场消息、恢复发送队列onmessage收到服务端推送JSON.parse、按 type 渲染onclose连接关闭提示用户、触发重连逻辑onerror运行时报错记录日志并等待 onclose 统一处理4.3 用 jQuery 渲染消息并做 XSS 转义消息列表渲染最常见的错误是直接拼 HTML 字符串聊天内容来自所有用户直接拼接等于把 XSS 漏洞敞开。正确做法是把文本内容用.text()写入再按 type 给出不同样式function renderMessage(msg) { const $item $(div classmsg/div); const $name $(span classmsg-user/span) .text(msg.userId : ); const $content $(span classmsg-content/span) .text(msg.content); $item.append($name).append($content); $(#messages).append($item); // 保留最近 200 条防止页面节点过多 while ($(#messages .msg).length 200) { $(#messages .msg).first().remove(); } $(#messages).scrollTop($(#messages)[0].scrollHeight); }这个方法的核心是用.text()而不是.html()写入用户输入jQuery 会把script、img onerror当作纯文本处理。保留 200 条上限可以避免无限制的内存增长很多聊天窗卡死都是消息列表节点太多导致的。scrollTop让新消息出现时视图自动滚到底部这比每次手动计算坐标简单可靠。这套代码在 jQuery 1.x 和 3.x 下行为一致因为只用了 attr、append、text、scrollTop 这几个稳定 API。5. 部署后连接不稳定心跳保活与断线重连5.1 1006 异常关闭的排查顺序浏览器报[websocket] onclose, code: 1006是最常见的线上问题。1006 是异常关闭意味着连接不是正常握手关闭而是链路中途断了。排查顺序我一般固定为先看后端日志有没有触发onError再看是否有反向代理或负载均衡设了空闲超时最后用浏览器 Network 面板确认握手请求真的返回 101而不是 200 或 502。5.2 Nginx 代理 WebSocket 的关键配置反向代理默认不转发 Upgrade 头必须在 location 里显式声明location /chat/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; }proxy_read_timeout决定连接空闲多久会被 Nginx 断开这是心跳无法生效时最常见的坑前端没有定时发数据Nginx 在超时后切断连接浏览器端表现为偶发 1006。5.3 心跳保活与断线重连把第 4 章的连接初始化逻辑抽成function initWebSocket() { ... }然后加上心跳和重连。前端每 30 秒发一条{type:ping}服务端在onMessage里识别到 ping 就回{type:pong}。这套约定能让链路一直有数据流动也能顺带探测半开连接。断线重连要加退避不能一断就连let reconnectTimes 0; ws.onclose function () { const delay Math.min(1000 * Math.pow(2, reconnectTimes), 30000); setTimeout(initWebSocket, delay); reconnectTimes; }; ws.onopen function () { reconnectTimes 0; };退避时间按 1s、2s、4s 翻倍到 30s 封顶避免服务端恢复时被所有客户端同时重连打爆。重连成功后建议重新发送最后一次未确认的消息发送时把未确认消息放进一个队列里收到服务端回执再移除。最后说一个容易忽略的细节initWebSocket每次执行时都要新创建一个 WebSocket 实例并把四个回调重新赋给这个新实例。不要在模块初始化时一次性绑好回调否则连接重建后回调会丢失或者旧实例的onmessage还在给失效连接补消息。本文还有配套的精品资源点击获取
返回列表