拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java WebSocket聊天系统课程设计:从选型到避坑全指南

Java WebSocket聊天系统课程设计:从选型到避坑全指南

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

1. Java 网络编程课程设计选 WebSocket 聊天系统:为什么它比 Socket 多线程方案更值得做

如果你正在为网络编程课程设计选题发愁,大概率会在这两条路里纠结:一条是经典的 Java Socket + 多线程 + 自定义协议,另一条是 Java 基于 WebSocket 的聊天系统。我当年也纠结过,后来两个都写过一遍,血泪经验是——Socket 方案能让你把 TCP 三次握手、线程池、粘包拆包全踩一遍,但答辩时老师最爱问的「实时性怎么保证」「浏览器能不能直接连」「消息怎么广播」,Socket 方案答起来很累。而 WebSocket 聊天系统天然带 HTTP 握手升级、全双工、服务端主动推送这几个特性,正好把网络编程里「应用层协议设计」和「长连接管理」两个核心考点都覆盖了。

这篇笔记面向的是要交课程设计报告、还要附源代码的本科生,也适合想拿一个能跑起来的小项目练手 Java 网络编程的初学者。我会把「Java 基于 WebSocket 的聊天系统」从选型理由、环境搭建、服务端和客户端实现、心跳保活、到报告怎么写,按能复现的粒度讲清楚。你照着做,能拿到一个支持多人在线、消息广播、私聊、在线列表、断线重连的聊天系统,报告里的架构图、时序图、测试用例也都有素材。中间我会把 websocket 心跳机制实现、websocket 实时推送数据这些热搜里高频出现的点单独拆开讲,因为这几个地方翻车的人最多。

2. WebSocket 握手、帧结构与 Java 服务端选型:先把协议底子打牢

2.1 从 HTTP 升级到 WebSocket 的那一次握手到底发生了什么

很多人写 WebSocket 只会在前端new WebSocket("ws://..."),后端加个@ServerEndpoint,跑通了就完事。但课程设计答辩一定会问「它和 HTTP 什么关系」。你得能说清楚:WebSocket 的连接建立阶段复用了 HTTP 的 GET 请求,客户端发过来的请求头里带Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key(一个 16 字节随机数的 Base64),服务端如果同意升级,就返回101 Switching Protocols,并把Sec-WebSocket-Key拼上固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA-1 再 Base64,作为Sec-WebSocket-Accept返回。这一步做完,底层 TCP 连接不变,但双方约定后面不再用 HTTP 报文格式,改用 WebSocket 帧。

帧结构是第二个必考点。一个 WebSocket 帧开头是 FIN、RSV、opcode,接着是 MASK 位和 payload 长度(7 位、7+16 位、7+64 位三种),客户端发往服务端的帧必须掩码,服务端发往客户端的帧不能掩码。opcode 里 0x1 是文本帧、0x2 是二进制帧、0x8 是关闭帧、0x9 是 Ping、0xA 是 Pong。心跳机制就是靠 0x9 和 0xA 这两个控制帧实现的,后面第 5 章会展开。你在报告里把这张帧结构表画出来,比贴十页代码都管用。

2.2 Java 侧三种实现路线怎么选

Java 做 WebSocket 服务端,主流有三条路,课程设计里选哪条直接决定你后面写代码的痛苦程度。

方案依赖上手难度适合场景课程设计推荐度
Jakarta WebSocket(原 Java EE)Tomcat 自带,@ServerEndpoint注解低部署在 Servlet 容器里,和 JSP/Servlet 课程衔接高,最省事
Spring Boot + spring-websocketSpring 生态中想顺便秀 Spring 技能中,配置多
Netty单独引入高想秀 NIO、EventLoop低,容易跑偏

