☰
Java课程设计实战:WebSocket聊天系统从原理到避坑
2026/10/1 3:36:09 网站建设 项目流程

简介:这份资源是面向高校网络编程课程设计与Java毕业设计场景的完整项目包,围绕基于WebSocket的多人聊天系统展开,适合正在做课程设计、需要可运行源码与配套报告的学生参考。项目实现了用户名密码登录、多人在线长连接、在线用户实时同步、群聊与一对一私聊、管理员禁言与解除禁言、历史记录缓存读取,以及数据库保存用户信息和聊天记录等功能,覆盖网络编程中连接维护、消息推送与数据持久化等核心知识点。压缩包共74个文件,约7.3MB,包含14个Java源文件、3个HTML页面、4个CSS样式、3个JavaScript脚本、1个SQL建表脚本、2个properties配置、1个pom.xml及1份课程设计报告docx,另有png、jpg截图与说明文档,便于理解项目结构与运行效果。目前已有238人学习下载,可作为课程设计选题、答辩材料与二次开发的参考模板。

1. 从课程设计到能跑的项目:WebSocket 聊天系统到底该怎么做

很多同学拿到“网络编程技术课程设计”这个题目时,第一反应是去搜一套现成的 Java 源码,改个包名、换个界面就交差。但真正动手才会发现,聊天系统这个题目的坑远比想象中多:TCP 粘包怎么处理、消息要不要持久化、在线状态怎么同步、断线之后能不能自动重连。如果只是把代码跑起来截几张图,答辩时老师随便追问一句“你的心跳机制怎么设计的”就会卡住。

WebSocket 聊天系统本质上是一个长连接 + 消息路由 + 状态管理的组合问题。它不像 HTTP 那样一问一答就结束,服务端需要维护每个客户端的连接会话,在多个用户之间转发消息,还要处理连接断开、超时、异常等边界情况。这套东西做下来,涉及 Java 网络编程、多线程并发、JSON 序列化、前端 WebSocket API 等多个知识点,正好覆盖网络编程课程设计的核心考核目标。

这篇文章面向的是正在做 Java 课程设计、需要一套能讲清楚原理又能实际跑起来的 WebSocket 聊天系统的同学。我会从协议选型讲到服务端实现、前端对接、消息可靠性保障,最后给出课程设计报告里技术方案部分的写法建议。整套方案基于 Java 原生 WebSocket API(JSR 356)和 Tomcat 内置实现,不依赖 Spring 等重型框架,适合课程设计场景下展示对底层网络编程的理解。

2. 协议选型与环境搭建:为什么不用轮询而选 WebSocket

2.1 轮询、长轮询和 WebSocket 的真实差距

在聊天系统里,最朴素的做法是前端每隔几秒发一次 HTTP 请求问服务端“有没有新消息”。这种做法叫轮询,实现简单,但问题很明显:消息延迟取决于轮询间隔,间隔短了请求量爆炸,间隔长了用户体验差。假设 50 个用户同时在线,轮询间隔 3 秒,服务端每秒要处理大约 17 次请求,其中绝大多数是无效查询。

长轮询稍微好一点,客户端发请求后服务端 hold 住不返回,直到有新消息或超时才响应。但每个客户端仍然需要反复建立 HTTP 连接,服务端要维护大量挂起的请求线程,资源消耗依然不小。

WebSocket 的思路完全不同。客户端和服务端通过一次 HTTP 握手升级协议,之后就在同一条 TCP 连接上双向收发数据帧。没有重复的请求头,没有连接建立开销,服务端可以主动推送消息。对于聊天这种需要实时双向通信的场景,WebSocket 是更自然的选择。

对比维度轮询长轮询WebSocket
实时性差(取决于间隔)较好好
服务端压力高(大量无效请求)中(挂起连接占线程)低(单连接双向)
实现复杂度低中中
适用场景消息频率极低消息频率较低实时双向通信

课程设计里选 WebSocket,答辩时能讲清楚“为什么不用轮询”本身就是加分项。

2.2 用 Maven 搭一个能跑 WebSocket 的 Java Web 项目

我一般用 Maven 管理依赖,项目结构清晰,也方便在报告里展示工程化能力。核心依赖只需要 Java WebSocket API 和 Tomcat 的 WebSocket 实现。

