Java Web聊天系统开发实战:WebSocket实时通信与消息持久化
2026/9/12 18:25:21 网站建设 项目流程

简介:面向Java Web课程大作业的聊天系统完整项目包,适合高校学生完成期末设计、课程实践或学习前后端分离开发模式时参考。压缩包共138个文件,大小约2.08MB,以66个Java源码为主,配合Vue组件、样式表、脚本、HTML页面,以及Maven构建配置、字体图标、图片和项目文档,覆盖后端与前端实现。后端按配置、控制器、服务层、数据访问、实体映射、传输对象、视图对象、过滤器拦截器监听器、通用工具等模块分层,服务层封装具体业务并遵循接口实现,数据访问完成JPA数据库操作,实体与数据库表对应,视图对象负责与前端交互,各部分职责清晰。已有1151人学习下载,资源附带详细文档说明和项目总文档,可帮助梳理大作业设计思路、模块调用关系及部署运行要点。源码与文档配合使用,能显著降低课程设计入门门槛,适合需要完整可运行示例的初学者。

1. Java Web 大作业的聊天系统,为什么值得认真做

Java Web 大作业里选在线聊天系统,通常不是因为题目冷门,而是它把一门 Web 课必须验的几件事全占了:HTTP 请求与会话跟踪、数据库读写、前端异步请求,以及最难讲清楚的实时通信。交一个能注册登录、能单聊、能看离线消息和未读数的系统,作为 web 期末作业比普通增删改查高一个档次。

下面按选型、建表、WebSocket 收发、持久化、演示复核五步展开,用 JSP + Servlet + WebSocket + MySQL 跑通整套链路。新手照做三天能交付,熟手也能在心跳参数、线程安全、已读语义这些细节上找到可复用的判断标准。

2. 聊天系统技术选型:轮询、SSE、WebSocket 怎么比

选型要先于编码。聊天业务的核心指标是端到端延迟:A 按下发送键,B 的屏幕上多久出现这条消息。HTTP 是请求-响应模型,服务端没有主动推送机制,所以所有实时方案本质上都在回答同一个问题:如何绕过“客户端不请求,服务端就出不了声”的限制。

2.1 聊天系统的四种实时方案:延迟、连接数与成本

课堂上能交的实时方案就四种,先摆开对比:短轮询、长轮询、SSE、WebSocket。

方案实现方式平均延迟连接模型服务端成本浏览器兼容
短轮询setTimeout 定时发 Ajax约为轮询间隔的一半每次请求一条 HTTP 连接无状态,最低所有
长轮询请求挂起直到有新消息可到毫秒级每个挂起请求占一条 Tomcat 线程线程池压力大所有
SSEEventSource 保持一条 HTTP 长连接毫秒级单向:只能服务端推给客户端需要管理推送连接IE 不支持
WebSocket101 握手后升级为 TCP 长连接毫秒级双向,一条连接复用需要自己维护会话集合IE10 及以上

对大作业而言,WebSocket 是四个方案里唯一双向的,私聊、群聊、在线状态提示都能覆盖,而且 HTTP Upgrade 握手过程本身就是 Java 面试八股文里的高频题,答辩时把原理讲出来,说服力比“我用了个现成组件”强得多。代价是要自己管理连接集合、心跳和重连,这正是后面两章的内容。

选型时还要看运行环境:Tomcat 8.5/9 对 JSR 356 规范支持完整,JDK 8 以上即可,不需要任何第三方框架;Spring Boot 自带的 WebSocket 底层也是同一套规范,只是包名和配置方式不同。大作业建议走原生 JSR 356,理由只有一个:所有代码都是自己写的,老师问到哪里都能答。

2.2 Java Web 技术栈落位:Tomcat 版本决定 javax 还是 jakarta

最容易翻车的是包名。javax.websocket 和 jakarta.websocket 由容器版本决定:Tomcat 9 及以下用 javax.,Tomcat 10 起用 jakarta.。机房环境常见的是 Tomcat 8.5/9 加 JDK 8,对应 javax;自己笔记本装了 Tomcat 10.1 写了 jakarta,到演示环境一编译全是红色报错,这是 Web 大作业最常见的现场事故。

判断方法很简单:命令行跑catalina version看版本号,或者翻 Tomcat 解压目录 lib 下的 websocket-api.jar 属于哪一代。Maven 工程里这样声明依赖:

<dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>

scope 写成 provided 的含义是“编译需要这个 API,但部署时不要打进 war 包”,因为 Tomcat 自带实现,重复打包会和容器冲突。不引依赖直接写代码也没问题,IDEA 里把 Tomcat 的 servlet-api.jar 和 websocket-api.jar 加进编译路径即可,效果一样。Servlet 版本跟着容器走:Tomcat 9 是 Servlet 4.0,Tomcat 8.5 兼容 3.1,大作业不涉及 Servlet 新特性,不用纠结。

JDBC 驱动建议用 MySQL Connector/J 8.0.x,连接串必须带characterEncoding=utf8&serverTimezone=Asia/Shanghai。MySQL 8 不写 serverTimezone 会在首次连接时直接抛异常,这个错误信息长、关键字乱,新手容易卡在上面半小时。写进 DBUtil 里一次配好,后面不再动。

2.3 聊天系统的三张表:从 ER 到建表 SQL

在线互动聊天系统的最小模型是“用户、消息、好友关系”三张表。如果题目加了群聊要求,再补 chat_group 和 group_member 两张,消息表加一个 group_id 列,先不展开。

CREATE DATABASE IF NOT EXISTS chat_db DEFAULT CHARACTER SET utf8mb4; USE chat_db; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL, nickname VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user INT NOT NULL, to_user INT NOT NULL, content VARCHAR(2000) NOT NULL, msg_type TINYINT NOT NULL DEFAULT 0 COMMENT '0单聊 1群聊 2系统', is_read TINYINT NOT NULL DEFAULT 0 COMMENT '0未读 1已读', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_from_to (from_user, to_user, id), KEY idx_to_read (to_user, is_read) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE friend_rel ( user_id INT NOT NULL, friend_id INT NOT NULL, PRIMARY KEY (user_id, friend_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三个字段级别的决定说清楚。第一,字符集用 utf8mb4 而不是 utf8:MySQL 的 utf8 实际是 utf8mb3,只有 3 字节,存表情符号会直接报 Incorrect string value;聊天是用户输入最自由的场景,emoji 一定会出现。第二,chat_message 主键用 BIGINT:聊天记录是 Web 大作业里唯一可能快速膨胀的表,用 BIGINT 是长期习惯,写库时不纠结类型是省事的选择。第三,两个二级索引对应两个查询路径:idx_from_to 支撑“两个用户之间的历史记录”,idx_to_read 支撑“某个用户的未读数统计”,没有这两个索引,数据过万后演示就会明显变慢。

password_hash 是密码的 SHA-256 摘要,不要存明文。大作业里用一个固定 salt 拼在密码后面再做摘要,已经能通过大部分课程的安全要求;JDK 自带的 MessageDigest 就能算,不需要引库。这些代码放在 UserDao 里,保证 JSP 页面里不出现任何 SQL。

3. 用 WebSocket 在 Java Web 里跑通单聊消息

JSR 356 里,一个 @ServerEndpoint 注解的 Java 类就是一个 WebSocket 端点。容器收到 Upgrade 请求时创建这个类的实例,每个连接一个实例,onOpen、onMessage、onClose 分别对应握手成功、收到文本帧、连接关闭。先写服务端,再写客户端,最后把登录态接上。

3.1 Java Web 的服务端 WebSocket:Session 管理与消息中转

package com.example.chat.ws; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; @ServerEndpoint( value = "/chat/{userId}", configurator = HttpSessionConfigurator.class ) public class ChatEndpoint { // 在线表:userId -> Session,全局唯一 private static final Map<String, Session> ONLINE = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { // 60 秒内没有任何数据帧交换,Tomcat 主动断开 session.setMaxIdleTimeout(60_000L); ONLINE.put(userId, session); broadcast("{\"type\":\"online\",\"userId\":\"" + userId + "\"}"); } @OnMessage public void onMessage(Session session, String raw) { // raw 是客户端传来的 JSON 文本帧,示例: // {"to":"1002","content":"在吗"} if (raw.contains("\"ping\"")) { // 心跳帧 sendText(session, "{\"type\":\"pong\"}"); return; } // 完整流程:解析 JSON -> ChatMessageDao.insert() -> sendTo } @OnClose public void onClose(@PathParam("userId") String userId) { ONLINE.remove(userId); broadcast("{\"type\":\"offline\",\"userId\":\"" + userId + "\"}"); } @OnError public void onError(Session session, Throwable t) { t.printStackTrace(); // 至少留一行日志,别空实现 } private static void sendTo(String userId, String json) { sendText(ONLINE.get(userId), json); } private static void broadcast(String json) { for (Session s : ONLINE.values()) { sendText(s, json); } } private static void sendText(Session s, String json) { if (s != null && s.isOpen()) { synchronized (s) { try { s.getBasicRemote().sendText(json); } catch (IOException e) { e.printStackTrace(); } } } } }

参数和写法逐个说。@ServerEndpoint 的 value 是端点路径模板,{userId} 是路径参数,客户端连接的地址形如 ws://localhost:8080/chat/1001。configurator 指向第 3.2 节的类,负责在握手阶段把 HttpSession 塞进连接属性。setMaxIdleTimeout 的单位是毫秒,60 秒意味着客户端心跳间隔必须小于 60 秒,否则空闲连接会被容器回收;这里写 60 秒、心跳 25 秒,留了两次心跳的余量。

参数数值作用
setMaxIdleTimeout60000 ms无任何数据帧交换 60 秒后容器断开
ONLINE 集合ConcurrentHashMaponOpen/onClose 多线程并发下安全
synchronized(s)每个 Session 一把锁避免并发 sendText 抛 IllegalStateException

ONLINE 集合用 ConcurrentHashMap 是必须的:多个浏览器同时登录、下线,onOpen 和 onClose 会在不同线程执行,普通 HashMap 在并发 put/remove 下可能丢数据甚至死循环。sendText 里 synchronized(s) 锁的是单个 Session,同一个 Session 可能同时被“转发线程”和“心跳线程”调用,Tomcat 阻塞模式下次并发 sendText 会抛 IllegalStateException;锁住 session 实例即可,跨 Session 发送不共享锁。

3.2 把 HttpSession 带进 WebSocket:configurator 的用法

WebSocket 的 Session 和 HTTP 的 HttpSession 是两回事。消息帧里没有 Cookie、没有会话 ID,所以要按“当前登录用户是谁”来路由消息,必须在握手那一刻把 HttpSession 保存下来。

package com.example.chat.ws; import javax.servlet.http.HttpSession; import javax.websocket.HandshakeResponse; import javax.websocket.server.HandshakeRequest; import javax.websocket.server.ServerEndpointConfig; public class HttpSessionConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { HttpSession httpSession = request.getHttpSession(); if (httpSession != null) { sec.getUserProperties().put("HTTP_SESSION", httpSession); } } }

modifyHandshake 执行时请求还是 HTTP 报文,能拿到完整的 Servlet 上下文,request.getHttpSession() 返回的就是登录时写入属性的那个对象。握手完成后进入 WebSocket 协议,这个 HttpSession 通过 getUserProperties() 挂到连接上,在 onOpen 里取出:

HttpSession httpSession = (HttpSession) session.getUserProperties().get("HTTP_SESSION"); Integer userId = (Integer) httpSession.getAttribute("userId"); if (userId == null) { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "not login")); return; }