我一般会推荐第一条。原因很实在:网络编程课程设计通常已经学过 Servlet,Tomcat 你本来就装了,@ServerEndpoint一个注解就能把普通类变成 WebSocket 端点,onOpen、onMessage、onClose、onError四个回调对应连接生命周期,代码量最少,报告里也好画时序图。Spring Boot 方案虽然时髦,但自动配置把很多细节藏起来了,答辩时老师问「握手在哪处理的」你答不上来,反而扣分。Netty 更适合作为进阶,除非你标题里明确写了 Netty,否则别给自己加难度。

选 Jakarta WebSocket 还有一个隐藏好处:它内置了Session.getAsyncRemote()和Session.getBasicRemote()两套发送接口,同步发送会阻塞当前线程,异步发送不会。广播消息时必须用异步,否则一个慢客户端能把整个广播线程拖死,这是新手最常翻的车之一。

2.3 环境与依赖:一份能直接抄的 pom 配置

课程设计最怕环境跑不起来。下面这份pom.xml片段是我验证过能跑的最小依赖集,Servlet 容器用 Tomcat 9(对应 Jakarta EE 8,包名还是javax.websocket;如果你用 Tomcat 10+,包名变成jakarta.websocket,改一下 import 即可)。

<dependencies> <!-- WebSocket API,作用域 provided,因为 Tomcat 自带实现 --> <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>

scope设成provided是关键,如果你写成默认的compile,打 war 包时会把 API 一起打进去,和 Tomcat 自带的实现冲突,启动时报ClassCastException或者端点注册不上。Gson 用来把消息对象转成 JSON 字符串,比手拼字符串靠谱得多,也方便前端JSON.parse。版本号用 2.10.1 是我本地验证过的,你换成 2.8 以上一般也没问题,但别用太老的版本,老版本对泛型支持有坑。

3. 服务端核心实现:会话管理、消息广播与私聊路由

3.1 用 ConcurrentHashMap 管理在线会话

WebSocket 服务端最核心的数据结构就是「谁在线」。每个连接对应一个javax.websocket.Session对象,你需要一个线程安全的容器把它存起来。为什么强调线程安全?因为onOpen、onMessage、onClose可能在不同线程里被调用,普通HashMap在并发 put/remove 时会死循环(JDK 7 的老问题,JDK 8 虽然改成红黑树但依然不安全)。