<!-- pom.xml 核心依赖 --> <dependencies> <!-- Servlet API,WebSocket 握手需要 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- Java WebSocket API 规范 --> <dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency> <!-- JSON 处理,用于消息编解码 --> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.10.1</version> </dependency> </dependencies>

这里javax.websocket-api的 scope 设为provided,因为 Tomcat 等容器已经内置了 WebSocket 实现,打包时不需要重复引入。Gson 用来把消息对象序列化成 JSON 字符串,前后端传输统一用 JSON 格式。

项目目录结构建议这样组织:

src/main/java/com/example/chat/ config/ -- WebSocket 配置类 endpoint/ -- WebSocket 端点 model/ -- 消息实体类 util/ -- 工具类 src/main/webapp/ index.html -- 聊天页面 js/chat.js -- 前端逻辑 css/style.css -- 样式

注意:如果你用的是 Tomcat 9 及以下版本,javax.websocket包名是正确的;Tomcat 10 及以上改成了jakarta.websocket,包名不匹配会直接报 ClassNotFoundException。课程设计里建议统一用 Tomcat 9 + JDK 8 或 11,环境稳定,资料也多。

2.3 服务端端点的最小可用实现

WebSocket 服务端的核心是@ServerEndpoint注解标注的类。每个客户端连接进来时,容器会创建一个端点实例,@OnOpen方法被调用;收到消息时触发@OnMessage;连接关闭时触发@OnClose。

