ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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份课程设计报告文档另有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.urljdbc:mysql://localhost:3306/websocket_chat?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.DriverserverTimezone一定要加否则 MySQL 8 以上版本启动时会报时区错误。characterEncodingutf8保证中文消息不乱码。改完配置后执行./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 MapString, 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) { ListChatMessage 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 targetUservalue 是消息列表设置一个过期时间。不过课程设计阶段直接查库更稳妥缓存反而容易引入数据不一致的坑。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(); } }没有这个 BeanServerEndpoint不会被注册握手直接 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 加characterEncodingutf8前端JSON.stringify默认就是 UTF-8一般不会出问题。如果还乱码检查 Tomcat 的server.xml里Connector有没有URIEncodingUTF-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:usersvalue 是用户名集合用户连接时SADD断开时SREM。查询在线列表直接SMEMBERS。这样即使后端部署两个实例用户连到哪个实例都能看到完整的在线列表。验证方法很简单启动两个后端实例端口分别 8080 和 8081前端用 Nginx 做负载均衡两个用户分别连到不同实例互相发消息能收到就说明 Redis 共享生效了。如果没条件搞多实例至少把 BCrypt 和已读未读做了面试时能讲清楚「为什么明文存密码不行」和「消息状态怎么同步」这两个点比单纯说「我做了个聊天室」有分量得多。最后说个血泪经验改这份源码之前先git init提交一版原始代码每改一个功能提交一次。我见过太多人改着改着跑不起来了想回退又没备份只能重新解压。从那以后我每次拆别人的源码包第一件事就是建 Git 仓库改坏了随时git checkout .后悔药管够。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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