这样身份由服务端会话决定,URL 里的 userId 只做路由展示,改参数冒充别人也绕不过登录校验。登录 Servlet 在验证通过后要记得写属性:req.getSession().setAttribute("userId", user.getId())。这是登录逻辑和实时逻辑唯一的交接点,写清楚一次,后续所有消息都从 HttpSession 取身份,不再信任客户端传来的任何 id。

3.3 聊天页 JS:握手、心跳与断线重连

客户端有三个动作:按路径建立连接、周期发心跳、断开重连。放在一个 connect() 函数里,页面加载时调用一次。

var ws = null; var HEARTBEAT_INTERVAL = 25000; // 服务端 idle 超时 60s,心跳必须小于它 var RECONNECT_DELAY = 3000; var heartbeatTimer = null; // JSP 里用表达式把登录信息写进页面,不要在 JS 里拼硬编码 var contextPath = '<%= request.getContextPath() %>'; var userId = '<%= session.getAttribute("userId") %>'; var wsUrl = 'ws://' + window.location.host + contextPath + '/chat/' + userId; function connect() { ws = new WebSocket(wsUrl); ws.onopen = function () { heartbeatTimer = setInterval(function () { ws.send('{"type":"ping"}'); }, HEARTBEAT_INTERVAL); }; ws.onmessage = function (evt) { var msg = JSON.parse(evt.data); if (msg.type === 'pong') { return; // 心跳回包,不渲染 } appendMessage(msg); // 正常消息渲染到聊天窗 }; ws.onclose = function () { clearInterval(heartbeatTimer); setTimeout(connect, RECONNECT_DELAY); }; } connect();