@ServerEndpoint("/chat/{username}") public class ChatEndpoint { // key 是用户名,value 是该用户的会话 private static final Map<String, Session> ONLINE_SESSIONS = new ConcurrentHashMap<>(); // 记录每个 Session 对应的用户名,onClose 时要用 private static final Map<Session, String> SESSION_USER = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("username") String username) { // 如果同名用户已在线,先踢掉旧连接,避免消息发错人 Session old = ONLINE_SESSIONS.get(username); if (old != null && old.isOpen()) { try { old.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "重复登录")); } catch (IOException ignored) {} } ONLINE_SESSIONS.put(username, session); SESSION_USER.put(session, username); // 广播最新在线列表 broadcast(buildSystemMsg(username + " 上线了", ONLINE_SESSIONS.keySet())); } @OnClose public void onClose(Session session) { String username = SESSION_USER.remove(session); if (username != null) { ONLINE_SESSIONS.remove(username); broadcast(buildSystemMsg(username + " 下线了", ONLINE_SESSIONS.keySet())); } } }

这段代码有三个设计点值得在报告里写。第一,用@PathParam把用户名放在 URL 路径里(ws://localhost:8080/chat/张三),比在消息体里传更直观,也方便服务端在onOpen阶段就完成身份登记。第二,同名用户踢旧连接是聊天系统的常见需求,否则一个账号开两个标签页,消息会随机发到其中一个,用户会觉得「消息丢了」。第三,SESSION_USER这个反向映射不能省,onClose回调只给你Session,不给你用户名,没有它你就不知道该从ONLINE_SESSIONS里删哪个 key。

3.2 消息协议设计:一条 JSON 走天下

课程设计里消息格式别搞太复杂,用一条统一的 JSON 就够,字段包括type、from、to、content、time、onlineUsers。type用枚举值区分:CHAT群聊、PRIVATE私聊、SYSTEM系统通知、ONLINE_LIST在线列表、PING/PONG心跳。这样前端拿到消息先看type再决定怎么渲染,逻辑清晰,报告里画一张消息字段表就能讲一页。

public class Message { private String type; // CHAT / PRIVATE / SYSTEM / ONLINE_LIST / PING / PONG private String from; private String to; // 私聊时的目标用户,群聊为 null private String content; private long time; private List<String> onlineUsers; // getter/setter 省略 }

time用System.currentTimeMillis(),前端自己格式化成HH:mm:ss,别在服务端格式化成字符串,否则时区问题能让你调半天。onlineUsers只在type为ONLINE_LIST或SYSTEM时有值,其他类型为 null,Gson 序列化时会自动忽略 null 字段(默认行为),前端判断msg.onlineUsers是否存在即可。

3.3 广播与私聊:异步发送是保命符

广播的实现看着简单,坑最多。下面这段是核心:

private void broadcast(Message msg) { String json = new Gson().toJson(msg); for (Session s : ONLINE_SESSIONS.values()) { if (s.isOpen()) { // 必须用 getAsyncRemote,同步发送会阻塞 s.getAsyncRemote().sendText(json, result -> { if (!result.isOK()) { // 发送失败,记录日志,不要在这里删 session,交给 onClose System.err.println("发送失败: " + result.getException().getMessage()); } }); } } } private void sendTo(String username, Message msg) { Session s = ONLINE_SESSIONS.get(username); if (s != null && s.isOpen()) { s.getAsyncRemote().sendText(new Gson().toJson(msg)); } }

getAsyncRemote().sendText()的第二个参数是SendHandler回调,发送结果通过result.isOK()判断。为什么强调异步?因为getBasicRemote().sendText()是阻塞的,如果某个客户端网络卡了,TCP 发送缓冲区满了,这个调用会一直阻塞,广播循环就卡在那里,后面所有用户都收不到消息。我当年第一次写就用了同步发送,本地测试两个人聊天没问题,一放到实验室局域网十个人同时在线,就出现「有人发消息别人半天收不到」,排查了一下午才定位到这儿,典型的血泪经验。

私聊路由就是sendTo,从ONLINE_SESSIONS里按用户名取 Session 单独发。注意私聊消息也要回显给发送者自己,否则发送方看不到自己发出去的内容,体验很怪。前端可以在发送后本地先渲染一条,也可以等服务端回推,我一般让服务端统一回推,保证消息顺序一致。

4. 客户端与前端联调:从 HTML 页面到断线重连

4.1 一个能跑的最小前端页面

课程设计不要求前端多漂亮,但至少要能演示。下面这个 HTML 页面包含连接、发送、接收、在线列表四个功能,直接丢到webapp目录下就能用。

<!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>WebSocket 聊天室</title></head> <body> <div id="login"> 用户名:<input id="username" value="user1"> <button onclick="connect()">连接</button> </div> <div id="online"></div> <div id="messages" style="height:300px;overflow-y:auto;border:1px solid #ccc"></div> <input id="input" style="width:70%" placeholder="输入消息,@用户名 可私聊"> <button onclick="send()">发送</button> <script> let ws = null; let reconnectTimer = null; function connect() { const username = document.getElementById('username').value.trim(); if (!username) return alert('请输入用户名'); // 用 ws 协议,端口和 Tomcat 一致 ws = new WebSocket(`ws://${location.host}/chat/${encodeURIComponent(username)}`); ws.onopen = () => { console.log('已连接'); // 连接成功后启动心跳 startHeartbeat(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'PONG') return; // 心跳响应不渲染 if (msg.type === 'ONLINE_LIST' || msg.type === 'SYSTEM') { renderOnline(msg.onlineUsers); } renderMessage(msg); }; ws.onclose = () => { console.log('连接关闭,3 秒后重连'); stopHeartbeat(); // 断线重连,避免用户手动刷新 reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = (e) => console.error('WebSocket 错误', e); } function send() { const content = document.getElementById('input').value.trim(); if (!content || !ws || ws.readyState !== WebSocket.OPEN) return; // @用户名 开头视为私聊 const match = content.match(/^@(\S+)\s+(.+)$/); const msg = match ? { type: 'PRIVATE', to: match[1], content: match[2] } : { type: 'CHAT', content: content }; ws.send(JSON.stringify(msg)); document.getElementById('input').value = ''; } </script> </body> </html>

location.host会自动取当前页面的域名和端口,避免你硬编码localhost:8080后换台机器就失效。encodeURIComponent处理中文用户名,否则 URL 里的中文可能被截断。断线重连用setTimeout递归调用connect,注意重连前要stopHeartbeat,否则旧的定时器还在跑,会往一个已关闭的 socket 发心跳,控制台报一堆错。

4.2 心跳机制实现:为什么你的连接总是「假死」

websocket 心跳机制实现是热搜里高频词,也是实际项目里最容易翻车的地方。现象是:客户端和服务端都显示连接着,但消息发不出去,或者过几分钟连接自动断了。原因通常是中间有 Nginx、防火墙或者云负载均衡,它们对空闲连接有超时限制(常见 60 秒或 300 秒),TCP 连接被悄悄回收,但两端应用层都不知道,这就是「假死」。

解决办法就是应用层心跳。服务端用@OnMessage收到PING就回PONG,客户端每 30 秒发一次PING,如果连续两次没收到PONG就主动ws.close()触发重连。

let heartbeatTimer = null; let pongTimeout = null; function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'PING' })); // 5 秒内没收到 PONG 就认为连接已死 pongTimeout = setTimeout(() => { console.warn('心跳超时,主动关闭连接'); ws.close(); }, 5000); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer); if (pongTimeout) clearTimeout(pongTimeout); heartbeatTimer = null; pongTimeout = null; }

服务端对应处理:

@OnMessage public void onMessage(String text, Session session) { Message msg = new Gson().fromJson(text, Message.class); if ("PING".equals(msg.getType())) { // 心跳直接回 PONG,不走广播 Message pong = new Message(); pong.setType("PONG"); session.getAsyncRemote().sendText(new Gson().toJson(pong)); return; } // ... 处理 CHAT / PRIVATE }

心跳间隔设 30 秒是个经验值:太短浪费流量,太长(比如 120 秒)可能中间设备 60 秒就断了,你还没发心跳。pongTimeout设 5 秒,是因为局域网内 PONG 往返通常几十毫秒,5 秒足够,超过说明连接确实有问题。注意onMessage里收到 PING 要return,别让它继续走后面的广播逻辑,否则心跳消息会被当成聊天内容广播出去,前端满屏 PING。

4.3 用 websocket test client 做接口验证

写完后端别急着开浏览器,先用websocket test client类工具(比如浏览器插件或者 Postman 的 WebSocket 功能)单独测服务端。连接ws://localhost:8080/chat/testuser,手动发{"type":"CHAT","content":"hello"},看能不能收到广播。这样能把「前端问题」和「后端问题」分开,不然浏览器控制台一堆报错,你根本不知道是 JS 写错了还是 Java 端点没注册上。我一般会先用测试工具把onOpen、onMessage、onClose三个回调都验证一遍,再联调前端。

5. 避坑与排查:课程设计里最容易翻车的 5 个点

5.1 现象:连接建立后立刻断开,控制台报 404

原因:@ServerEndpoint的路径和前端请求路径不一致,或者web.xml里metadata-complete="true"导致注解没被扫描。Tomcat 9 默认支持注解扫描,但如果你从老项目拷的web.xml,很可能带着metadata-complete="true"。

解决:检查@ServerEndpoint("/chat/{username}")和前端ws://host/chat/xxx是否一致,注意应用上下文路径(如果 war 包名是chat.ws,完整路径是/chat.ws/chat/xxx)。把web.xml里的metadata-complete改成false或直接删掉这个属性。

5.2 现象:广播时部分用户收不到消息,或者服务端线程卡死

原因:用了getBasicRemote().sendText()同步发送,某个客户端网络慢导致阻塞。或者广播时直接遍历ONLINE_SESSIONS.values(),遍历过程中有用户上下线,ConcurrentHashMap虽然不会抛ConcurrentModificationException,但弱一致性迭代器可能漏掉刚加入的用户。

解决:统一改用getAsyncRemote()。如果对实时性要求高,可以先把values()拷贝一份再遍历:new ArrayList<>(ONLINE_SESSIONS.values()),避免迭代期间集合变化。

5.3 现象:中文消息乱码,前端显示问号

原因:Tomcat 的 WebSocket 默认用 UTF-8,但如果你在onMessage里手动处理字节数组,或者前端send时没编码,就可能乱码。另外@OnMessage如果写成onMessage(byte[] data)而不是onMessage(String text),需要自己new String(data, StandardCharsets.UTF_8)。

解决:服务端用String参数接收文本消息,前端JSON.stringify后send,两端都是 UTF-8。检查 Tomcat 的server.xml里 Connector 有没有URIEncoding="UTF-8",虽然 WebSocket 不走 URI 编码,但 HTTP 握手阶段会用到。

5.4 现象:心跳发了但服务端不回 PONG,或者回了前端收不到

原因:服务端onMessage里判断type时用了==比较字符串,应该用equals。或者前端onmessage里把 PONG 也渲染成消息了,看起来像「服务端没回」。

解决:字符串比较一律"PING".equals(msg.getType()),把常量放前面避免 NPE。前端onmessage里先判断if (msg.type === 'PONG') return;,再走渲染逻辑。

5.5 现象:部署到服务器后连不上,本地却正常

原因:服务器防火墙没放行端口,或者 Nginx 反代没配置 WebSocket 的Upgrade头。Nginx 默认不转发Upgrade和Connection头,需要显式配置。

解决:防火墙放行 Tomcat 端口。Nginx 配置里加:

location /chat/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; # 要大于心跳间隔 }

proxy_read_timeout必须大于心跳间隔,否则 Nginx 会在心跳到达前主动断开连接,你又回到「假死」问题。

6. 课程设计报告怎么写才不被扣分:架构图、时序图与测试用例

报告是课程设计的另一半分数,代码跑通只完成了一半。我见过太多人代码写得不错,报告里全是代码截图,最后分数不高。老师要看的是你的设计思路和验证过程,不是代码复读机。

架构图我一般画三层:浏览器层(多个客户端)、网络传输层(HTTP 握手升级 + WebSocket 帧)、服务端层(ChatEndpoint + Session 管理 + 消息路由)。用 draw.io 或者 Visio 画,别用截图。时序图重点画两条:一条是「客户端 A 发群聊消息到服务端广播给 B、C」,另一条是「心跳保活与断线重连」。时序图里把onOpen、onMessage、broadcast、onClose这几个方法名标上,和代码对应,老师一看就知道你真写了。

测试用例表至少覆盖五种场景:单用户连接、多用户群聊、私聊、用户上下线通知、心跳超时重连。每种场景写清楚「前置条件、操作步骤、预期结果、实际结果」。比如心跳超时重连这条:前置条件是客户端已连接,操作是手动关闭服务端或断网 35 秒,预期结果是客户端 3 秒后自动重连并重新出现在在线列表,实际结果填「通过」。这张表比任何文字描述都有说服力。

最后说个我自己的习惯:报告里专门留一节写「遇到的问题与解决」,把第 5 章那五个坑挑两三个写进去,配上你的排查过程。老师特别喜欢看这个,因为它证明你是真动手了,而不是网上抄的。我当年把「同步发送导致广播阻塞」这个坑写进去,答辩时老师还追问了异步和同步的区别,正好是我准备过的,直接加分。希望帮到你。

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

返回列表