ARTICLE DETAIL

资讯详情

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

JavaWeb一对一网页聊天系统实战:WebSocket会话绑定与消息可靠投递

JavaWeb一对一网页聊天系统实战:WebSocket会话绑定与消息可靠投递 简介这是一套基于JavaWeb技术栈实现的一对一网页聊天系统源码面向正在学习JSP、Servlet与Ajax的初学者及课程设计开发者帮助理解前后端异步通信与实时消息刷新的完整流程。压缩包共54个文件约2.88MB包含12个java源文件与12个class编译文件、8个jsp页面、7个jar依赖包以及4个xml配置另有js脚本与项目配置文件覆盖登录、注册、好友列表、聊天等模块。核心逻辑由TalkServlet处理用户发送消息的请求TalkFromServlet负责响应页面每秒一次的轮询更新前端通过js与ajax完成请求发送与消息渲染后端连接MySQL存储数据并部署于Tomcat另附c3p0连接池配置与建表sql。目前已有346人学习下载。整套代码结构清晰适合作为JavaWeb综合练习或毕业设计参考读者可据此掌握Servlet请求分发、Ajax定时轮询与JSP动态渲染的配合方式并在此基础上自行完善界面与扩展功能。1. 从零搭一套 JavaWeb 一对一网页聊天系统为什么它比群聊更值得练手很多人做 JavaWeb 项目第一反应是搞个群聊或者论坛因为逻辑简单、能跑就行。但真正能让你在面试或者实际工作中站住脚的恰恰是一对一网页聊天系统。它逼着你处理会话绑定、消息可靠投递、在线状态同步这些群聊里可以糊弄过去的问题。你搜 javaweb 项目完整案例翻十篇有八篇是图书管理或者学生成绩系统聊天类的少一对一私聊的更少。原因很简单它涉及并发和状态管理不是单纯 CRUD 能糊弄的。这套系统适合谁适合已经学完 Servlet、JSP、JDBC想找一个能写进简历、能讲清楚技术难点的 JavaWeb 项目完整案例的人。也适合那些在头歌实训答案 jsp 里挣扎过想真正理解 HTTP 无状态和 WebSocket 有状态之间差别的开发者。你不需要 SpringBoot 也能做但如果你正在看计算机英文文献关于 SpringBoot JavaWeb 的内容这套方案同样能平滑迁移过去。核心就一件事让两个浏览器窗口之间消息不丢、不乱、不重复。2. 一对一聊天的技术选型为什么 WebSocket 是底线轮询只能算备胎2.1 从 HTTP 轮询到 WebSocket 握手的本质区别HTTP 协议是无状态的服务器发完响应就忘了你是谁。群聊可以用轮询硬撑因为消息广播出去谁收到算谁的。但一对一聊天不行A 发给 B 的消息必须精确落到 B 的会话里不能让别人看到也不能丢了。轮询的做法是客户端每隔两秒问一次服务器“有没有我的新消息”服务器查库返回。这个方案能跑但延迟高、服务器压力大而且消息顺序在并发下容易乱。WebSocket 解决的就是这个问题。它在 TCP 之上做了一次 HTTP 握手然后升级成全双工通道。握手阶段还是 HTTP所以能带 Cookie、Session 这些身份信息。升级之后服务器可以主动推消息给客户端不需要客户端问。对于一对一聊天这意味着 A 发出消息的瞬间服务器就能通过 B 的 WebSocket 连接推过去延迟在毫秒级。选型上如果你用原生 JavaWebjavax.websocket 或者 jakarta.websocket 是标准 APITomcat 7 以上就支持。如果你用 SpringBootspring-boot-starter-websocket 封装得更好但底层逻辑一样。我一般建议先用手写 WebSocket 端点的方式跑通再用 Spring 封装这样你能看清握手、编解码、会话管理每一步在干什么。2.2 会话绑定怎么把 HTTP Session 和 WebSocket Session 对上号这是整个系统最容易翻车的地方。用户登录时你在 HTTP Session 里存了 userId。但 WebSocket 握手是独立的 HTTP 请求默认情况下它拿不到你之前那个 HttpSession。很多人在这里卡住现象是登录成功了但 WebSocket 连接建立后不知道是谁。解决办法是在握手阶段拦截请求从 Cookie 或者 URL 参数里拿 token然后手动绑定。原生 JavaWeb 里可以继承 ServerEndpointConfig.Configurator重写 modifyHandshake 方法把 HttpSession 里的属性塞进 WebSocket 的 userProperties 里。SpringBoot 里可以用 HandshakeInterceptor在 beforeHandshake 里做同样的事。// 原生 JavaWeb 的握手配置器 public class HttpSessionConfigurator extends ServerEndpointConfig.Configurator { Override public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) { // 从握手请求里拿 HttpSession HttpSession httpSession (HttpSession) request.getHttpSession(); if (httpSession ! null) { // 把 userId 塞进 WebSocket 会话的属性里 config.getUserProperties().put(userId, httpSession.getAttribute(userId)); } } }这段代码的关键在 modifyHandshake它在 WebSocket 握手完成之前执行。request.getHttpSession() 拿到的就是登录时创建的那个 HttpSession前提是 Cookie 里的 JSESSIONID 被正确带上了。如果拿不到检查前端建立 WebSocket 时是不是跨域了跨域情况下 Cookie 默认不发送需要设置 withCredentials 或者改用 token 放在 URL 参数里。参数说明config.getUserProperties() 返回的是一个 Map生命周期跟 WebSocket 会话一致。你可以在 OnOpen 方法里通过 session.getUserProperties().get(userId) 取出来。注意不要往这里塞大对象它会被序列化塞个 userId 字符串就够了。2.3 消息格式设计JSON 还是纯文本字段怎么定消息格式决定了你后面扩展的难易程度。纯文本最简单A 发“你好”服务器直接转发给 B。但你需要知道谁发的、发给谁、什么时间发的、是什么类型的消息文字、图片、心跳。所以 JSON 是更合理的选择。我一般会定这几个字段fromUserId、toUserId、content、msgType、timestamp。msgType 用来区分普通消息、心跳包、系统通知。timestamp 用服务器时间不要用客户端时间否则两台机器时间不同步会导致消息排序错乱。{ fromUserId: 1001, toUserId: 1002, content: 在吗, msgType: text, timestamp: 1710000000000 }服务端收到消息后先解析 JSON校验 toUserId 是否在线。在线就直接推不在线就存离线消息表。这里有个坑不要用 fastjson 的 autoType 特性有安全风险。用 Jackson 或者 Gson手动指定目标类。2.4 在线状态管理用 ConcurrentHashMap 还是 Redis单机环境下用一个 ConcurrentHashMap 存 userId 到 WebSocket Session 的映射就够了。key 是 userIdvalue 是 Session 对象。用户连接时 put断开时 remove。发消息时先查这个 map有就推没有就存离线。但如果你部署了多个 Tomcat 实例这个 map 就不通了。A 连在实例 1B 连在实例 2实例 1 的内存里没有 B 的 Session。这时候需要 Redis 做会话共享或者用消息队列做实例间转发。对于练手项目单机足够但你要知道这个边界在哪里。面试官问“你怎么支持横向扩展”你得答得上来。// 单机在线会话管理 public class SessionManager { // key: userId, value: WebSocket Session private static final ConcurrentHashMapString, Session ONLINE_SESSIONS new ConcurrentHashMap(); public static void addSession(String userId, Session session) { ONLINE_SESSIONS.put(userId, session); } public static void removeSession(String userId) { ONLINE_SESSIONS.remove(userId); } public static Session getSession(String userId) { return ONLINE_SESSIONS.get(userId); } public static boolean isOnline(String userId) { return ONLINE_SESSIONS.containsKey(userId); } }ConcurrentHashMap 保证了多线程下的安全但注意 remove 的时候要判断是不是同一个 Session避免用户重连后旧连接断开把新连接误删。可以在 remove 前比较 session.getId()。3. 从建表到跑通一个能抄作业的最小可运行版本3.1 数据库设计三张表撑起核心逻辑不要一上来就搞十几张表。一对一聊天核心就三张用户表、消息表、离线消息表。用户表存账号密码和昵称。消息表存所有已投递的消息用于历史记录查询。离线消息表存目标用户不在线时的消息用户上线后拉取并清空。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, content TEXT NOT NULL, msg_type VARCHAR(20) DEFAULT text, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_from_to (from_user_id, to_user_id), INDEX idx_create_time (create_time) ); CREATE TABLE offline_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, content TEXT NOT NULL, msg_type VARCHAR(20) DEFAULT text, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_to_user (to_user_id) );message 表的索引建在 from_user_id 和 to_user_id 的联合上因为查历史记录通常是“查 A 和 B 之间的消息”。offline_message 表的索引建在 to_user_id 上因为用户上线时只查自己的离线消息。注意 content 用 TEXT 而不是 VARCHAR聊天消息可能很长。3.2 WebSocket 服务端端点OnOpen、OnMessage、OnClose 怎么写原生 JavaWeb 的 WebSocket 端点用注解标记。ServerEndpoint 指定路径OnOpen 在连接建立时触发OnMessage 在收到消息时触发OnClose 在连接关闭时触发。配合前面的 Configurator 拿到 userId。ServerEndpoint(value /chat/{userId}, configurator HttpSessionConfigurator.class) public class ChatEndpoint { OnOpen public void onOpen(Session session, PathParam(userId) String userId) { // 绑定用户和会话 SessionManager.addSession(userId, session); // 上线后拉取离线消息 ListOfflineMessage offlineList MessageService.getOfflineMessages(userId); for (OfflineMessage msg : offlineList) { session.getAsyncRemote().sendText( JsonUtil.toJson(msg)); } MessageService.clearOfflineMessages(userId); } OnMessage public void onMessage(String message, Session session) { // 解析消息 ChatMessage chatMsg JsonUtil.parse(message, ChatMessage.class); String toUserId chatMsg.getToUserId(); // 存消息表 MessageService.saveMessage(chatMsg); // 判断对方是否在线 Session targetSession SessionManager.getSession(toUserId); if (targetSession ! null targetSession.isOpen()) { targetSession.getAsyncRemote().sendText( JsonUtil.toJson(chatMsg)); } else { // 存离线消息 MessageService.saveOfflineMessage(chatMsg); } } OnClose public void onClose(Session session, PathParam(userId) String userId) { SessionManager.removeSession(userId); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }onOpen 里先绑定会话再拉离线消息。注意拉完要清空否则下次上线会重复收到。onMessage 里先存库再转发保证消息不丢。转发用 getAsyncRemote() 而不是 getBasicRemote()前者是非阻塞的后者会阻塞当前线程。如果对方不在线存离线表。onClose 里移除会话但要注意重连场景前面提过要比较 sessionId。3.3 前端页面用原生 WebSocket API 建立连接和收发消息前端不需要框架也能跑。核心是 new WebSocket(url)然后监听 onopen、onmessage、onclose。url 里带上 userId服务端通过 PathParam 取。// 建立连接 const userId document.getElementById(userId).value; const ws new WebSocket(ws://localhost:8080/chat/${userId}); ws.onopen function() { console.log(连接已建立); // 发送心跳包每 30 秒一次 setInterval(() { ws.send(JSON.stringify({msgType: heartbeat})); }, 30000); }; ws.onmessage function(event) { const msg JSON.parse(event.data); if (msg.msgType heartbeat) return; // 把消息渲染到聊天窗口 appendMessage(msg.fromUserId, msg.content, msg.timestamp); }; ws.onclose function() { console.log(连接已关闭); // 可以在这里做重连 }; // 发送消息 function sendMessage() { const toUserId document.getElementById(toUserId).value; const content document.getElementById(content).value; const msg { fromUserId: userId, toUserId: toUserId, content: content, msgType: text, timestamp: Date.now() }; ws.send(JSON.stringify(msg)); }心跳包的作用是防止连接被中间代理断开。很多代理会在 60 秒无数据后切断连接30 秒发一次心跳能保持活跃。onmessage 里要过滤心跳包不要渲染到界面。重连逻辑可以加指数退避第一次 1 秒后重连第二次 2 秒第三次 4 秒避免频繁重连打爆服务器。3.4 在 IDEA 里跑起来Tomcat 配置和常见启动报错IDEA 里跑 JavaWeb 项目先确认 Project Structure 里 Modules 的 Web facet 配置了 web.xml 或者注解扫描。Artifacts 里要有一个 war exploded 或者 war 包。Tomcat 配置里 Deployment 选这个 ArtifactApplication context 设成 /chat 或者 /。常见报错一404路径不对。检查 ServerEndpoint 的 value 和前端 new WebSocket 的 url 是否匹配。常见报错二WebSocket 连接失败报 200 或者 403。200 通常是握手被拦截了检查 Configurator 有没有抛异常。403 通常是跨域或者 Cookie 没带上。常见报错三ClassNotFoundExceptionjavax.websocket 的包没引入。Tomcat 7 以上自带但如果你用 Maven需要加 javax.websocket-api 的 provided 依赖。4. 避坑与排查一对一聊天系统里那些让你加班到凌晨的坑4.1 消息重复投递为什么对方收到了两条一样的消息现象A 发一条消息B 的界面出现两条。原因通常是重连逻辑没处理好。B 的网络抖动了一下WebSocket 断开又重连重连时 onOpen 拉取了离线消息但之前那条消息其实已经通过在线通道推过一次了只是 B 的前端没收到确认服务端以为没推成功又存了离线。解决消息表加一个唯一标识 msgId前端渲染前先查重。或者服务端在存离线前先查消息表确认这条消息是否已经投递过。4.2 会话覆盖用户多标签页登录后消息乱窜现象用户在两个浏览器标签页都登录了同一个账号A 发消息过来只有一个标签页收到另一个收不到。原因SessionManager 里 userId 对应的 Session 被后登录的覆盖了。解决把 value 改成 List 一个用户可以有多个会话发消息时遍历所有会话推送。但要注意如果用户在一个标签页发消息另一个标签页不应该重复显示自己发的消息需要前端根据 fromUserId 过滤。4.3 心跳包导致的空消息渲染现象聊天窗口每隔 30 秒出现一条空白消息。原因前端 onmessage 没有过滤 msgType 为 heartbeat 的消息。解决在 onmessage 开头加判断if (msg.msgType heartbeat) return; 同时服务端收到心跳包也不要存库直接忽略。4.4 离线消息拉取后没有清空导致重复现象用户每次上线都收到同样的离线消息。原因onOpen 里拉取离线消息后没有删除或者删除失败但没报错。解决拉取和删除放在同一个事务里先查再删删完再返回。如果删除失败要回滚避免消息丢失。另外拉取时按 create_time 升序保证消息顺序。4.5 Tomcat 热部署导致 WebSocket 连接全部断开现象你改了一行代码IDEA 自动热部署所有在线用户的 WebSocket 全部断开前端疯狂重连。原因热部署会重新加载类WebSocket 端点被销毁。解决开发阶段关掉自动热部署手动重启。或者前端重连逻辑加退避避免瞬间大量重连请求打爆服务器。5. 进阶技巧用消息确认机制把“不丢消息”做到极致前面说的方案能跑但有一个隐患服务端调用 sendText 之后并不保证对方一定收到了。网络可能在最后一跳丢包或者对方浏览器卡顿没处理。要做到“不丢消息”需要加一层应用层的 ACK 确认。具体做法服务端推送消息时给每条消息生成一个 msgId存到一个“待确认”的 map 里key 是 msgIdvalue 是重试次数和时间戳。客户端收到消息后回一个 ack 包带上 msgId。服务端收到 ack 就从待确认 map 里移除。如果超过 5 秒没收到 ack就重试推送最多重试 3 次。3 次还没确认就存离线消息表等对方下次上线拉取。// 待确认消息管理 public class AckManager { private static final ConcurrentHashMapString, AckEntry PENDING_ACK new ConcurrentHashMap(); public static void addPending(String msgId, String toUserId, String content) { PENDING_ACK.put(msgId, new AckEntry(toUserId, content, System.currentTimeMillis(), 0)); } public static void ack(String msgId) { PENDING_ACK.remove(msgId); } // 定时任务每 5 秒扫描一次 Scheduled(fixedRate 5000) public void retry() { long now System.currentTimeMillis(); PENDING_ACK.forEach((msgId, entry) - { if (now - entry.getTimestamp() 5000) { if (entry.getRetryCount() 3) { // 重试 3 次失败存离线 MessageService.saveOfflineMessage( entry.toOfflineMessage()); PENDING_ACK.remove(msgId); } else { // 重试推送 Session session SessionManager .getSession(entry.getToUserId()); if (session ! null session.isOpen()) { session.getAsyncRemote().sendText( entry.getContent()); entry.setRetryCount( entry.getRetryCount() 1); entry.setTimestamp(now); } } } }); } }这个机制会增加复杂度但能把消息可靠性从“尽力而为”提升到“至少一次”。注意至少一次意味着可能重复所以客户端还是要做去重。msgId 可以用 UUID也可以用 fromUserId timestamp 随机数生成。验证方法用两个浏览器窗口一个正常在线一个用开发者工具把网络调成 offline发几条消息再把网络恢复看离线消息是否按顺序到达且没有重复。这个测试能覆盖大部分边界情况。我自己的习惯是任何聊天类项目先跑通基本收发再加 ACK最后加离线。顺序反了容易在调试时被各种状态搞晕。这套方案单机跑没问题如果要上生产把 SessionManager 换成 Redis把定时重试换成延迟队列就是一套能扛住横向扩展的架构。希望帮到你。本文还有配套的精品资源点击获取
返回列表