ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java基于WebSocket的聊天系统:从课程设计到答辩完整拆解

Java基于WebSocket的聊天系统:从课程设计到答辩完整拆解

简介:面向网络编程技术课程设计场景,一套Java基于WebSocket的聊天系统项目包含完整源代码与课程设计报告。系统实现用户名密码登录、多人同时在线、在线用户同步显示、文字消息收发、群聊、管理员禁言/解除禁言、一对一会话及历史记录缓存读取,数据库负责保存用户信息与聊天记录,功能覆盖常见即时通讯模块。资源包共74个文件、约7.3MB,其中14个java源码文件为核心后端逻辑,3个html与3个js文件支撑前端交互,配以css样式、properties与xml配置、sql建表脚本,以及png/jpg截图和docx课程设计报告,便于对照代码查看界面效果与设计思路。目前已有238人浏览学习,适合网络编程课程设计、Java Web开发实践或毕业设计参考。获取后可得到可直接导入IDE的Maven项目结构,通过在线用户列表与私聊群聊模块理解WebSocket长连接机制,结合报告与源码梳理鉴权、消息推送、禁言等功能的实现路径。

1. Java 基于 WebSocket 的聊天系统:课程设计从能跑到能答辩的完整拆解

网络编程课程设计一遇到"聊天系统"四个字,很多人的第一反应是直接去搜一个现成的 Spring Boot 项目改改交差。但真拿到这份 Java + WebSocket 的源代码和课程设计报告时,你会发现它和你平时复制粘贴的 CRUD 项目完全不一样:推送消息、在线用户维护、群聊禁言、私聊、历史记录缓存,每一个模块都在和网络连接的状态打交道,调不通的时候连日志都看不到,纯属黑匣子。我拆完这套websocket-master源码后最大的感受是,它的技术选型和学习路径都挺典型的——后端是 Java 生态里最常见的 Maven 工程,前端用原生 WebSocket API 建长连接,数据库负责持久化用户信息和聊天记录。适合正在做网络编程课程设计、Java 毕设,或者想弄懂 WebSocket 实际怎么落地的人。这篇笔记我会把代码结构和关键实现拆开讲,哪些是能直接抄的,哪些地方得按你自己的环境调,都会说清楚。

2. WebSocket 与 HTTP 的选型:为什么课程设计大多选它

2.1 长连接和短轮询的差别,直接决定项目工作量

聊天系统最核心的需求是"服务端主动推消息给客户端"。如果用纯 HTTP,常见做法是前端每隔几秒轮询一次,问"有没有新消息"。这种方式对小型课程设计其实也能实现,但有两个明显问题:一是实时性差,你永远有延迟;二是每次轮询都要带上完整的 HTTP 头,在线用户一多,服务端压力全花在没意义的请求上。而 WebSocket 建立的是 TCP 长连接,一次握手成功后,服务端可以随时把消息推给指定的客户端,不需要客户端反复发起请求。这份源码里的聊天模块,核心处理逻辑全都挂在 WebSocket 端点(Endpoint)上,只要客户端和服务端的握手成功,后续就是一个持续的数据通道。

这也是为什么大多数网络编程课程设计只要涉及"实时聊天",老师都会建议用 WebSocket。我在实际拆这份代码时发现,整个工程从pom.xml里引的依赖就能看出来,它走的是 Java EE / Spring WebSocket 的标准实现路线,没有引入额外的重量级框架。对于答辩来说,你能说清楚"为什么用 WebSocket 而不是轮询",比背几个接口名重要得多。

2.2 前后端各自要管好什么:一份责任划分清单

聊天系统最怕的是逻辑全挤在后端,或者前端只管显示,导致整个项目没有层次。拿到这份源码,建议你先把它拆成三个关注点,这样后面看代码不会迷路。

关注点对应模块具体职责
连接生命周期握手、建立、关闭拿到用户身份,加入在线列表,断开时移除
消息路由群聊、私聊、系统消息判断消息类型,决定是广播给所有人还是发给指定用户
数据落地用户表、聊天记录表登录校验、消息入库、历史记录读取