URL 构造是踩坑重灾区:window.location.host 只给主机和端口,contextPath 必须自己拼。项目部署在 ROOT 下时 contextPath 是空字符串,没问题;打成 chat.war 部署后路径多一层 /chat,漏掉的请求全部 404。心跳用同一个连接发自定义 JSON 帧,服务端收到后回 pong,不需要重新握手。断线重连时要注意:onclose 触发后到重连成功之间的消息会丢,课程规模的兜底做法是维护一个 pendingMessages 数组,onclose 之前把发送动作塞进队列,onopen 之后按顺序补发,处理完清空。

注意:浏览器对同一个域名的并发连接数有限制,HTTP/1.1 下通常是 6 条,演示时一个浏览器开六七个标签页各连一个 WebSocket,后面的连接会排队。多开窗口测试时用 Chrome 普通窗口、无痕窗口、Edge 混着开,比单浏览器开十个标签可靠。

4. 聊天系统的离线消息与未读数持久化设计

WebSocket 解决的是“在线时实时到达”,历史记录和未读数是数据库的事。单聊的最小可靠路径是:A 发消息 → 服务端先 INSERT 落库(is_read=0)→ 目标在线则走 WebSocket 推送,不在线则不推 → B 收到后确认已读,或打开会话窗口时批量标记已读。这条路径不需要消息队列,也不需要状态机,适合大作业的代码规模。

4.1 聊天系统的落库时机:先写库再推送

先落库再推送,顺序不能反。反过来“先推后写”会出现一种很难复现的问题:服务端推送成功,但 INSERT 前 JVM 崩了,客户端屏幕上有一条消息,数据库里没有,刷新页面消息消失。对聊天系统来说,消失比乱序更严重。

落库顺序客户端感知故障后果
先写库再推送延迟多一次 INSERT,毫秒级最多丢推送,数据库始终一致
先推送再写库表面上更快崩溃后出现“屏幕有、库里无”的消息

“推送成功”还要重新定义。sendText 返回正常只表示数据交给了底层网络缓冲区,不代表对方浏览器渲染完成,更不代表对方已读。因此已读状态不由服务端“是否推成功”决定,而由客户端确认:B 的页面收到一条 from_user=1001 的消息后,调用一次标记接口,把该对用户的未读批量置 1;B 主动打开与 A 的会话窗口时,同样先标记一次再拉历史。两处共用同一个 UPDATE,代码只写一遍。

4.2 未读数统计与历史记录 SQL:三句话写完

DAO 层三个方法对应三个 SQL。插入时拿到自增 id,前端用 id 做消息去重和顺序校正:

