简介:这份资源是面向高校网络编程课程设计与Java毕业设计场景的完整项目包,围绕基于WebSocket的多人聊天系统展开,适合正在准备课程设计、需要可运行源码与配套报告的学生参考。项目实现了用户名密码登录、多人同时在线、在线用户实时同步、群聊与一对一私聊、管理员禁言与解除禁言、历史记录缓存读取,以及数据库保存用户信息和聊天记录等功能,覆盖网络编程中长连接通信与消息推送的核心知识点。压缩包共74个文件,约7.3MB,包含14个Java源文件、3个HTML页面、4个CSS样式、3个JavaScript脚本、1个SQL建库脚本、2个properties配置、1个pom.xml及1份课程设计报告文档,另有png、jpg截图与说明文档辅助理解。目前已有238人学习下载,可作为课程设计选题、功能扩展或答辩材料整理的参考。
1. 从一份能跑起来的 WebSocket 聊天系统源码说起
前阵子帮学弟看网络编程课程设计,他发来一个websocket-master.zip,说跑不起来,登录页面能打开但一发消息就断。我解压一看,是个标准的 Maven 工程,pom.xml、mvnw、src、test都在,还附了一份网络编程技术_课程设计.docx。这类 Java 课程设计源码我拆过不少,大部分问题不在代码本身,而在环境、数据库和 WebSocket 握手这三处。这份资源的核心价值很明确:它把「用户名密码登录 + 在线用户列表 + 群聊 + 私聊 + 禁言 + 历史记录 + 数据库持久化」这一整套聊天系统该有的功能都实现了,而且用的是原生 WebSocket 而不是 STOMP 那种封装层,对理解网络编程里的长连接、会话管理、消息推送特别友好。适合正在做课程设计、想找一个能改能扩的 Java WebSocket 聊天系统底稿的人,也适合想搞明白 WebSocket 心跳机制和在线状态维护的开发者。下面我按「先跑通、再拆解、后避坑」的顺序,把这份源码从头到尾过一遍。
2. 环境搭建与数据库初始化:让登录功能先跑通
2.1 技术栈确认与依赖梳理
拿到源码第一步不是急着mvn spring-boot:run,而是先看pom.xml里到底引了什么。这份工程用的是 Spring Boot 打底,WebSocket 依赖是spring-boot-starter-websocket,数据库层看包名大概率是 MyBatis 或 MyBatis-Plus,前端页面放在src/main/resources/static下,用原生 HTML + JavaScript 的 WebSocket API 直连。这种组合的好处是链路短,浏览器到后端就一层握手,出问题好定位;坏处是没有 STOMP 那种订阅模型,群聊和私聊的消息路由得自己写。
先确认 JDK 版本。Spring Boot 2.x 系列一般要求 JDK 8 或 11,如果你本机装的是 JDK 17 以上,启动时可能报java.lang.UnsupportedClassVersionError。我一般会先跑一遍java -version和mvn -version,确保 Maven 用的 JDK 和项目要求一致。常见做法是在pom.xml里看<java.version>标签,没有的话就看 Spring Boot 父工程的版本号反推。
# 查看当前 JDK 和 Maven 版本 java -version mvn -version # 如果项目自带 mvnw,优先用它,避免本机 Maven 版本差异 ./mvnw -versionmvnw是 Maven Wrapper,它会根据.mvn/wrapper/maven-wrapper.properties里指定的 Maven 版本去下载对应发行版。用./mvnw而不是本机mvn的好处是版本可控,不会因为本机 Maven 太新或太旧导致插件行为不一致。Windows 下对应的是mvnw.cmd,在 CMD 或 PowerShell 里执行mvnw.cmd -version即可。
2.2 数据库建库建表与连接配置
登录功能依赖数据库读取用户名和密码,所以数据库不通,后面全白搭。先找到配置文件,通常在src/main/resources/application.properties或application.yml。里面会有spring.datasource.url、username、password这几项。你需要先在 MySQL 里建一个库,比如websocket_chat,字符集用utf8mb4,然后执行建表语句。
-- 创建数据库 CREATE DATABASE websocket_chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 用户表:存储登录凭证和禁言状态 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, muted TINYINT DEFAULT 0 COMMENT '0未禁言 1已禁言', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 聊天记录表:群聊和私聊都落这里 CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(50) NOT NULL, to_user VARCHAR(50) DEFAULT NULL COMMENT 'NULL表示群聊', content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 插入一个测试用户,密码明文仅用于课程设计演示 INSERT INTO user (username, password, muted) VALUES ('admin', '123456', 0); INSERT INTO user (username, password, muted) VALUES ('test', '123456', 0);建表时注意username加了唯一索引,因为登录逻辑大概率是select * from user where username = ? and password = ?,如果表里有多条同名记录,登录会出玄学问题。muted字段是禁言功能的开关,管理员禁言时把它置 1,发消息前后端查一下这个字段就能拦截。chat_message表里to_user为 NULL 表示群聊消息,不为 NULL 表示私聊,这样一张表就能同时存两种消息,查询历史记录时按to_user过滤即可。
配置好数据库后,把application.properties里的连接信息改成你自己的:
spring.datasource.url=jdbc:mysql://localhost:3306/websocket_chat?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=你的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.DriverserverTimezone一定要加,否则 MySQL 8 以上版本启动时会报时区错误。characterEncoding=utf8保证中文消息不乱码。改完配置后执行./mvnw spring-boot:run,看到控制台输出Started Application就算启动成功。浏览器打开http://localhost:8080,用刚才插入的admin/123456登录,能进去就说明数据库这条链路通了。
2.3 登录接口与密码校验的边界
登录这块有个常见坑:课程设计源码里密码往往是明文比对,admin和123456直接查库匹配。这在演示环境没问题,但如果你要把它改成毕业设计或者拿去面试讲,最好换成 BCrypt 哈希。不过换哈希之前先确认登录接口的 SQL 写法,如果是 MyBatis 的 XML 映射,找到对应的select语句,把password = #{password}改成先按用户名查、再在 Java 层比对哈希值。
// 登录逻辑示意:先按用户名查,再校验密码 User user = userMapper.selectByUsername(username); if (user == null) { return Result.error("用户不存在"); } // 明文比对仅用于课程设计演示,生产环境应使用 BCrypt if (!user.getPassword().equals(password)) { return Result.error("密码错误"); } // 登录成功,返回用户信息,前端后续用 username 建立 WebSocket 连接 return Result.success(user);这段逻辑的关键点是先查用户再比密码,而不是一条 SQL 同时匹配用户名和密码。这样做的好处是能区分「用户不存在」和「密码错误」,调试时更容易定位问题。另外登录成功后前端要把username存到sessionStorage或全局变量里,因为后面建立 WebSocket 连接、发消息、私聊都要带上这个标识。
3. WebSocket 握手与在线用户列表维护
3.1 服务端端点注册与握手拦截
WebSocket 的核心在服务端端点。这份源码里应该有一个用@ServerEndpoint("/chat/{username}")注解的类,或者用WebSocketConfig注册WebSocketHandler。两种方式我都见过,@ServerEndpoint更直观,适合课程设计;WebSocketHandler更灵活,适合扩展。不管哪种,握手阶段都要把 URL 里的username取出来,存到当前会话的Session属性里,后面广播消息时才知道是谁发的。
@ServerEndpoint("/chat/{username}") @Component public class ChatEndpoint { // 在线用户列表:key 是用户名,value 是会话对象 private static final Map<String, Session> ONLINE_USERS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("username") String username) { // 握手成功,把用户加入在线列表 ONLINE_USERS.put(username, session); // 广播在线用户列表变化 broadcastOnlineUsers(); System.out.println("用户 " + username + " 已连接,当前在线:" + ONLINE_USERS.size()); } @OnClose public void onClose(@PathParam("username") String username) { // 断开连接,从在线列表移除 ONLINE_USERS.remove(username); broadcastOnlineUsers(); } }这里用ConcurrentHashMap而不是普通HashMap,因为 WebSocket 是多线程环境,多个用户同时连接或断开时,普通HashMap会出现并发修改异常。@OnOpen里把用户放进在线列表后立刻广播一次,前端就能实时刷新在线人员。@OnClose里移除用户并再次广播,保证列表同步。注意@PathParam取的是 URL 路径里的{username},前端建立连接时要把用户名拼进去,比如ws://localhost:8080/chat/admin。
3.2 在线用户列表的广播与前端渲染
广播在线用户列表的逻辑很简单:把ONLINE_USERS的 key 集合转成 JSON,然后遍历所有在线会话逐个发送。这里有个细节,广播时不要给自己也发一份导致重复渲染,或者前端做去重。我一般会在消息体里加一个type字段区分消息类型,比如type: "onlineUsers"表示在线列表,type: "chat"表示聊天消息,前端根据type走不同分支。
private void broadcastOnlineUsers() { // 把在线用户名集合转成 JSON 字符串 String usersJson = JSON.toJSONString(ONLINE_USERS.keySet()); String message = "{\"type\":\"onlineUsers\",\"data\":" + usersJson + "}"; ONLINE_USERS.forEach((username, session) -> { try { // 同步发送,避免并发写同一个 Session 出错 session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } }); }getBasicRemote()是同步发送,getAsyncRemote()是异步发送。课程设计里用同步就够了,异步虽然性能好但容易在并发场景下出现消息顺序错乱。前端收到onlineUsers类型的消息后,把data数组渲染到在线用户列表的 DOM 里。
// 前端 WebSocket 连接与消息处理 const username = sessionStorage.getItem('username'); const ws = new WebSocket(`ws://localhost:8080/chat/${username}`); ws.onmessage = function(event) { const msg = JSON.parse(event.data); if (msg.type === 'onlineUsers') { // 渲染在线用户列表 const listEl = document.getElementById('onlineList'); listEl.innerHTML = msg.data.map(u => `<li>${u}</li>`).join(''); } else if (msg.type === 'chat') { // 追加聊天消息到消息区 appendMessage(msg.from, msg.content); } };前端这段代码的关键是ws.onmessage里根据type分流。sessionStorage.getItem('username')取的是登录时存进去的用户名,拼到 WebSocket URL 里。如果登录后直接刷新页面,sessionStorage还在,但 WebSocket 会断开重连,所以最好在onclose里加一个重连逻辑,或者提示用户重新登录。
3.3 心跳机制:为什么你的连接总是断
WebSocket 长连接最容易被忽略的就是心跳。浏览器和服务器之间的连接如果长时间没有数据传输,中间的代理、防火墙或者 Nginx 会主动断开。表现就是用户挂着页面没操作,过几分钟再发消息就发不出去了,控制台报WebSocket is already in CLOSING or CLOSED state。解决办法是前端定时发心跳包,后端收到后回一个 pong,保持连接活跃。
// 前端心跳:每 30 秒发一次 ping const heartbeatInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); // 收到 pong 后不做处理,仅维持连接 ws.onmessage = function(event) { const msg = JSON.parse(event.data); if (msg.type === 'pong') return; // ... 其他消息处理 };后端在@OnMessage里判断type为ping时,直接回一个{"type":"pong"},不落库、不广播。心跳间隔一般设 30 秒到 60 秒,太短浪费资源,太长起不到保活作用。如果部署在 Nginx 后面,还要确认 Nginx 的proxy_read_timeout大于心跳间隔,否则 Nginx 会先断。
4. 群聊、私聊与禁言:消息路由的三种分支
4.1 群聊消息的接收与广播
群聊的逻辑是:任意用户发一条消息,后端把这条消息广播给所有在线用户。但要注意,广播之前先查一下发送者是否被禁言。如果muted = 1,直接给发送者回一条「你已被禁言」的提示,不广播。
@OnMessage public void onMessage(String message, @PathParam("username") String username) { JSONObject json = JSON.parseObject(message); String type = json.getString("type"); // 心跳包直接回 pong if ("ping".equals(type)) { sendTo(username, "{\"type\":\"pong\"}"); return; } // 发消息前检查禁言状态 User user = userMapper.selectByUsername(username); if (user != null && user.getMuted() == 1) { sendTo(username, "{\"type\":\"system\",\"content\":\"你已被禁言,无法发送消息\"}"); return; } // 群聊:广播给所有人 if ("group".equals(type)) { String content = json.getString("content"); // 先落库 chatMessageMapper.insert(new ChatMessage(username, null, content)); // 再广播 String broadcastMsg = "{\"type\":\"chat\",\"from\":\"" + username + "\",\"content\":\"" + content + "\"}"; ONLINE_USERS.forEach((u, s) -> sendTo(u, broadcastMsg)); } }这段代码里禁言检查放在最前面,避免被禁言的用户还能通过私聊绕过。群聊消息先落库再广播,保证历史记录不丢。广播时把发送者用户名带上,前端渲染时能区分是谁说的。注意content如果包含双引号或换行,直接拼 JSON 会出问题,稳妥做法是用JSON.toJSONString构造对象再转字符串。
4.2 一对一私聊的消息定向投递
私聊和群聊的区别在于投递范围。群聊是遍历所有在线用户,私聊是只发给to_user对应的那个 Session。如果对方不在线,可以选择落库但不投递,等对方上线后再拉历史记录。
// 私聊:只发给目标用户 if ("private".equals(type)) { String toUser = json.getString("to"); String content = json.getString("content"); // 落库,to_user 字段记录接收者 chatMessageMapper.insert(new ChatMessage(username, toUser, content)); // 构造私聊消息体 String privateMsg = "{\"type\":\"private\",\"from\":\"" + username + "\",\"content\":\"" + content + "\"}"; // 发给接收者 sendTo(toUser, privateMsg); // 同时回显给自己,方便前端展示 sendTo(username, privateMsg); }私聊的关键是to_user字段。落库时把接收者写进去,查询历史记录时用where (from_user = ? and to_user = ?) or (from_user = ? and to_user = ?)这种双向条件,才能把两个人之间的对话完整拉出来。如果对方不在线,sendTo里判断ONLINE_USERS.containsKey(toUser)为 false 就跳过发送,消息已经落库,对方下次登录后通过历史记录接口拉取。
4.3 禁言与解除禁言的管理端实现
禁言功能需要管理员权限。源码里大概率是在用户表加一个role字段或者单独维护一个管理员列表。禁言操作就是更新user表的muted字段,然后给被禁言的用户发一条系统通知。
// 管理员禁言接口 @PostMapping("/admin/mute") public Result muteUser(@RequestParam String targetUser, @RequestParam int muted) { // 更新数据库禁言状态 userMapper.updateMuted(targetUser, muted); // 通知被操作用户 String notice = muted == 1 ? "你已被管理员禁言" : "你的禁言已被解除"; sendTo(targetUser, "{\"type\":\"system\",\"content\":\"" + notice + "\"}"); return Result.success(); }禁言状态存在数据库里而不是内存里,好处是服务重启后禁言不丢失。解除禁言就是把muted改回 0,同样发一条系统通知。被禁言的用户尝试发消息时,onMessage里查库发现muted = 1就拦截,前端收到系统提示后可以把输入框置灰,提升体验。
5. 历史记录缓存与数据库持久化:消息不丢的保障
5.1 聊天记录落库的时机与字段设计
聊天记录落库的时机很关键。群聊和私聊都在onMessage里落库,但心跳包和系统通知不落库。chat_message表的字段设计要能区分消息类型,to_user为 NULL 是群聊,不为 NULL 是私聊。发送时间用DATETIME默认CURRENT_TIMESTAMP,查询时按时间倒序取最近 N 条。
-- 查询群聊历史记录:最近 50 条 SELECT * FROM chat_message WHERE to_user IS NULL ORDER BY send_time DESC LIMIT 50; -- 查询两人之间的私聊记录 SELECT * FROM chat_message WHERE (from_user = 'admin' AND to_user = 'test') OR (from_user = 'test' AND to_user = 'admin') ORDER BY send_time ASC;群聊查询用to_user IS NULL过滤,私聊查询用双向条件。注意私聊查询用ASC正序,因为要按对话顺序展示;群聊用DESC取最近 50 条后再在 Java 层反转,避免一次性拉太多数据。
5.2 历史记录缓存读取的两种策略
「历史记录缓存读取」这个功能,常见做法有两种:一种是前端登录后主动调 REST 接口拉最近 N 条记录,渲染到消息区;另一种是后端在用户 WebSocket 连接建立时,通过@OnOpen主动推送给该用户。两种都可以,我一般用第一种,因为 REST 接口好调试,用 Postman 就能验证数据对不对。
// 历史记录查询接口 @GetMapping("/chat/history") public Result getHistory(@RequestParam String username, @RequestParam(required = false) String targetUser) { List<ChatMessage> messages; if (targetUser == null) { // 群聊历史 messages = chatMessageMapper.selectGroupHistory(); } else { // 私聊历史 messages = chatMessageMapper.selectPrivateHistory(username, targetUser); } return Result.success(messages); }前端在ws.onopen之后调这个接口,把返回的消息列表渲染出来。如果消息量大,可以加分页参数page和size,但课程设计里一般取最近 50 条就够了。缓存方面,如果不想每次都查库,可以在后端用ConcurrentHashMap做一个简单的内存缓存,key 是username + targetUser,value 是消息列表,设置一个过期时间。不过课程设计阶段直接查库更稳妥,缓存反而容易引入数据不一致的坑。
5.3 消息顺序与时间戳的坑
多用户并发发消息时,数据库自增 ID 不一定能保证前端展示顺序和实际发送顺序一致。因为两个用户几乎同时插入,ID 小的不一定先到服务器。解决办法是用send_time排序,但DATETIME精度只到秒,同一秒内的消息还是可能乱序。更稳妥的做法是用TIMESTAMP(3)毫秒精度,或者前端按接收顺序追加,不依赖数据库排序。
-- 把 send_time 改成毫秒精度 ALTER TABLE chat_message MODIFY send_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3);改完字段后,插入时不用手动传时间,数据库自动填毫秒级时间戳。查询时ORDER BY send_time ASC, id ASC,双重排序保证顺序稳定。这个坑我在实际项目里踩过,前端消息偶尔跳序,查了半天才发现是时间精度问题。
6. 避坑与排查:这份源码最容易翻车的五个地方
6.1 启动报数据库连接失败
现象:./mvnw spring-boot:run启动时报Communications link failure或Access denied for user。原因通常是application.properties里的数据库地址、端口、用户名、密码和本机 MySQL 不一致,或者 MySQL 服务没启动。解决:先mysql -u root -p能登进去,确认库websocket_chat存在,再核对配置文件里的spring.datasource.url端口是不是 3306,密码有没有多余空格。如果 MySQL 8 以上,驱动类名必须是com.mysql.cj.jdbc.Driver,老的com.mysql.jdbc.Driver会报弃用警告甚至连不上。
6.2 WebSocket 连接 404
现象:前端控制台报WebSocket connection to 'ws://localhost:8080/chat/admin' failed: Error during WebSocket handshake: Unexpected response code: 404。原因一般是后端端点路径和前端写的对不上,或者@ServerEndpoint没被 Spring 扫描到。解决:确认后端@ServerEndpoint("/chat/{username}")里的路径,前端new WebSocket的 URL 必须完全一致。如果用的是WebSocketConfig注册,检查registry.addHandler(handler, "/chat/*")的路径。另外@ServerEndpoint需要配合ServerEndpointExporterBean 才能生效,Spring Boot 里要手动声明。
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }没有这个 Bean,@ServerEndpoint不会被注册,握手直接 404。这个坑很隐蔽,因为代码看起来没问题,但就是连不上。
6.3 消息发送后对方收不到
现象:A 发消息,B 在线但收不到,A 自己能看到。原因通常是广播时遍历的ONLINE_USERS里没有 B,或者 B 的 Session 已经失效但没被移除。解决:在sendTo方法里加异常捕获,发送失败时把该用户从ONLINE_USERS移除,避免死连接一直占位。另外检查@OnClose是否正常触发,如果用户直接关浏览器,@OnClose可能延迟触发,导致在线列表短暂不准。
private void sendTo(String username, String message) { Session session = ONLINE_USERS.get(username); if (session == null || !session.isOpen()) { // 会话已失效,清理掉 ONLINE_USERS.remove(username); return; } try { session.getBasicRemote().sendText(message); } catch (IOException e) { // 发送失败也清理 ONLINE_USERS.remove(username); } }6.4 中文消息乱码
现象:发送中文消息,对方收到的是???或乱码。原因通常是数据库字符集不是utf8mb4,或者 WebSocket 传输时没指定编码。解决:建库时用utf8mb4,连接 URL 加characterEncoding=utf8,前端JSON.stringify默认就是 UTF-8,一般不会出问题。如果还乱码,检查 Tomcat 的server.xml里Connector有没有URIEncoding="UTF-8"。
6.5 禁言后用户仍能发消息
现象:管理员禁言了某用户,但该用户还能发群聊消息。原因通常是禁言检查只在前端做了,后端onMessage里没查库,或者查库时用了缓存导致muted状态没更新。解决:禁言检查必须放在后端onMessage的最前面,每次发消息都查一次数据库。如果用了 MyBatis 二级缓存,记得在更新muted后清缓存,或者直接不用缓存。
7. 进阶技巧:把这份课程设计改成能写进简历的项目
课程设计源码能跑通只是及格线,如果你想拿它去面试或者做毕业设计,得做几处升级。第一处是把明文密码换成 BCrypt,引入spring-security-crypto依赖,登录时用BCrypt.checkpw比对,注册时用BCrypt.hashpw存哈希。第二处是把在线用户列表从单机内存改成 Redis,这样多实例部署时在线状态能共享,面试官问「你的聊天系统怎么支持横向扩展」你就有话说了。第三处是加消息已读未读状态,在chat_message表加is_read字段,私聊时对方打开对话窗口就标记已读,前端显示未读红点。
// BCrypt 密码校验示例 import org.springframework.security.crypto.bcrypt.BCrypt; // 注册时存哈希 String hashed = BCrypt.hashpw(rawPassword, BCrypt.gensalt()); // 登录时校验 boolean match = BCrypt.checkpw(inputPassword, storedHash);Redis 存在线用户的话,用SET结构,key 是online:users,value 是用户名集合,用户连接时SADD,断开时SREM。查询在线列表直接SMEMBERS。这样即使后端部署两个实例,用户连到哪个实例都能看到完整的在线列表。
验证方法很简单:启动两个后端实例,端口分别 8080 和 8081,前端用 Nginx 做负载均衡,两个用户分别连到不同实例,互相发消息能收到就说明 Redis 共享生效了。如果没条件搞多实例,至少把 BCrypt 和已读未读做了,面试时能讲清楚「为什么明文存密码不行」和「消息状态怎么同步」这两个点,比单纯说「我做了个聊天室」有分量得多。
最后说个血泪经验:改这份源码之前先git init提交一版原始代码,每改一个功能提交一次。我见过太多人改着改着跑不起来了,想回退又没备份,只能重新解压。从那以后我每次拆别人的源码包,第一件事就是建 Git 仓库,改坏了随时git checkout .后悔药管够。希望帮到你。
本文还有配套的精品资源,点击获取