前端部分你会看到它用 WebSocket 对象创建连接,监听onmessage事件来接收消息。后端的在线用户列表是整个系统的核心枢纽——谁上线了、谁下线了、谁被禁言了,都要在这个列表上有状态标记。源码里维护在线用户的方式是典型的 Java 集合结构:用ConcurrentHashMap保存会话,以用户名为 key,以 WebSocketSession对象为 value。为什么不用普通的HashMap?因为 WebSocket 支持多线程并发访问,当多个用户同时发送消息时,Session会被不同线程操作,ConcurrentHashMap能在保证线程安全的同时不锁整个集合,这是课程设计里很重要的一个选型细节,答辩时老师问到的概率极高。

3. 搭建消息中枢:后端 WebSocket 端点与在线用户管理

3.1 从 pom.xml 看技术栈:你需要准备什么环境

打开源码包里的pom.xml,你会发现它的依赖数量并不多,这是课程设计项目的典型特征,不是越复杂越好。核心依赖主要有两个方向:一个是 Java 对 WebSocket 的支持,在 Spring Boot 里通常体现为spring-boot-starter-websocket;另一个是数据库访问层,对应到本项目就是处理用户信息和聊天记录持久化。由于项目正文里有mvnw.cmd和 Maven Wrapper,说明它自带 Maven 包装器,你不需要单独装特定版本的 Maven,直接执行mvnw命令就能构建,这对环境不一致的课程设计场景非常友好。

我建议你按下面的步骤把工程先跑起来,再开始看业务代码:

# 在项目根目录执行,Maven 会自动读取 pom.xml 并下载依赖 ./mvnw clean package # 打包成功后生成 target 目录下的 jar 文件 java -jar target/websocket-chat-1.0.jar

clean package会先清理之前编译的产物,再重新打包。这一步能提前把依赖缺失、JDK 版本不匹配这类环境问题暴露出来。如果构建时卡在下载依赖,多半是网络原因,把 Maven 仓库地址换成国内镜像即可。打完包直接跑起来,控制台出现 Spring Boot 启动日志,说明环境没问题,接下来才值得去啃代码。

3.2 后端核心类:Session 管理与消息广播的实现逻辑

打开src/main/java下的代码,你会看到一个专门负责 WebSocket 端点处理的类,通常标记了@ServerEndpoint注解。这个类就是整个聊天系统的入口,它里面的核心方法对应 WebSocket 的生命周期:onOpen、onMessage、onClose、onError。源码里最常见的写法是这样:

