ARTICLE DETAIL

资讯详情

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

Java Web聊天系统课设速通:Spring Boot+WebSocket+MySQL完整实现

Java Web聊天系统课设速通:Spring Boot+WebSocket+MySQL完整实现 简介一套基于前后端分离架构的Java Web聊天系统大作业项目适合需要完成课程设计、期末大作业或毕设的在校生。后端按分层规范拆分为config、controller、service、dao、entity、dto、utils、vo等模块config统一管理配置controller对外提供APIservice遵循接口实现dao负责JPA数据库操作entity映射数据表字段vo降低前端耦合前端由Vue组件配合scss样式构成覆盖聊天页面与交互逻辑。资源包共138个文件以66个Java源码、12个Vue组件、11个scss样式为核心另有properties、xml、jar等配置依赖文件及docx文档、png截图压缩包大小仅2.08MB便于快速下载部署已有1152人学习/下载。通过源码可学习JPA数据库映射、过滤器与拦截器、VO与前端交互数据封装等关键写法模块边界明确利于二次开发文档说明和目录结构注释能有效减少项目理解成本适合作为同类聊天系统开发的直接参考模板。1. Java Web大作业聊天系统为什么说它是课设里性价比最高的一个题“Java Web大作业 聊天系统”这行字几乎每年期末都会出现在任务书里。它看着不起眼拉开的却是一条完整的实时双向通信链路浏览器连上WebSocket、服务端路由消息、数据库落消息、会话列表算未读。很多人从拿到题到交作业时间全耗在“为什么对面那个人收不到消息”上。我的结论是不引Redis、不碰Netty用Spring Boot 原生WebSocket MySQL再加一点前端重连逻辑一个能现场演示的聊天系统一个周末就能从零跑通。这篇笔记适合正在做课设的Java学习者也适合想把第一个完整web项目写进简历的初级工程师后面的代码和参数都能直接复制改包名。2. 先把模型立住技术选型、四张表的边界和推送闭环2.1 技术选型Spring Boot WebSocket还是Servlet JSP老一套聊天系统最怕的不是写代码是选错路。常见做法是两条路线你先看任务书里有没有限定技术栈。第一条是学校机房路线Servlet JSP Tomcat。项目结构老但老师一眼能看懂。JSP页面里写聊天室消息推送用Tomcat 7以后自带的WebSocket支持也能跑通。缺点是前端和后端混在一起会话管理全靠HttpSession写到后面你自己都不想维护。第二条是Spring Boot Thymeleaf WebSocket MyBatis Plus。这是目前大多数Java Web大作业的主流方案也是面试时能讲出东西的组合。Spring Boot负责把项目跑起来MyBatis Plus帮你省掉大量JDBC样板代码Thymeleaf渲染首屏页面WebSocket负责实时推消息。如果你用的是IDEA 2024创建项目默认可能会拉到Spring Boot 3.x它要求JDK 17以上。学校机房如果还是JDK 8建议创建时直接选Spring Boot 2.7.x省得到时候启动就报“不支持major version”。对比项Servlet JSPSpring Boot WebSocket上手门槛低但代码分散中但结构清晰WebSocket支持Tomcat自带Spring原生支持数据库操作JDBC手写MyBatis Plus自动映射写简历价值一般能聊清实时通信链路踩坑面少但坑深多但资料齐全至于为什么不建议上Netty原因很简单课设场景里原生WebSocket已经够用Netty带来的线程模型和编解码复杂度够你再熬两个晚上。同理Redis也别急着引在线状态和未读数放内存里演示完全没问题真上了Redis反而多一个部署环节。2.2 四张核心表用户、会话、会话成员、消息建表SQL直接抄聊天系统的核心不是聊天本身是会话模型。单聊和群聊如果能用同一套表结构表达后面所有接口都好写。我一般会建四张表用户表、会话表、会话成员表、消息表。建表SQL放在下面直接复制到MySQL 8里执行就能用。-- 用户表 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名唯一, password varchar(100) NOT NULL COMMENT 密码建议BCrypt或MD5加盐, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL可空, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;-- 会话表 CREATE TABLE conversation ( id bigint NOT NULL AUTO_INCREMENT, type tinyint DEFAULT 1 COMMENT 1单聊 2群聊, name varchar(100) DEFAULT NULL COMMENT 群聊名称单聊为空, last_message_id bigint DEFAULT NULL COMMENT 最后一条消息ID用于会话排序, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会话表;-- 会话成员表 CREATE TABLE conversation_member ( id bigint NOT NULL AUTO_INCREMENT, conversation_id bigint NOT NULL, user_id bigint NOT NULL, unread_count int DEFAULT 0 COMMENT 该成员的未读消息数, last_read_msg_id bigint DEFAULT 0 COMMENT 该成员最后已读的消息ID, is_muted tinyint DEFAULT 0 COMMENT 是否免打扰, PRIMARY KEY (id), UNIQUE KEY uk_conv_user (conversation_id, user_id), KEY idx_user_conv (user_id, conversation_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会话成员表;-- 消息表 CREATE TABLE message ( id bigint NOT NULL AUTO_INCREMENT, conversation_id bigint NOT NULL, sender_id bigint NOT NULL, content text COMMENT 消息内容, msg_type tinyint DEFAULT 1 COMMENT 1文本 2图片 3系统, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_conv_id (conversation_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT消息表;这里有两个字段设计是返工重灾区。第一conversation_member里一定要有last_read_msg_id而不是简单放一个is_read布尔字段。未读数不是靠标记算的是靠“会话内最新消息ID - 已读消息ID”之间有多少条算出来的。第二消息表里conversation_id和id必须建联合索引因为查历史消息永远是这个条件。我用MyBatis Plus时一般先手写这份SQL把库建好再让代码生成器生成实体类和Mapper。网上那些“根据Java实体类生成建表SQL”的工具我也试过生成的表在时间字段默认值、索引设计上经常和你想要的不一致课设阶段手写SQL反而最稳。2.3 推送闭环先理清HTTP轮询为什么会被聊天系统判死刑聊天系统的核心矛盾是HTTP协议是单向的浏览器发请求服务器才能给响应。可聊天要求服务器主动把消息推给浏览器这时候你有两个选择。第一个选择是轮询。前端每秒钟发一次“有没有新消息”的请求后端查一次数据库。这个方案在30人以下的小demo里勉强能跑但消息延迟、服务器压力、流量浪费全都不好看。老师演示的时候你发一条消息对面要等下一次轮询才能看到场面很尴尬。第二个选择是WebSocket。浏览器和服务器建立一条长连接两边都能随时发数据。它的握手阶段依然是HTTP但握手成功后协议升级为WebSocket数据帧直接走TCP。这才是聊天系统该用的方案。完整的推送闭环是A用户在浏览器里点发送消息通过WebSocket连接发到后端后端先把消息插入MySQL再从内存里的连接表找到B用户的WebSocket Session把消息转发过去。这里有个顺序不能反——必须先落库再推送。如果先推给B客户端数据库写入却失败了B看到了一条实际上不存在的消息刷新页面后这条消息就消失了这就是典型的“幽灵消息”。2.4 在线状态与未读数会话列表像不像样就看这4个字段怎么填在线状态不用往数据库里存存了也来不及更新。我一般是在后端维护一个全局的ConcurrentHashMapLong, SessionuserId 对应 WebSocket Session。用户上线就put断开就remove前端直接查这个Map就知道谁在线。未读数不能只靠内存因为用户刷新页面后内存状态还在但页面上的数字应该继续保持。这份数据放在conversation_member表里unread_count在发送消息时对会话内的其他成员1在用户点开会话时清零。会话列表的排序有个小技巧不要按updated_at排按conversation.last_message_id排。因为同一毫秒内可能有多条消息进来datetime精度不够而自增ID是严格单调递增的用ID排序能保证“最后发消息的会话排最上面”。这三个字段——last_message_id、last_read_msg_id、unread_count——决定了会话列表像不像一个产品。3. 从登录到发消息把聊天核心链路一行行跑通3.1 登录态不做重活一个登录拦截器 HttpSession 就够了大作业级别的聊天系统不需要引入Spring Security那套过滤器链配置够你研究一晚上。常见做法是一个拦截器判断HttpSession里有没有userId没有就跳转到登录页。给一段直接能用的配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /login, /register, /chat/**, /css/**, /js/**, /error ); } }这个配置里最关键的是/chat/**必须放行。WebSocket握手阶段虽然会携带Cookie但Spring Boot的拦截器对WebSocket握手请求的过滤行为和普通HTTP请求不太一样如果你把拦截器加在/chat/**上握手请求会被拦下来前端WebSocket就一直连不上。这里的/chat/**是WebSocket端点的访问路径不是页面路径。LoginInterceptor的内部逻辑很简单从HttpSession里取值public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); if (session null || session.getAttribute(userId) null) { response.sendRedirect(/login); return false; } UserHolder.set((Long) session.getAttribute(userId)); return true; } }密码存储这块别存明文。最少也要MD5加盐盐值用用户名或注册时间存储格式 MD5(密码 盐)。如果你愿意多花半小时用BCrypt加密Spring Security里的BCryptPasswordEncoder单独抽出来就能用这也是面试时能讲一句“我了解密码哈希和非对称加密的区别”的点。3.2 WebSocket端点这样写连接、发消息、离线通知三件事一次讲清这是整个项目最核心的一段代码直接决定能不能聊天。用ServerEndpoint注解的方式在Spring Boot里管理WebSocket需要注意一个Spring Bean注入的坑我给你写的是绕过这个坑的完整写法。Component ServerEndpoint(value /chat/{userId}, configurator ChatConfigurator.class) public class ChatWebSocketServer { // 全局连接表userId - Session private static final MapLong, Session ONLINE_MAP new ConcurrentHashMap(); // 静态注入Service避免ServerEndpoint里Autowired失效 private static MessageService messageService; Autowired public void setMessageService(MessageService service) { ChatWebSocketServer.messageService service; } private Session session; private Long userId; OnOpen public void onOpen(Session session, PathParam(userId) Long userId) { this.session session; this.userId userId; Session old ONLINE_MAP.put(userId, session); if (old ! null) { try { old.close(); } catch (IOException ignored) {} } broadcastOnline(userId, true); } OnMessage public void onMessage(String text) { // 前端发来结构{conversationId:1,receiverId:2,content:你好,msgType:1} JSONObject obj JSON.parseObject(text); Long conversationId obj.getLong(conversationId); Long receiverId obj.getLong(receiverId); String content obj.getString(content); Integer msgType obj.getInteger(msgType); // 1. 落库 Message msg messageService.saveMessage(conversationId, userId, content, msgType); // 2. 推送给接收方 Session toSession ONLINE_MAP.get(receiverId); if (toSession ! null) { toSession.getBasicRemote().sendText(JSON.toJSONString(msg)); } // 3. 更新会话列表置顶信息 messageService.touchConversation(conversationId, msg.getId()); } OnClose public void onClose() { ONLINE_MAP.remove(userId); broadcastOnline(userId, false); } OnError public void onError(Session session, Throwable error) { ONLINE_MAP.remove(userId); } }这段代码有三个容易翻车的点。第一ServerEndpoint创建的实例不是Spring管理的直接在上面写Autowired MessageService一定是null所以要用静态setter注入的方式。第二ONLINE_MAP必须是static因为WebSocket端点每个连接都会new一个实例非static的Map等于每个连接各自维护一份A发的消息B永远看不到。第三broadcastOnline是给所有在线用户广播一条系统消息前端收到后更新好友列表里的在线状态这样不需要查数据库就能知道谁上下线。ChatConfigurator是为了解决握手阶段跨域校验的问题下一章详细讲。3.3 会话列表接口未读数、最后一条消息与排序一起返回会话列表页面加载时需要一个接口返回当前用户参与的所有会话包括对端信息、最后一条消息内容、未读数。这个接口写不好前端就得一次发三个请求代码又乱又慢。RestController RequestMapping(/api/conversation) public class ConversationController { Autowired private ConversationService conversationService; GetMapping(/list) public Result list() { Long userId UserHolder.get(); ListConversationVO list conversationService.listByUserId(userId); return Result.success(list); } }ConversationServiceImpl里的核心逻辑是先查conversation_member表里当前用户参与的所有会话ID再批量查会话基础信息和成员信息最后填充最后一条消息和未读数。批量查很重要不要在循环里单条查数据库一个用户10个会话就是10次查询加上成员就是20次课设规模下虽然不至于垮但老师看代码时会皱眉头。返回给前端的ConversationVO长这样public class ConversationVO { private Long conversationId; private Integer type; // 1单聊 2群聊 private String name; // 群聊名或对方昵称 private String lastMessage; // 最后一条消息预览 private Integer unreadCount; // 未读数 private Long lastMessageId; // 最后消息ID用于排序 private Boolean online; // 对方是否在线 }online字段不用查数据库后端在组装VO的时候直接查一下全局ONLINE_MAP就知道。这也是把在线状态放内存的好处接口响应速度比查数据库快得多。3.4 原生JS做最小前端不引Vue一个HTML文件跑通聊天页我见过不少组员在课设里硬上Vue Element UI最后卡在node_modules安装和跨域代理上。聊天系统这种规模原生JS加一个HTML模板页面完全够部署时还少一个构建步骤。let ws; let currentUserId document.getElementById(currentUserId).value; function connect() { ws new WebSocket(ws:// location.host /chat/ currentUserId); ws.onopen function() { console.log(websocket connected); }; ws.onmessage function(evt) { const data JSON.parse(evt.data); if (data.type chat) { appendMessage(data); } else if (data.type online) { updateUserStatus(data.userId, data.online); } else if (data.type read) { updateUnread(data.conversationId); } }; ws.onclose function() { setTimeout(connect, 2000); }; } connect(); function sendMessage() { const content document.getElementById(msgInput).value.trim(); if (!content) return; ws.send(JSON.stringify({ conversationId: currentConversationId, receiverId: targetUserId, content: content, msgType: 1 })); document.getElementById(msgInput).value ; }注意connect()在onclose里重试这是后面要详细讲的断线重连。前端渲染消息时一定要做HTML转义否则用户发一段script标签就能在你的页面上执行任意脚本。这算是大作业级别的web安全第一课function escapeHtml(str) { return str.replace(/[]/g, function(m) { return { : amp;, : lt;, : gt;, : quot;, : #39; }[m]; }); }4. 聊天系统大作业最容易踩的5个坑现象、原因、解决办法4.1 对方收到的消息全是问号中文乱码的根因不在Tomcat现象A发“你好”B收到的是“ä½ å¥½”或者一排问号数据库里存的也是乱码。原因Tomcat 8以后的URI编码默认就是UTF-8WebSocket的文本帧也是UTF-8。真正出问题的地方在MySQL连接串没指定字符集或者建表时用了latin1。解决MySQL连接串加characterEncodingutf8建库时明确指定utf8mb4spring: datasource: url: jdbc:mysql://localhost:3306/chat?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai然后在MySQL里确认一下表字符集SHOW TABLE STATUS LIKE message;Collation列是utf8mb4_general_ci或utf8mb4_unicode_ci就对了。4.2 WebSocket连不上浏览器控制台直接报403现象页面能打开登录也正常但WebSocket一直connectingNetwork面板里握手请求返回403。原因Tomcat的WebSocket容器默认会校验请求的Origin头如果前端页面的端口和你WebSocket服务端口不一样或者前端页面是从文件系统打开的Origin不匹配就会被拒绝。解决写一个Configurator重写checkOrigin放行public class ChatConfigurator extends ServerEndpointConfig.Configurator { Override public boolean checkOrigin(String originHeader) { return true; } }课设阶段直接返回true没问题生产环境需要校验白名单。还有一种情况是反向代理没配WebSocket升级头如果你用Nginx转发需要加Upgrade和Connection两个Header课设阶段本地启动没有这个问题。4.3 前端拿到的ID后几位变成0Long类型精度丢失现象后端返回的消息ID是162331234567890123前端console里打印出来变成了162331234567890120最后一位直接变了。原因JavaScript的Number类型最大安全整数是2^53超过这个范围精度就丢。数据库自增ID超过这个数后Jackson默认把Long序列化成数字前端解析时精度就坏了典型的黑匣子问题。解决在实体类ID字段上指定toString序列化JsonSerialize(using ToStringSerializer.class) private Long id;这样前端拿到的是字符串162331234567890123精度完整。同理前端传给后端的ID要么字符串要么BigInteger别让精度问题变成“明明库里有这条消息却查不到”的玄学。4.4 Autowired注入为nullServerEndpoint端点里拿不到Spring Bean现象消息一发送后端直接NPE日志显示messageService为null。原因ServerEndpoint的实例不是Spring容器管理的Spring的依赖注入不会对WebSocket端点生效。你在端点上写Autowired等于注入了一个null。解决刚才代码里已经用了静态setter注入。或者用一个Spring上下文工具类手动取Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext ctx; Override public void setApplicationContext(ApplicationContext applicationContext) { ctx applicationContext; } public static T T getBean(ClassT clazz) { return ctx.getBean(clazz); } }在OnMessage里通过SpringContextHolder.getBean(MessageService.class)拿到Service。这两种方式选一种就行我推荐静态setter代码更直白课设答辩时也好讲清楚。4.5 用户关掉浏览器后仍然显示在线onClose没被触发的场景现象A直接关浏览器标签页B的好友列表里A依然是绿色在线发消息也没人回。原因浏览器进程被杀、笔记本合盖、断网这些情况下WebSocket的onClose不一定会立刻触发服务端的Session还留在ONLINE_MAP里。解决双保险。前端在beforeunload事件里主动关闭WebSocketwindow.addEventListener(beforeunload, function() { ws.close(); });后端再做心跳检测启动一个定时任务每30秒扫描一次所有连接发送一条ping帧如果在10秒内没有收到pong响应就调用onClose清理连接并广播离线状态。这个定时任务用Spring的Scheduled就能做不需要额外依赖。5. 从“能交”到“能演示”断线重连、消息去重与验收动作5.1 断线自动重连不要让老师看到你刷新页面才出消息后端一重启前端所有WebSocket连接全部断开这时候如果没有重连逻辑页面就像死了一样。在onclose里加一个指数退避重连let reconnectAttempts 0; function connect() { ws new WebSocket(ws:// location.host /chat/ currentUserId); ws.onopen function() { reconnectAttempts 0; }; ws.onclose function() { const delay Math.min(1000 * Math.pow(2, reconnectAttempts), 15000); setTimeout(connect, delay); reconnectAttempts; }; }这是我在聊天系统里加过最值的一行逻辑重连成功的那一刻未读数会通过后端重新推过来页面自己就活了。5.2 消息幂等与去重客户端重试导致重复插库断线重连本身会带来一个新问题用户点发送消息发出去了但客户端没收到成功回执前端自动重发数据库里就出现了两条一模一样的消息。解决方式是客户端生成一个clientMsgId服务端保存消息前先查这个字段是否存在存在就丢弃。ALTER TABLE message ADD COLUMN client_msg_id varchar(64) DEFAULT NULL COMMENT 客户端消息幂等ID; ALTER TABLE message ADD UNIQUE KEY uk_client_msg_id (client_msg_id);前端发送时带上这个字段const message { conversationId: currentConversationId, receiverId: targetUserId, content: content, msgType: 1, clientMsgId: Date.now() _ Math.random().toString(16).slice(2) };5.3 演示前的三个验证动作第一开两个浏览器一个普通窗口一个隐身窗口分别登两个账号A发消息看B是否秒出。第二F12打开Network面板选WS标签看WebSocket帧里的收发信息确认消息推送链路是通的。第三A发消息后直接断网B端收到消息后A重连回来看A的会话列表是否置顶、未读数是否正确。这三个动作全过课设演示基本不会翻车。我以前做这个项目时就是吃了重连和消息重复的亏答辩现场老师连点两次发送数据库里多了两条一样的消息当场被问住。后来才把clientMsgId加进表里。这段血泪经验写出来希望帮到你。本文还有配套的精品资源点击获取
返回列表