JavaWeb一对一网页聊天系统:WebSocket实时通信与消息持久化方案
简介:这是一套基于JSP+JavaScript+Ajax+Servlet+Tomcat+MySQL实现的一对一网页聊天系统源码,面向正在学习JavaWeb技术栈、需要完成课程设计或练手项目的学生与初级开发者,重点解决如何用Ajax轮询机制实现消息实时收发这一核心问题。压缩包共54个文件,约2.88MB,包含12个Java类与12个class文件承载Servlet与业务逻辑,8个JSP页面负责登录、注册、好友列表与聊天界面,另有7个jar依赖包及若干Eclipse工程配置文件,导入后即可在Tomcat下运行。系统通过TalkServlet处理用户发送消息的请求,TalkFromServlet每秒接收一次页面轮询以刷新最新消息,配合c3p0连接池与MySQL完成数据持久化,并附有建表SQL脚本。目前已有348人学习下载,适合借此理解前后端交互流程、Ajax定时请求与Servlet分工设计,UI部分尚未美化,便于读者在此基础上自行扩展界面与功能。
1. 一对一网页聊天系统:从需求到交付的完整方案
做 JavaWeb 课程设计时,很多同学一上来就选“仿微信”“在线聊天室”,结果做到一半发现消息发不出去、刷新页面记录全丢、两个浏览器窗口互相看不见对方。一对一网页聊天系统看起来简单,真正落地时却卡在会话绑定、消息实时性和历史记录持久化这三件事上。这套方案要解决的就是:两个已登录用户之间建立稳定的点对点会话,消息实时到达、刷新不丢、断线可恢复。适合正在做 JavaWeb 毕设或课程设计、需要一套能跑通并讲清楚的学生,也适合想快速搭一个内部沟通原型的开发者。下面按需求、架构、模块、避坑、验收的顺序拆开讲,每个环节都给到能直接改的参数和代码。
2. 需求与选型:为什么不用轮询而选 WebSocket
2.1 功能边界先划清楚
一对一聊天系统的功能范围必须收窄,否则很容易做成“四不像”。核心需求只有四条:用户登录后能看到在线联系人列表;选中一个联系人进入私聊窗口;发送的消息实时出现在对方窗口;双方都能翻看历史消息。非核心的群聊、文件传输、消息撤回、已读回执一律先砍掉,等核心链路跑通再考虑扩展。
这里有个血泪经验:很多同学把“在线状态”做成数据库字段,用户关掉浏览器后状态还是“在线”,联系人列表里全是僵尸用户。正确的做法是在服务端维护一个内存中的在线会话表,WebSocket 连接建立时写入、断开时移除,数据库只存用户基本信息和消息记录。在线状态是运行时状态,不该持久化。
2.2 技术选型对比
| 方案 | 实时性 | 实现复杂度 | 服务端压力 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 差(秒级延迟) | 低 | 高(频繁请求) | 消息量极小的演示 |
| 长轮询 | 中 | 中 | 中 | 兼容老旧浏览器 |
| WebSocket | 好(毫秒级) | 中 | 低(长连接) | 本项目首选 |
| SSE | 好(单向) | 低 | 低 | 只需服务端推送 |
一对一聊天需要双向通信,SSE 只能服务端推客户端,发消息还得另开接口,反而更绕。WebSocket 在 JavaWeb 体系里有成熟支持:Spring Boot 的spring-boot-starter-websocket直接提供@ServerEndpoint或WebSocketHandler两种写法。对于课程设计,推荐用原生@ServerEndpoint注解方式,代码量少、依赖轻,答辩时也容易讲清楚握手和消息帧的关系。
提示:如果项目要求必须用 Servlet 3.0 以上容器,确认
web.xml的metadata-complete没有设为 true,否则注解扫描会失效,WebSocket 端点注册不上。
2.3 数据模型设计
消息表是一对一系统的核心,字段设计直接决定查询效率。通常的做法是建一张message表,包含发送者、接收者、内容、时间戳和会话标识。会话标识用“小 ID_大 ID”拼接,保证同一对用户无论谁先发消息都落到同一个会话里,查询时不用写 OR 条件。
CREATE TABLE message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(64) NOT NULL COMMENT '会话标识,格式 minId_maxId', from_user_id BIGINT NOT NULL COMMENT '发送者ID', to_user_id BIGINT NOT NULL COMMENT '接收者ID', content TEXT NOT NULL COMMENT '消息内容', send_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0 COMMENT '0未读 1已读', INDEX idx_session_time (session_id, send_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;session_id上的联合索引是关键,历史消息查询按会话加时间排序,走这个索引可以避免全表扫描。is_read字段为后续扩展已读回执留了口子,但第一版不实现,避免范围蔓延。用户表沿用登录模块已有的结构,不重复设计。
3. 总体架构:三层结构与四个核心模块
3.1 分层职责划分
系统按表现层、业务层、数据层三层组织。表现层是 JSP 或 Thymeleaf 页面加前端 JavaScript,负责登录表单、联系人列表渲染和聊天窗口交互;业务层是 Spring 管理的 Service 和 WebSocket 端点,负责会话管理、消息路由和持久化;数据层是 MyBatis 或 JPA 访问 MySQL。分层不是为了好看,而是让 WebSocket 端点和普通 HTTP 接口共用同一套 Service,消息落库逻辑只写一遍。
四个核心模块分别是:认证模块(登录、Session 校验)、在线管理模块(WebSocket 连接注册与注销)、消息路由模块(根据接收者 ID 找到对应连接并推送)、历史查询模块(按会话分页拉取记录)。模块之间通过用户 ID 和会话 ID 两个标识串联,边界清晰。
3.2 WebSocket 端点注册
服务端用@ServerEndpoint声明端点,路径里带用户 ID 作为连接参数。连接建立时把当前Session存入一个线程安全的 Map,键是用户 ID,值是 WebSocket Session。断开时移除。这里必须用ConcurrentHashMap,普通 HashMap 在多用户并发连接时会出现结构损坏,表现为偶发的连接丢失,排查起来非常玄学。
@ServerEndpoint("/chat/{userId}") @Component public class ChatEndpoint { // 在线用户连接表,key 为用户ID private static final Map<Long, Session> ONLINE = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("userId") Long userId) { ONLINE.put(userId, session); // 广播在线状态变更给联系人列表(可选) } @OnClose public void onClose(@PathParam("userId") Long userId) { ONLINE.remove(userId); } @OnMessage public void onMessage(String text, @PathParam("userId") Long userId) { // 解析 JSON,取 toUserId 和 content ChatMessage msg = JSON.parseObject(text, ChatMessage.class); msg.setFromUserId(userId); msg.setSessionId(buildSessionId(userId, msg.getToUserId())); messageService.save(msg); // 先落库 Session target = ONLINE.get(msg.getToUserId()); if (target != null && target.isOpen()) { target.getAsyncRemote().sendText(JSON.toJSONString(msg)); } } }@PathParam把 URL 里的用户 ID 注入方法参数,省去从 Session 属性里再取一次。getAsyncRemote()是异步发送,避免某个慢客户端阻塞整个端点线程。先落库再推送的顺序不能反,否则对方收到消息但服务端崩溃时记录就丢了,刷新页面看不到,用户会以为消息没发出去。
3.3 前端连接与重连
前端在进入聊天页时建立 WebSocket 连接,地址用ws://加当前主机和用户 ID。连接成功后把状态标记为在线,发送按钮才可用。断线重连是必须做的,网络抖动或服务重启后连接会断,不重连的话用户发消息没反应,体验直接翻车。
let ws = null; let reconnectTimer = null; function connect(userId) { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/chat/${userId}`); ws.onopen = () => { clearInterval(reconnectTimer); setStatus('在线'); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); appendMessage(msg); // 渲染到聊天窗口 }; ws.onclose = () => { setStatus('已断开,重连中'); reconnectTimer = setTimeout(() => connect(userId), 3000); }; }重连间隔设 3 秒,太短会在服务端故障时形成请求风暴,太长用户等不及。onclose里不要直接递归调用connect,用setTimeout包一层,否则断线瞬间可能触发大量并发重连。状态提示要明确,用户看到“重连中”就不会反复点发送按钮。
4. 核心模块实现:消息路由与历史记录
4.1 消息路由的完整链路
一条消息从发送到对方看到,经过四步:前端组装 JSON 通过 WebSocket 发出;服务端端点解析并补全发送者 ID 和会话 ID;Service 落库;根据接收者 ID 查在线表并推送。任何一步失败都要有兜底,落库失败返回错误帧给发送方,对方不在线则只落库不推送,等对方上线后通过历史接口拉取。
会话 ID 的生成规则要统一,通常取两个用户 ID 的较小值和较大值拼接,中间用下划线连接。这样 A 发给 B 和 B 发给 A 得到同一个session_id,历史查询只需一个等值条件。如果直接用发送者加接收者两个字段查询,SQL 里要写(from=a and to=b) or (from=b and to=a),索引利用不充分,数据量上来后明显变慢。
4.2 历史消息分页查询
聊天窗口打开时先拉最近 20 条,向上滚动再加载更早的记录。接口按session_id和send_time倒序取,前端反转后渲染。分页用游标方式而不是offset,因为新消息不断插入,offset分页会出现重复或遗漏。
public List<Message> history(String sessionId, Long beforeTime, int size) { return messageMapper.selectBySessionBeforeTime(sessionId, beforeTime, size); }对应的 SQL 是WHERE session_id = ? AND send_time < ? ORDER BY send_time DESC LIMIT ?。beforeTime传当前已加载的最早一条消息时间,首次加载传当前时间。游标分页在消息持续写入的场景下稳定,不会因为新数据插入导致翻页错位。
4.3 未读消息与上线拉取
对方不在线时消息只落库,is_read保持 0。用户重新建立 WebSocket 连接后,前端调用一个未读接口,按会话分组返回未读数,用户点进某个会话时再拉取具体消息并把is_read置 1。这个设计让离线消息不丢,同时避免上线瞬间拉取全部历史造成卡顿。
未读统计的 SQL 用GROUP BY session_id加COUNT(*),条件带上to_user_id = 当前用户 AND is_read = 0。数据量大时给to_user_id和is_read建联合索引,否则每次上线都全表扫描,用户多了服务端扛不住。
5. 避坑与排查:五个最容易翻车的点
5.1 消息发出去对方收不到
现象是发送方界面正常,接收方毫无反应。原因通常是接收者的 WebSocket 连接不在在线表里,或者键的类型不一致——比如存的时候用Long,取的时候用String,ConcurrentHashMap查不到但不报错。解决方法是统一用户 ID 类型,在onOpen和onMessage里打印日志确认键值,排查时先看在线表的实际内容。
5.2 刷新页面后历史消息空白
现象是聊天记录刷新即消失。原因多半是消息只推给了对方没有落库,或者落库了但查询条件写错。检查session_id生成规则是否前后一致,发送时用一套规则、查询时用另一套,数据在库里但查不出来。建议把会话 ID 的生成抽成一个工具方法,发送和查询都调它,杜绝两处逻辑分叉。
5.3 多标签页登录互相顶掉
同一账号开两个标签页,后开的把先开的连接覆盖,先开的页面发消息没反应。原因是在线表以用户 ID 为键,后连接覆盖前连接。如果业务允许同账号多端在线,键要改成“用户 ID + 连接标识”,值是连接集合;如果不允许,就在新连接建立时主动关闭旧连接并提示用户。课程设计通常选后者,逻辑简单且符合“一对一”的语义。
5.4 中文消息乱码
现象是英文正常、中文变成问号或乱码。WebSocket 文本帧默认按 UTF-8 处理,乱码一般出在数据库连接串没指定字符集,或者前端JSON.stringify后服务端用错编码解析。检查 JDBC URL 是否带characterEncoding=utf8,建表语句是否utf8mb4,两端 JSON 序列化统一用 UTF-8。三处任一遗漏都会复现。
5.5 连接数上来后服务端卡死
现象是几个人测试正常,十几个人同时在线后消息延迟明显甚至端点无响应。原因是在@OnMessage里用了同步发送getBasicRemote().sendText(),某个客户端网络慢就阻塞端点线程。改成getAsyncRemote(),并把落库操作放到独立线程池或至少确认数据库连接池够用。默认连接池 10 个连接,并发消息一多就排队,调大到 20 到 30 并观察慢查询日志。
6. 验收与答辩:怎么证明这套系统真的能跑
验收分三步走,每一步都有可观察的证据,不靠嘴说。第一步功能验收:准备两个浏览器(或一个正常窗口加一个无痕窗口),分别登录两个账号,互发十条中英文混合消息,确认实时到达、刷新后记录还在、断网重连后能继续收发。第二步边界验收:对方下线时发消息,重新上线后能看到未读;同一账号开两个标签页,确认顶号逻辑符合预期;连续快速发送二十条,确认顺序不乱、不丢。第三步压力粗测:用脚本模拟十个连接同时发消息,观察服务端日志有无异常、消息落库条数是否与发送条数一致。
答辩时老师最常问的三个问题提前准备好答案:为什么选 WebSocket 不选轮询,答实时性和服务端压力;消息怎么保证不丢,答先落库再推送加离线拉取;在线状态存哪里,答内存在线表不持久化。把这三个点讲清楚,基本就稳了。
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 实时收发 | 双账号互发 | 对方 1 秒内显示 |
| 历史持久化 | 刷新页面 | 记录完整不丢 |
| 断线重连 | 停服务再启 | 3 秒内自动恢复 |
| 离线消息 | 对方下线后发送 | 上线后可见未读 |
| 并发发送 | 快速连发 20 条 | 顺序正确无丢失 |
我一般会在答辩前一天把上面这张表完整走一遍,任何一项不过就当场修,不带着侥幸上场。有一次偷懒没测断线重连,现场演示时服务刚好重启,连接断了页面毫无提示,场面相当尴尬。从那以后我每次交付前都强制走一遍“停服务再启”的重连测试,确认状态提示和自动恢复都正常才敢收工。希望帮到你。
本文还有配套的精品资源,点击获取