@ServerEndpoint("/chat/{username}") public class ChatEndpoint { // 用 ConcurrentHashMap 保存所有在线用户的 Session,键是用户名,值是 WebSocket Session private static Map<String, Session> onlineUsers = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("username") String username) { onlineUsers.put(username, session); // 广播一条系统消息,告诉所有人谁上线了 sendToAll("[系统] " + username + " 加入了聊天室"); } @OnMessage public void onMessage(String message, Session session) { // 解析消息,如果是以 @用户名 开头,就走私聊逻辑 if (message.startsWith("@")) { String targetUser = message.substring(1, message.indexOf(":")); sendToUser(targetUser, message); } else { sendToAll(message); } } @OnClose public void onClose(Session session, @PathParam("username") String username) { onlineUsers.remove(username); sendToAll("[系统] " + username + " 离开了聊天室"); } }

这里有几个参数值得你注意。@ServerEndpoint("/chat/{username}")中的{username}是路径参数,前端在创建 WebSocket 连接时会把当前登录的用户名拼进 URL,后端用@PathParam取出来。onlineUsers声明为static,是因为 WebSocket 端点的实例在每次连接时都会重新创建,如果不用静态变量,不同用户的消息就无法共享同一个在线列表。sendToAll是广播方法,底层逻辑是遍历onlineUsers里的每个 Session,调用getBasicRemote().sendText()发送数据。

我在改这份代码时习惯先把onOpen里的业务逻辑简化:只做用户加入列表和广播系统消息,把数据库操作放到后面再做。因为聊天系统最核心的链路是"一个用户发消息,其他人马上能看到",这条链路通了,再往上加登录校验和禁言功能会清晰很多。

3.3 登录校验与在线用户列表:数据库的第一次介入

这个项目的登录设计很朴素,用户名和密码都从数据库读取,没有做注册功能,所以在课程设计报告里你需要重点写清楚数据库表结构。我拆源码时发现它一般会有两张表:一张是用户表,存用户名和密码;另一张是聊天记录表,存发送者、接收者、消息内容和发送时间。登录校验的逻辑可以抽象成这样一个流程:

public User login(String username, String password) { // 使用 MyBatis 或 JPA 查询数据库 User user = userMapper.findByUsernameAndPassword(username, password); if (user != null) { // 登录成功,把用户放入 Session 或 Redis,标记在线状态 return user; } return null; }

很多初学者会直接把密码明文存数据库,如果课程设计只是演示功能,老师一般不会深究。但如果你想让答辩更有亮点,可以在报告里加一句"密码使用 MD5 加盐存储,避免明文入库"。这属于加分项,不需要改太多代码,只需要在插入用户数据时多做一步加密处理。在线用户列表的维护是整个系统最容易出错的地方,原因在于 WebSocket 的onClose方法并不总是可靠触发——客户端断网、浏览器直接关闭时,服务端要等 TCP 超时才知道连接断了。如果不在onError和onClose里都做清理,在线列表里就会残留一堆死 Session,后面广播消息时就会报IllegalStateException。

4. 前端长连接与消息收发:从握手到广播的完整链路

4.1 前端 WebSocket 对象:参数和后端是怎么对齐的

前端页面打开后,第一步是创建 WebSocket 连接。JavaScript 里的 WebSocket API 已经很成熟,源码里常见的写法是:

// username 是当前登录的用户名,从页面隐藏域或者 localStorage 中获取 let username = document.getElementById('username').value; let ws = new WebSocket('ws://localhost:8080/chat/' + username); ws.onopen = function() { console.log('连接成功'); // 连接成功后,可以发送一条上线通知 }; ws.onmessage = function(event) { let message = event.data; // 在这里处理消息并渲染到页面的消息列表区域 appendMessage(message); }; ws.onclose = function() { console.log('连接关闭'); };

这段代码里最需要注意的是 URL 的协议头是ws://而不是http://,并且路径里的username必须和后端@ServerEndpoint("/chat/{username}")的占位符对应上。如果前端没有传username或者路径不一致,后端会直接命中 404 或握手失败。我在调试时经常遇到的翻车场景是前端写成了ws://localhost:8080/chat?username=xxx,用了查询参数,而后端是从路径变量里取值,这种不匹配改起来很快,但排查起来会让人怀疑人生。

4.2 消息格式约定:群聊、私聊和系统消息怎么区分

源码里对所有消息的处理方式,简单说就是用固定格式区分类型,然后前端根据类型渲染不同的样式。比如群聊消息就是一段纯文本,私聊消息前面加@用户名:,系统消息前缀是[系统]。后端的onMessage方法拿到消息后,先做字符串判断,再决定广播还是单发。这种设计的好处是代码量小,课程设计完全够用;坏处是如果消息内容本身以@开头,会被误判为私聊,这是协议设计层面的问题。

如果你觉得纯字符串判断不够优雅,可以改成 JSON 消息格式:

{ "type": "chat", "target": "all", "content": "你好,世界", "sender": "张三", "timestamp": 1710000000000 }

后端解析时用 Java 内置的JsonParser或者 Jackson,判断target字段是all还是具体用户名。我一般会建议课程设计用 JSON,因为这样前端的onmessage处理逻辑可以更统一,不需要对字符串做各种indexOf切割,后期加"已读回执"、"消息类型"这些扩展功能也更方便。源码里如果用的还是字符串方案,你可以照着重构成 JSON 格式,这个改进在答辩时很能体现你的理解和改动能力。

4.3 在线用户列表的实时刷新:前端渲染与后端广播的配合

在线用户列表看起来是前端的功能,但实际上它依赖后端每次onOpen和onClose时广播新列表。常见做法是后端定义一个专门的"系统消息",每次有用户上线或下线,就广播一次完整的在线用户集合给所有人。前端收到后,把列表重新渲染一遍。

ws.onmessage = function(event) { let data = JSON.parse(event.data); if (data.type === 'system') { // 更新在线用户列表 renderUserList(data.onlineUsers); } else if (data.type === 'chat') { appendMessage(data.sender + '说:' + data.content); } };

这里有一个要注意的地方:如果在线用户数量不多,全量推送列表没有问题;但人数一旦上百,每次上线都推送全量列表就会浪费带宽。更优的做法是后端只通知"谁上线了、谁下线了",由前端自己维护列表。不过课程设计阶段,全量推送是最稳的,代码逻辑简单,不容易出并发问题。我拆这份源码时发现它的在线列表数据是从后端实时读取的,没有做缓存,这意味着每次广播都要遍历ConcurrentHashMap,复杂度是 O(n),但对于小型聊天室完全够用。

5. 避坑/常见问题:必踩的五个坑与排查方法

5.1 Maven 构建失败:依赖下载不了,卡在package阶段

现象:执行mvnw clean package时,控制台一直停留在下载依赖的进度条,最后报Could not transfer artifact错误。

原因:默认的 Maven 中央仓库在海外,网络访问不稳定,尤其在学校机房的网络环境下,很多 jar 包下载超时。

解决:打开pom.xml所在的.mvn目录或者在~/.m2/settings.xml里配置阿里云镜像。如果工程已经自带了settings.xml,直接在mirrors节点下加一个镜像地址即可。配置好之后重新构建,依赖下载速度会明显提升。这个坑几乎每个用 Maven 的课程设计都会遇到,不是代码问题,是环境问题。

5.2 前端连接后端一直 404,浏览器控制台报 WebSocket 握手失败

现象:前端new WebSocket("ws://localhost:8080/chat/zhangsan")之后,控制台显示WebSocket connection failed,后端日志也没看到连接请求。

原因:最常见的两种情况,一种是路径不对,后端端点路径和前端路径不一致;另一种是上下文路径问题,如果你的 Spring Boot 项目设置了server.servlet.context-path=/api,那 WebSocket 的路径也会变成ws://localhost:8080/api/chat/zhangsan,少写/api就握手不到。

解决:先看后端启动日志里 WebSocket 端点注册的完整路径,再用浏览器的开发者工具查看请求 URL。一个简单的做法是在前端代码里把 URL 打成字符串打印出来,然后手动复制到浏览器直接访问,能看到响应就说明路径对了。这一点是血泪经验,多少人花半天时间调握手,最后只是路径少个字母。

5.3 在线用户列表出现幽灵用户,明明下线了还显示在线

现象:用户关闭浏览器页面,但聊天室里的在线用户列表还保留着他的名字;过一会儿再广播消息,后端报The WebSocket session [xxx] has been closed。

原因:浏览器关闭时,WebSocket 关闭帧不一定能及时发到服务端,服务端onClose方法没有被触发。部分前端的beforeunload事件里没有主动关闭连接,或者断网时 TCP 连接要进行超时重试,导致服务端迟迟不知道客户端已经跑了。

解决:在服务端onOpen方法里给 Session 注册一个监听器,定期检查 Session 是否仍然有效。如果是 Spring WebSocket,可以配置心跳检测,比如每 30 秒发送一次 ping 帧,如果连续几次没有收到 pong,就主动关闭这个 Session。前端也在页面卸载时调用ws.close(),这样大部分情况下能正常触发onClose。从那以后我每次写 WebSocket 项目都会强制检查"关闭连接"这条路径是否覆盖了所有异常场景。

5.4 广播消息时部分用户收到,部分用户收不到

现象:群聊发送一条消息,A 用户能看到,B 用户看不到;在后端日志里广播代码确实执行了,没有报错。

原因:问题通常出在ConcurrentHashMap里的 Session 对象已经失效,或者前端创建连接时有多个 WebSocket 实例共存。比如用户打开多个浏览器标签页,每个标签页都会建立一个独立连接,后端onlineUsers以用户名为 key,后连接的那个 Session 覆盖了先前的,导致之前标签页的 Session 被顶掉,发消息时那条连接自然收不到。

解决:在广播方法里发送消息之前,检查session.isOpen(),如果为 false 就移除并跳过。另外,建议前端在尝试建立新连接时先把旧的ws实例关闭,避免覆盖。代码里处理方式是这样:

public void sendToAll(String message) { onlineUsers.forEach((username, session) -> { try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } catch (IOException e) { onlineUsers.remove(username); } }); }

isOpen()判断是防止你对一个已经关闭的 Session 调sendText,那会直接抛异常,影响整个广播操作。

5.5 数据库保存聊天记录:同时写库和推送,怎么保证顺序不乱

现象:聊天记录存到数据库后,前端的消息顺序和数据库里的顺序不一致,刷新页面后历史记录顺序是乱的。

原因:推送消息和写数据库是两个独立操作,如果先写库再推送,可能出现"推送成功了,写库失败"或者反过来;如果没有用时间戳排序,历史记录读取出来就是按插入顺序或者主键顺序,不一定跟发送顺序一致。

解决:在消息类里显式加上发送时间字段,前端渲染时用这个字段排序,数据库查询时也按这个字段排序,不要依赖数据库的自增 ID。具体做法是把时间戳放在消息对象里,作为排序依据。历史记录查询的逻辑可以这样写:

SELECT sender, receiver, content, send_time FROM chat_record WHERE (sender = ? AND receiver = ?) OR (sender = ? AND receiver = ?) ORDER BY send_time ASC

这个查询方式主要解决私聊记录的有序取回,群聊记录需要在chat_record表里加一个group_id字段,然后按group_id和send_time联合排序。我在拆这份源码时发现它的历史记录缓存用了本地内存存储,也就是用一个List把最近的消息暂存在内存里,这个方案可以当作演示,但服务端一重启缓存就丢了,如果时间允许,把内存缓存替换成像 Redis 这样的外部缓存,答辩加分会很明显。

6. 让答辩更稳的进阶技巧:心跳机制、历史记录缓存与私聊的边界处理

WebSocket 课程设计做到能聊天只是及格,答辩时要想让老师眼前一亮,必须在三个点上做强化。第一个是心跳机制。很多同学不知道 WebSocket 连接看似还在,实际已经断了,我一般会建议在后端加一个定时器,每 30 秒向所有 Session 发一次 Ping,收到 Pong 的才保留,两轮没回应的直接判离线。这个机制能完美解决前面提到的"幽灵用户"问题,而且代码量很小,只需要一个ScheduledExecutorService就能实现。第二个是把历史记录缓存从本地内存改成 Redis,课程设计里不需要整套缓存策略,最简单的做法是:发送消息时同时写入 MySQL 和 Redis 的 List 结构,读取历史记录时先查 Redis,查不到再查 MySQL。这样前端刷新页面加载历史记录的速度会快很多,而且能证明你对缓存一致性有基本理解。第三个是私聊的权限边界,源码里私聊的入口是消息以@用户名开头,但如果被私聊的用户不在线,这条消息会丢失,你得在后端加一层判断,如果目标用户不在线,就返回一条"用户不在线"给发送方,这样体验才完整。

我拆完这份websocket-master后最深的感触是,网络编程课程设计的难点不在于写多少代码,而在于怎么把"连接管理"这件事想清楚。任何聊天系统的所有问题,最后都归结为"连接还在不在、消息该发给谁、发不出去怎么办"这三个问题。如果你也正在被 WebSocket 的调试折磨,别急着改代码,先把你项目里的在线用户列表打印出来,看每一次连接事件是不是符合预期,再看消息的路由判断逻辑是怎么走的,思路就顺了。这份源码和报告我建议先按原文把整个流程跑通,再动手改数据和样式,希望能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进