@ServerEndpoint("/chat/{username}") public class ChatEndpoint { // 用 ConcurrentHashMap 保存在线用户,key 是用户名,value 是端点实例 private static final Map<String, ChatEndpoint> onlineUsers = new ConcurrentHashMap<>(); // 每个端点对应一个会话,用于发送消息 private Session session; private String username; @OnOpen public void onOpen(Session session, @PathParam("username") String username) { this.session = session; this.username = username; onlineUsers.put(username, this); // 广播用户上线消息 broadcast(Message.system(username + " 加入了聊天室")); // 发送当前在线用户列表 sendOnlineList(); } @OnMessage public void onMessage(String message) { // 解析 JSON 消息,根据类型分发处理 Message msg = new Gson().fromJson(message, Message.class); if ("chat".equals(msg.getType())) { broadcast(Message.chat(username, msg.getContent())); } } @OnClose public void onClose() { onlineUsers.remove(username); broadcast(Message.system(username + " 离开了聊天室")); } @OnError public void onError(Session session, Throwable error) { // 记录日志,避免异常导致连接状态不一致 error.printStackTrace(); } private void broadcast(Message msg) { String json = new Gson().toJson(msg); onlineUsers.values().forEach(endpoint -> { try { endpoint.session.getBasicRemote().sendText(json); } catch (IOException e) { // 发送失败说明连接可能已断开,从在线列表移除 onlineUsers.remove(endpoint.username); } }); } }

这段代码里几个关键点值得在报告里展开写:ConcurrentHashMap保证多线程环境下在线用户列表的线程安全;getBasicRemote()是阻塞式发送,适合课程设计这种并发量不高的场景,如果追求更高吞吐可以用getAsyncRemote();广播时遍历所有在线端点逐个发送,发送失败就移除,这是一种简单的容错策略。

3. 消息协议设计与前后端联调:让数据跑通

3.1 自定义 JSON 消息格式的字段设计

WebSocket 只负责传输文本或二进制帧,具体传什么格式需要自己定义。课程设计里推荐用 JSON,可读性好,调试方便。消息格式至少需要这几个字段:

{ "type": "chat", "from": "张三", "content": "大家好", "timestamp": 1718000000000, "onlineCount": 5 }

type字段区分消息类型,常见的有chat(普通聊天)、system(系统通知)、onlineList(在线列表)、heartbeat(心跳)。from标识发送者,content是消息正文,timestamp用于前端显示时间。onlineCount可选,用于前端展示当前在线人数。

对应的 Java 实体类:

public class Message { private String type; private String from; private String content; private long timestamp; private int onlineCount; // 静态工厂方法,方便构造不同类型的消息 public static Message chat(String from, String content) { Message m = new Message(); m.type = "chat"; m.from = from; m.content = content; m.timestamp = System.currentTimeMillis(); return m; } public static Message system(String content) { Message m = new Message(); m.type = "system"; m.from = "系统"; m.content = content; m.timestamp = System.currentTimeMillis(); return m; } // getter/setter 省略 }

用静态工厂方法构造消息,比每次 new 完再逐个 set 字段更简洁,也避免漏设字段导致前端解析出错。

3.2 前端 WebSocket 连接的建立与消息渲染

前端用浏览器原生WebSocketAPI 连接服务端,地址格式是ws://host:port/contextPath/chat/username。注意协议头是ws://而不是http://,如果服务端配了 HTTPS 则对应wss://。

// chat.js 核心逻辑 let ws = null; function connect(username) { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${location.host}/chat/${encodeURIComponent(username)}`; ws = new WebSocket(wsUrl); ws.onopen = function() { console.log('WebSocket 连接已建立'); // 连接建立后启动心跳 startHeartbeat(); }; ws.onmessage = function(event) { const msg = JSON.parse(event.data); renderMessage(msg); }; ws.onclose = function() { console.log('连接已关闭,尝试重连'); stopHeartbeat(); // 3 秒后自动重连 setTimeout(() => connect(username), 3000); }; ws.onerror = function(error) { console.error('WebSocket 错误:', error); }; } function renderMessage(msg) { const container = document.getElementById('message-list'); const div = document.createElement('div'); div.className = `message ${msg.type}`; const time = new Date(msg.timestamp).toLocaleTimeString(); div.innerHTML = `<span class="time">${time}</span> <span class="from">${msg.from}</span> <span class="content">${msg.content}</span>`; container.appendChild(div); container.scrollTop = container.scrollHeight; }

onclose里做自动重连是实际项目中的常见做法,课程设计里加上这个逻辑能体现对连接可靠性的考虑。重连间隔设 3 秒,避免频繁重连给服务端造成压力。

3.3 用 websocket test client 快速验证服务端

在写前端页面之前,建议先用 WebSocket 测试工具验证服务端是否正常工作。浏览器插件“WebSocket Test Client”或者在线工具都可以。连接地址填ws://localhost:8080/你的项目名/chat/testUser,连接成功后发送一条 JSON 消息:

{"type":"chat","from":"testUser","content":"hello"}

如果服务端广播逻辑正常,你应该能在测试工具里收到自己发送的消息回显。这一步能快速定位问题是在服务端还是前端,避免前后端同时调试时互相甩锅。

提示:测试时注意 URL 里的项目名(context path)。如果你在 IDEA 里配置 Tomcat 时 Application context 设的是/chat,那完整路径就是ws://localhost:8080/chat/chat/testUser,两个 chat 容易搞混。建议把 context path 设为/或者用一个不容易混淆的名字。

4. 心跳机制与断线重连:让连接真正可靠

4.1 WebSocket 心跳机制实现的两种思路

WebSocket 连接建立后,如果中间有防火墙或代理设备,长时间没有数据传输可能会被强制断开。服务端和客户端都不知道对方已经掉线,这就是所谓的“假连接”。心跳机制的目的就是定期发送一个轻量级的数据包,确认双方都还活着。

常见做法有两种:一种是客户端定时发送 ping 消息,服务端收到后回复 pong;另一种是服务端定时向所有客户端发送 ping,客户端回复 pong。课程设计里推荐第一种,因为客户端发起更可控,服务端只需要被动响应。

// 服务端处理心跳消息 @OnMessage public void onMessage(String message) { Message msg = new Gson().fromJson(message, Message.class); if ("heartbeat".equals(msg.getType())) { // 收到心跳直接回复 pong,不广播 try { session.getBasicRemote().sendText("{\"type\":\"pong\"}"); } catch (IOException e) { e.printStackTrace(); } return; } // 其他消息正常处理 if ("chat".equals(msg.getType())) { broadcast(Message.chat(username, msg.getContent())); } }

前端定时器每 30 秒发一次心跳:

let heartbeatTimer = null; function startHeartbeat() { heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'heartbeat' })); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }

30 秒的间隔是一个经验值。太短会增加不必要的流量,太长则起不到及时检测的作用。如果课程设计答辩时被问到“为什么选 30 秒”,可以从“大多数 NAT 超时时间是 60 秒以上,30 秒能保证在超时前刷新连接状态”这个角度回答。

4.2 断线重连时如何不丢消息

自动重连只能解决连接恢复的问题,重连期间对方发送的消息仍然会丢失。要解决这个问题,需要在服务端为每个用户维护一个消息队列,用户离线期间的消息暂存起来,重连后推送。

// 离线消息缓存 private static final Map<String, List<Message>> offlineMessages = new ConcurrentHashMap<>(); @OnClose public void onClose() { onlineUsers.remove(username); broadcast(Message.system(username + " 离开了聊天室")); } @OnOpen public void onOpen(Session session, @PathParam("username") String username) { this.session = session; this.username = username; onlineUsers.put(username, this); // 检查是否有离线消息,有则推送 List<Message> pending = offlineMessages.remove(username); if (pending != null && !pending.isEmpty()) { pending.forEach(msg -> { try { session.getBasicRemote().sendText(new Gson().toJson(msg)); } catch (IOException e) { e.printStackTrace(); } }); } broadcast(Message.system(username + " 加入了聊天室")); }

发送消息时,如果目标用户不在线,就把消息存入offlineMessages:

