简介:这是一套基于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 连接推过去,延迟在毫秒级。
选型上,如果你用原生 JavaWeb,javax.websocket 或者 jakarta.websocket 是标准 API,Tomcat 7 以上就支持。如果你用 SpringBoot,spring-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 是 userId,value 是 Session 对象。用户连接时 put,断开时 remove。发消息时先查这个 map,有就推,没有就存离线。
但如果你部署了多个 Tomcat 实例,这个 map 就不通了。A 连在实例 1,B 连在实例 2,实例 1 的内存里没有 B 的 Session。这时候需要 Redis 做会话共享,或者用消息队列做实例间转发。对于练手项目,单机足够,但你要知道这个边界在哪里。面试官问“你怎么支持横向扩展”,你得答得上来。
// 单机在线会话管理 public class SessionManager { // key: userId, value: WebSocket Session private static final ConcurrentHashMap<String, 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); // 上线后拉取离线消息 List<OfflineMessage> 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 选这个 Artifact,Application context 设成 /chat 或者 /。
常见报错一:404,路径不对。检查 @ServerEndpoint 的 value 和前端 new WebSocket 的 url 是否匹配。常见报错二:WebSocket 连接失败,报 200 或者 403。200 通常是握手被拦截了,检查 Configurator 有没有抛异常。403 通常是跨域或者 Cookie 没带上。常见报错三:ClassNotFoundException,javax.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 是 msgId,value 是重试次数和时间戳。客户端收到消息后,回一个 ack 包,带上 msgId。服务端收到 ack 就从待确认 map 里移除。如果超过 5 秒没收到 ack,就重试推送,最多重试 3 次。3 次还没确认,就存离线消息表,等对方下次上线拉取。
// 待确认消息管理 public class AckManager { private static final ConcurrentHashMap<String, 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,把定时重试换成延迟队列,就是一套能扛住横向扩展的架构。希望帮到你。
本文还有配套的精品资源,点击获取