public long insert(Message m) { String sql = "INSERT INTO chat_message(from_user, to_user, content, msg_type) " + "VALUES(?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setInt(1, m.getFromUser()); ps.setInt(2, m.getToUser()); ps.setString(3, m.getContent()); ps.setInt(4, m.getMsgType()); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { return rs.next() ? rs.getLong(1) : 0L; } } catch (SQLException e) { throw new RuntimeException("insert chat_message failed", e); } }

历史记录查询把 ORDER BY id DESC 和 LIMIT 放在内层,外层再按 id ASC 翻回正序。直接用 create_time 排序不行:DATETIME 精度到秒,同一秒内两条消息的顺序无法保证,自增 id 严格单调,按 id 排序就是按发送顺序排序。

public List<Message> history(int userId, int otherId, int limit) { String sql = "SELECT * FROM ( " + "SELECT * FROM chat_message " + "WHERE (from_user = ? AND to_user = ?) " + " OR (from_user = ? AND to_user = ?) " + "ORDER BY id DESC LIMIT ? " + ") t ORDER BY t.id ASC"; // 内层取最新 limit 条,外层把顺序翻回正序,否则列表是倒的 }

未读数按发送方分组统计,一条 GROUP BY 完成:

SELECT from_user AS senderId, COUNT(*) AS unread FROM chat_message WHERE to_user = ? AND is_read = 0 GROUP BY from_user;

标记已读的 UPDATE 带上 from_user 和 to_user 两个条件,只影响会话双方:

UPDATE chat_message SET is_read = 1 WHERE from_user = ? AND to_user = ? AND is_read = 0;

三个 SQL 全部参数化,不要用字符串拼接。聊天内容里出现单引号、emoji、跨行文本都正常,手工转义总有覆盖不到的边角;PreparedStatement 的参数绑定同时解决注入和转义。好友列表通过普通 Servlet 返回 JSON,不走 WebSocket,这能少处理一类“登录后连接未建立”的时序问题:先拉好友列表,再开 WebSocket,两个动作互不依赖。

4.3 并发写库与消息顺序:聊天系统翻车的三个坑

第一个坑是中文乱码。WebSocket 文本帧由容器按 UTF-8 解码,乱码集中在 HTTP 和数据库两层。HTTP 侧检查三点:JSP 顶部 pageEncoding="UTF-8"、Servlet 里 response.setCharacterEncoding("UTF-8") 且在 getWriter() 之前调用、Tomcat 的 Connector 配 URIEncoding="UTF-8"。数据库侧是 JDBC URL 的 characterEncoding=utf8(MySQL 5.7)或 characterEncoding=utf8mb4(8.0 驱动按实际),配合建表语句里的 utf8mb4,三处一致才可能全链路不乱。

第二个坑是未读数的原子性。B 连收 A 的三条消息,同时自己打开窗口触发标记已读,如果代码是“先查未读条数,再决定是否 UPDATE”,两次操作之间 INSERT 插进来,统计就会错。正确做法是不查直接 UPDATE,让数据库把“置为已读”做成原子操作,未读数查询永远反映 UPDATE 之后的状态。

第三个坑是消息顺序。WebSocket 单条连接内的帧是按序到达的,但“解析 → 落库 → 推送”如果拆到不同线程,消息在 B 端出现的顺序可能和入库顺序不一致。课程规模的正确做法是在 onMessage 里串行完成三步,不额外开线程;真要用异步提升吞吐,就让前端按 id 排序渲染,而不是按到达时间。

提示:DAO 层每次操作都从 DriverManager 取连接、finally 里关闭,课程规模下没有问题;不要在类里放一个 static Connection 给所有线程共享——两个 WebSocket 线程同时写库时,PreparedStatement 互相覆盖,这是比“慢”更难查的错。想省事可以引入 HikariCP,但大作业不加依赖也完全够。

5. Java Web 聊天系统演示前,先核三处参数

5.1 双浏览器双账号:验证的不是界面而是链路

普通窗口和无痕窗口分别登录两个账号,互发三条消息,再关掉 B,让 A 连发两条,重开 B 验证离线消息和未读数。全程盯着 DevTools 的 Network 面板:WS 请求状态码必须是 101 Switching Protocols,Messages 帧里能看到自己定义的 JSON 结构。404 先查 contextPath,403 查 configurator 的登录校验,这两条占了 WebSocket 演示事故的大头。

5.2 心跳、空闲超时、线程池:三个数字一次对齐

参数位置推荐值依据
心跳间隔客户端 JS25 s小于服务端空闲超时的一半
setMaxIdleTimeout服务端 @OnOpen60 s允许漏两次心跳
重连间隔客户端 onclose3 s 起步连续失败递增,上限 30 s

Tomcat 的 HTTP 线程池默认 200,普通请求一个占一个线程;WebSocket 升级后由容器的 NIO 读线程处理,空闲连接不再持续占用线程池名额,所以演示时挂几十个聊天连接不会撑爆默认配置。这个对比也是答辩时解释“为什么聊天系统不能靠轮询”最硬的理由。

5.3 一条 ab 命令给 HTTP 接口兜底

ab -n 2000 -c 50 -p login.json -T application/json \ http://localhost:8080/chat/api/login

login.json 内容为 {"username":"student","password":"123456"}。重点看 Failed requests 是否为 0、Requests per second 和 Time per request 两个数字,能证明登录和取历史的接口在课堂上不会当场超时。WebSocket 侧的“并发”不必上压测工具,开三个不同浏览器窗口总共模拟十个连接,观察日志没有连接异常和消息丢失即可。把这三个数字核完,剩下就是讲清握手流程;以后每次改心跳相关代码时,记住 25 秒和 60 秒的匹配关系要写在同一处注释里,这个约束比参数本身更容易丢。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询