private void sendToUser(String targetUser, Message msg) { ChatEndpoint target = onlineUsers.get(targetUser); if (target != null) { try { target.session.getBasicRemote().sendText(new Gson().toJson(msg)); } catch (IOException e) { // 发送失败,转为离线消息 offlineMessages.computeIfAbsent(targetUser, k -> new ArrayList<>()).add(msg); } } else { offlineMessages.computeIfAbsent(targetUser, k -> new ArrayList<>()).add(msg); } }

这个离线消息队列用内存实现,重启服务会丢失。课程设计里够用了,但如果想在报告里体现深度,可以提一句“生产环境可以用 Redis List 或数据库表替代内存队列”。

4.3 连接状态监控与在线用户列表同步

在线用户列表需要实时同步给所有客户端。每当有用户上线或下线,服务端就广播一次最新的在线列表。

private void sendOnlineList() { Message msg = new Message(); msg.setType("onlineList"); msg.setContent(String.join(",", onlineUsers.keySet())); msg.setOnlineCount(onlineUsers.size()); broadcast(msg); }

前端收到onlineList类型的消息后更新侧边栏:

function renderOnlineList(msg) { const list = document.getElementById('online-list'); const users = msg.content ? msg.content.split(',') : []; list.innerHTML = users.map(u => `<li>${u}</li>`).join(''); document.getElementById('online-count').textContent = msg.onlineCount; }

这里有一个容易翻车的点:onlineUsers.keySet()返回的是ConcurrentHashMap的键集合视图,遍历时如果有其他线程修改 map,虽然ConcurrentHashMap本身是线程安全的,但String.join遍历过程中仍然可能拿到不一致的快照。更稳妥的做法是先复制一份:

List<String> userList = new ArrayList<>(onlineUsers.keySet()); msg.setContent(String.join(",", userList));

5. 避坑与排查:那些答辩时容易被追问的细节

5.1 连接建立成功但收不到消息

现象:前端onopen回调触发了,但发送消息后onmessage一直不执行。

原因:最常见的是服务端@OnMessage方法签名不对。JSR 356 规定@OnMessage方法可以接收String、ByteBuffer、PongMessage等参数,但如果同时写了多个@OnMessage方法且参数类型有歧义,容器可能不会按预期调用。另一个原因是前端发送时用了ws.send()但服务端广播时session.getBasicRemote()抛了异常被吞掉。

解决:检查服务端@OnMessage方法是否只有一个,参数类型是否为String。在广播的catch块里加日志输出,确认是否有发送失败的情况。前端可以在ws.send()后加一行console.log('sent')确认发送动作执行了。

5.2 多用户并发时消息错乱

现象:A 用户发送的消息,B 用户收到了但显示发送者是 C。

原因:@ServerEndpoint标注的类,容器会为每个连接创建一个新实例。如果把username定义成静态变量,多个连接会互相覆盖。上面的代码里username是实例变量,这是正确的做法。但如果在onOpen里把username存到了某个静态 map 里,而 key 用了session.getId()而不是用户名,也可能出现映射错乱。

解决:确保每个端点的用户标识是实例级别的,在线用户 map 的 key 用用户名,value 用端点实例。不要在端点类里用静态变量保存与单个连接相关的状态。

5.3 中文消息乱码

现象:前端发送中文,服务端收到的是???或者乱码。

原因:WebSocket 协议本身对文本帧使用 UTF-8 编码,一般不会乱码。但如果前端页面没有声明 UTF-8 编码,或者 Tomcat 的 connector 配置了错误的 URIEncoding,可能在握手阶段就出了问题。

解决:在 HTML 的<head>里加<meta charset="UTF-8">。检查 Tomcat 的server.xml,Connector 标签上不要设置URIEncoding="ISO-8859-1"。如果用了 Gson,确认new Gson().toJson()输出的字符串在发送前没有被二次编码。

5.4 心跳包导致消息列表被污染

现象:聊天窗口里出现了{"type":"heartbeat"}这样的原始 JSON 文本。

原因:前端onmessage里没有根据type字段过滤,把所有收到的消息都渲染到了聊天列表。

解决:在renderMessage函数开头加判断,heartbeat和pong类型的消息不渲染:

function renderMessage(msg) { if (msg.type === 'heartbeat' || msg.type === 'pong') { return; // 心跳消息不展示 } // 正常渲染逻辑 }

5.5 服务端重启后前端一直重连失败

现象:服务端重启后,前端每隔 3 秒重连一次,但一直连不上,控制台报WebSocket connection failed。

原因:服务端重启需要时间,如果前端重连间隔太短,可能在服务端还没完全启动时就发起连接,连续失败后浏览器可能触发节流机制,重连间隔被拉长。

解决:重连逻辑里加一个退避策略,每次失败后重连间隔递增,比如 3 秒、6 秒、12 秒,上限 30 秒。同时监听onopen事件,连接成功后重置重连间隔。

let reconnectDelay = 3000; const MAX_DELAY = 30000; function scheduleReconnect(username) { setTimeout(() => { connect(username); reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); }, reconnectDelay); } // 在 onopen 里重置 ws.onopen = function() { reconnectDelay = 3000; // ... };

6. 课程设计报告的技术方案写法与进阶方向

课程设计报告里“系统设计”部分最容易被写成流水账。我的经验是:不要按“先建数据库、再写实体类、再写 Service”这种代码顺序写,而是按“问题 → 方案 → 验证”的结构组织。比如消息可靠性这一节,先写“WebSocket 连接可能因网络波动断开,导致消息丢失”,再写“采用心跳检测 + 离线消息队列的方案”,最后写“通过模拟断网测试,验证重连后能收到离线期间的消息”。这种写法让老师看到你是在解决实际问题,而不是在罗列代码。

技术方案部分建议包含这几个表格:

设计点可选方案选定方案选择理由
通信协议轮询/长轮询/WebSocketWebSocket实时双向,服务端压力小
消息格式纯文本/JSON/ProtobufJSON可读性好,调试方便
在线状态存储内存 Map/Redis/数据库内存 ConcurrentHashMap课程设计规模小,无需外部依赖
消息可靠性无保障/心跳+离线队列心跳+离线队列平衡实现复杂度和可靠性

如果你想让课程设计更有亮点,可以在现有基础上加两个进阶功能。第一个是消息已读回执:接收方收到消息后自动回复一条{"type":"ack","msgId":"xxx"},发送方收到 ack 后在消息旁边显示“已读”标记。第二个是简单的消息持久化:把聊天记录写入 MySQL,表结构只需要id、from_user、to_user、content、timestamp五个字段,前端加载时拉取最近 50 条历史消息。这两个功能实现难度不大,但能在答辩时体现你对“聊天系统”这个场景的完整思考。

最后说一个我踩过的坑:课程设计验收时,老师很可能让你现场演示两个浏览器窗口互发消息。如果你只在本机测试过,记得提前把项目部署到另一台电脑或者用手机浏览器连同一个局域网测试一下。防火墙、IP 地址、端口占用这些问题,现场翻车比代码写错更尴尬。希望帮到你。

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

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

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

立即咨询